From 7697365b6ef12e7292b6fe2d55980a95ce668cf1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 28 Jul 2026 02:56:51 +0000 Subject: [PATCH] fix(leads): load every campaign field into the edit form, and select it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing a campaign left the sender name, reply-to and angle blank. They were not merely missing from the form: save() submits the whole form, so saving an edit wrote those blanks back and wiped the columns. Underneath was a worse one. The page casts its campaign rows with `as CampaignSummary`, which tells TypeScript the shape without checking that the query asked for it, and the select had never been widened past the original nine columns. Everything added since read as undefined at runtime with no compile error — so the run history could not be keyed (campaign id was undefined), the waiting_for_auth badge never appeared, and the edit form got no pitch fields. All of it compiled, and all of it was broken. save() also hardcoded autoSend false and active true. That is right for a new campaign, which should start paused-for-sending so its first drafts get read, but on an edit it switched sending off and un-paused a campaign the user had deliberately paused. It now carries the existing values through. The regression guard is a source-level test asserting the select requests every column the page and panel go on to read. Source-level checks are unusual, but the type system has been explicitly told to look away here, so nothing else would catch it. Reverting the fix fails it in eleven places. Co-Authored-By: Claude Opus 5 (1M context) --- app/(app)/projects/[id]/leads/page.tsx | 4 +- components/leads/campaign-panel.tsx | 27 ++++++++--- tests/leads-page-columns.test.ts | 67 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 tests/leads-page-columns.test.ts diff --git a/app/(app)/projects/[id]/leads/page.tsx b/app/(app)/projects/[id]/leads/page.tsx index f71e4cd9..4297b244 100644 --- a/app/(app)/projects/[id]/leads/page.tsx +++ b/app/(app)/projects/[id]/leads/page.tsx @@ -83,7 +83,9 @@ export default async function LeadsPage({ .limit(10), supabase .from("outreach_campaigns") - .select("name, active, auto_send, daily_send_limit, max_score, queries, seed_urls, last_run_at, last_run_note") + .select( + "id, name, active, auto_send, daily_send_limit, max_score, queries, seed_urls, last_run_at, last_run_note, auth_required_hosts, pitch_mode, pitch_intro, pitch_ask, pitch_facts, scan_prospects, angle, sender_name, reply_to", + ) .eq("project_id", projectId) .order("updated_at", { ascending: false }) .limit(10), diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx index e72223c2..22a2321c 100644 --- a/components/leads/campaign-panel.tsx +++ b/components/leads/campaign-panel.tsx @@ -25,6 +25,9 @@ export type CampaignSummary = { seed_urls: string[]; last_run_at: string | null; last_run_note: string | null; + angle: string | null; + sender_name: string | null; + reply_to: string | null; pitch_mode: "audit" | "custom"; pitch_intro: string | null; pitch_ask: string | null; @@ -123,20 +126,30 @@ export function CampaignPanel({ setPitchAsk(c.pitch_ask ?? ""); setPitchFacts((c.pitch_facts ?? []).join("\n")); setScanProspects(c.scan_prospects); + // Every field the form submits has to be loaded, not just the ones that + // changed recently: save() sends the whole form, so anything left blank + // here is written back as blank and silently wipes the column. + setAngle(c.angle ?? ""); + setSenderName(c.sender_name ?? ""); + setReplyTo(c.reply_to ?? ""); setOpen(true); }; const save = () => - act(async () => - saveCampaignAction({ + act(async () => { + // A new campaign deliberately starts paused-for-sending so its first + // drafts can be read. An edit must not quietly re-apply that: it would + // switch sending off, and un-pause a campaign the user had paused. + const existing = campaigns.find((c) => c.name === editing); + const result = await saveCampaignAction({ projectId, name, queries, seedUrls, maxScore, dailySendLimit: dailyLimit, - autoSend: false, - active: true, + autoSend: existing?.auto_send ?? false, + active: existing?.active ?? true, angle, senderName, replyTo, @@ -145,8 +158,10 @@ export function CampaignPanel({ pitchAsk, pitchFacts, scanProspects, - }), - ); + }); + if (result.ok) setEditing(null); + return result; + }); return (
diff --git a/tests/leads-page-columns.test.ts b/tests/leads-page-columns.test.ts new file mode 100644 index 00000000..be4676fc --- /dev/null +++ b/tests/leads-page-columns.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +// The leads page casts its campaign rows with `as CampaignSummary`, which +// tells TypeScript what the shape is without checking that the query asked +// for it. A column missing from the select then reads as `undefined` at +// runtime with no compile error — which is exactly how the run history and +// the waiting_for_auth badge shipped broken. +// +// This asserts the select actually requests every field the page and panel +// go on to read. It is a source-level check, which is unusual, but it is the +// only thing that catches a mismatch the type system is being told to ignore. + +const PAGE = path.join(process.cwd(), "app/(app)/projects/[id]/leads/page.tsx"); + +/** Fields the page maps or the panel renders. */ +const REQUIRED_COLUMNS = [ + "id", + "name", + "active", + "auto_send", + "daily_send_limit", + "max_score", + "queries", + "seed_urls", + "last_run_at", + "last_run_note", + "auth_required_hosts", + "pitch_mode", + "pitch_intro", + "pitch_ask", + "pitch_facts", + "scan_prospects", + "angle", + "sender_name", + "reply_to", +]; + +function campaignSelect(source: string): string { + const idx = source.indexOf('.from("outreach_campaigns")'); + expect(idx, "leads page no longer queries outreach_campaigns").toBeGreaterThan(-1); + const after = source.slice(idx); + const match = after.match(/\.select\(\s*"([^"]+)"/); + expect(match, "could not find the campaign .select(...)").not.toBeNull(); + return match![1]; +} + +describe("leads page campaign query", () => { + const source = readFileSync(PAGE, "utf8"); + const selected = campaignSelect(source) + .split(",") + .map((c) => c.trim()) + .filter(Boolean); + + for (const column of REQUIRED_COLUMNS) { + it(`selects ${column}`, () => { + expect(selected).toContain(column); + }); + } + + it("selects id, without which run history cannot be keyed", () => { + // Called out separately because its absence fails silently: the map + // lookup just misses and every campaign shows an empty history. + expect(selected).toContain("id"); + }); +});