From d0a12d33daf143f2ea3a8dbe8f91aabcaee04f8e Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 26 Jun 2026 20:43:01 -0600 Subject: [PATCH] fix(communications): create email_templates table + resolve platform_admin brand fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs caused the 'New Template' modal on /admin/communications to fail with a database error: 1. The `email_templates` table was defined in `db/schema/marketing.ts` (Drizzle) but never migrated, so the modal's `upsertTemplate` server action errored with 'relation "email_templates" does not exist'. Adds migration 0092 to create the table (and `campaigns`) to match the Drizzle schema. Uses CREATE TABLE IF NOT EXISTS, gen_random_uuid() PKs, and the standard set_updated_at trigger pattern from 0001_init.sql. 2. `/admin/communications/page.tsx` resolved `effectiveBrandId` with a hard-coded UUID ('64294306-...') for sessions without a brand (e.g., dev platform_admin). That UUID isn't in the local dev DB (brands are '11111111-...' and '22222222-...'), so the modal would FK-violate on insert with 'violates foreign key constraint email_templates_brand_id_fkey'. Replaced with a `listBrandsForAdmin()` lookup that picks the first accessible brand. 3. `listBrandsForAdmin()` (`src/actions/brands.ts`) was SELECTing `logo_url` from `brands`, but that column doesn't exist (the brands table only has id/name/slug/plan_tier/limits/stripe_*/...). The query threw 'column "logo_url" does not exist', got caught by the silent try/catch, returned [], which made #2 above render the page with brandId="" — and any submit would then error with 'invalid input syntax for type uuid: ""'. Removed logo_url from the SELECT; BrandListItem.logo_url is always null until a future migration adds the column. E2E verified: opening /admin/communications → Templates tab → New Template → fill name → Create Template inserts a row in email_templates with the correct brand_id. --- .../0092_email_templates_and_campaigns.sql | 87 +++++++++++++++++++ src/actions/brands.ts | 8 +- src/app/admin/communications/page.tsx | 25 +++++- 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 db/migrations/0092_email_templates_and_campaigns.sql 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),