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
12 changes: 11 additions & 1 deletion app/(app)/projects/[id]/leads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { MailboxConnect, type ConnectedMailbox } from "@/components/leads/mailbo
import { SeedLogins } from "@/components/leads/seed-logins";
import { FunnelPanel } from "@/components/leads/funnel-panel";
import { RepliesPanel, type ReplyRow } from "@/components/leads/replies-panel";
import { ContactsPanel } from "@/components/leads/contacts-panel";
import { contactNiches } from "@/lib/outreach/contactsExport";
import { campaignFunnels, projectFunnel } from "@/lib/outreach/funnel";
import { listSeedCredentials, type StoredSeedCredential } from "@/lib/outreach/seedCredentials";
import { RefreshLeads } from "@/components/leads/refresh-leads";
Expand Down Expand Up @@ -162,9 +164,10 @@ export default async function LeadsPage({
// Measured outcomes, project-wide and per campaign. Both read the same
// tables the pipeline already writes, so this costs two queries rather than
// any new bookkeeping.
const [funnel, perCampaignFunnel] = await Promise.all([
const [funnel, perCampaignFunnel, contacts] = await Promise.all([
projectFunnel(projectId),
campaignFunnels(projectId),
contactNiches(projectId),
]);

// What came back. Read from the connected mailbox by the reply-scan cron,
Expand Down Expand Up @@ -240,6 +243,13 @@ export default async function LeadsPage({

<RepliesPanel replies={replies} />

<ContactsPanel
projectId={projectId}
total={contacts.total}
withEmail={contacts.withEmail}
niches={contacts.niches}
/>

<SeedLogins
projectId={projectId}
waitingHosts={waitingHosts}
Expand Down
43 changes: 43 additions & 0 deletions app/api/projects/[id]/contacts.csv/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { requireProjectAccess } from "@/lib/lx/currentSite";
import { contactsForProject, toCsv } from "@/lib/outreach/contactsExport";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* The organization's contact list, as CSV.
*
* Access-checked against the project like every other outreach surface; the
* contacts themselves are org-scoped, which is deliberate — the same person
* found by two projects is one contact, and the export reflects that rather
* than the project that happened to request it.
*
* Viewers are allowed. Reading the list is not an outreach action, and a
* read-only member being unable to look at it would make the export useless
* to exactly the people most likely to want it as a file.
*/
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const access = await requireProjectAccess(id, { allowViewer: true });
if (!access.ok) {
return NextResponse.json({ ok: false, error: "not found" }, { status: 404 });
}

const rows = await contactsForProject(id);
const stamp = new Date().toISOString().slice(0, 10);

// Leading BOM: without it Excel reads the file as the local codepage and
// mangles every accented name in it.
return new NextResponse(`${toCsv(rows)}`, {
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="contacts-${stamp}.csv"`,
"cache-control": "no-store",
},
});
}
66 changes: 66 additions & 0 deletions components/leads/contacts-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { NicheCount } from "@/lib/outreach/contactsExport";

/**
* The contact list, as an asset rather than a table.
*
* Contacts are org-scoped and outlive any single campaign — the same person
* found by two projects is one record. That is the whole reason the table is
* separate from prospects, and until now it had no surface at all, so the
* durable thing the pipeline was building could not be seen or taken away.
*
* Segment sizes rather than a single total, because how many of a kind you
* have is what decides whether a list is worth anything.
*/
export function ContactsPanel({
projectId,
total,
withEmail,
niches,
}: {
projectId: string;
total: number;
withEmail: number;
niches: NicheCount[];
}) {
if (total === 0) return null;

return (
<section className="card p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h2 className="text-lg font-semibold">Contacts</h2>
<p className="text-sm text-[var(--color-muted)]">
Everyone this organization has found, deduplicated across every campaign and project.{" "}
{withEmail} of {total} have an address.
</p>
</div>
<a href={`/api/projects/${projectId}/contacts.csv`} className="btn text-sm" download>
Export CSV
</a>
</div>

<ul className="mt-3 flex flex-wrap gap-2">
{niches.map((n) => (
<li
key={n.niche}
className="rounded-lg border border-[var(--color-border)] px-3 py-1.5 text-sm"
>
{n.niche === "unsorted" ? (
<span className="text-[var(--color-muted)]">unsorted</span>
) : (
n.niche
)}{" "}
<span className="font-mono text-xs text-[var(--color-muted)]">
{n.withEmail}/{n.contacts}
</span>
</li>
))}
</ul>

<p className="mt-3 text-xs text-[var(--color-muted)]">
Niche comes from the campaign that found them. Anyone who asked not to be contacted is
excluded from both the counts and the export.
</p>
</section>
);
}
133 changes: 133 additions & 0 deletions lib/outreach/contactsExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// The contacts table, as a file you can actually take away.
//
// The table is org-scoped and durable — it outlives any one campaign, which is
// the whole reason it exists separately from prospects. But a list nobody can
// see or export is not an asset, it is a table, and it had no surface at all
// until this.

import { serviceClient } from "@/lib/supabase/service";

export type ContactExportRow = {
email: string | null;
full_name: string | null;
title: string | null;
company_name: string | null;
company_site: string | null;
niche: string | null;
host: string | null;
country: string | null;
phone: string | null;
linkedin_url: string | null;
source_url: string | null;
first_seen_at: string | null;
};

export const EXPORT_COLUMNS: Array<keyof ContactExportRow> = [
"email",
"full_name",
"title",
"company_name",
"company_site",
"niche",
"host",
"country",
"phone",
"linkedin_url",
"source_url",
"first_seen_at",
];

/**
* One CSV field.
*
* Quoted whenever it contains a delimiter, a quote or a newline, with inner
* quotes doubled — RFC 4180. A leading =, +, - or @ is prefixed with a single
* quote as well: spreadsheets treat those as formulas, and a contact list is
* exactly the kind of file that gets opened in one without a thought.
*/
export function csvField(value: unknown): string {
if (value === null || value === undefined) return "";
let s = String(value);
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}

export function toCsv(rows: ContactExportRow[]): string {
const head = EXPORT_COLUMNS.join(",");
const body = rows.map((r) => EXPORT_COLUMNS.map((c) => csvField(r[c])).join(","));
// CRLF and a trailing newline, which is what RFC 4180 says and what Excel
// wants.
return [head, ...body].join("\r\n") + "\r\n";
}

/** Every contact the organization owning this project knows about. */
export async function contactsForProject(projectId: string): Promise<ContactExportRow[]> {
const sb = serviceClient();
const { data: project } = await sb
.from("projects")
.select("organization_id")
.eq("id", projectId)
.maybeSingle();
const orgId = (project?.organization_id as string | null) ?? null;
if (!orgId) return [];

const { data } = await sb
.from("outreach_contacts")
.select(EXPORT_COLUMNS.join(", "))
.eq("organization_id", orgId)
// Anyone who asked not to be contacted is not part of a list that exists
// to be contacted. Excluding them here rather than at the point of sending
// means the exported file cannot reintroduce them somewhere else.
.eq("do_not_contact", false)
.order("first_seen_at", { ascending: false })
.limit(50_000);

return (data as ContactExportRow[] | null) ?? [];
}

export type NicheCount = { niche: string; contacts: number; withEmail: number };

/**
* What the list is made of.
*
* Segment size is what decides whether a list is worth anything, so the
* breakdown is the summary rather than the total.
*/
export async function contactNiches(projectId: string): Promise<{
total: number;
withEmail: number;
niches: NicheCount[];
}> {
const sb = serviceClient();
const { data: project } = await sb
.from("projects")
.select("organization_id")
.eq("id", projectId)
.maybeSingle();
const orgId = (project?.organization_id as string | null) ?? null;
if (!orgId) return { total: 0, withEmail: 0, niches: [] };

const { data } = await sb
.from("outreach_contacts")
.select("niche, email")
.eq("organization_id", orgId)
.eq("do_not_contact", false)
.limit(50_000);

const rows = (data as { niche: string | null; email: string | null }[] | null) ?? [];
const byNiche = new Map<string, NicheCount>();
for (const r of rows) {
const key = r.niche ?? "unsorted";
const entry = byNiche.get(key) ?? { niche: key, contacts: 0, withEmail: 0 };
entry.contacts += 1;
if (r.email) entry.withEmail += 1;
byNiche.set(key, entry);
}

return {
total: rows.length,
withEmail: rows.filter((r) => r.email).length,
niches: [...byNiche.values()].sort((a, b) => b.contacts - a.contacts),
};
}
35 changes: 34 additions & 1 deletion lib/outreach/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,19 @@ export async function researchProspect(input: {
}
}

// The same record the unscanned path writes. Missing it here is why the
// shared contacts table held nine rows against seventy-seven addressable
// leads: nearly all of them arrived through this branch, which wrote the
// address onto the prospect and nowhere else.
const scannedContactId = await recordContact({
projectId: input.projectId,
host,
email: contact?.email ?? null,
label: input.discoveryLabel,
campaignId: input.campaignId,
source: contact?.source === "manual" ? "manual" : contact?.source === "guess" ? "guess" : "page",
});

const { data, error } = await serviceClient()
.from("outreach_prospects")
.upsert(
Expand All @@ -403,6 +416,7 @@ export async function researchProspect(input: {
discovery_label: input.discoveryLabel ?? null,
contact_email: contact?.email ?? null,
contact_source: contact?.source ?? null,
contact_id: scannedContactId,
audit_id: audit.id,
report_token: audit.share_token,
score: audit.score,
Expand Down Expand Up @@ -454,16 +468,33 @@ async function recordContact(input: {
host: string;
email: string | null;
label?: string | null;
campaignId?: string | null;
source: "page" | "search" | "guess" | "manual";
}): Promise<string | null> {
const { data: project } = await serviceClient()
const sb = serviceClient();
const { data: project } = await sb
.from("projects")
.select("organization_id")
.eq("id", input.projectId)
.maybeSingle();
const organizationId = (project?.organization_id as string | null) ?? null;
if (!organizationId) return null;

// The campaign that found them is the best available statement of what
// they are: someone found by "game designers in Austin" is a game
// designer in Austin. It is the only niche the pipeline can know without
// asking a model to guess one, and a list without a niche cannot be
// segmented, which is most of what makes a list worth anything.
let niche: string | null = null;
if (input.campaignId) {
const { data: campaign } = await sb
.from("outreach_campaigns")
.select("name")
.eq("id", input.campaignId)
.maybeSingle();
niche = (campaign?.name as string | null) ?? null;
}

const res = await upsertContact({
organizationId,
source: input.source,
Expand All @@ -475,6 +506,7 @@ async function recordContact(input: {
// one rather than guessed into full_name.
companyName: input.label ?? null,
companySite: `https://${input.host}`,
niche,
},
});
return res?.id ?? null;
Expand Down Expand Up @@ -540,6 +572,7 @@ async function researchWithoutScan(input: {
host,
email: contact?.email ?? null,
label: input.discoveryLabel,
campaignId: input.campaignId,
source: contact?.source === "manual" ? "manual" : contact?.source === "guess" ? "guess" : "page",
});

Expand Down
Loading
Loading