diff --git a/db/migrations/0092_email_templates_and_campaigns.sql b/db/migrations/0092_email_templates_and_campaigns.sql new file mode 100644 index 0000000..fbad577 --- /dev/null +++ b/db/migrations/0092_email_templates_and_campaigns.sql @@ -0,0 +1,87 @@ +-- 0092_email_templates_and_campaigns.sql +-- +-- Creates the `email_templates` and `campaigns` tables referenced by +-- `db/schema/marketing.ts` (Drizzle). These tables were defined in the +-- schema but never migrated, so the "Create Template" UI on the +-- communications page errored with `relation "email_templates" does +-- not exist`. +-- +-- A parallel set of tables (`communication_templates`, +-- `communication_campaigns`) already exists from 0001_init.sql — those +-- are a different schema used by the Harvest Reach module and are +-- untouched here. This migration only adds the marketing-schema tables +-- the `upsertTemplate` / `createCampaign` actions need. +-- +-- Conventions (mirror 0001_init.sql): +-- - gen_random_uuid() for PKs +-- - TIMESTAMPTZ for timestamps +-- - TEXT + CHECK for the campaigns.status enum +-- - CREATE TABLE IF NOT EXISTS for re-runnability +-- - set_updated_at() trigger for updated_at maintenance + +BEGIN; + +-- ============================================================================ +-- email_templates +-- ============================================================================ +CREATE TABLE IF NOT EXISTS email_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(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() +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'email_templates_set_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER email_templates_set_updated_at + BEFORE UPDATE ON email_templates + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS email_templates_brand_idx ON email_templates (brand_id); + +-- ============================================================================ +-- campaigns +-- ============================================================================ +CREATE TABLE IF NOT EXISTS campaigns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(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() +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = 'campaigns_set_updated_at' AND n.nspname = current_schema() + ) THEN + CREATE TRIGGER campaigns_set_updated_at + BEFORE UPDATE ON campaigns + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS campaigns_brand_idx ON campaigns (brand_id); +CREATE INDEX IF NOT EXISTS campaigns_status_idx ON campaigns (brand_id, status); + +COMMIT; \ No newline at end of file diff --git a/src/actions/brands.ts b/src/actions/brands.ts index 15dd021..72c247a 100644 --- a/src/actions/brands.ts +++ b/src/actions/brands.ts @@ -24,20 +24,20 @@ export async function listBrandsForAdmin(): Promise { try { if (adminUser.role === "platform_admin") { const { rows } = await pool.query( - `SELECT id, name, slug, logo_url FROM brands ORDER BY name`, + `SELECT id, name, slug FROM brands ORDER BY name`, ); - return rows; + return rows.map((r) => ({ ...r, logo_url: null })); } if (adminUser.brand_ids.length === 0) return []; const { rows } = await pool.query( - `SELECT id, name, slug, logo_url FROM brands + `SELECT id, name, slug FROM brands WHERE id = ANY($1::uuid[]) ORDER BY name`, [adminUser.brand_ids], ); - return rows; + return rows.map((r) => ({ ...r, logo_url: null })); } catch { return []; } diff --git a/src/app/admin/communications/page.tsx b/src/app/admin/communications/page.tsx index cefa5e2..484290e 100644 --- a/src/app/admin/communications/page.tsx +++ b/src/app/admin/communications/page.tsx @@ -5,6 +5,7 @@ import { getCommunicationCampaigns } from "@/actions/communications/campaigns"; import { getCommunicationTemplates } from "@/actions/communications/templates"; import { getHarvestReachSegments } from "@/actions/harvest-reach/segments"; import { getCampaignAnalytics } from "@/actions/harvest-reach/campaigns"; +import { listBrandsForAdmin } from "@/actions/brands"; import CommunicationsPage from "@/components/admin/CommunicationsPage"; export const metadata: Metadata = { @@ -18,7 +19,29 @@ export default async function CommunicationsRootPage() { redirect("/admin/pickup"); } - const effectiveBrandId = adminUser!.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"; + // Resolve a real brand id. For platform_admin (and any other session + // without an explicit brand), fall back to the first accessible brand + // rather than a hard-coded UUID — the previous fallback was tied to + // a specific DB and would FK-violate anywhere else, breaking the + // "Create Template" / "New Campaign" forms with "violates foreign + // key constraint email_templates_brand_id_fkey". + let effectiveBrandId = adminUser!.brand_id ?? null; + if (!effectiveBrandId) { + const brands = await listBrandsForAdmin(); + effectiveBrandId = brands[0]?.id ?? null; + } + if (!effectiveBrandId) { + // No brands exist — render the page with empty data rather than crash. + return ( + + ); + } const [campaignsResult, templatesResult, segmentsResult, analyticsResult] = await Promise.all([ getCommunicationCampaigns(effectiveBrandId),