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
33 changes: 32 additions & 1 deletion app/(app)/projects/[id]/leads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CampaignSummary["runs"]>();
for (const r of (runData as Record<string, unknown>[] | 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<string, number>();
for (const p of prospects) byStatus.set(p.status, (byStatus.get(p.status) ?? 0) + 1);
Expand Down
150 changes: 142 additions & 8 deletions components/leads/campaign-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[];
};

/**
Expand All @@ -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<string | null>(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<Record<string, { ok: boolean; text: string }>>({});
const [editing, setEditing] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState<string | null>(null);
const [open, setOpen] = useState(campaigns.length === 0);
const [note, setNote] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -98,7 +158,13 @@ export function CampaignPanel({
auto-send on.
</p>
</div>
<button onClick={() => setOpen(!open)} className="btn text-sm">
<button
onClick={() => {
if (open) setEditing(null);
setOpen(!open);
}}
className="btn text-sm"
>
{open ? "Close" : "New campaign"}
</button>
</div>
Expand Down Expand Up @@ -132,22 +198,33 @@ export function CampaignPanel({
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => act(async () => runCampaignAction({ projectId, name: c.name }))}
onClick={() =>
rowAct(`run:${c.name}`, c.name, () =>
runCampaignAction({ projectId, name: c.name }),
)
}
disabled={pending}
className="btn text-sm"
>
Run now
{busy === `run:${c.name}` ? "Running…" : "Run now"}
</button>
<button onClick={() => startEdit(c)} disabled={pending} className="btn text-sm">
Edit
</button>
<button
onClick={() =>
act(async () =>
rowAct(`active:${c.name}`, c.name, () =>
toggleCampaignAction({ projectId, name: c.name, field: "active", value: !c.active }),
)
}
disabled={pending}
className="btn text-sm"
>
{c.active ? "Pause" : "Resume"}
{busy === `active:${c.name}`
? "Saving…"
: c.active
? "Pause"
: "Resume"}
</button>
<button
onClick={() => {
Expand All @@ -159,17 +236,74 @@ export function CampaignPanel({
) {
return;
}
act(async () =>
rowAct(`send:${c.name}`, c.name, () =>
toggleCampaignAction({ projectId, name: c.name, field: "auto_send", value: !c.auto_send }),
);
}}
disabled={pending || (!c.auto_send && !canSendLive)}
className="btn text-sm"
title={canSendLive ? "" : "OUTREACH_POSTAL_ADDRESS must be set before live sending"}
>
{c.auto_send ? "Stop sending" : "Enable sending"}
{busy === `send:${c.name}`
? "Saving…"
: c.auto_send
? "Stop sending"
: "Enable sending"}
</button>
{c.runs.length > 0 && (
<button
onClick={() => setShowHistory(showHistory === c.name ? null : c.name)}
className="btn text-sm"
>
{showHistory === c.name ? "Hide history" : "History"}
</button>
)}
</div>

{rowNote[c.name] && (
<p
className={`w-full text-sm ${
rowNote[c.name].ok ? "" : "text-[var(--color-danger,#f87171)]"
}`}
>
{rowNote[c.name].text}
</p>
)}

{c.auth_required_hosts.length > 0 && (
<p className="w-full text-xs">
<span className="badge badge-warn">waiting_for_auth</span>{" "}
<span className="text-[var(--color-muted)]">
needs a sign-in for {c.auth_required_hosts.length} seed — add one under Seed
logins below.
</span>
</p>
)}

{showHistory === c.name && (
<div className="w-full rounded border border-[var(--color-border)] p-3">
<p className="text-xs font-medium">Recent ticks</p>
<ul className="mt-2 space-y-2">
{c.runs.map((r) => (
<li key={r.ran_at} className="text-xs">
<span className={r.ok ? "" : "text-[var(--color-danger,#f87171)]"}>
{r.ok ? "✓" : "✕"} {r.ran_at.slice(0, 16).replace("T", " ")}
</span>{" "}
<span className="text-[var(--color-muted)]">{r.summary}</span>
{r.errors.length > 0 && (
<ul className="ml-4 mt-1 list-disc text-[var(--color-muted)]">
{r.errors.slice(0, 5).map((e, i) => (
<li key={i} className="break-all">
{e}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
)}
</li>
))}
</ul>
Expand Down Expand Up @@ -314,7 +448,7 @@ export function CampaignPanel({
</label>
<div className="sm:col-span-2">
<button onClick={save} disabled={pending || !name.trim()} className="btn btn-primary">
{pending ? "Saving…" : "Create campaign"}
{pending ? "Saving…" : editing ? `Save changes to "${editing}"` : "Create campaign"}
</button>
<p className="mt-2 text-xs text-[var(--color-muted)]">
Starts in drafts-only mode. Read a few of what it writes, then enable sending.
Expand Down
27 changes: 23 additions & 4 deletions lib/outreach/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,14 +271,33 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise<TickR
}
}

const ranAt = new Date().toISOString();
const summary = summarize(result);

await sb
.from("outreach_campaigns")
.update({
last_run_at: new Date().toISOString(),
last_run_note: summarize(result),
})
.update({ last_run_at: ranAt, last_run_note: summary })
.eq("id", campaign.id);

// Keep the trail as well as the newest line. last_run_note is overwritten
// every tick, so without this a run that errored vanishes fifteen minutes
// later and "has this ever found anything?" has no answer.
await sb.from("outreach_campaign_runs").insert({
campaign_id: campaign.id,
project_id: campaign.project_id,
ran_at: ranAt,
summary,
discovered: result.discovered,
scans_started: result.scansStarted,
researched: result.researched,
drafted: result.drafted,
sent: result.sent,
errors: result.errors,
skipped: result.skipped,
awaiting_auth: result.awaitingAuth,
ok: result.errors.length === 0,
});

return result;
}

Expand Down
49 changes: 49 additions & 0 deletions supabase/migrations/20260728030000_outreach_campaign_runs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- A log of what each campaign tick actually did.
--
-- Campaigns kept one string, last_run_note, overwritten every 15 minutes. So
-- a tick that errored was invisible the moment the next one ran, and there
-- was no way to answer "has this campaign ever found anything?" — which is
-- the first question anyone asks of an automation that appears to be idle.

create table if not exists public.outreach_campaign_runs (
id uuid primary key default gen_random_uuid(),
campaign_id uuid not null references public.outreach_campaigns(id) on delete cascade,
project_id uuid not null references public.projects(id) on delete cascade,
ran_at timestamptz not null default now(),
-- The one-line summary shown in the UI.
summary text not null,
discovered int not null default 0,
scans_started int not null default 0,
researched int not null default 0,
drafted int not null default 0,
sent int not null default 0,
-- Full detail, so a failed run can be diagnosed after the fact rather than
-- from a truncated summary.
errors jsonb not null default '[]'::jsonb,
skipped jsonb not null default '[]'::jsonb,
awaiting_auth jsonb not null default '[]'::jsonb,
-- Whether the run is worth the user's attention.
ok boolean not null default true
);

create index if not exists outreach_campaign_runs_campaign_idx
on public.outreach_campaign_runs(campaign_id, ran_at desc);

alter table public.outreach_campaign_runs enable row level security;

-- Reads go through the service client after a project access check, matching
-- the rest of the outreach tables. No browser-side writes: this is a record
-- of what the runner did, and it stays honest by being unwritable from a
-- session.
drop policy if exists "outreach_campaign_runs owner read" on public.outreach_campaign_runs;
create policy "outreach_campaign_runs owner read"
on public.outreach_campaign_runs for select
using (
exists (
select 1 from public.projects p
where p.id = project_id and p.owner_id = auth.uid()
)
);

comment on table public.outreach_campaign_runs is
'Per-tick history for outreach campaigns. last_run_note holds only the newest; this keeps the trail.';
Loading