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"); + }); +});