Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/(app)/projects/[id]/leads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
27 changes: 21 additions & 6 deletions components/leads/campaign-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -145,8 +158,10 @@ export function CampaignPanel({
pitchAsk,
pitchFacts,
scanProspects,
}),
);
});
if (result.ok) setEditing(null);
return result;
});

return (
<section className="card p-4">
Expand Down
67 changes: 67 additions & 0 deletions tests/leads-page-columns.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading