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:
+134
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user