feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 7s

- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject,
  deleteObject, presigned URL helpers, and BUCKETS constant
- Wire product images, brand logos, and water log photos to MinIO via the
  new storage client
- Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call)
- Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge
  Function proxy)
- Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass
  brand_settings.logo_url from all call sites
- Update email templates to use dynamic logoUrl instead of hardcoded Supabase
  bucket URLs
- Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage,
  TimeTrackingFieldClient; use brand_settings props + local public/ fallback
- Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to
  public/ for local development
- Add MinIO env vars to .env.example (endpoint, access key, secret, buckets)
- Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props
- Fix admin/users.ts logoUrl type (null → undefined for optional string)
- Remove stale sb- cookie from wholesale-auth
- Migrate tuxedo/about page to remove supabase import and use pool query for
  wholesale_settings lookup
This commit is contained in:
openclaw
2026-06-09 11:32:17 -06:00
parent bb464bda65
commit c86e97e816
348 changed files with 6616 additions and 3096 deletions
+23 -22
View File
@@ -1,24 +1,24 @@
/**
* Drizzle client + tenant-scoped query helper.
* Drizzle client + brand-scoped query helper.
*
* The app connects to Postgres directly via the `pg` driver. Drizzle sits
* on top, providing typed queries. The `withTenant` wrapper is the only
* sanctioned way to run a tenant-scoped query — it sets the
* `app.current_tenant_id` GUC transaction-locally, and the database's
* RLS policies enforce tenant isolation even if application code forgets
* a `WHERE tenant_id = $1`.
* on top, providing typed queries. The `withBrand` wrapper is the only
* sanctioned way to run a brand-scoped query — it sets the
* `app.current_brand_id` GUC transaction-locally, and the database's
* RLS policies enforce brand isolation even if application code forgets
* a `WHERE brand_id = $1`.
*
* Usage (read):
* const products = await withTenant(tenantId, (db) =>
* const products = await withBrand(brandId, (db) =>
* db.select().from(productsTable).where(eq(productsTable.active, true)),
* );
*
* Usage (platform admin — sees all tenants):
* const allTenants = await withPlatformAdmin((db) =>
* db.select().from(tenantsTable),
* Usage (platform admin — sees all brands):
* const allBrands = await withPlatformAdmin((db) =>
* db.select().from(brandsTable),
* );
*
* Usage (no tenant — for the rare case the query isn't tenant-scoped):
* Usage (no brand — for the rare case the query isn't brand-scoped):
* const plans = await withDb((db) => db.select().from(plansTable));
*/
@@ -57,10 +57,10 @@ function getPool(): Pool {
}
/**
* Run `fn` with a Drizzle client. No tenant context is set — the caller
* Run `fn` with a Drizzle client. No brand context is set — the caller
* is responsible for RLS bypass (e.g. for the `plans` and `add_ons` tables,
* which are not tenant-scoped). For tenant-scoped reads, prefer
* `withTenant` or `withPlatformAdmin`.
* which are not brand-scoped). For brand-scoped reads, prefer
* `withBrand` or `withPlatformAdmin`.
*/
export async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
const client = await getPool().connect();
@@ -73,22 +73,22 @@ export async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
}
/**
* Run `fn` inside a transaction with the current tenant id set as a
* transaction-local GUC. RLS policies on tenant-scoped tables will allow
* reads/writes only for rows where `tenant_id` matches. Pass `null` to
* Run `fn` inside a transaction with the current brand id set as a
* transaction-local GUC. RLS policies on brand-scoped tables will allow
* reads/writes only for rows where `brand_id` matches. Pass `null` to
* fail open (don't set the GUC) — only useful for the migrations
* themselves, never for app code.
*/
export async function withTenant<T>(
tenantId: string,
export async function withBrand<T>(
brandId: string,
fn: (db: Db) => Promise<T>,
): Promise<T> {
return runInTransaction(async (client) => {
// set_config(setting, value, is_local) — is_local=true makes it
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
// leaks across pooled connections.
await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [
tenantId,
await client.query("SELECT set_config('app.current_brand_id', $1, true)", [
brandId,
]);
await client.query("SELECT set_config('app.platform_admin', 'false', true)");
const db = drizzle(client, { schema });
@@ -97,13 +97,14 @@ export async function withTenant<T>(
}
/**
* Run `fn` as platform admin. RLS policies permit access to all tenants.
* Run `fn` as platform admin. RLS policies permit access to all brands.
* Use sparingly — typically only in the /admin/platform routes.
*/
export async function withPlatformAdmin<T>(
fn: (db: Db) => Promise<T>,
): Promise<T> {
return runInTransaction(async (client) => {
await client.query("SELECT set_config('app.current_brand_id', '', true)");
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
const db = drizzle(client, { schema });
return fn(db);
+1666 -381
View File
File diff suppressed because it is too large Load Diff
+4 -7
View File
@@ -9,19 +9,16 @@ import {
timestamp,
index,
} from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
import { users } from "./tenants";
import { brands } from "./brands";
export const auditLog = pgTable(
"audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").references(() => tenants.id, {
brandId: uuid("brand_id").references(() => brands.id, {
onDelete: "cascade",
}),
userId: uuid("user_id").references(() => users.id, {
onDelete: "set null",
}),
userId: uuid("user_id"), // FK to neon_auth.user(id) — plain UUID, enforced at DB level
action: text("action").notNull(),
targetType: text("target_type"),
targetId: uuid("target_id"),
@@ -31,7 +28,7 @@ export const auditLog = pgTable(
.defaultNow(),
},
(t) => ({
tenantIdx: index("audit_log_tenant_idx").on(t.tenantId, t.createdAt),
brandIdx: index("audit_log_brand_idx").on(t.brandId, t.createdAt),
}),
);
+19 -43
View File
@@ -1,6 +1,6 @@
/**
* Billing tables: plans, add-ons, subscriptions, tenant_add_ons.
* Source of truth: `db/migrations/0001_init.sql`.
* Billing: plans, add_ons, brand_add_ons.
* Source: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
@@ -11,17 +11,13 @@ import {
jsonb,
primaryKey,
} from "drizzle-orm/pg-core";
import {
planCodeEnum,
addOnCodeEnum,
subscriptionStatusEnum,
addOnStatusEnum,
} from "./enums";
import { tenants } from "./tenants";
import { brands } from "./brands";
export const plans = pgTable("plans", {
id: uuid("id").primaryKey().defaultRandom(),
code: text("code", { enum: planCodeEnum }).notNull().unique(),
code: text("code", {
enum: ["starter", "farm", "enterprise"],
}).notNull().unique(),
name: text("name").notNull(),
monthlyPriceCents: integer("monthly_price_cents").notNull(),
maxUsers: integer("max_users").notNull(),
@@ -35,7 +31,12 @@ export const plans = pgTable("plans", {
export const addOns = pgTable("add_ons", {
id: uuid("id").primaryKey().defaultRandom(),
code: text("code", { enum: addOnCodeEnum }).notNull().unique(),
code: text("code", {
enum: [
"wholesale_portal", "harvest_reach", "ai_tools",
"water_log", "square_sync", "sms_campaigns",
],
}).notNull().unique(),
name: text("name").notNull(),
monthlyPriceCents: integer("monthly_price_cents").notNull(),
description: text("description"),
@@ -44,37 +45,17 @@ export const addOns = pgTable("add_ons", {
.defaultNow(),
});
export const subscriptions = pgTable("subscriptions", {
tenantId: uuid("tenant_id")
.primaryKey()
.references(() => tenants.id, { onDelete: "cascade" }),
planId: uuid("plan_id")
.notNull()
.references(() => plans.id),
status: text("status", { enum: subscriptionStatusEnum })
.notNull()
.default("trialing"),
stripeSubscriptionId: text("stripe_subscription_id"),
currentPeriodEnd: timestamp("current_period_end", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const tenantAddOns = pgTable(
"tenant_add_ons",
export const brandAddOns = pgTable(
"brand_add_ons",
{
tenantId: uuid("tenant_id")
brandId: uuid("brand_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
.references(() => brands.id, { onDelete: "cascade" }),
addOnId: uuid("add_on_id")
.notNull()
.references(() => addOns.id, { onDelete: "cascade" }),
stripeSubscriptionId: text("stripe_subscription_id"),
status: text("status", { enum: addOnStatusEnum })
status: text("status", { enum: ["active", "canceled"] })
.notNull()
.default("active"),
createdAt: timestamp("created_at", { withTimezone: true })
@@ -82,15 +63,10 @@ export const tenantAddOns = pgTable(
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.tenantId, t.addOnId] }),
pk: primaryKey({ columns: [t.brandId, t.addOnId] }),
}),
);
export type Plan = typeof plans.$inferSelect;
export type NewPlan = typeof plans.$inferInsert;
export type AddOn = typeof addOns.$inferSelect;
export type NewAddOn = typeof addOns.$inferInsert;
export type Subscription = typeof subscriptions.$inferSelect;
export type NewSubscription = typeof subscriptions.$inferInsert;
export type TenantAddOn = typeof tenantAddOns.$inferSelect;
export type NewTenantAddOn = typeof tenantAddOns.$inferInsert;
export type BrandAddOn = typeof brandAddOns.$inferSelect;
+71 -23
View File
@@ -1,28 +1,76 @@
/**
* Brand settings. One row per tenant. Source of truth:
* `db/migrations/0001_init.sql`.
* Brand settings + brand features. Source: `db/migrations/0001_init.sql`.
*/
import { pgTable, uuid, text, jsonb, timestamp } from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
import {
pgTable,
uuid,
text,
jsonb,
boolean,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
export const brandSettings = pgTable("brand_settings", {
tenantId: uuid("tenant_id")
.primaryKey()
.references(() => tenants.id, { onDelete: "cascade" }),
brandName: text("brand_name").notNull(),
tagline: text("tagline"),
aboutHtml: text("about_html"),
primaryColor: text("primary_color").default("#0F766E"),
logoStorageKey: text("logo_storage_key"),
heroStorageKey: text("hero_storage_key"),
contactEmail: text("contact_email"),
contactPhone: text("contact_phone"),
customFooterText: text("custom_footer_text"),
featureFlags: jsonb("feature_flags").notNull().default({}),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const brandSettings = pgTable(
"brand_settings",
{
brandId: uuid("brand_id")
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
legalBusinessName: text("legal_business_name"),
phone: text("phone"),
email: text("email"),
websiteUrl: text("website_url"),
streetAddress: text("street_address"),
city: text("city"),
state: text("state"),
postalCode: text("postal_code"),
country: text("country").default("US"),
logoUrl: text("logo_url"),
logoUrlDark: text("logo_url_dark"),
heroImageUrl: text("hero_image_url"),
tagline: text("tagline"),
aboutHtml: text("about_html"),
primaryColor: text("primary_color").default("#0F766E"),
defaultEmailSignature: text("default_email_signature"),
invoiceFooterNotes: text("invoice_footer_notes"),
fromEmail: text("from_email"),
fromName: text("from_name"),
replyToEmail: text("reply_to_email"),
customFooterText: text("custom_footer_text"),
featureFlags: jsonb("feature_flags").notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const brandFeatures = pgTable(
"brand_features",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
featureKey: text("feature_key").notNull(),
enabled: boolean("enabled").notNull().default(false),
enabledAt: timestamp("enabled_at", { withTimezone: true }),
disabledAt: timestamp("disabled_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandFeatureIdx: uniqueIndex("brand_features_brand_key_idx").on(
t.brandId,
t.featureKey,
),
}),
);
export type BrandSettings = typeof brandSettings.$inferSelect;
export type NewBrandSettings = typeof brandSettings.$inferInsert;
export type BrandFeature = typeof brandFeatures.$inferSelect;
+129
View File
@@ -0,0 +1,129 @@
/**
* Brands (Tenants). Source of truth: `db/migrations/0001_init.sql`.
* Multi-brand isolation: every business table has `brand_id` FK → brands.id.
*/
import {
pgTable,
uuid,
text,
integer,
boolean,
timestamp,
index,
uniqueIndex,
primaryKey,
} from "drizzle-orm/pg-core";
export const brands = pgTable(
"brands",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
planTier: text("plan_tier", {
enum: ["starter", "farm", "enterprise"],
}).notNull().default("starter"),
maxUsers: integer("max_users").notNull().default(2),
maxProducts: integer("max_products").notNull().default(25),
maxStopsMonthly: integer("max_stops_monthly").notNull().default(10),
stripeCustomerId: text("stripe_customer_id"),
stripeSubscriptionId: text("stripe_subscription_id"),
stripeSubscriptionStatus: text("stripe_subscription_status", {
enum: [
"trialing", "active", "past_due", "canceled",
"incomplete", "incomplete_expired",
],
}),
stripeCurrentPeriodEnd: timestamp("stripe_current_period_end", {
withTimezone: true,
}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
slugIdx: uniqueIndex("brands_slug_idx").on(t.slug),
}),
);
// ── Admin Users ──────────────────────────────────────────────────────────────
export const adminUsers = pgTable(
"admin_users",
{
id: uuid("id").primaryKey().defaultRandom(),
// FK to neon_auth.user(id) is enforced at DB level by the migration SQL.
// Drizzle can't reference tables in other schemas, so no .references() here.
userId: uuid("user_id"),
email: text("email").notNull().unique(),
name: text("name"),
role: text("role", {
enum: ["platform_admin", "brand_admin", "store_employee"],
}).notNull().default("brand_admin"),
canManageOrders: boolean("can_manage_orders").notNull().default(true),
canManageProducts: boolean("can_manage_products").notNull().default(true),
canManageStops: boolean("can_manage_stops").notNull().default(true),
canManageCustomers: boolean("can_manage_customers").notNull().default(true),
canManageWholesale: boolean("can_manage_wholesale").notNull().default(false),
canManageBilling: boolean("can_manage_billing").notNull().default(false),
canManageSettings: boolean("can_manage_settings").notNull().default(false),
canManageWaterLog: boolean("can_manage_water_log").notNull().default(false),
canManageTimeTracking: boolean("can_manage_time_tracking").notNull().default(false),
canManageRouteTrace: boolean("can_manage_route_trace").notNull().default(false),
canManageReports: boolean("can_manage_reports").notNull().default(true),
canManageCommunications: boolean("can_manage_communications").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
userIdIdx: uniqueIndex("admin_users_user_id_idx").on(t.userId),
emailIdx: uniqueIndex("admin_users_email_idx").on(t.email),
}),
);
export const adminUserBrands = pgTable(
"admin_user_brands",
{
adminUserId: uuid("admin_user_id")
.notNull()
.references(() => adminUsers.id, { onDelete: "cascade" }),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
addedAt: timestamp("added_at", { withTimezone: true })
.notNull()
.defaultNow(),
addedBy: uuid("added_by").references(() => adminUsers.id),
},
(t) => ({
pk: primaryKey({ columns: [t.adminUserId, t.brandId] }),
}),
);
// ── Neon Auth stub (for Drizzle typing only — table is managed by Neon Auth) ──
// The actual table is neon_auth.user (singular). We can't FK-reference a table
// in another schema via Drizzle, so we store userId as a plain UUID and rely on
// the DB-level FK constraint (enforced by the migration SQL).
export const authUsers = pgTable("user", {
id: uuid("id").primaryKey(),
name: text("name"),
email: text("email"),
emailVerified: boolean("email_verified"),
image: text("image"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
});
export type Brand = typeof brands.$inferSelect;
export type NewBrand = typeof brands.$inferInsert;
export type AdminUser = typeof adminUsers.$inferSelect;
export type NewAdminUser = typeof adminUsers.$inferInsert;
export type AdminUserBrand = typeof adminUserBrands.$inferSelect;
export type NewAdminUserBrand = typeof adminUserBrands.$inferInsert;
+315
View File
@@ -0,0 +1,315 @@
/**
* Communications (Harvest Reach).
* Source: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
integer,
boolean,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { brands, adminUsers } from "./brands";
export const communicationSettings = pgTable(
"communication_settings",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.unique()
.references(() => brands.id, { onDelete: "cascade" }),
defaultSenderEmail: text("default_sender_email"),
defaultSenderName: text("default_sender_name"),
replyToEmail: text("reply_to_email"),
emailProvider: text("email_provider").notNull().default("resend"),
emailFooterHtml: text("email_footer_html"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const communicationTemplates = pgTable(
"communication_templates",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
subject: text("subject").notNull(),
bodyText: text("body_text").notNull().default(""),
bodyHtml: text("body_html"),
templateType: text("template_type").notNull(),
campaignType: text("campaign_type"),
createdBy: uuid("created_by"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("communication_templates_brand_idx").on(t.brandId),
}),
);
export const communicationSegments = pgTable(
"communication_segments",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
rules: jsonb("rules").notNull().default({}),
createdBy: uuid("created_by"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("communication_segments_brand_idx").on(t.brandId),
}),
);
export const communicationCampaigns = pgTable(
"communication_campaigns",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
subject: text("subject"),
bodyText: text("body_text"),
bodyHtml: text("body_html"),
templateId: uuid("template_id").references(
() => communicationTemplates.id,
{ onDelete: "set null" },
),
campaignType: text("campaign_type").notNull(),
status: text("status", {
enum: ["draft", "scheduled", "sending", "sent", "canceled"],
}).notNull().default("draft"),
audienceRules: jsonb("audience_rules").notNull().default({}),
brandName: text("brand_name"),
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
sentAt: timestamp("sent_at", { withTimezone: true }),
recipientCount: integer("recipient_count").notNull().default(0),
createdBy: uuid("created_by"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("communication_campaigns_brand_idx").on(t.brandId),
statusIdx: index("communication_campaigns_status_idx").on(
t.brandId,
t.status,
),
}),
);
export const communicationContacts = pgTable(
"communication_contacts",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
email: text("email"),
phone: text("phone"),
firstName: text("first_name"),
lastName: text("last_name"),
fullName: text("full_name"),
source: text("source").notNull(),
externalId: text("external_id"),
customerId: uuid("customer_id"),
emailOptIn: boolean("email_opt_in").notNull().default(true),
smsOptIn: boolean("sms_opt_in").notNull().default(false),
emailOptInAt: timestamp("email_opt_in_at", { withTimezone: true }),
smsOptInAt: timestamp("sms_opt_in_at", { withTimezone: true }),
unsubscribedAt: timestamp("unsubscribed_at", { withTimezone: true }),
tags: text("tags").array().default([]),
metadata: jsonb("metadata").default({}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("communication_contacts_brand_idx").on(t.brandId),
emailIdx: index("communication_contacts_email_idx").on(
t.brandId,
t.email,
),
}),
);
export const communicationMessageLogs = pgTable(
"communication_message_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
campaignId: uuid("campaign_id").references(
() => communicationCampaigns.id,
{ onDelete: "set null" },
),
contactId: uuid("contact_id").references(
() => communicationContacts.id,
{ onDelete: "set null" },
),
customerEmail: text("customer_email"),
deliveryMethod: text("delivery_method").notNull(),
subject: text("subject"),
bodyPreview: text("body_preview"),
status: text("status").notNull(),
sentAt: timestamp("sent_at", { withTimezone: true }),
errorMessage: text("error_message"),
eventType: text("event_type"),
eventId: uuid("event_id"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("communication_message_logs_brand_idx").on(t.brandId),
campaignIdx: index(
"communication_message_logs_campaign_idx",
).on(t.campaignId),
}),
);
export const customerCommunicationPreferences = pgTable(
"customer_communication_preferences",
{
id: uuid("id").primaryKey().defaultRandom(),
customerId: uuid("customer_id").notNull(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
emailOptIn: boolean("email_opt_in").notNull().default(true),
smsOptIn: boolean("sms_opt_in").notNull().default(false),
emailOptInAt: timestamp("email_opt_in_at", { withTimezone: true }),
smsOptInAt: timestamp("sms_opt_in_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
customerBrandIdx: uniqueIndex(
"customer_communication_prefs_customer_brand_idx",
).on(t.customerId, t.brandId),
}),
);
// Email automation sequences
export const abandonedCartRecovery = pgTable(
"abandoned_cart_recovery",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
customerId: uuid("customer_id"),
contactEmail: text("contact_email").notNull(),
contactName: text("contact_name"),
cartSnapshot: jsonb("cart_snapshot").notNull(),
brandName: text("brand_name"),
locale: text("locale").default("en"),
sequenceStep: integer("sequence_step").default(0),
lastEmailSentAt: timestamp("last_email_sent_at", {
withTimezone: true,
}),
nextEmailAt: timestamp("next_email_at", { withTimezone: true }),
status: text("status", {
enum: ["active", "recovered", "expired", "manually_closed"],
}).default("active"),
recoveredOrderId: uuid("recovered_order_id"),
recoveredAt: timestamp("recovered_at", { withTimezone: true }),
expiredAt: timestamp("expired_at", { withTimezone: true }),
manuallyClosedAt: timestamp("manually_closed_at", {
withTimezone: true,
}),
manuallyClosedBy: uuid("manually_closed_by").references(
() => adminUsers.id,
),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const welcomeEmailSequence = pgTable(
"welcome_email_sequence",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
contactId: uuid("contact_id").references(
() => communicationContacts.id,
{ onDelete: "cascade" },
),
contactEmail: text("contact_email").notNull(),
contactName: text("contact_name"),
brandName: text("brand_name"),
locale: text("locale").default("en"),
sequenceStep: integer("sequence_step").default(0),
lastEmailSentAt: timestamp("last_email_sent_at", {
withTimezone: true,
}),
nextEmailAt: timestamp("next_email_at", { withTimezone: true }),
status: text("status", {
enum: ["active", "completed", "unsubscribed", "bounced"],
}).default("active"),
completedAt: timestamp("completed_at", { withTimezone: true }),
unsubscribedAt: timestamp("unsubscribed_at", { withTimezone: true }),
bouncedAt: timestamp("bounced_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export type CommunicationSettings =
typeof communicationSettings.$inferSelect;
export type CommunicationTemplate = typeof communicationTemplates.$inferSelect;
export type CommunicationSegment = typeof communicationSegments.$inferSelect;
export type CommunicationCampaign = typeof communicationCampaigns.$inferSelect;
export type CommunicationContact = typeof communicationContacts.$inferSelect;
export type CommunicationMessageLog = typeof communicationMessageLogs.$inferSelect;
export type CustomerCommunicationPreference =
typeof customerCommunicationPreferences.$inferSelect;
export type AbandonedCartRecovery = typeof abandonedCartRecovery.$inferSelect;
export type WelcomeEmailSequence = typeof welcomeEmailSequence.$inferSelect;
+21 -14
View File
@@ -1,5 +1,5 @@
/**
* Customers. Source of truth: `db/migrations/0001_init.sql`.
* Customers. Source: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
@@ -7,25 +7,35 @@ import {
text,
boolean,
timestamp,
varchar,
bigint,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { tenants } from "./tenants";
import { brands } from "./brands";
export const customers = pgTable(
"customers",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
brandId: uuid("brand_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
.references(() => brands.id, { onDelete: "cascade" }),
email: text("email"),
phone: text("phone"),
smsOptIn: boolean("sms_opt_in").notNull().default(false),
firstName: text("first_name"),
lastName: text("last_name"),
fullName: text("full_name"),
source: text("source").notNull().default("system"),
externalId: text("external_id"),
customerId: uuid("customer_id"),
emailOptIn: boolean("email_opt_in").notNull().default(true),
notes: text("notes"),
smsOptIn: boolean("sms_opt_in").notNull().default(false),
emailOptInAt: timestamp("email_opt_in_at", { withTimezone: true }),
smsOptInAt: timestamp("sms_opt_in_at", { withTimezone: true }),
unsubscribedAt: timestamp("unsubscribed_at", { withTimezone: true }),
tags: text("tags").array().notNull().default([]),
metadata: jsonb("metadata").notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
@@ -34,12 +44,9 @@ export const customers = pgTable(
.defaultNow(),
},
(t) => ({
tenantIdx: index("customers_tenant_idx").on(t.tenantId),
emailIdx: uniqueIndex("customers_tenant_email_idx")
.on(t.tenantId, t.email)
.where(sql`${t.email} IS NOT NULL`),
brandIdx: index("customers_brand_idx").on(t.brandId),
}),
);
export type Customer = typeof customers.$inferSelect;
export type NewCustomer = typeof customers.$inferInsert;
export type NewCustomer = typeof customers.$inferInsert;
+6 -9
View File
@@ -5,33 +5,30 @@ import {
pgTable,
uuid,
text,
bigint,
integer,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
import { users } from "./tenants";
import { brands } from "./brands";
export const files = pgTable(
"files",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").references(() => tenants.id, {
brandId: uuid("brand_id").references(() => brands.id, {
onDelete: "cascade",
}),
storageKey: text("storage_key").notNull().unique(),
mimeType: text("mime_type").notNull(),
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
sizeBytes: integer("size_bytes").notNull(),
purpose: text("purpose"),
uploadedBy: uuid("uploaded_by").references(() => users.id, {
onDelete: "set null",
}),
uploadedBy: uuid("uploaded_by"), // FK to neon_auth.user(id) — plain UUID, enforced at DB level
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("files_tenant_idx").on(t.tenantId),
brandIdx: index("files_brand_idx").on(t.brandId),
}),
);
+9 -7
View File
@@ -1,17 +1,19 @@
/**
* Schema barrel. Re-exports every Drizzle table + inferred row type.
*
* Usage:
* import { products, type Product } from "@/db/schema";
* Source of truth: `db/migrations/0001_init.sql`.
*/
export * from "./enums";
export * from "./tenants";
export * from "./brands";
export * from "./billing";
export * from "./products";
export * from "./stops";
export * from "./customers";
export * from "./orders";
export * from "./brand";
export * from "./wholesale";
export * from "./water-log";
export * from "./communications";
export * from "./marketing";
export * from "./files";
export * from "./audit";
export * from "./time-tracking";
export * from "./shipping";
export * from "./support";
export * from "./files";
+8 -8
View File
@@ -11,15 +11,15 @@ import {
index,
} from "drizzle-orm/pg-core";
import { campaignStatusEnum } from "./enums";
import { tenants } from "./tenants";
import { brands } from "./brands";
export const emailTemplates = pgTable(
"email_templates",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
brandId: uuid("brand_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
subject: text("subject").notNull(),
bodyHtml: text("body_html").notNull(),
@@ -31,7 +31,7 @@ export const emailTemplates = pgTable(
.defaultNow(),
},
(t) => ({
tenantIdx: index("email_templates_tenant_idx").on(t.tenantId),
brandIdx: index("email_templates_brand_idx").on(t.brandId),
}),
);
@@ -39,9 +39,9 @@ export const campaigns = pgTable(
"campaigns",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
brandId: uuid("brand_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
.references(() => brands.id, { onDelete: "cascade" }),
templateId: uuid("template_id").references((): any => emailTemplates.id, {
onDelete: "set null",
}),
@@ -60,8 +60,8 @@ export const campaigns = pgTable(
.defaultNow(),
},
(t) => ({
tenantIdx: index("campaigns_tenant_idx").on(t.tenantId),
statusIdx: index("campaigns_status_idx").on(t.tenantId, t.status),
brandIdx: index("campaigns_brand_idx").on(t.brandId),
statusIdx: index("campaigns_status_idx").on(t.brandId, t.status),
}),
);
+28 -10
View File
@@ -1,5 +1,5 @@
/**
* Orders + order_items. Source of truth: `db/migrations/0001_init.sql`.
* Orders + order_items. Source: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
@@ -9,24 +9,36 @@ import {
timestamp,
index,
} from "drizzle-orm/pg-core";
import { orderStatusEnum, fulfillmentEnum, itemFulfillmentEnum } from "./enums";
import { tenants } from "./tenants";
import { brands } from "./brands";
import { customers } from "./customers";
import { stops } from "./stops";
import { products } from "./products";
export const orders = pgTable(
"orders",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
brandId: uuid("brand_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
.references(() => brands.id, { onDelete: "cascade" }),
customerId: uuid("customer_id").references(() => customers.id, {
onDelete: "set null",
}),
stopId: uuid("stop_id").references(() => stops.id, {
onDelete: "set null",
}),
totalCents: integer("total_cents").notNull().default(0),
status: text("status", { enum: orderStatusEnum }).notNull().default("pending"),
fulfillment: text("fulfillment", { enum: fulfillmentEnum }).notNull(),
status: text("status", {
enum: ["pending", "confirmed", "fulfilled", "canceled"],
}).notNull().default("pending"),
fulfillment: text("fulfillment", {
enum: ["pickup", "ship", "mixed"],
}).notNull(),
customerAddress: text("customer_address"),
customerCity: text("customer_city"),
customerState: text("customer_state"),
customerZip: text("customer_zip"),
idempotencyKey: text("idempotency_key"),
notes: text("notes"),
placedAt: timestamp("placed_at", { withTimezone: true })
.notNull()
@@ -36,9 +48,10 @@ export const orders = pgTable(
.defaultNow(),
},
(t) => ({
tenantIdx: index("orders_tenant_idx").on(t.tenantId),
brandIdx: index("orders_brand_idx").on(t.brandId),
statusIdx: index("orders_status_idx").on(t.brandId, t.status),
stopIdx: index("orders_stop_idx").on(t.stopId),
customerIdx: index("orders_customer_idx").on(t.customerId),
statusIdx: index("orders_status_idx").on(t.tenantId, t.status),
}),
);
@@ -54,7 +67,12 @@ export const orderItems = pgTable(
}),
quantity: integer("quantity").notNull(),
priceCents: integer("price_cents").notNull(),
fulfillment: text("fulfillment", { enum: itemFulfillmentEnum }).notNull(),
fulfillment: text("fulfillment", {
enum: ["pickup", "ship"],
}).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
orderIdx: index("order_items_order_idx").on(t.orderId),
+21 -9
View File
@@ -1,5 +1,5 @@
/**
* Products + product_images. Source of truth: `db/migrations/0001_init.sql`.
* Products. Source: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
@@ -10,21 +10,30 @@ import {
timestamp,
index,
} from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
import { brands } from "./brands";
export const products = pgTable(
"products",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
brandId: uuid("brand_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
sku: text("sku"),
type: text("type", { enum: ["standard", "wholesale", "both"] })
.notNull()
.default("standard"),
priceCents: integer("price_cents").notNull(),
inventory: integer("inventory").notNull().default(0),
unit: text("unit"),
active: boolean("active").notNull().default(true),
isTaxable: boolean("is_taxable").notNull().default(false),
pickupType: text("pickup_type", { enum: ["pickup", "ship", "all"] })
.notNull()
.default("all"),
imageUrl: text("image_url"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
@@ -33,11 +42,16 @@ export const products = pgTable(
.defaultNow(),
},
(t) => ({
tenantIdx: index("products_tenant_idx").on(t.tenantId),
activeIdx: index("products_active_idx").on(t.tenantId, t.active),
brandIdx: index("products_brand_idx").on(t.brandId),
activeIdx: index("products_active_idx").on(t.brandId, t.active),
}),
);
export type Product = typeof products.$inferSelect;
export type NewProduct = typeof products.$inferInsert;
// ── Product Images ─────────────────────────────────────────────────────────────
export const productImages = pgTable(
"product_images",
{
@@ -53,11 +67,9 @@ export const productImages = pgTable(
.defaultNow(),
},
(t) => ({
productIdx: index("product_images_product_idx").on(t.productId, t.position),
productIdx: index("product_images_product_idx").on(t.productId),
}),
);
export type Product = typeof products.$inferSelect;
export type NewProduct = typeof products.$inferInsert;
export type ProductImage = typeof productImages.$inferSelect;
export type NewProductImage = typeof productImages.$inferInsert;
+106
View File
@@ -0,0 +1,106 @@
/**
* Shipping + Payments. Source: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
numeric,
boolean,
date,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { orders } from "./orders";
export const shippingSettings = pgTable(
"shipping_settings",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.unique()
.references(() => brands.id, { onDelete: "cascade" }),
carrier: text("carrier").notNull().default("fedex"),
fedexAccountNumber: text("fedex_account_number"),
fedexApiKey: text("fedex_api_key"),
fedexApiSecret: text("fedex_api_secret"),
fedexUseProduction: boolean("fedex_use_production")
.notNull()
.default(false),
defaultServiceType: text("default_service_type")
.notNull()
.default("FEDEX_GROUND"),
refrigeratedHandlingNotes: text("refrigerated_handling_notes"),
fragileHandlingNotes: text("fragile_handling_notes"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const shipments = pgTable(
"shipments",
{
id: uuid("id").primaryKey().defaultRandom(),
orderId: uuid("order_id")
.notNull()
.references(() => orders.id, { onDelete: "cascade" }),
carrier: text("carrier").notNull().default("fedex"),
serviceType: text("service_type").notNull(),
trackingNumber: text("tracking_number"),
labelUrl: text("label_url"),
rateCharged: numeric("rate_charged", { precision: 10, scale: 2 }),
estimatedDeliveryDate: date("estimated_delivery_date"),
isRefrigerated: boolean("is_refrigerated").notNull().default(false),
isFragile: boolean("is_fragile").notNull().default(false),
handlingNotes: text("handling_notes"),
status: text("status", {
enum: [
"created", "label_printed", "picked_up",
"in_transit", "delivered", "exception",
],
}).notNull().default("created"),
fedexShipmentId: text("fedex_shipment_id"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
createdBy: uuid("created_by"),
},
(t) => ({
orderIdx: index("shipments_order_idx").on(t.orderId),
}),
);
export const paymentSettings = pgTable(
"payment_settings",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.unique()
.references(() => brands.id, { onDelete: "cascade" }),
provider: text("provider"),
stripePublishableKey: text("stripe_publishable_key"),
stripeSecretKey: text("stripe_secret_key"),
squareAccessToken: text("square_access_token"),
squareLocationId: text("square_location_id"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export type ShippingSettings = typeof shippingSettings.$inferSelect;
export type Shipment = typeof shipments.$inferSelect;
export type PaymentSettings = typeof paymentSettings.$inferSelect;
+55 -11
View File
@@ -1,28 +1,39 @@
/**
* Stops. Source of truth: `db/migrations/0001_init.sql`.
* Stops + Locations. Source: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
jsonb,
date,
boolean,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { stopStatusEnum } from "./enums";
import { tenants } from "./tenants";
import { brands } from "./brands";
export const stops = pgTable(
"stops",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
brandId: uuid("brand_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
address: text("address").notNull(),
schedule: jsonb("schedule").notNull().default([]),
status: text("status", { enum: stopStatusEnum }).notNull().default("active"),
location: text("location").notNull(),
address: text("address"),
city: text("city"),
state: text("state"),
zip: text("zip"),
date: date("date").notNull(),
// "time" is a reserved word — quoted in SQL, unquoted in JS
time: text("time"),
cutoffDate: date("cutoff_date"),
status: text("status", { enum: ["active", "paused", "closed"] })
.notNull()
.default("active"),
isPublic: boolean("is_public").notNull().default(true),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
@@ -32,10 +43,43 @@ export const stops = pgTable(
.defaultNow(),
},
(t) => ({
tenantIdx: index("stops_tenant_idx").on(t.tenantId),
statusIdx: index("stops_status_idx").on(t.tenantId, t.status),
brandIdx: index("stops_brand_idx").on(t.brandId),
statusIdx: index("stops_status_idx").on(t.brandId, t.status),
dateIdx: index("stops_date_idx").on(t.brandId, t.date),
}),
);
export const locations = pgTable(
"locations",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
address: text("address"),
city: text("city"),
state: text("state"),
zip: text("zip"),
phone: text("phone"),
contactName: text("contact_name"),
contactEmail: text("contact_email"),
notes: text("notes"),
active: boolean("active").notNull().default(true),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("locations_brand_idx").on(t.brandId),
}),
);
export type Stop = typeof stops.$inferSelect;
export type NewStop = typeof stops.$inferInsert;
export type Location = typeof locations.$inferSelect;
export type NewLocation = typeof locations.$inferInsert;
+297
View File
@@ -0,0 +1,297 @@
/**
* Support tables: referrals, changelogs, onboarding, api_keys,
* notifications, operational events, audit logs.
* Source: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
varchar,
boolean,
timestamp,
jsonb,
integer,
numeric,
time,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { brands, adminUsers } from "./brands";
// ── Referrals ──────────────────────────────────────────────────────────────
export const referralCodes = pgTable(
"referral_codes",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id").references(() => brands.id, {
onDelete: "cascade",
}),
referrerUserId: uuid("referrer_user_id").notNull(),
referralCode: varchar("referral_code", { length: 50 }).notNull().unique(),
referrerEmail: varchar("referrer_email", { length: 255 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true }),
maxUses: integer("max_uses").default(1),
currentUses: integer("current_uses").default(0),
isActive: boolean("is_active").default(true),
rewardType: varchar("reward_type", { length: 50 }).default("percentage"),
rewardValue: numeric("reward_value", { precision: 10, scale: 2 }).default("20.00"),
metadata: jsonb("metadata").default({}),
},
);
export const referralRedemptions = pgTable(
"referral_redemptions",
{
id: uuid("id").primaryKey().defaultRandom(),
referralCodeId: uuid("referral_code_id").references(
() => referralCodes.id,
{ onDelete: "cascade" },
),
brandId: uuid("brand_id").references(() => brands.id, {
onDelete: "cascade",
}),
referredUserId: uuid("referred_user_id").notNull(),
referredEmail: varchar("referred_email", { length: 255 }).notNull(),
redeemedAt: timestamp("redeemed_at", { withTimezone: true })
.notNull()
.defaultNow(),
rewardType: varchar("reward_type", { length: 50 }),
rewardValue: numeric("reward_value", { precision: 10, scale: 2 }),
isCreditApplied: boolean("is_credit_applied").default(false),
signupPlan: varchar("signup_plan", { length: 50 }),
signupValue: numeric("signup_value", { precision: 10, scale: 2 }),
metadata: jsonb("metadata").default({}),
},
);
// ── Changelogs ─────────────────────────────────────────────────────────────
export const changelogs = pgTable(
"changelogs",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id").references(() => brands.id, {
onDelete: "cascade",
}),
version: varchar("version", { length: 50 }).notNull(),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
content: jsonb("content").notNull().default([]),
releasedAt: timestamp("released_at", { withTimezone: true })
.notNull()
.defaultNow(),
isPublished: boolean("is_published").default(false),
featureType: varchar("feature_type", { length: 50 }).default("general"),
metadata: jsonb("metadata").default({}),
},
(t) => ({
brandVersionIdx: uniqueIndex("changelogs_brand_version_idx").on(
t.brandId,
t.version,
),
}),
);
export const changelogReads = pgTable(
"changelog_reads",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull(),
changelogId: uuid("changelog_id").references(() => changelogs.id, {
onDelete: "cascade",
}),
readAt: timestamp("read_at", { withTimezone: true })
.notNull()
.defaultNow(),
dismissed: boolean("dismissed").default(false),
},
(t) => ({
userChangelogIdx: uniqueIndex("changelog_reads_user_changelog_idx").on(
t.userId,
t.changelogId,
),
}),
);
// ── Onboarding ─────────────────────────────────────────────────────────────
export const onboardingProgress = pgTable(
"onboarding_progress",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id").references(() => brands.id, {
onDelete: "cascade",
}),
userId: uuid("user_id").notNull(),
currentStep: varchar("current_step", { length: 50 }).notNull(),
completedSteps: jsonb("completed_steps").default([]),
skippedSteps: jsonb("skipped_steps").default([]),
data: jsonb("data").default({}),
startedAt: timestamp("started_at", { withTimezone: true })
.notNull()
.defaultNow(),
completedAt: timestamp("completed_at", { withTimezone: true }),
isCompleted: boolean("is_completed").default(false),
metadata: jsonb("metadata").default({}),
},
(t) => ({
brandUserIdx: uniqueIndex("onboarding_progress_brand_user_idx").on(
t.brandId,
t.userId,
),
}),
);
// ── API Keys ────────────────────────────────────────────────────────────────
export const apiKeys = pgTable(
"api_keys",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id").references(() => brands.id, {
onDelete: "cascade",
}),
name: varchar("name", { length: 255 }).notNull(),
keyHash: varchar("key_hash", { length: 255 }).notNull(),
keyPrefix: varchar("key_prefix", { length: 20 }),
permissions: jsonb("permissions").default(["read"]),
rateLimit: integer("rate_limit").default(1000),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }),
isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
createdBy: uuid("created_by").notNull(),
},
(t) => ({
brandIdx: index("api_keys_brand_idx").on(t.brandId),
}),
);
// ── Notification Preferences ───────────────────────────────────────────────
export const notificationPreferences = pgTable(
"notification_preferences",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull(),
brandId: uuid("brand_id").references(() => brands.id, {
onDelete: "cascade",
}),
emailOrders: boolean("email_orders").default(true),
emailMarketing: boolean("email_marketing").default(false),
emailReports: boolean("email_reports").default(true),
emailBilling: boolean("email_billing").default(true),
smsOrders: boolean("sms_orders").default(false),
smsMarketing: boolean("sms_marketing").default(false),
pushOrders: boolean("push_orders").default(true),
pushMarketing: boolean("push_marketing").default(false),
quietHoursStart: time("quiet_hours_start"),
quietHoursEnd: time("quiet_hours_end"),
timezone: varchar("timezone", { length: 50 }).default("America/New_York"),
metadata: jsonb("metadata").default({}),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
userBrandIdx: uniqueIndex("notification_preferences_user_brand_idx").on(
t.userId,
t.brandId,
),
}),
);
// ── Operational Events ─────────────────────────────────────────────────────
export const operationalEvents = pgTable(
"operational_events",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
eventType: text("event_type").notNull(),
entityType: text("entity_type"),
entityId: uuid("entity_id"),
actorType: text("actor_type"),
actorId: uuid("actor_id"),
source: text("source").notNull().default("system"),
payload: jsonb("payload").notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("operational_events_brand_idx").on(t.brandId),
typeIdx: index("operational_events_type_idx").on(t.brandId, t.eventType),
}),
);
// ── Audit Logs ─────────────────────────────────────────────────────────────
export const auditLogs = pgTable(
"audit_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id"),
brandId: uuid("brand_id").references(() => brands.id, {
onDelete: "set null",
}),
action: text("action").notNull(),
entityType: text("entity_type"),
entityId: uuid("entity_id"),
details: jsonb("details"),
ipAddress: text("ip_address"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("audit_logs_brand_idx").on(t.brandId),
userIdx: index("audit_logs_user_idx").on(t.userId),
createdIdx: index("audit_logs_created_idx").on(t.createdAt),
}),
);
export const adminActionLogs = pgTable(
"admin_action_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
adminUserId: uuid("admin_user_id")
.notNull()
.references(() => adminUsers.id),
brandId: uuid("brand_id").references(() => brands.id, {
onDelete: "set null",
}),
action: text("action").notNull(),
targetType: text("target_type"),
targetId: uuid("target_id"),
metadata: jsonb("metadata"),
ipAddress: text("ip_address"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
adminIdx: index("admin_action_logs_admin_idx").on(t.adminUserId),
createdIdx: index("admin_action_logs_created_idx").on(t.createdAt),
}),
);
export type ReferralCode = typeof referralCodes.$inferSelect;
export type ReferralRedemption = typeof referralRedemptions.$inferSelect;
export type Changelog = typeof changelogs.$inferSelect;
export type ChangelogRead = typeof changelogReads.$inferSelect;
export type OnboardingProgress = typeof onboardingProgress.$inferSelect;
export type ApiKey = typeof apiKeys.$inferSelect;
export type NotificationPreference = typeof notificationPreferences.$inferSelect;
export type OperationalEvent = typeof operationalEvents.$inferSelect;
export type AuditLog = typeof auditLogs.$inferSelect;
export type AdminActionLog = typeof adminActionLogs.$inferSelect;
-85
View File
@@ -1,85 +0,0 @@
/**
* Tenancy + auth tables. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
timestamp,
index,
uniqueIndex,
primaryKey,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import {
tenantStatusEnum,
authProviderEnum,
roleEnum,
} from "./enums";
export const tenants = pgTable("tenants", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
status: text("status", { enum: tenantStatusEnum }).notNull().default("trial"),
trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }),
stripeCustomerId: text("stripe_customer_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").unique(),
name: text("name"),
image: text("image"),
/**
* Bcrypt / scrypt / argon2 hash, or null for OAuth-only users.
* Format is self-describing (algorithm$N$salt$hash) so we can
* migrate to a stronger KDF later without losing existing hashes.
* See `src/lib/passwords.ts` for the encoder/decoder.
*/
passwordHash: text("password_hash"),
authProvider: text("auth_provider", { enum: authProviderEnum })
.notNull()
.default("dev"),
authSubject: text("auth_subject"),
emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
authSubjectIdx: uniqueIndex("users_auth_subject_idx")
.on(t.authProvider, t.authSubject)
.where(sql`${t.authSubject} IS NOT NULL`),
}),
);
export const tenantUsers = pgTable(
"tenant_users",
{
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
role: text("role", { enum: roleEnum }).notNull().default("brand_admin"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.tenantId, t.userId] }),
userIdx: index("tenant_users_user_idx").on(t.userId),
}),
);
export type Tenant = typeof tenants.$inferSelect;
export type NewTenant = typeof tenants.$inferInsert;
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type TenantUser = typeof tenantUsers.$inferSelect;
export type NewTenantUser = typeof tenantUsers.$inferInsert;
+161
View File
@@ -0,0 +1,161 @@
/**
* Time Tracking. Source: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
numeric,
integer,
boolean,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
export const timeTrackingSettings = pgTable(
"time_tracking_settings",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.unique()
.references(() => brands.id, { onDelete: "cascade" }),
payPeriodStartDay: integer("pay_period_start_day").notNull().default(0),
payPeriodLengthDays: integer("pay_period_length_days")
.notNull()
.default(7),
dailyOvertimeThreshold: numeric("daily_overtime_threshold", {
precision: 5,
scale: 2,
}).notNull().default("8.0"),
weeklyOvertimeThreshold: numeric("weekly_overtime_threshold", {
precision: 5,
scale: 2,
}).notNull().default("40.0"),
overtimeMultiplier: numeric("overtime_multiplier", {
precision: 3,
scale: 2,
}).notNull().default("1.50"),
overtimeNotifications: boolean("overtime_notifications")
.notNull()
.default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const timeTrackingWorkers = pgTable(
"time_tracking_workers",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
role: text("role", { enum: ["worker", "time_admin"] })
.notNull()
.default("worker"),
lang: text("lang").notNull().default("en"),
pin: text("pin").notNull(),
active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("time_tracking_workers_brand_idx").on(t.brandId),
}),
);
export const timeTrackingTasks = pgTable(
"time_tracking_tasks",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
nameEs: text("name_es"),
unit: text("unit", { enum: ["hours", "pieces", "units"] })
.notNull()
.default("hours"),
sortOrder: integer("sort_order").notNull().default(0),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("time_tracking_tasks_brand_idx").on(t.brandId),
}),
);
export const timeTrackingLogs = pgTable(
"time_tracking_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
workerId: uuid("worker_id")
.notNull()
.references(() => timeTrackingWorkers.id, { onDelete: "cascade" }),
taskId: uuid("task_id").references(() => timeTrackingTasks.id, {
onDelete: "set null",
}),
taskName: text("task_name").notNull(),
clockIn: timestamp("clock_in", { withTimezone: true }).notNull(),
clockOut: timestamp("clock_out", { withTimezone: true }),
lunchBreakMinutes: integer("lunch_break_minutes").notNull().default(0),
notes: text("notes"),
submittedVia: text("submitted_via", {
enum: ["manual", "field", "import"],
}).notNull().default("manual"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId),
workerIdx: index("time_tracking_logs_worker_idx").on(t.workerId),
clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn),
}),
);
export const timeTrackingNotificationLog = pgTable(
"time_tracking_notification_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
workerId: uuid("worker_id").references(
() => timeTrackingWorkers.id,
{ onDelete: "set null" },
),
notificationType: text("notification_type").notNull(),
recipient: text("recipient").notNull(),
subject: text("subject"),
body: text("body").notNull(),
status: text("status", {
enum: ["pending", "sent", "failed"],
}).notNull().default("pending"),
sentAt: timestamp("sent_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect;
export type TimeTrackingWorker = typeof timeTrackingWorkers.$inferSelect;
export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
export type TimeTrackingNotificationLog =
typeof timeTrackingNotificationLog.$inferSelect;
+115
View File
@@ -0,0 +1,115 @@
/**
* Water Log. Source: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
numeric,
boolean,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { adminUsers } from "./brands";
export const waterHeadgates = pgTable(
"water_headgates",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const waterIrrigators = pgTable(
"water_irrigators",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
pinHash: text("pin_hash").notNull(),
languagePreference: text("language_preference")
.notNull()
.default("en"),
active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const waterSessions = pgTable(
"water_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
irrigatorId: uuid("irrigator_id")
.notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const waterLogEntries = pgTable(
"water_log_entries",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
headgateId: uuid("headgate_id")
.notNull()
.references(() => waterHeadgates.id, { onDelete: "cascade" }),
irrigatorId: uuid("irrigator_id")
.notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
measurement: numeric("measurement").notNull(),
unit: text("unit").notNull(),
notes: text("notes"),
submittedVia: text("submitted_via").notNull().default("app"),
loggedAt: timestamp("logged_at", { withTimezone: true })
.notNull()
.defaultNow(),
loggedBy: uuid("logged_by").references(() => adminUsers.id),
},
(t) => ({
brandIdx: index("water_log_entries_brand_idx").on(t.brandId),
headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId),
}),
);
export const waterAlertLog = pgTable(
"water_alert_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
alertType: text("alert_type").notNull(),
headgateId: uuid("headgate_id").references(() => waterHeadgates.id),
message: text("message").notNull(),
sentTo: text("sent_to"),
sentAt: timestamp("sent_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
export type WaterSession = typeof waterSessions.$inferSelect;
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
+373
View File
@@ -0,0 +1,373 @@
/**
* Wholesale portal. Source: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
numeric,
integer,
boolean,
timestamp,
date,
index,
uniqueIndex,
jsonb,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { authUsers } from "./brands";
export const wholesaleSettings = pgTable(
"wholesale_settings",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.unique()
.references(() => brands.id, { onDelete: "cascade" }),
requireApproval: boolean("require_approval").notNull().default(true),
minOrderAmount: numeric("min_order_amount", { precision: 10, scale: 2 }),
onlinePaymentEnabled: boolean("online_payment_enabled")
.notNull()
.default(false),
pickupLocation: text("pickup_location"),
fobLocation: text("fob_location"),
fromEmail: text("from_email"),
invoiceBusinessName: text("invoice_business_name"),
lastInvoiceNumber: integer("last_invoice_number").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const wholesaleCustomers = pgTable(
"wholesale_customers",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").references(() => authUsers.id, {
onDelete: "set null",
}),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
companyName: text("company_name"),
contactName: text("contact_name"),
email: text("email"),
phone: text("phone"),
billingAddress: text("billing_address"),
shippingAddress: text("shipping_address"),
accountStatus: text("account_status", {
enum: ["active", "suspended", "inactive"],
}).notNull().default("active"),
creditLimit: numeric("credit_limit", { precision: 10, scale: 2 })
.notNull()
.default("0"),
depositsEnabled: boolean("deposits_enabled").notNull().default(false),
depositThreshold: numeric("deposit_threshold", { precision: 10, scale: 2 }),
depositPercentage: integer("deposit_percentage"),
orderEmail: text("order_email"),
invoiceEmail: text("invoice_email"),
adminNotes: text("admin_notes"),
role: text("role", { enum: ["buyer", "admin"] })
.notNull()
.default("buyer"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("wholesale_customers_brand_idx").on(t.brandId),
}),
);
export const wholesaleProducts = pgTable(
"wholesale_products",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
rcProductId: uuid("rc_product_id"),
name: text("name").notNull(),
description: text("description"),
unitType: text("unit_type").notNull().default("each"),
availability: text("availability", {
enum: ["available", "unavailable", "limited", "seasonal"],
}).notNull().default("unavailable"),
qtyAvailable: numeric("qty_available", { precision: 10, scale: 2 })
.default("0"),
priceTiers: jsonb("price_tiers").notNull().default([]),
hpSku: text("hp_sku"),
hpItemId: text("hp_item_id"),
internalNotes: text("internal_notes"),
handlingInstructions: text("handling_instructions"),
transportTemp: text("transport_temp"),
storageWarning: text("storage_warning"),
loadingNotes: text("loading_notes"),
productLabel: text("product_label"),
packStyle: text("pack_style"),
containerType: text("container_type"),
containerSizeCode: text("container_size_code"),
unitsPerContainer: integer("units_per_container"),
containerNotes: text("container_notes"),
defaultPickupLocation: text("default_pickup_location"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("wholesale_products_brand_idx").on(t.brandId),
}),
);
export const wholesaleOrders = pgTable(
"wholesale_orders",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
customerId: uuid("customer_id")
.notNull()
.references(() => wholesaleCustomers.id),
wcOrderId: numeric("wc_order_id"),
status: text("status", {
enum: [
"pending", "confirmed", "in_production", "ready",
"fulfilled", "canceled",
],
}).notNull().default("pending"),
fulfillmentStatus: text("fulfillment_status", {
enum: ["unfulfilled", "partial", "fulfilled"],
}).notNull().default("unfulfilled"),
paymentStatus: text("payment_status", {
enum: ["unpaid", "deposit_paid", "paid", "refunded"],
}).notNull().default("unpaid"),
anticipatedPickupDate: date("anticipated_pickup_date"),
subtotal: numeric("subtotal", { precision: 10, scale: 2 })
.notNull()
.default("0"),
depositRequired: numeric("deposit_required", { precision: 10, scale: 2 })
.default("0"),
depositPaid: numeric("deposit_paid", { precision: 10, scale: 2 })
.notNull()
.default("0"),
balanceDue: numeric("balance_due", { precision: 10, scale: 2 })
.notNull()
.default("0"),
assignedEmployeeId: uuid("assigned_employee_id"),
fulfillmentNotes: text("fulfillment_notes"),
internalNotes: text("internal_notes"),
invoiceNumber: text("invoice_number"),
invoicePdfPath: text("invoice_pdf_path"),
invoiceToken: text("invoice_token"),
invoiceType: text("invoice_type"),
depositPercentage: integer("deposit_percentage"),
fulfilledAt: timestamp("fulfilled_at", { withTimezone: true }),
fulfilledBy: uuid("fulfilled_by"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("wholesale_orders_brand_idx").on(t.brandId),
customerIdx: index("wholesale_orders_customer_idx").on(t.customerId),
statusIdx: index("wholesale_orders_status_idx").on(t.brandId, t.status),
}),
);
export const wholesaleOrderItems = pgTable(
"wholesale_order_items",
{
id: uuid("id").primaryKey().defaultRandom(),
wholesaleOrderId: uuid("wholesale_order_id")
.notNull()
.references(() => wholesaleOrders.id, { onDelete: "cascade" }),
productId: uuid("product_id")
.notNull()
.references(() => wholesaleProducts.id),
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
unitPrice: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
lineTotal: numeric("line_total", { precision: 10, scale: 2 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
orderIdx: index("wholesale_order_items_order_idx").on(
t.wholesaleOrderId,
),
}),
);
export const wholesaleDeposits = pgTable(
"wholesale_deposits",
{
id: uuid("id").primaryKey().defaultRandom(),
wholesaleOrderId: uuid("wholesale_order_id")
.notNull()
.references(() => wholesaleOrders.id, { onDelete: "cascade" }),
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
paymentMethod: text("payment_method"),
reference: text("reference"),
recordedBy: uuid("recorded_by").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const wholesaleNotifications = pgTable(
"wholesale_notifications",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
customerId: uuid("customer_id")
.notNull()
.references(() => wholesaleCustomers.id, { onDelete: "cascade" }),
notificationType: text("notification_type", {
enum: [
"order_confirmed", "order_ready", "pickup_reminder",
"payment_reminder", "invoice",
],
}).notNull(),
channel: text("channel", { enum: ["email", "sms"] }).notNull(),
recipient: text("recipient").notNull(),
subject: text("subject"),
body: text("body").notNull(),
status: text("status", {
enum: ["pending", "sent", "failed"],
}).notNull().default("pending"),
sentAt: timestamp("sent_at", { withTimezone: true }),
errorMessage: text("error_message"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("wholesale_notifications_brand_idx").on(t.brandId),
}),
);
export const wholesaleCustomerProductPricing = pgTable(
"wholesale_customer_product_pricing",
{
id: uuid("id").primaryKey().defaultRandom(),
customerId: uuid("customer_id")
.notNull()
.references(() => wholesaleCustomers.id, { onDelete: "cascade" }),
productId: uuid("product_id")
.notNull()
.references(() => wholesaleProducts.id, { onDelete: "cascade" }),
customPriceCents: integer("custom_price_cents").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
customerProductIdx: uniqueIndex(
"wholesale_customer_product_pricing_cust_prod_idx",
).on(t.customerId, t.productId),
}),
);
export const wholesaleWebhookSettings = pgTable(
"wholesale_webhook_settings",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.unique()
.references(() => brands.id, { onDelete: "cascade" }),
url: text("url").notNull(),
secret: text("secret").notNull(),
enabled: boolean("enabled").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
);
export const wholesaleSyncLog = pgTable(
"wholesale_sync_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
eventType: text("event_type").notNull(),
orderId: uuid("order_id"),
payload: jsonb("payload"),
status: text("status", {
enum: ["pending", "sent", "failed"],
}).notNull().default("pending"),
response: text("response"),
attempts: integer("attempts").notNull().default(0),
nextRetry: timestamp("next_retry", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("wholesale_sync_log_brand_idx").on(t.brandId),
}),
);
export const userCarts = pgTable(
"user_carts",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
customerId: uuid("customer_id")
.notNull()
.references(() => wholesaleCustomers.id, { onDelete: "cascade" }),
items: jsonb("items").notNull().default([]),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
customerIdx: uniqueIndex("user_carts_customer_idx").on(
t.brandId,
t.customerId,
),
}),
);
export type WholesaleSettings = typeof wholesaleSettings.$inferSelect;
export type WholesaleCustomer = typeof wholesaleCustomers.$inferSelect;
export type WholesaleProduct = typeof wholesaleProducts.$inferSelect;
export type WholesaleOrder = typeof wholesaleOrders.$inferSelect;
export type WholesaleOrderItem = typeof wholesaleOrderItems.$inferSelect;
export type WholesaleDeposit = typeof wholesaleDeposits.$inferSelect;
export type WholesaleNotification = typeof wholesaleNotifications.$inferSelect;
export type WholesaleWebhookSettings =
typeof wholesaleWebhookSettings.$inferSelect;
export type WholesaleSyncLog = typeof wholesaleSyncLog.$inferSelect;
export type UserCart = typeof userCarts.$inferSelect;
+42 -193
View File
@@ -6,36 +6,20 @@
* npm run db:seed
*
* Populates:
* - 3 plans (Starter / Farm / Enterprise)
* - 6 add-ons
* - 2 tenants (Tuxedo, Indian River Direct)
* - 1 platform-admin user + 1 brand_admin per tenant
* - brand_settings per tenant
* - sample products, stops, customers per tenant
* - sample email templates + a draft campaign
* - 2 brands (Tuxedo, Indian River Direct)
* - brand_settings per brand
* - sample products, stops, customers per brand
* - sample communication templates + a draft campaign
*
* NOTE: Admin users are managed by Neon Auth. To create an admin user:
* 1. Sign up / sign in via the app (creates a neon_auth.user row)
* 2. Manually insert into admin_users + admin_user_brands:
* INSERT INTO admin_users (email, name, role) VALUES ('you@example.com', 'Your Name', 'brand_admin');
* INSERT INTO admin_user_brands (admin_user_id, brand_id, role) VALUES (<admin_user_id>, <brand_id>, 'brand_admin');
*/
import "dotenv/config";
import { Pool } from "pg";
import { randomBytes, scryptSync } from "node:crypto";
// `src/lib/passwords.ts` has `import "server-only"` which throws when this
// script runs outside a Next.js server context. Inline the same scrypt
// format here (must match `verifyPassword` in passwords.ts) so the seed
// can run from a plain Node process.
const SALT_LEN = 16;
const KEY_LEN = 64;
const DEFAULT_N = 16384;
const ALGO = "scrypt";
function hashPassword(plain: string): string {
const salt = randomBytes(SALT_LEN).toString("hex");
const hash = scryptSync(plain, salt, KEY_LEN, { N: DEFAULT_N }).toString("hex");
return `${ALGO}$${DEFAULT_N}$${salt}$${hash}`;
}
// Seed needs elevated privileges (inserts into plans, add_ons, tenants —
// tables that are intentionally not RLS-scoped but the runtime app user
// can't create rows in without setting up GUCs we don't want to bother
// with here). Use DATABASE_ADMIN_URL (the superuser) for the seed.
const pool = new Pool({
connectionString:
process.env.DATABASE_ADMIN_URL ??
@@ -48,52 +32,8 @@ async function main() {
try {
await client.query("BEGIN");
// ── Plans ────────────────────────────────────────────────────────────
const planRows = [
{ code: "starter", name: "Starter", price: 4900, maxUsers: 1, maxProducts: 25, maxStopsMonthly: 10, features: ["products", "stops", "orders", "basic_pickup"] },
{ code: "farm", name: "Farm", price: 14900, maxUsers: 5, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support"] },
{ code: "enterprise", name: "Enterprise", price: 39900, maxUsers: 9999, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support", "ai_intelligence", "sms_campaigns", "square_sync", "water_log", "custom_development", "dedicated_sla"] },
];
for (const p of planRows) {
await client.query(
`INSERT INTO plans (code, name, monthly_price_cents, max_users, max_products, max_stops_monthly, features)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
monthly_price_cents = EXCLUDED.monthly_price_cents,
max_users = EXCLUDED.max_users,
max_products = EXCLUDED.max_products,
max_stops_monthly = EXCLUDED.max_stops_monthly,
features = EXCLUDED.features`,
[p.code, p.name, p.price, p.maxUsers, p.maxProducts, p.maxStopsMonthly, JSON.stringify(p.features)],
);
}
console.log(`Seeded ${planRows.length} plans`);
// ── Add-ons ──────────────────────────────────────────────────────────
const addOnRows = [
{ code: "wholesale_portal", name: "Wholesale Portal", price: 9900, description: "Self-serve wholesale storefront with order approval." },
{ code: "harvest_reach", name: "Harvest Reach", price: 7900, description: "Email + SMS campaigns and automations." },
{ code: "ai_tools", name: "AI Intelligence Pack", price: 5900, description: "Smart pricing, product descriptions, and demand forecasting." },
{ code: "water_log", name: "Water Log", price: 3900, description: "Irrigation tracking and reporting." },
{ code: "square_sync", name: "Square Sync", price: 3900, description: "Sync inventory and sales to Square POS." },
{ code: "sms_campaigns", name: "SMS Campaigns", price: 2900, description: "Send SMS blasts and automations." },
];
for (const a of addOnRows) {
await client.query(
`INSERT INTO add_ons (code, name, monthly_price_cents, description)
VALUES ($1, $2, $3, $4)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
monthly_price_cents = EXCLUDED.monthly_price_cents,
description = EXCLUDED.description`,
[a.code, a.name, a.price, a.description],
);
}
console.log(`Seeded ${addOnRows.length} add-ons`);
// ── Tenants + users + content ────────────────────────────────────────
const tenantsData = [
// ── Brands ──────────────────────────────────────────────────────────────
const brandsData = [
{
slug: "tuxedo",
name: "Tuxedo Citrus",
@@ -104,8 +44,6 @@ async function main() {
primaryColor: "#F59E0B",
contactEmail: "hello@tuxedocitrus.example",
contactPhone: "(555) 010-2200",
planCode: "farm",
addOns: ["wholesale_portal", "harvest_reach"],
},
{
slug: "indian-river-direct",
@@ -117,89 +55,36 @@ async function main() {
primaryColor: "#0F766E",
contactEmail: "orders@indianriverdirect.example",
contactPhone: "(555) 010-3300",
planCode: "starter",
addOns: ["wholesale_portal"],
},
];
for (const t of tenantsData) {
// Upsert tenant
const tenantRes = await client.query<{ id: string }>(
`INSERT INTO tenants (name, slug, status, trial_ends_at)
VALUES ($1, $2, 'active', now() + interval '30 days')
for (const b of brandsData) {
// Upsert brand
const brandRes = await client.query<{ id: string }>(
`INSERT INTO brands (name, slug, plan_tier)
VALUES ($1, $2, 'starter')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id`,
[t.name, t.slug],
[b.name, b.slug],
);
const tenantId = tenantRes.rows[0].id;
const brandId = brandRes.rows[0].id;
// Plan + subscription
const planRes = await client.query<{ id: string }>(
`SELECT id FROM plans WHERE code = $1`,
[t.planCode],
);
const planId = planRes.rows[0].id;
await client.query(
`INSERT INTO subscriptions (tenant_id, plan_id, status, current_period_end)
VALUES ($1, $2, 'active', now() + interval '30 days')
ON CONFLICT (tenant_id) DO UPDATE SET
plan_id = EXCLUDED.plan_id,
status = EXCLUDED.status,
current_period_end = EXCLUDED.current_period_end`,
[tenantId, planId],
);
// Add-ons (composite PK — ON CONFLICT has a target)
for (const code of t.addOns) {
const addOnRes = await client.query<{ id: string }>(
`SELECT id FROM add_ons WHERE code = $1`,
[code],
);
if (!addOnRes.rows[0]) continue;
const addOnId = addOnRes.rows[0].id;
await client.query(
`INSERT INTO tenant_add_ons (tenant_id, add_on_id, status)
VALUES ($1, $2, 'active')
ON CONFLICT (tenant_id, add_on_id) DO NOTHING`,
[tenantId, addOnId],
);
}
// Brand settings (PK is tenant_id)
// Brand settings (PK is brand_id)
await client.query(
`INSERT INTO brand_settings
(tenant_id, brand_name, tagline, about_html, primary_color, contact_email, contact_phone)
(brand_id, brand_name, tagline, about_html, primary_color, contact_email, contact_phone)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (tenant_id) DO UPDATE SET
ON CONFLICT (brand_id) DO UPDATE SET
brand_name = EXCLUDED.brand_name,
tagline = EXCLUDED.tagline,
about_html = EXCLUDED.about_html,
primary_color = EXCLUDED.primary_color,
contact_email = EXCLUDED.contact_email,
contact_phone = EXCLUDED.contact_phone`,
[
tenantId, t.brandName, t.tagline, t.aboutHtml,
t.primaryColor, t.contactEmail, t.contactPhone,
],
[brandId, b.brandName, b.tagline, b.aboutHtml, b.primaryColor, b.contactEmail, b.contactPhone],
);
// Brand admin user
const userRes = await client.query<{ id: string }>(
`INSERT INTO users (email, name, auth_provider, auth_subject)
VALUES ($1, $2, 'dev', $3)
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
RETURNING id`,
[`admin@${t.slug}.example`, `${t.brandName} Admin`, `dev-${t.slug}-admin`],
);
const userId = userRes.rows[0].id;
await client.query(
`INSERT INTO tenant_users (tenant_id, user_id, role)
VALUES ($1, $2, 'brand_admin')
ON CONFLICT (tenant_id, user_id) DO NOTHING`,
[tenantId, userId],
);
// Sample products — use WHERE NOT EXISTS (no good unique target other than id)
// Sample products
const products = [
{ name: "Ruby Red Grapefruit", price: 2400, unit: "40 lb case", desc: "Sweet, juicy, seedless." },
{ name: "Navel Oranges", price: 2200, unit: "40 lb case", desc: "Classic eating orange, easy-peel." },
@@ -208,12 +93,12 @@ async function main() {
];
for (const p of products) {
await client.query(
`INSERT INTO products (tenant_id, name, description, price_cents, inventory, unit, active)
`INSERT INTO products (brand_id, name, description, price_cents, inventory, unit, active)
SELECT $1, $2, $3, $4, 100, $5, true
WHERE NOT EXISTS (
SELECT 1 FROM products WHERE tenant_id = $1 AND name = $2
SELECT 1 FROM products WHERE brand_id = $1 AND name = $2
)`,
[tenantId, p.name, p.desc, p.price, p.unit],
[brandId, p.name, p.desc, p.price, p.unit],
);
}
@@ -224,16 +109,16 @@ async function main() {
];
for (const s of stops) {
await client.query(
`INSERT INTO stops (tenant_id, name, address, schedule, status)
`INSERT INTO stops (brand_id, name, address, schedule, status)
SELECT $1, $2, $3, $4::jsonb, 'active'
WHERE NOT EXISTS (
SELECT 1 FROM stops WHERE tenant_id = $1 AND name = $2
SELECT 1 FROM stops WHERE brand_id = $1 AND name = $2
)`,
[tenantId, s.name, s.address, JSON.stringify(s.schedule)],
[brandId, s.name, s.address, JSON.stringify(s.schedule)],
);
}
// Sample customers (email has a unique index per tenant)
// Sample customers
const customers = [
{ name: "Green Grocer Co.", email: "buyer@greengrocer.example", phone: "555-010-0010" },
{ name: "Sunset Cafe", email: "orders@sunsetcafe.example", phone: "555-010-0020" },
@@ -241,73 +126,37 @@ async function main() {
];
for (const c of customers) {
await client.query(
`INSERT INTO customers (tenant_id, name, email, phone, sms_opt_in, email_opt_in)
`INSERT INTO customers (brand_id, name, email, phone, sms_opt_in, email_opt_in)
SELECT $1, $2, $3, $4, true, true
WHERE NOT EXISTS (
SELECT 1 FROM customers WHERE tenant_id = $1 AND email = $3
SELECT 1 FROM customers WHERE brand_id = $1 AND email = $3
)`,
[tenantId, c.name, c.email, c.phone],
[brandId, c.name, c.email, c.phone],
);
}
// Sample email template (id-based, use WHERE NOT EXISTS)
// Sample communication template
const tmplRes = await client.query<{ id: string }>(
`INSERT INTO email_templates (tenant_id, name, subject, body_html)
`INSERT INTO communication_templates (brand_id, name, subject, body_html)
SELECT $1, 'Weekly Availability', 'This week at the grove', '<h1>Fresh this week</h1><p>Hi {{name}}, our harvest is in. Reply to reserve.</p>'
WHERE NOT EXISTS (
SELECT 1 FROM email_templates WHERE tenant_id = $1 AND name = 'Weekly Availability'
SELECT 1 FROM communication_templates WHERE brand_id = $1 AND name = 'Weekly Availability'
)
RETURNING id`,
[tenantId],
[brandId],
);
if (tmplRes.rows[0]) {
await client.query(
`INSERT INTO campaigns (tenant_id, template_id, name, status)
`INSERT INTO communication_campaigns (brand_id, template_id, name, status)
SELECT $1, $2, 'Welcome series — week 1', 'draft'
WHERE NOT EXISTS (
SELECT 1 FROM campaigns WHERE tenant_id = $1 AND name = 'Welcome series — week 1'
SELECT 1 FROM communication_campaigns WHERE brand_id = $1 AND name = 'Welcome series — week 1'
)`,
[tenantId, tmplRes.rows[0].id],
[brandId, tmplRes.rows[0].id],
);
}
}
console.log(`Seeded ${tenantsData.length} tenants with sample data`);
// ── Platform admin (seeded for dev sign-in via Credentials provider) ─
// email: admin@route-commerce.local
// password: admin (override with SEED_ADMIN_PASSWORD)
//
// The user is attached to the Tuxedo tenant with the `platform_admin`
// role so `getAdminUser()` resolves it and grants cross-tenant
// visibility. To rotate the password, re-run `npm run db:seed`
// (the UPSERT updates `password_hash`).
const tuxedoRes = await client.query<{ id: string }>(
`SELECT id FROM tenants WHERE slug = 'tuxedo'`,
);
const tuxedoId = tuxedoRes.rows[0]?.id;
if (tuxedoId) {
const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? "admin";
const passwordHash = hashPassword(adminPassword);
const adminRes = await client.query<{ id: string }>(
`INSERT INTO users (email, name, auth_provider, auth_subject, password_hash)
VALUES ($1, 'Platform Admin', 'dev', 'dev-platform-admin', $2)
ON CONFLICT (email) DO UPDATE SET
name = EXCLUDED.name,
password_hash = EXCLUDED.password_hash
RETURNING id`,
["admin@route-commerce.local", passwordHash],
);
const adminId = adminRes.rows[0].id;
await client.query(
`INSERT INTO tenant_users (tenant_id, user_id, role)
VALUES ($1, $2, 'platform_admin')
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[tuxedoId, adminId],
);
console.log(
`Seeded platform admin: admin@route-commerce.local / ${adminPassword === "admin" ? "admin" : "(SEED_ADMIN_PASSWORD)"}`,
);
}
console.log(`Seeded ${brandsData.length} brands with sample data`);
await client.query("COMMIT");
console.log("✅ Seed complete");