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
30 changes: 30 additions & 0 deletions app/actions/leads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ok<{ note: string }> | 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;
Expand Down
20 changes: 20 additions & 0 deletions lib/credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
151 changes: 151 additions & 0 deletions lib/outreach/funnel.ts
Original file line number Diff line number Diff line change
@@ -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, "replyRate" | "closeRate" | "rateNote">): 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<FunnelCounts> {
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<CampaignFunnel[]> {
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<string, number>();
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<string, { status: string }[]>();
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<FunnelCounts, "replyRate" | "closeRate" | "rateNote"> {
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 };
}
18 changes: 16 additions & 2 deletions lib/outreach/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResearchResult> {
const check = isAllowedTargetUrl(input.url);
if (!check.ok) return { status: "error", message: check.reason };
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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<ResearchResult> {
Expand All @@ -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];
Expand Down
9 changes: 9 additions & 0 deletions lib/outreach/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TickResult> {
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,
Expand Down Expand Up @@ -188,6 +195,7 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise<TickR
url: p.site_url ?? `https://${p.target_key}`,
campaignId: campaign.id,
skipScan: !campaign.scan_prospects,
contactSearchBudget,
});
if (res.status === "researched") {
result.researched += 1;
Expand Down Expand Up @@ -261,6 +269,7 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise<TickR
// every newly discovered domain, so missing it meant a campaign with
// scanning off still scanned everything it found.
skipScan: !campaign.scan_prospects,
contactSearchBudget,
});
if (res.status === "scanning") {
result.scansStarted += 1;
Expand Down
Loading
Loading