feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
This commit is contained in:
+42
-193
@@ -6,36 +6,20 @@
|
||||
* npm run db:seed
|
||||
*
|
||||
* Populates:
|
||||
* - 3 plans (Starter / Farm / Enterprise)
|
||||
* - 6 add-ons
|
||||
* - 2 tenants (Tuxedo, Indian River Direct)
|
||||
* - 1 platform-admin user + 1 brand_admin per tenant
|
||||
* - brand_settings per tenant
|
||||
* - sample products, stops, customers per tenant
|
||||
* - sample email templates + a draft campaign
|
||||
* - 2 brands (Tuxedo, Indian River Direct)
|
||||
* - brand_settings per brand
|
||||
* - sample products, stops, customers per brand
|
||||
* - sample communication templates + a draft campaign
|
||||
*
|
||||
* NOTE: Admin users are managed by Neon Auth. To create an admin user:
|
||||
* 1. Sign up / sign in via the app (creates a neon_auth.user row)
|
||||
* 2. Manually insert into admin_users + admin_user_brands:
|
||||
* INSERT INTO admin_users (email, name, role) VALUES ('you@example.com', 'Your Name', 'brand_admin');
|
||||
* INSERT INTO admin_user_brands (admin_user_id, brand_id, role) VALUES (<admin_user_id>, <brand_id>, 'brand_admin');
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { Pool } from "pg";
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
|
||||
// `src/lib/passwords.ts` has `import "server-only"` which throws when this
|
||||
// script runs outside a Next.js server context. Inline the same scrypt
|
||||
// format here (must match `verifyPassword` in passwords.ts) so the seed
|
||||
// can run from a plain Node process.
|
||||
const SALT_LEN = 16;
|
||||
const KEY_LEN = 64;
|
||||
const DEFAULT_N = 16384;
|
||||
const ALGO = "scrypt";
|
||||
function hashPassword(plain: string): string {
|
||||
const salt = randomBytes(SALT_LEN).toString("hex");
|
||||
const hash = scryptSync(plain, salt, KEY_LEN, { N: DEFAULT_N }).toString("hex");
|
||||
return `${ALGO}$${DEFAULT_N}$${salt}$${hash}`;
|
||||
}
|
||||
|
||||
// Seed needs elevated privileges (inserts into plans, add_ons, tenants —
|
||||
// tables that are intentionally not RLS-scoped but the runtime app user
|
||||
// can't create rows in without setting up GUCs we don't want to bother
|
||||
// with here). Use DATABASE_ADMIN_URL (the superuser) for the seed.
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
process.env.DATABASE_ADMIN_URL ??
|
||||
@@ -48,52 +32,8 @@ async function main() {
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// ── Plans ────────────────────────────────────────────────────────────
|
||||
const planRows = [
|
||||
{ code: "starter", name: "Starter", price: 4900, maxUsers: 1, maxProducts: 25, maxStopsMonthly: 10, features: ["products", "stops", "orders", "basic_pickup"] },
|
||||
{ code: "farm", name: "Farm", price: 14900, maxUsers: 5, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support"] },
|
||||
{ code: "enterprise", name: "Enterprise", price: 39900, maxUsers: 9999, maxProducts: 9999, maxStopsMonthly: 9999, features: ["products", "stops", "orders", "basic_pickup", "wholesale_portal", "harvest_reach", "priority_support", "ai_intelligence", "sms_campaigns", "square_sync", "water_log", "custom_development", "dedicated_sla"] },
|
||||
];
|
||||
for (const p of planRows) {
|
||||
await client.query(
|
||||
`INSERT INTO plans (code, name, monthly_price_cents, max_users, max_products, max_stops_monthly, features)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
monthly_price_cents = EXCLUDED.monthly_price_cents,
|
||||
max_users = EXCLUDED.max_users,
|
||||
max_products = EXCLUDED.max_products,
|
||||
max_stops_monthly = EXCLUDED.max_stops_monthly,
|
||||
features = EXCLUDED.features`,
|
||||
[p.code, p.name, p.price, p.maxUsers, p.maxProducts, p.maxStopsMonthly, JSON.stringify(p.features)],
|
||||
);
|
||||
}
|
||||
console.log(`Seeded ${planRows.length} plans`);
|
||||
|
||||
// ── Add-ons ──────────────────────────────────────────────────────────
|
||||
const addOnRows = [
|
||||
{ code: "wholesale_portal", name: "Wholesale Portal", price: 9900, description: "Self-serve wholesale storefront with order approval." },
|
||||
{ code: "harvest_reach", name: "Harvest Reach", price: 7900, description: "Email + SMS campaigns and automations." },
|
||||
{ code: "ai_tools", name: "AI Intelligence Pack", price: 5900, description: "Smart pricing, product descriptions, and demand forecasting." },
|
||||
{ code: "water_log", name: "Water Log", price: 3900, description: "Irrigation tracking and reporting." },
|
||||
{ code: "square_sync", name: "Square Sync", price: 3900, description: "Sync inventory and sales to Square POS." },
|
||||
{ code: "sms_campaigns", name: "SMS Campaigns", price: 2900, description: "Send SMS blasts and automations." },
|
||||
];
|
||||
for (const a of addOnRows) {
|
||||
await client.query(
|
||||
`INSERT INTO add_ons (code, name, monthly_price_cents, description)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
monthly_price_cents = EXCLUDED.monthly_price_cents,
|
||||
description = EXCLUDED.description`,
|
||||
[a.code, a.name, a.price, a.description],
|
||||
);
|
||||
}
|
||||
console.log(`Seeded ${addOnRows.length} add-ons`);
|
||||
|
||||
// ── Tenants + users + content ────────────────────────────────────────
|
||||
const tenantsData = [
|
||||
// ── Brands ──────────────────────────────────────────────────────────────
|
||||
const brandsData = [
|
||||
{
|
||||
slug: "tuxedo",
|
||||
name: "Tuxedo Citrus",
|
||||
@@ -104,8 +44,6 @@ async function main() {
|
||||
primaryColor: "#F59E0B",
|
||||
contactEmail: "hello@tuxedocitrus.example",
|
||||
contactPhone: "(555) 010-2200",
|
||||
planCode: "farm",
|
||||
addOns: ["wholesale_portal", "harvest_reach"],
|
||||
},
|
||||
{
|
||||
slug: "indian-river-direct",
|
||||
@@ -117,89 +55,36 @@ async function main() {
|
||||
primaryColor: "#0F766E",
|
||||
contactEmail: "orders@indianriverdirect.example",
|
||||
contactPhone: "(555) 010-3300",
|
||||
planCode: "starter",
|
||||
addOns: ["wholesale_portal"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of tenantsData) {
|
||||
// Upsert tenant
|
||||
const tenantRes = await client.query<{ id: string }>(
|
||||
`INSERT INTO tenants (name, slug, status, trial_ends_at)
|
||||
VALUES ($1, $2, 'active', now() + interval '30 days')
|
||||
for (const b of brandsData) {
|
||||
// Upsert brand
|
||||
const brandRes = await client.query<{ id: string }>(
|
||||
`INSERT INTO brands (name, slug, plan_tier)
|
||||
VALUES ($1, $2, 'starter')
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id`,
|
||||
[t.name, t.slug],
|
||||
[b.name, b.slug],
|
||||
);
|
||||
const tenantId = tenantRes.rows[0].id;
|
||||
const brandId = brandRes.rows[0].id;
|
||||
|
||||
// Plan + subscription
|
||||
const planRes = await client.query<{ id: string }>(
|
||||
`SELECT id FROM plans WHERE code = $1`,
|
||||
[t.planCode],
|
||||
);
|
||||
const planId = planRes.rows[0].id;
|
||||
await client.query(
|
||||
`INSERT INTO subscriptions (tenant_id, plan_id, status, current_period_end)
|
||||
VALUES ($1, $2, 'active', now() + interval '30 days')
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
plan_id = EXCLUDED.plan_id,
|
||||
status = EXCLUDED.status,
|
||||
current_period_end = EXCLUDED.current_period_end`,
|
||||
[tenantId, planId],
|
||||
);
|
||||
|
||||
// Add-ons (composite PK — ON CONFLICT has a target)
|
||||
for (const code of t.addOns) {
|
||||
const addOnRes = await client.query<{ id: string }>(
|
||||
`SELECT id FROM add_ons WHERE code = $1`,
|
||||
[code],
|
||||
);
|
||||
if (!addOnRes.rows[0]) continue;
|
||||
const addOnId = addOnRes.rows[0].id;
|
||||
await client.query(
|
||||
`INSERT INTO tenant_add_ons (tenant_id, add_on_id, status)
|
||||
VALUES ($1, $2, 'active')
|
||||
ON CONFLICT (tenant_id, add_on_id) DO NOTHING`,
|
||||
[tenantId, addOnId],
|
||||
);
|
||||
}
|
||||
|
||||
// Brand settings (PK is tenant_id)
|
||||
// Brand settings (PK is brand_id)
|
||||
await client.query(
|
||||
`INSERT INTO brand_settings
|
||||
(tenant_id, brand_name, tagline, about_html, primary_color, contact_email, contact_phone)
|
||||
(brand_id, brand_name, tagline, about_html, primary_color, contact_email, contact_phone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
brand_name = EXCLUDED.brand_name,
|
||||
tagline = EXCLUDED.tagline,
|
||||
about_html = EXCLUDED.about_html,
|
||||
primary_color = EXCLUDED.primary_color,
|
||||
contact_email = EXCLUDED.contact_email,
|
||||
contact_phone = EXCLUDED.contact_phone`,
|
||||
[
|
||||
tenantId, t.brandName, t.tagline, t.aboutHtml,
|
||||
t.primaryColor, t.contactEmail, t.contactPhone,
|
||||
],
|
||||
[brandId, b.brandName, b.tagline, b.aboutHtml, b.primaryColor, b.contactEmail, b.contactPhone],
|
||||
);
|
||||
|
||||
// Brand admin user
|
||||
const userRes = await client.query<{ id: string }>(
|
||||
`INSERT INTO users (email, name, auth_provider, auth_subject)
|
||||
VALUES ($1, $2, 'dev', $3)
|
||||
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id`,
|
||||
[`admin@${t.slug}.example`, `${t.brandName} Admin`, `dev-${t.slug}-admin`],
|
||||
);
|
||||
const userId = userRes.rows[0].id;
|
||||
await client.query(
|
||||
`INSERT INTO tenant_users (tenant_id, user_id, role)
|
||||
VALUES ($1, $2, 'brand_admin')
|
||||
ON CONFLICT (tenant_id, user_id) DO NOTHING`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
// Sample products — use WHERE NOT EXISTS (no good unique target other than id)
|
||||
// Sample products
|
||||
const products = [
|
||||
{ name: "Ruby Red Grapefruit", price: 2400, unit: "40 lb case", desc: "Sweet, juicy, seedless." },
|
||||
{ name: "Navel Oranges", price: 2200, unit: "40 lb case", desc: "Classic eating orange, easy-peel." },
|
||||
@@ -208,12 +93,12 @@ async function main() {
|
||||
];
|
||||
for (const p of products) {
|
||||
await client.query(
|
||||
`INSERT INTO products (tenant_id, name, description, price_cents, inventory, unit, active)
|
||||
`INSERT INTO products (brand_id, name, description, price_cents, inventory, unit, active)
|
||||
SELECT $1, $2, $3, $4, 100, $5, true
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM products WHERE tenant_id = $1 AND name = $2
|
||||
SELECT 1 FROM products WHERE brand_id = $1 AND name = $2
|
||||
)`,
|
||||
[tenantId, p.name, p.desc, p.price, p.unit],
|
||||
[brandId, p.name, p.desc, p.price, p.unit],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,16 +109,16 @@ async function main() {
|
||||
];
|
||||
for (const s of stops) {
|
||||
await client.query(
|
||||
`INSERT INTO stops (tenant_id, name, address, schedule, status)
|
||||
`INSERT INTO stops (brand_id, name, address, schedule, status)
|
||||
SELECT $1, $2, $3, $4::jsonb, 'active'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM stops WHERE tenant_id = $1 AND name = $2
|
||||
SELECT 1 FROM stops WHERE brand_id = $1 AND name = $2
|
||||
)`,
|
||||
[tenantId, s.name, s.address, JSON.stringify(s.schedule)],
|
||||
[brandId, s.name, s.address, JSON.stringify(s.schedule)],
|
||||
);
|
||||
}
|
||||
|
||||
// Sample customers (email has a unique index per tenant)
|
||||
// Sample customers
|
||||
const customers = [
|
||||
{ name: "Green Grocer Co.", email: "buyer@greengrocer.example", phone: "555-010-0010" },
|
||||
{ name: "Sunset Cafe", email: "orders@sunsetcafe.example", phone: "555-010-0020" },
|
||||
@@ -241,73 +126,37 @@ async function main() {
|
||||
];
|
||||
for (const c of customers) {
|
||||
await client.query(
|
||||
`INSERT INTO customers (tenant_id, name, email, phone, sms_opt_in, email_opt_in)
|
||||
`INSERT INTO customers (brand_id, name, email, phone, sms_opt_in, email_opt_in)
|
||||
SELECT $1, $2, $3, $4, true, true
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM customers WHERE tenant_id = $1 AND email = $3
|
||||
SELECT 1 FROM customers WHERE brand_id = $1 AND email = $3
|
||||
)`,
|
||||
[tenantId, c.name, c.email, c.phone],
|
||||
[brandId, c.name, c.email, c.phone],
|
||||
);
|
||||
}
|
||||
|
||||
// Sample email template (id-based, use WHERE NOT EXISTS)
|
||||
// Sample communication template
|
||||
const tmplRes = await client.query<{ id: string }>(
|
||||
`INSERT INTO email_templates (tenant_id, name, subject, body_html)
|
||||
`INSERT INTO communication_templates (brand_id, name, subject, body_html)
|
||||
SELECT $1, 'Weekly Availability', 'This week at the grove', '<h1>Fresh this week</h1><p>Hi {{name}}, our harvest is in. Reply to reserve.</p>'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM email_templates WHERE tenant_id = $1 AND name = 'Weekly Availability'
|
||||
SELECT 1 FROM communication_templates WHERE brand_id = $1 AND name = 'Weekly Availability'
|
||||
)
|
||||
RETURNING id`,
|
||||
[tenantId],
|
||||
[brandId],
|
||||
);
|
||||
if (tmplRes.rows[0]) {
|
||||
await client.query(
|
||||
`INSERT INTO campaigns (tenant_id, template_id, name, status)
|
||||
`INSERT INTO communication_campaigns (brand_id, template_id, name, status)
|
||||
SELECT $1, $2, 'Welcome series — week 1', 'draft'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM campaigns WHERE tenant_id = $1 AND name = 'Welcome series — week 1'
|
||||
SELECT 1 FROM communication_campaigns WHERE brand_id = $1 AND name = 'Welcome series — week 1'
|
||||
)`,
|
||||
[tenantId, tmplRes.rows[0].id],
|
||||
[brandId, tmplRes.rows[0].id],
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(`Seeded ${tenantsData.length} tenants with sample data`);
|
||||
|
||||
// ── Platform admin (seeded for dev sign-in via Credentials provider) ─
|
||||
// email: admin@route-commerce.local
|
||||
// password: admin (override with SEED_ADMIN_PASSWORD)
|
||||
//
|
||||
// The user is attached to the Tuxedo tenant with the `platform_admin`
|
||||
// role so `getAdminUser()` resolves it and grants cross-tenant
|
||||
// visibility. To rotate the password, re-run `npm run db:seed`
|
||||
// (the UPSERT updates `password_hash`).
|
||||
const tuxedoRes = await client.query<{ id: string }>(
|
||||
`SELECT id FROM tenants WHERE slug = 'tuxedo'`,
|
||||
);
|
||||
const tuxedoId = tuxedoRes.rows[0]?.id;
|
||||
if (tuxedoId) {
|
||||
const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? "admin";
|
||||
const passwordHash = hashPassword(adminPassword);
|
||||
const adminRes = await client.query<{ id: string }>(
|
||||
`INSERT INTO users (email, name, auth_provider, auth_subject, password_hash)
|
||||
VALUES ($1, 'Platform Admin', 'dev', 'dev-platform-admin', $2)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
password_hash = EXCLUDED.password_hash
|
||||
RETURNING id`,
|
||||
["admin@route-commerce.local", passwordHash],
|
||||
);
|
||||
const adminId = adminRes.rows[0].id;
|
||||
await client.query(
|
||||
`INSERT INTO tenant_users (tenant_id, user_id, role)
|
||||
VALUES ($1, $2, 'platform_admin')
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||
[tuxedoId, adminId],
|
||||
);
|
||||
console.log(
|
||||
`Seeded platform admin: admin@route-commerce.local / ${adminPassword === "admin" ? "admin" : "(SEED_ADMIN_PASSWORD)"}`,
|
||||
);
|
||||
}
|
||||
console.log(`Seeded ${brandsData.length} brands with sample data`);
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log("✅ Seed complete");
|
||||
|
||||
Reference in New Issue
Block a user