feat: remove dev_session, add Drizzle schema + RLS + real auth

BREAKING: dev_session cookie bypass removed. Admin access now requires
a real Auth.js v5 session (Google OAuth in production). Provision users
by inserting into users + tenant_users tables.

New in this commit:
- db/migrations/0001_init.sql: 18-table SaaS schema with RLS (tenants,
  users, tenant_users, plans, add_ons, subscriptions, tenant_add_ons,
  products, product_images, stops, customers, orders, order_items,
  brand_settings, email_templates, campaigns, files, audit_log)
- db/schema/: Drizzle TypeScript mirror of every table
- db/client.ts: withTenant() / withPlatformAdmin() query wrappers that
  set Postgres GUCs (app.current_tenant_id, app.platform_admin) for
  RLS enforcement. Never query a tenant-scoped table without one.
- db/seed.ts: seeds 3 plans, 6 add-ons, 2 tenants (Tuxedo, Indian River
  Direct), brand_settings, sample products/stops/customers
- scripts/migrate.js: applies migrations in lexical order with tracking
- scripts/db-reset.js: drops + recreates DB, runs migrate + seed
- DATABASE_URL now uses rc_app (non-superuser, NOBYPASSRLS). RLS is
  enforced even for the app user. DATABASE_ADMIN_URL for migrations.
- src/lib/admin-permissions.ts: getAdminUser() reads Auth.js session,
  looks up user + tenant in Postgres. brand_id kept as alias for
  backward compat.
- src/middleware.ts: Auth.js-only route protection, dev_session gone
- src/app/login/LoginClient.tsx: Google OAuth only, no demo mode
- src/components/admin/AdminSidebar.tsx + AdminHeader.tsx: signOutAction
  replaces supabase signout
- @/db/* path aliases in tsconfig.json + vitest.config.ts
- drizzle.config.ts added
- db/auth_schema.sql removed (was a stub; replaced by real schema)
- src/app/api/dev-login/route.ts deleted
- tests: updated to remove dev_session coverage
This commit is contained in:
2026-06-07 01:23:44 +00:00
parent f96dcd01f2
commit 7cd0603cfb
41 changed files with 2917 additions and 1950 deletions
+134
View File
@@ -0,0 +1,134 @@
/**
* Drizzle client + tenant-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`.
*
* Usage (read):
* const products = await withTenant(tenantId, (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 (no tenant — for the rare case the query isn't tenant-scoped):
* const plans = await withDb((db) => db.select().from(plansTable));
*/
import "server-only";
import { Pool, type PoolClient } from "pg";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
type Schema = typeof schema;
export type Db = NodePgDatabase<Schema>;
let _pool: Pool | null = null;
function getPool(): Pool {
if (_pool) return _pool;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"DATABASE_URL is not set. Add it to .env.local (see .env.example).",
);
}
_pool = new Pool({
connectionString,
max: parseInt(process.env.PG_POOL_MAX ?? "10", 10),
idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10),
connectionTimeoutMillis: parseInt(
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "10000",
10,
),
allowExitOnIdle: false,
});
_pool.on("error", (err) => {
console.error("[db] idle client error", err);
});
return _pool;
}
/**
* Run `fn` with a Drizzle client. No tenant 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`.
*/
export async function withDb<T>(fn: (db: Db) => Promise<T>): Promise<T> {
const client = await getPool().connect();
try {
const db = drizzle(client, { schema });
return await fn(db);
} finally {
client.release();
}
}
/**
* 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
* fail open (don't set the GUC) — only useful for the migrations
* themselves, never for app code.
*/
export async function withTenant<T>(
tenantId: 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.platform_admin', 'false', true)");
const db = drizzle(client, { schema });
return fn(db);
});
}
/**
* Run `fn` as platform admin. RLS policies permit access to all tenants.
* 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.platform_admin', 'true', true)");
const db = drizzle(client, { schema });
return fn(db);
});
}
async function runInTransaction<T>(
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await getPool().connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
try {
await client.query("ROLLBACK");
} catch {
// ignore secondary rollback failure
}
throw err;
} finally {
client.release();
}
}
export { schema };
+523
View File
@@ -0,0 +1,523 @@
-- 0001_init.sql
--
-- Route Commerce SaaS schema. Single migration, idempotent, no DROP.
-- Follows CLAUDE.md conventions:
-- - Status enums as TEXT with CHECK (no PG ENUM type)
-- - TIMESTAMPTZ everywhere
-- - gen_random_uuid() for PKs
-- - CREATE OR REPLACE FUNCTION for helpers
--
-- Multi-tenancy: every business table has `tenant_id` and an RLS policy
-- that compares it to `current_setting('app.current_tenant_id')`. The
-- application MUST set this GUC (transaction-local) before any query
-- against a tenant-scoped table. See `db/client.ts` for the wrapper.
--
-- Platform admin (sees all tenants) sets `app.platform_admin = 'true'`
-- instead. RLS policies allow that to bypass the tenant filter.
BEGIN;
-- ════════════════════════════════════════════════════════════════════════
-- 0. Extensions
-- ════════════════════════════════════════════════════════════════════════
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ════════════════════════════════════════════════════════════════════════
-- 1. Tenancy + auth
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'trial'
CHECK (status IN ('trial', 'active', 'past_due', 'suspended', 'churned')),
trial_ends_at TIMESTAMPTZ,
stripe_customer_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE,
name TEXT,
image TEXT,
auth_provider TEXT NOT NULL DEFAULT 'dev'
CHECK (auth_provider IN ('dev', 'google', 'email')),
auth_subject TEXT,
email_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS users_auth_subject_idx
ON users (auth_provider, auth_subject)
WHERE auth_subject IS NOT NULL;
CREATE TABLE IF NOT EXISTS tenant_users (
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'brand_admin'
CHECK (role IN ('platform_admin', 'brand_admin', 'store_employee')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE INDEX IF NOT EXISTS tenant_users_user_idx ON tenant_users (user_id);
-- ════════════════════════════════════════════════════════════════════════
-- 2. Billing
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE
CHECK (code IN ('starter', 'farm', 'enterprise')),
name TEXT NOT NULL,
monthly_price_cents INTEGER NOT NULL CHECK (monthly_price_cents >= 0),
max_users INTEGER NOT NULL,
max_products INTEGER NOT NULL,
max_stops_monthly INTEGER NOT NULL,
features JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS add_ons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE
CHECK (code IN (
'wholesale_portal',
'harvest_reach',
'ai_tools',
'water_log',
'square_sync',
'sms_campaigns'
)),
name TEXT NOT NULL,
monthly_price_cents INTEGER NOT NULL CHECK (monthly_price_cents >= 0),
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS subscriptions (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES plans(id),
status TEXT NOT NULL DEFAULT 'trialing'
CHECK (status IN ('trialing', 'active', 'past_due', 'canceled', 'incomplete')),
stripe_subscription_id TEXT,
current_period_end TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_add_ons (
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
add_on_id UUID NOT NULL REFERENCES add_ons(id) ON DELETE CASCADE,
stripe_subscription_id TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'canceled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, add_on_id)
);
-- ════════════════════════════════════════════════════════════════════════
-- 3. Products
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
inventory INTEGER NOT NULL DEFAULT 0,
unit TEXT,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS products_tenant_idx ON products (tenant_id);
CREATE INDEX IF NOT EXISTS products_active_idx ON products (tenant_id, active);
CREATE TABLE IF NOT EXISTS product_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
alt_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS product_images_product_idx
ON product_images (product_id, position);
-- ════════════════════════════════════════════════════════════════════════
-- 4. Stops
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS stops (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
address TEXT NOT NULL,
schedule JSONB NOT NULL DEFAULT '[]'::jsonb,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'paused', 'closed')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS stops_tenant_idx ON stops (tenant_id);
CREATE INDEX IF NOT EXISTS stops_status_idx ON stops (tenant_id, status);
-- ════════════════════════════════════════════════════════════════════════
-- 5. Customers
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
sms_opt_in BOOLEAN NOT NULL DEFAULT false,
email_opt_in BOOLEAN NOT NULL DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS customers_tenant_idx ON customers (tenant_id);
CREATE UNIQUE INDEX IF NOT EXISTS customers_tenant_email_idx
ON customers (tenant_id, email)
WHERE email IS NOT NULL;
-- ════════════════════════════════════════════════════════════════════════
-- 6. Orders
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
total_cents INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'fulfilled', 'canceled')),
fulfillment TEXT NOT NULL
CHECK (fulfillment IN ('pickup', 'ship', 'mixed')),
notes TEXT,
placed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS orders_tenant_idx ON orders (tenant_id);
CREATE INDEX IF NOT EXISTS orders_customer_idx ON orders (customer_id);
CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (tenant_id, status);
CREATE TABLE IF NOT EXISTS order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id) ON DELETE SET NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
fulfillment TEXT NOT NULL
CHECK (fulfillment IN ('pickup', 'ship'))
);
CREATE INDEX IF NOT EXISTS order_items_order_idx ON order_items (order_id);
-- ════════════════════════════════════════════════════════════════════════
-- 7. Brand settings
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS brand_settings (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
brand_name TEXT NOT NULL,
tagline TEXT,
about_html TEXT,
primary_color TEXT DEFAULT '#0F766E',
logo_storage_key TEXT,
hero_storage_key TEXT,
contact_email TEXT,
contact_phone TEXT,
custom_footer_text TEXT,
feature_flags JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ════════════════════════════════════════════════════════════════════════
-- 8. Marketing
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS email_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
subject TEXT NOT NULL,
body_html TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS email_templates_tenant_idx ON email_templates (tenant_id);
CREATE TABLE IF NOT EXISTS campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
template_id UUID REFERENCES email_templates(id) ON DELETE SET NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'canceled')),
scheduled_for TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
recipient_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS campaigns_tenant_idx ON campaigns (tenant_id);
CREATE INDEX IF NOT EXISTS campaigns_status_idx ON campaigns (tenant_id, status);
-- ════════════════════════════════════════════════════════════════════════
-- 9. Files
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
purpose TEXT,
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS files_tenant_idx ON files (tenant_id);
-- ════════════════════════════════════════════════════════════════════════
-- 10. Audit log
-- ════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
target_type TEXT,
target_id UUID,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS audit_log_tenant_idx
ON audit_log (tenant_id, created_at DESC);
-- ════════════════════════════════════════════════════════════════════════
-- 11. RLS helpers
-- ════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION current_tenant_id()
RETURNS UUID
LANGUAGE sql
STABLE
AS $$
SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::UUID;
$$;
CREATE OR REPLACE FUNCTION is_platform_admin()
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE(NULLIF(current_setting('app.platform_admin', true), ''), 'false') = 'true';
$$;
-- ════════════════════════════════════════════════════════════════════════
-- 12. Enable RLS on tenant-scoped tables
-- ════════════════════════════════════════════════════════════════════════
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE product_images ENABLE ROW LEVEL SECURITY;
ALTER TABLE stops ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE brand_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE email_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
-- RLS does not apply to the table owner by default. We FORCE it so the
-- application (which connects as the same user that owns the tables in
-- dev) cannot accidentally bypass tenant isolation. In production, the
-- application should connect as a non-owner role; this is belt-and-
-- suspenders for the dev/local case.
ALTER TABLE products FORCE ROW LEVEL SECURITY;
ALTER TABLE product_images FORCE ROW LEVEL SECURITY;
ALTER TABLE stops FORCE ROW LEVEL SECURITY;
ALTER TABLE customers FORCE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
ALTER TABLE order_items FORCE ROW LEVEL SECURITY;
ALTER TABLE brand_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE email_templates FORCE ROW LEVEL SECURITY;
ALTER TABLE campaigns FORCE ROW LEVEL SECURITY;
ALTER TABLE files FORCE ROW LEVEL SECURITY;
ALTER TABLE audit_log FORCE ROW LEVEL SECURITY;
-- ════════════════════════════════════════════════════════════════════════
-- 13. RLS policies: tenant match OR platform admin
-- ════════════════════════════════════════════════════════════════════════
-- Drop existing policies (idempotent — same migration may be re-applied)
DROP POLICY IF EXISTS tenant_isolation ON products;
DROP POLICY IF EXISTS tenant_isolation ON product_images;
DROP POLICY IF EXISTS tenant_isolation ON stops;
DROP POLICY IF EXISTS tenant_isolation ON customers;
DROP POLICY IF EXISTS tenant_isolation ON orders;
DROP POLICY IF EXISTS tenant_isolation ON order_items;
DROP POLICY IF EXISTS tenant_isolation ON brand_settings;
DROP POLICY IF EXISTS tenant_isolation ON email_templates;
DROP POLICY IF EXISTS tenant_isolation ON campaigns;
DROP POLICY IF EXISTS tenant_isolation ON files;
DROP POLICY IF EXISTS tenant_isolation ON audit_log;
CREATE POLICY tenant_isolation ON products
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON product_images
FOR ALL
USING (
EXISTS (
SELECT 1 FROM products p
WHERE p.id = product_images.product_id
AND (p.tenant_id = current_tenant_id() OR is_platform_admin())
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM products p
WHERE p.id = product_images.product_id
AND (p.tenant_id = current_tenant_id() OR is_platform_admin())
)
);
CREATE POLICY tenant_isolation ON stops
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON customers
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON orders
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON order_items
FOR ALL
USING (
EXISTS (
SELECT 1 FROM orders o
WHERE o.id = order_items.order_id
AND (o.tenant_id = current_tenant_id() OR is_platform_admin())
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM orders o
WHERE o.id = order_items.order_id
AND (o.tenant_id = current_tenant_id() OR is_platform_admin())
)
);
CREATE POLICY tenant_isolation ON brand_settings
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON email_templates
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON campaigns
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin())
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin());
CREATE POLICY tenant_isolation ON files
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL)
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL);
CREATE POLICY tenant_isolation ON audit_log
FOR ALL
USING (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL)
WITH CHECK (tenant_id = current_tenant_id() OR is_platform_admin() OR tenant_id IS NULL);
-- ════════════════════════════════════════════════════════════════════════
-- 14. updated_at triggers
-- ════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS tenants_updated_at ON tenants;
CREATE TRIGGER tenants_updated_at BEFORE UPDATE ON tenants
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS users_updated_at ON users;
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS subscriptions_updated_at ON subscriptions;
CREATE TRIGGER subscriptions_updated_at BEFORE UPDATE ON subscriptions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS products_updated_at ON products;
CREATE TRIGGER products_updated_at BEFORE UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS stops_updated_at ON stops;
CREATE TRIGGER stops_updated_at BEFORE UPDATE ON stops
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS customers_updated_at ON customers;
CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS orders_updated_at ON orders;
CREATE TRIGGER orders_updated_at BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS brand_settings_updated_at ON brand_settings;
CREATE TRIGGER brand_settings_updated_at BEFORE UPDATE ON brand_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS email_templates_updated_at ON email_templates;
CREATE TRIGGER email_templates_updated_at BEFORE UPDATE ON email_templates
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS campaigns_updated_at ON campaigns;
CREATE TRIGGER campaigns_updated_at BEFORE UPDATE ON campaigns
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
COMMIT;
+39
View File
@@ -0,0 +1,39 @@
/**
* Audit log. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
jsonb,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
import { users } from "./tenants";
export const auditLog = pgTable(
"audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").references(() => tenants.id, {
onDelete: "cascade",
}),
userId: uuid("user_id").references(() => users.id, {
onDelete: "set null",
}),
action: text("action").notNull(),
targetType: text("target_type"),
targetId: uuid("target_id"),
payload: jsonb("payload"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("audit_log_tenant_idx").on(t.tenantId, t.createdAt),
}),
);
export type AuditLog = typeof auditLog.$inferSelect;
export type NewAuditLog = typeof auditLog.$inferInsert;
+96
View File
@@ -0,0 +1,96 @@
/**
* Billing tables: plans, add-ons, subscriptions, tenant_add_ons.
* Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
primaryKey,
} from "drizzle-orm/pg-core";
import {
planCodeEnum,
addOnCodeEnum,
subscriptionStatusEnum,
addOnStatusEnum,
} from "./enums";
import { tenants } from "./tenants";
export const plans = pgTable("plans", {
id: uuid("id").primaryKey().defaultRandom(),
code: text("code", { enum: planCodeEnum }).notNull().unique(),
name: text("name").notNull(),
monthlyPriceCents: integer("monthly_price_cents").notNull(),
maxUsers: integer("max_users").notNull(),
maxProducts: integer("max_products").notNull(),
maxStopsMonthly: integer("max_stops_monthly").notNull(),
features: jsonb("features").notNull().default([]),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const addOns = pgTable("add_ons", {
id: uuid("id").primaryKey().defaultRandom(),
code: text("code", { enum: addOnCodeEnum }).notNull().unique(),
name: text("name").notNull(),
monthlyPriceCents: integer("monthly_price_cents").notNull(),
description: text("description"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.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",
{
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
addOnId: uuid("add_on_id")
.notNull()
.references(() => addOns.id, { onDelete: "cascade" }),
stripeSubscriptionId: text("stripe_subscription_id"),
status: text("status", { enum: addOnStatusEnum })
.notNull()
.default("active"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
pk: primaryKey({ columns: [t.tenantId, 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;
+28
View File
@@ -0,0 +1,28 @@
/**
* Brand settings. One row per tenant. Source of truth:
* `db/migrations/0001_init.sql`.
*/
import { pgTable, uuid, text, jsonb, timestamp } from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
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 type BrandSettings = typeof brandSettings.$inferSelect;
export type NewBrandSettings = typeof brandSettings.$inferInsert;
+45
View File
@@ -0,0 +1,45 @@
/**
* Customers. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
boolean,
timestamp,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { tenants } from "./tenants";
export const customers = pgTable(
"customers",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
email: text("email"),
phone: text("phone"),
smsOptIn: boolean("sms_opt_in").notNull().default(false),
emailOptIn: boolean("email_opt_in").notNull().default(true),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.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`),
}),
);
export type Customer = typeof customers.$inferSelect;
export type NewCustomer = typeof customers.$inferInsert;
+75
View File
@@ -0,0 +1,75 @@
/**
* Shared enums for the SaaS schema. Mirrored in SQL as TEXT + CHECK.
*
* Usage:
* import { tenantStatusEnum, type TenantStatus } from "@/db/schema/enums";
* import { pgEnum } from "drizzle-orm/pg-core";
*
* export const tenantStatus = pgEnum("tenant_status", tenantStatusEnum);
*/
export const tenantStatusEnum = [
"trial",
"active",
"past_due",
"suspended",
"churned",
] as const;
export type TenantStatus = (typeof tenantStatusEnum)[number];
export const authProviderEnum = ["dev", "google", "email"] as const;
export type AuthProvider = (typeof authProviderEnum)[number];
export const roleEnum = ["platform_admin", "brand_admin", "store_employee"] as const;
export type Role = (typeof roleEnum)[number];
export const planCodeEnum = ["starter", "farm", "enterprise"] as const;
export type PlanCode = (typeof planCodeEnum)[number];
export const addOnCodeEnum = [
"wholesale_portal",
"harvest_reach",
"ai_tools",
"water_log",
"square_sync",
"sms_campaigns",
] as const;
export type AddOnCode = (typeof addOnCodeEnum)[number];
export const subscriptionStatusEnum = [
"trialing",
"active",
"past_due",
"canceled",
"incomplete",
] as const;
export type SubscriptionStatus = (typeof subscriptionStatusEnum)[number];
export const addOnStatusEnum = ["active", "canceled"] as const;
export type AddOnStatus = (typeof addOnStatusEnum)[number];
export const stopStatusEnum = ["active", "paused", "closed"] as const;
export type StopStatus = (typeof stopStatusEnum)[number];
export const orderStatusEnum = [
"pending",
"confirmed",
"fulfilled",
"canceled",
] as const;
export type OrderStatus = (typeof orderStatusEnum)[number];
export const fulfillmentEnum = ["pickup", "ship", "mixed"] as const;
export type Fulfillment = (typeof fulfillmentEnum)[number];
export const itemFulfillmentEnum = ["pickup", "ship"] as const;
export type ItemFulfillment = (typeof itemFulfillmentEnum)[number];
export const campaignStatusEnum = [
"draft",
"scheduled",
"sending",
"sent",
"canceled",
] as const;
export type CampaignStatus = (typeof campaignStatusEnum)[number];
+39
View File
@@ -0,0 +1,39 @@
/**
* Files. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
bigint,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
import { users } from "./tenants";
export const files = pgTable(
"files",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").references(() => tenants.id, {
onDelete: "cascade",
}),
storageKey: text("storage_key").notNull().unique(),
mimeType: text("mime_type").notNull(),
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
purpose: text("purpose"),
uploadedBy: uuid("uploaded_by").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("files_tenant_idx").on(t.tenantId),
}),
);
export type File = typeof files.$inferSelect;
export type NewFile = typeof files.$inferInsert;
+17
View File
@@ -0,0 +1,17 @@
/**
* Schema barrel. Re-exports every Drizzle table + inferred row type.
*
* Usage:
* import { products, type Product } from "@/db/schema";
*/
export * from "./enums";
export * from "./tenants";
export * from "./billing";
export * from "./products";
export * from "./stops";
export * from "./customers";
export * from "./orders";
export * from "./brand";
export * from "./marketing";
export * from "./files";
export * from "./audit";
+71
View File
@@ -0,0 +1,71 @@
/**
* Marketing: email templates + campaigns.
* Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
integer,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { campaignStatusEnum } from "./enums";
import { tenants } from "./tenants";
export const emailTemplates = pgTable(
"email_templates",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
subject: text("subject").notNull(),
bodyHtml: text("body_html").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("email_templates_tenant_idx").on(t.tenantId),
}),
);
export const campaigns = pgTable(
"campaigns",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
templateId: uuid("template_id").references((): any => emailTemplates.id, {
onDelete: "set null",
}),
name: text("name").notNull(),
status: text("status", { enum: campaignStatusEnum })
.notNull()
.default("draft"),
scheduledFor: timestamp("scheduled_for", { withTimezone: true }),
sentAt: timestamp("sent_at", { withTimezone: true }),
recipientCount: integer("recipient_count").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("campaigns_tenant_idx").on(t.tenantId),
statusIdx: index("campaigns_status_idx").on(t.tenantId, t.status),
}),
);
export type EmailTemplate = typeof emailTemplates.$inferSelect;
export type NewEmailTemplate = typeof emailTemplates.$inferInsert;
export type Campaign = typeof campaigns.$inferSelect;
export type NewCampaign = typeof campaigns.$inferInsert;
+67
View File
@@ -0,0 +1,67 @@
/**
* Orders + order_items. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
integer,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { orderStatusEnum, fulfillmentEnum, itemFulfillmentEnum } from "./enums";
import { tenants } from "./tenants";
import { customers } from "./customers";
import { products } from "./products";
export const orders = pgTable(
"orders",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
customerId: uuid("customer_id").references(() => customers.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(),
notes: text("notes"),
placedAt: timestamp("placed_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("orders_tenant_idx").on(t.tenantId),
customerIdx: index("orders_customer_idx").on(t.customerId),
statusIdx: index("orders_status_idx").on(t.tenantId, t.status),
}),
);
export const orderItems = pgTable(
"order_items",
{
id: uuid("id").primaryKey().defaultRandom(),
orderId: uuid("order_id")
.notNull()
.references(() => orders.id, { onDelete: "cascade" }),
productId: uuid("product_id").references(() => products.id, {
onDelete: "set null",
}),
quantity: integer("quantity").notNull(),
priceCents: integer("price_cents").notNull(),
fulfillment: text("fulfillment", { enum: itemFulfillmentEnum }).notNull(),
},
(t) => ({
orderIdx: index("order_items_order_idx").on(t.orderId),
}),
);
export type Order = typeof orders.$inferSelect;
export type NewOrder = typeof orders.$inferInsert;
export type OrderItem = typeof orderItems.$inferSelect;
export type NewOrderItem = typeof orderItems.$inferInsert;
+63
View File
@@ -0,0 +1,63 @@
/**
* Products + product_images. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
integer,
boolean,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { tenants } from "./tenants";
export const products = pgTable(
"products",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
priceCents: integer("price_cents").notNull(),
inventory: integer("inventory").notNull().default(0),
unit: text("unit"),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("products_tenant_idx").on(t.tenantId),
activeIdx: index("products_active_idx").on(t.tenantId, t.active),
}),
);
export const productImages = pgTable(
"product_images",
{
id: uuid("id").primaryKey().defaultRandom(),
productId: uuid("product_id")
.notNull()
.references(() => products.id, { onDelete: "cascade" }),
storageKey: text("storage_key").notNull(),
position: integer("position").notNull().default(0),
altText: text("alt_text"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
productIdx: index("product_images_product_idx").on(t.productId, t.position),
}),
);
export type Product = typeof products.$inferSelect;
export type NewProduct = typeof products.$inferInsert;
export type ProductImage = typeof productImages.$inferSelect;
export type NewProductImage = typeof productImages.$inferInsert;
+41
View File
@@ -0,0 +1,41 @@
/**
* Stops. Source of truth: `db/migrations/0001_init.sql`.
*/
import {
pgTable,
uuid,
text,
jsonb,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { stopStatusEnum } from "./enums";
import { tenants } from "./tenants";
export const stops = pgTable(
"stops",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
name: text("name").notNull(),
address: text("address").notNull(),
schedule: jsonb("schedule").notNull().default([]),
status: text("status", { enum: stopStatusEnum }).notNull().default("active"),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tenantIdx: index("stops_tenant_idx").on(t.tenantId),
statusIdx: index("stops_status_idx").on(t.tenantId, t.status),
}),
);
export type Stop = typeof stops.$inferSelect;
export type NewStop = typeof stops.$inferInsert;
+78
View File
@@ -0,0 +1,78 @@
/**
* 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"),
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;
+285
View File
@@ -0,0 +1,285 @@
/**
* Seed script. Idempotent — safe to re-run. Uses `ON CONFLICT (col) DO UPDATE`
* (with a target) for tables that have a unique constraint; uses
* `WHERE NOT EXISTS` for tables that don't.
*
* 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
*/
import "dotenv/config";
import { Pool } from "pg";
// 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 ??
process.env.DATABASE_URL ??
"postgres://postgres:postgres@localhost:5432/route_commerce",
});
async function main() {
const client = await pool.connect();
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 = [
{
slug: "tuxedo",
name: "Tuxedo Citrus",
brandName: "Tuxedo Citrus Co.",
tagline: "Sun-ripened citrus, delivered.",
aboutHtml:
"<p>Family-run citrus grove in the Indian River region. We grow, pack, and ship premium grapefruit, oranges, and specialty varieties to wholesale buyers across the country.</p>",
primaryColor: "#F59E0B",
contactEmail: "hello@tuxedocitrus.example",
contactPhone: "(555) 010-2200",
planCode: "farm",
addOns: ["wholesale_portal", "harvest_reach"],
},
{
slug: "indian-river-direct",
name: "Indian River Direct",
brandName: "Indian River Direct",
tagline: "From our groves to your store.",
aboutHtml:
"<p>Direct-from-grove wholesale produce. Family farms, fair pricing, fast delivery.</p>",
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')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id`,
[t.name, t.slug],
);
const tenantId = tenantRes.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)
await client.query(
`INSERT INTO brand_settings
(tenant_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
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,
],
);
// 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)
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." },
{ name: "Honeybells", price: 3200, unit: "20 lb case", desc: "Limited season — bell-shaped, super sweet." },
{ name: "Tangelos", price: 2800, unit: "40 lb case", desc: "Tangerine-grapefruit hybrid." },
];
for (const p of products) {
await client.query(
`INSERT INTO products (tenant_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
)`,
[tenantId, p.name, p.desc, p.price, p.unit],
);
}
// Sample stops
const stops = [
{ name: "Downtown Farmers Market", address: "100 Main St", schedule: [{ day: "Saturday", time: "08:00" }] },
{ name: "Eastside Pickup Hub", address: "555 Oak Ave", schedule: [{ day: "Wednesday", time: "16:00" }] },
];
for (const s of stops) {
await client.query(
`INSERT INTO stops (tenant_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
)`,
[tenantId, s.name, s.address, JSON.stringify(s.schedule)],
);
}
// Sample customers (email has a unique index per tenant)
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" },
{ name: "Northside Co-op", email: "produce@northsidecoop.example", phone: "555-010-0030" },
];
for (const c of customers) {
await client.query(
`INSERT INTO customers (tenant_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
)`,
[tenantId, c.name, c.email, c.phone],
);
}
// Sample email template (id-based, use WHERE NOT EXISTS)
const tmplRes = await client.query<{ id: string }>(
`INSERT INTO email_templates (tenant_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'
)
RETURNING id`,
[tenantId],
);
if (tmplRes.rows[0]) {
await client.query(
`INSERT INTO campaigns (tenant_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'
)`,
[tenantId, tmplRes.rows[0].id],
);
}
}
console.log(`Seeded ${tenantsData.length} tenants with sample data`);
// ── Platform admin user (not tied to any single tenant) ──────────────
await client.query(
`INSERT INTO users (email, name, auth_provider, auth_subject)
VALUES ('platform@route-commerce.example', 'Platform Admin', 'dev', 'dev-platform-admin')
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`,
);
console.log("Seeded platform admin user");
await client.query("COMMIT");
console.log("✅ Seed complete");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
main()
.then(() => pool.end())
.catch((err) => {
console.error("❌ Seed failed:", err);
pool.end();
process.exit(1);
});