From 9ee323c7d7bf63eb5f4fbc8f2636d94a6165aad9 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Tue, 28 Jul 2026 02:25:06 +0000
Subject: [PATCH] feat(leads): edit campaigns, per-button feedback, and a run
history
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three gaps that showed up together once campaigns could pitch something
other than an audit.
Existing campaigns could not be changed. The panel only ever created
one, so a campaign built before the pitch setting existed was stuck on
the audit pitch with no way to move it. Editing loads the campaign back
into the form; saving already upserted on (project, name), so keeping
the name edits in place rather than creating a duplicate.
Every button shared one transition. Clicking any of them disabled all of
them, none changed its label, and the result appeared in a single line at
the top of the panel, far from the row it belonged to — so on a panel
with several campaigns there was no way to tell which one had just run,
or whether anything had happened at all. Each button now reports its own
progress and its own outcome, next to its own campaign.
Campaigns kept one string, last_run_note, overwritten every fifteen
minutes. A tick that errored was invisible by the next one, and "has this
ever found anything?" had no answer. Runs are now recorded, with their
errors, and the last few are readable per campaign.
Campaigns waiting on a seed login also say so on the row, pointing at
the panel that fixes it.
Co-Authored-By: Claude Opus 5 (1M context)
---
app/(app)/projects/[id]/leads/page.tsx | 33 +++-
components/leads/campaign-panel.tsx | 150 +++++++++++++++++-
lib/outreach/runner.ts | 27 +++-
.../20260728030000_outreach_campaign_runs.sql | 49 ++++++
4 files changed, 246 insertions(+), 13 deletions(-)
create mode 100644 supabase/migrations/20260728030000_outreach_campaign_runs.sql
diff --git a/app/(app)/projects/[id]/leads/page.tsx b/app/(app)/projects/[id]/leads/page.tsx
index ea08cc3e..f71e4cd9 100644
--- a/app/(app)/projects/[id]/leads/page.tsx
+++ b/app/(app)/projects/[id]/leads/page.tsx
@@ -91,7 +91,38 @@ export default async function LeadsPage({
const prospects = (prospectData as ProspectRow[] | null) ?? [];
const sends = (sendData as SendRow[] | null) ?? [];
- const campaigns = (campaignData as CampaignSummary[] | null) ?? [];
+ const campaignRows = (campaignData as (CampaignSummary & { id: string })[] | null) ?? [];
+
+ // Recent ticks per campaign. last_run_note holds only the newest line, so
+ // without this there is no way to see that a campaign has been erroring for
+ // an hour, or that it has never found anything at all.
+ const { data: runData } = await supabase
+ .from("outreach_campaign_runs")
+ .select("campaign_id, ran_at, summary, ok, errors")
+ .in("campaign_id", campaignRows.map((c) => c.id))
+ .order("ran_at", { ascending: false })
+ .limit(60);
+
+ const runsByCampaign = new Map();
+ for (const r of (runData as Record[] | null) ?? []) {
+ const key = r.campaign_id as string;
+ const list = runsByCampaign.get(key) ?? [];
+ if (list.length >= 8) continue;
+ list.push({
+ ran_at: r.ran_at as string,
+ summary: (r.summary as string) ?? "",
+ ok: (r.ok as boolean) ?? true,
+ errors: Array.isArray(r.errors) ? (r.errors as string[]) : [],
+ });
+ runsByCampaign.set(key, list);
+ }
+
+ const campaigns: CampaignSummary[] = campaignRows.map((c) => ({
+ ...c,
+ pitch_facts: Array.isArray(c.pitch_facts) ? c.pitch_facts : [],
+ auth_required_hosts: Array.isArray(c.auth_required_hosts) ? c.auth_required_hosts : [],
+ runs: runsByCampaign.get(c.id) ?? [],
+ }));
const byStatus = new Map();
for (const p of prospects) byStatus.set(p.status, (byStatus.get(p.status) ?? 0) + 1);
diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx
index efd74a63..e72223c2 100644
--- a/components/leads/campaign-panel.tsx
+++ b/components/leads/campaign-panel.tsx
@@ -8,6 +8,13 @@ import {
toggleCampaignAction,
} from "@/app/actions/leads";
+export type CampaignRun = {
+ ran_at: string;
+ summary: string;
+ ok: boolean;
+ errors: string[];
+};
+
export type CampaignSummary = {
name: string;
active: boolean;
@@ -18,6 +25,13 @@ export type CampaignSummary = {
seed_urls: string[];
last_run_at: string | null;
last_run_note: string | null;
+ pitch_mode: "audit" | "custom";
+ pitch_intro: string | null;
+ pitch_ask: string | null;
+ pitch_facts: string[];
+ scan_prospects: boolean;
+ auth_required_hosts: string[];
+ runs: CampaignRun[];
};
/**
@@ -37,6 +51,14 @@ export function CampaignPanel({
}) {
const router = useRouter();
const [pending, start] = useTransition();
+ // Which button is in flight, so only that one shows a spinner instead of
+ // every button in the panel going quiet at once.
+ const [busy, setBusy] = useState(null);
+ // Result of the last action per campaign, shown next to that campaign
+ // rather than in one shared line at the top of the panel.
+ const [rowNote, setRowNote] = useState>({});
+ const [editing, setEditing] = useState(null);
+ const [showHistory, setShowHistory] = useState(null);
const [open, setOpen] = useState(campaigns.length === 0);
const [note, setNote] = useState(null);
const [error, setError] = useState(null);
@@ -66,6 +88,44 @@ export function CampaignPanel({
} else setError(res.error);
});
+ // Runs one campaign-row action: marks that button busy, then reports the
+ // outcome inline next to the campaign it belongs to.
+ const rowAct = (
+ key: string,
+ campaignName: string,
+ fn: () => Promise<{ ok: true; note?: string } | { ok: false; error: string }>,
+ ) =>
+ start(async () => {
+ setBusy(key);
+ setRowNote((prev) => ({ ...prev, [campaignName]: undefined as never }));
+ const res = await fn();
+ setRowNote((prev) => ({
+ ...prev,
+ [campaignName]: res.ok
+ ? { ok: true, text: res.note ?? "Done." }
+ : { ok: false, text: res.error },
+ }));
+ setBusy(null);
+ if (res.ok) router.refresh();
+ });
+
+ // Load an existing campaign into the form so it can be changed. Saving
+ // upserts on (project, name), so keeping the name edits in place.
+ const startEdit = (c: CampaignSummary) => {
+ setEditing(c.name);
+ setName(c.name);
+ setQueries(c.queries.join("\n"));
+ setSeedUrls(c.seed_urls.join("\n"));
+ setMaxScore(c.max_score);
+ setDailyLimit(c.daily_send_limit);
+ setPitchMode(c.pitch_mode);
+ setPitchIntro(c.pitch_intro ?? "");
+ setPitchAsk(c.pitch_ask ?? "");
+ setPitchFacts((c.pitch_facts ?? []).join("\n"));
+ setScanProspects(c.scan_prospects);
+ setOpen(true);
+ };
+
const save = () =>
act(async () =>
saveCampaignAction({
@@ -98,7 +158,13 @@ export function CampaignPanel({
auto-send on.