diff --git a/app/actions/leads.ts b/app/actions/leads.ts index 65ff587a..a71f7d9b 100644 --- a/app/actions/leads.ts +++ b/app/actions/leads.ts @@ -109,6 +109,36 @@ export async function findLeadsAction(input: { }; } +/** + * Record what actually came of a lead. + * + * Nothing else in the pipeline can set these. A send is observable, a reply + * is not — the sending mailbox knows, and until reply detection reads it, + * only the user does. Without a way to record the outcome the funnel can + * only ever report sends, and a reply rate that is structurally zero is + * worse than no reply rate at all. + */ +export async function markLeadOutcomeAction(input: { + projectId: string; + host: string; + outcome: "replied" | "won" | "lost" | "contacted"; +}): Promise | Err> { + const auth = await requireLeadAccess(input.projectId); + if (!auth.ok) return auth; + + const prospect = await projectProspect(input.projectId, input.host); + if (!prospect) return { ok: false, error: "That lead isn't in this project." }; + + const { error } = await serviceClient() + .from("outreach_prospects") + .update({ status: input.outcome }) + .eq("id", prospect.id); + if (error) return { ok: false, error: error.message }; + + revalidatePath(leadsPath(input.projectId)); + return { ok: true, note: `Marked ${input.host} as ${input.outcome}.` }; +} + /** Re-run research on one lead: pick up a finished scan, refresh the contact. */ export async function researchLeadAction(input: { projectId: string; diff --git a/lib/credits.ts b/lib/credits.ts index 5f8a400f..2842d444 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -15,6 +15,26 @@ export const SCAN_CREDITS = 20; // Credits charged for one outreach send (email / SMS recipient / social post). export const OUTREACH_CREDITS = 1; +/** + * One billable lead-generation tick: discovery, contact lookup and drafting. + * + * Priced off measured cost rather than a guess. At the per-tick caps the run + * spends roughly 3.8c on ValueSERP (five discovery queries plus the contact + * fallback), 0.9c on drafting, and a fraction of a cent on rendering — about + * 4.9c at the ceiling and closer to 2c in ordinary use. Search dominates; + * the AI is under a fifth of it, so pricing off model cost alone would + * undercharge by roughly five times. + * + * Three credits is 15c at rack and 7.5c on the deepest pack, which keeps a + * margin at every tier. Two would have been exactly break-even for anyone on + * the 100-scan pack. + * + * Charged only when a tick actually spends: the cron fires every fifteen + * minutes, so billing an idle campaign per tick would cost a user 288 credits + * a day for no work. + */ +export const LEAD_RUN_CREDITS = 3; + export type CreditPack = { id: string; label: string; diff --git a/lib/outreach/funnel.ts b/lib/outreach/funnel.ts new file mode 100644 index 00000000..4d1dae19 --- /dev/null +++ b/lib/outreach/funnel.ts @@ -0,0 +1,151 @@ +// Measured outreach funnel, at three levels: the whole project, one campaign, +// and one tick. +// +// Everything here is counted from what actually happened — live sends in +// outreach_sends, prospect statuses that a human or a reply-check moved — not +// from benchmark rates. Published conversion numbers are worth very little +// when they are somebody else's: the point of showing these is that they are +// this account's own, on this campaign, and get truer the longer it runs. +// +// Rates are deliberately absent until there is enough volume to mean +// anything. One reply out of three sends is not a 33% reply rate, and +// rendering it as one invites a decision that the sample cannot support. + +import { serviceClient } from "@/lib/supabase/service"; + +/** Below this many sends, a percentage is noise rather than a rate. */ +const MIN_SENDS_FOR_RATE = 20; + +export type FunnelCounts = { + /** Live sends. Dry runs are excluded — nobody received them. */ + sent: number; + /** Distinct people contacted, which is what a rate should divide by. */ + contacted: number; + replied: number; + won: number; + lost: number; + /** Null until the sample is large enough to carry a percentage. */ + replyRate: number | null; + closeRate: number | null; + /** Why a rate is missing, for the UI to show instead of a number. */ + rateNote: string | null; +}; + +export type CampaignFunnel = FunnelCounts & { campaign: string }; + +function rates(counts: Omit): FunnelCounts { + if (counts.sent < MIN_SENDS_FOR_RATE) { + return { + ...counts, + replyRate: null, + closeRate: null, + rateNote: `needs ${MIN_SENDS_FOR_RATE - counts.sent} more sends before a rate means anything`, + }; + } + const replyRate = counts.contacted > 0 ? counts.replied / counts.contacted : 0; + // Close rate is of people who replied, not of everyone contacted — a deal + // comes out of a conversation, and dividing by silence flatters nothing. + const closeRate = counts.replied > 0 ? counts.won / counts.replied : null; + return { + ...counts, + replyRate, + closeRate, + rateNote: counts.replied === 0 ? "no replies yet, so there is no close rate to show" : null, + }; +} + +/** + * The whole project: every campaign plus anything sent by hand. + */ +export async function projectFunnel(projectId: string): Promise { + const sb = serviceClient(); + const [{ count: sent }, { data: prospects }] = await Promise.all([ + sb + .from("outreach_sends") + .select("id", { count: "exact", head: true }) + .eq("project_id", projectId) + .eq("channel", "email") + .eq("dry_run", false), + sb + .from("outreach_prospects") + .select("status") + .eq("project_id", projectId) + .eq("channel", "email") + .in("status", ["contacted", "replied", "won", "lost"]), + ]); + + return rates(tally(sent ?? 0, (prospects as { status: string }[] | null) ?? [])); +} + +/** Per campaign, ordered by volume so the busiest reads first. */ +export async function campaignFunnels(projectId: string): Promise { + const sb = serviceClient(); + const [{ data: sends }, { data: prospects }] = await Promise.all([ + sb + .from("outreach_sends") + .select("campaign") + .eq("project_id", projectId) + .eq("channel", "email") + .eq("dry_run", false), + sb + .from("outreach_prospects") + .select("status, campaign_id") + .eq("project_id", projectId) + .eq("channel", "email") + .in("status", ["contacted", "replied", "won", "lost"]), + ]); + + // Sends record the campaign by name; prospects by id. Names are what the + // user sees, so they are the join key here and the id is mapped onto it. + const { data: campaigns } = await sb + .from("outreach_campaigns") + .select("id, name") + .eq("project_id", projectId); + const nameById = new Map( + ((campaigns as { id: string; name: string }[] | null) ?? []).map((c) => [c.id, c.name]), + ); + + const sentByCampaign = new Map(); + for (const s of ((sends as { campaign: string | null }[] | null) ?? [])) { + const key = s.campaign ?? "(manual)"; + sentByCampaign.set(key, (sentByCampaign.get(key) ?? 0) + 1); + } + + const statusesByCampaign = new Map(); + for (const p of ((prospects as { status: string; campaign_id: string | null }[] | null) ?? [])) { + const key = (p.campaign_id && nameById.get(p.campaign_id)) || "(manual)"; + const list = statusesByCampaign.get(key) ?? []; + list.push({ status: p.status }); + statusesByCampaign.set(key, list); + } + + const names = new Set([...sentByCampaign.keys(), ...statusesByCampaign.keys()]); + return [...names] + .map((campaign) => ({ + campaign, + ...rates(tally(sentByCampaign.get(campaign) ?? 0, statusesByCampaign.get(campaign) ?? [])), + })) + .sort((a, b) => b.sent - a.sent); +} + +function tally( + sent: number, + prospects: { status: string }[], +): Omit { + let replied = 0; + let won = 0; + let lost = 0; + for (const p of prospects) { + if (p.status === "replied") replied += 1; + // A won or lost prospect replied first — it cannot become either without + // a conversation — so it counts toward replies as well as its own bucket. + else if (p.status === "won") { + won += 1; + replied += 1; + } else if (p.status === "lost") { + lost += 1; + replied += 1; + } + } + return { sent, contacted: prospects.length, replied, won, lost }; +} diff --git a/lib/outreach/pipeline.ts b/lib/outreach/pipeline.ts index d04628aa..5d3733ef 100644 --- a/lib/outreach/pipeline.ts +++ b/lib/outreach/pipeline.ts @@ -276,6 +276,15 @@ export async function researchProspect(input: { discoveryLabel?: string | null; /** Don't queue a scan for prospects we have no audit for. */ skipScan?: boolean; + /** + * Shared, mutable allowance for the search-based contact fallback. + * + * The fallback spends SERP calls per prospect, so it is the most variable + * cost in a tick. The budget is passed rather than counted internally + * because it has to be shared across every prospect the tick researches. + * Omitted means unlimited, which is what one-off manual research wants. + */ + contactSearchBudget?: { remaining: number }; }): Promise { const check = isAllowedTargetUrl(input.url); if (!check.ok) return { status: "error", message: check.reason }; @@ -344,7 +353,9 @@ export async function researchProspect(input: { // Same second step as the unscanned path: a scan we can talk about is // worth nothing if there is no address to send it to. - if (!contact) { + const auditBudget = input.contactSearchBudget; + if (!contact && (!auditBudget || auditBudget.remaining > 0)) { + if (auditBudget) auditBudget.remaining -= 1; const viaSearch = await findContactViaSearch({ host, label: input.discoveryLabel }); if (viaSearch.candidates.length) { candidates = [...candidates, ...viaSearch.candidates]; @@ -412,6 +423,7 @@ async function researchWithoutScan(input: { notes?: string | null; discoveredVia?: string | null; discoveryLabel?: string | null; + contactSearchBudget?: { remaining: number }; host: string; target: string; }): Promise { @@ -431,7 +443,9 @@ async function researchWithoutScan(input: { // The site published nothing reachable. Rather than park the prospect at // "new" forever, look the business up — plenty of portfolios hide contact // behind a form or an image, and the address exists somewhere else. - if (!contact) { + const budget = input.contactSearchBudget; + if (!contact && (!budget || budget.remaining > 0)) { + if (budget) budget.remaining -= 1; const viaSearch = await findContactViaSearch({ host, label: input.discoveryLabel }); if (viaSearch.candidates.length) { candidates = [...candidates, ...viaSearch.candidates]; diff --git a/lib/outreach/runner.ts b/lib/outreach/runner.ts index d2fc6c7a..ff02e2e5 100644 --- a/lib/outreach/runner.ts +++ b/lib/outreach/runner.ts @@ -78,9 +78,16 @@ export type TickResult = { const MAX_DISCOVER_PER_TICK = 15; const MAX_RESEARCH_PER_TICK = 8; const MAX_SEND_PER_TICK = 5; +// Ceiling on search-based contact lookups per tick. The fallback is the most +// variable cost in a run — SERP calls scale with how many prospects publish +// no address — so it gets a ceiling like every other per-tick stage. +const MAX_CONTACT_SEARCHES_PER_TICK = 10; export async function runEmailCampaignTick(campaign: CampaignRow): Promise { const sb = serviceClient(); + // Shared across every prospect this tick researches, so the ceiling is per + // run rather than per prospect. + const contactSearchBudget = { remaining: MAX_CONTACT_SEARCHES_PER_TICK }; const result: TickResult = { campaign: campaign.name, discovered: 0, @@ -188,6 +195,7 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise