fix(communications): create email_templates table + resolve platform_admin brand fallback
Deploy to route.crispygoat.com / deploy (push) Failing after 3m19s

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.
This commit is contained in:
Tyler
2026-06-26 20:43:01 -06:00
parent 6d238bd55b
commit d0a12d33da
3 changed files with 115 additions and 5 deletions
+4 -4
View File
@@ -24,20 +24,20 @@ export async function listBrandsForAdmin(): Promise<BrandListItem[]> {
try {
if (adminUser.role === "platform_admin") {
const { rows } = await pool.query<BrandListItem>(
`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<BrandListItem>(
`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 [];
}
+24 -1
View File
@@ -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 (
<CommunicationsPage
campaigns={[]}
templates={[]}
brandId=""
initialSegments={[]}
initialAnalytics={{ success: true, analytics: null }}
/>
);
}
const [campaignsResult, templatesResult, segmentsResult, analyticsResult] = await Promise.all([
getCommunicationCampaigns(effectiveBrandId),