From 4448c2c600144d350c8cd34d4669e86d12bcef1a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 26 Jul 2026 08:44:41 +0000 Subject: [PATCH] feat(leads): ownership filter, send log, and an admin leads dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps found by actually running the campaign against the real audience. 1. Ownership. ~15 of the 108 captured leads scanned a site they plainly don't own — x.com, github.com, wikipedia.org, linkedin.com, share.google. They were trying the tool, not auditing their property, so "want us to fix it?" reads as a mailshot that didn't look at its own data. New third-party-scan exclusion; they stay leads, they're just the wrong audience for this pitch. 2. No send log. Nothing recorded who had been mailed, so a re-run would mail everyone twice. Adds campaign_sends with a unique index on (campaign, lower(email)) — the database is the guard; the application check is only an optimisation and would race under a concurrent run. The row is written AFTER a confirmed send, so a failed send can still be retried. 3. No UI. Results lived only in SQL. /admin/leads now shows captured leads and their status, campaign sends, watches, and live per-segment audience counts with exclusion breakdowns — computed with selectRecipients, the same function the sender uses, so the page can't drift from what would actually go out. Co-Authored-By: Claude Opus 5 (1M context) --- app/(app)/admin/leads/page.tsx | 291 ++++++++++++++++++ app/(app)/admin/page.tsx | 5 + app/api/admin/lead-campaign/route.ts | 25 +- lib/leadCampaign.ts | 29 ++ .../20260726140000_campaign_sends.sql | 30 ++ tests/lead-campaign.test.ts | 37 +++ 6 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 app/(app)/admin/leads/page.tsx create mode 100644 supabase/migrations/20260726140000_campaign_sends.sql diff --git a/app/(app)/admin/leads/page.tsx b/app/(app)/admin/leads/page.tsx new file mode 100644 index 00000000..8fa8ae5f --- /dev/null +++ b/app/(app)/admin/leads/page.tsx @@ -0,0 +1,291 @@ +import Link from "next/link"; +import { redirect, notFound } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { buildShareCard } from "@/lib/audit/share-card"; +import { + selectRecipients, + type ExclusionReason, + type LeadRow, + type Segment, +} from "@/lib/leadCampaign"; + +export const metadata = { title: "Leads", robots: { index: false, follow: false } }; +export const dynamic = "force-dynamic"; + +const CAMPAIGN_ID = "lead-reengagement-2026-07"; + +function fmt(ts: string | null): string { + if (!ts) return "—"; + return new Date(ts).toISOString().slice(0, 16).replace("T", " "); +} + +export default async function AdminLeadsPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect("/login"); + const { data: me } = await supabase + .from("profiles") + .select("is_admin") + .eq("id", user.id) + .maybeSingle(); + // 404 rather than redirect, matching /admin — a non-admin who guesses the + // URL shouldn't get confirmation the page exists. + if (!me?.is_admin) notFound(); + + const svc = serviceClient(); + + const [{ data: auditRows }, { data: sendRows }, { data: watchRows }] = await Promise.all([ + svc + .from("audits") + .select("pdf_email, phone, target_url, share_token, status, score, engine, summary, completed_at") + .not("pdf_email", "is", null) + .eq("status", "complete") + .order("completed_at", { ascending: false }) + .limit(2000), + svc + .from("campaign_sends") + .select("email, subject, sent_at, campaign") + .order("sent_at", { ascending: false }) + .limit(500), + svc + .from("scan_watches") + .select("email, target_url, engine, cadence, last_score, verified_at, unsubscribed_at, last_notified_at, next_run_at") + .order("created_at", { ascending: false }) + .limit(200), + ]); + + const sends = sendRows ?? []; + const sentSet = new Set(sends.map((s) => String(s.email ?? "").toLowerCase())); + + const emails = Array.from( + new Set( + (auditRows ?? []) + .map((r) => String(r.pdf_email ?? "").trim().toLowerCase()) + .filter(Boolean), + ), + ); + + const [{ data: contacts }, { data: profiles }] = await Promise.all([ + emails.length + ? svc.from("marketing_contacts").select("email, unsubscribed_at, consented_at").in("email", emails) + : Promise.resolve({ data: [] as never[] }), + emails.length + ? svc.from("profiles").select("email").in("email", emails) + : Promise.resolve({ data: [] as never[] }), + ]); + const cMap = new Map( + (contacts ?? []).map((c) => [String(c.email).toLowerCase(), c as Record]), + ); + const custs = new Set((profiles ?? []).map((p) => String(p.email ?? "").toLowerCase())); + + const seen = new Set(); + const leads: Array = []; + for (const r of (auditRows ?? []) as Array>) { + const email = String(r.pdf_email ?? "").trim().toLowerCase(); + if (!email || seen.has(email)) continue; + seen.add(email); + const card = buildShareCard(r as Parameters[0]); + const c = cMap.get(email); + leads.push({ + email, + host: card.host, + reportToken: (r.share_token as string | null) ?? null, + score: card.score, + scoreLabel: card.label, + kind: card.kind, + scaleHint: card.scaleHint, + topIssues: [], + isCustomer: custs.has(email), + unsubscribedAt: (c?.unsubscribed_at as string | null) ?? null, + consentedAt: (c?.consented_at as string | null) ?? null, + alreadySent: sentSet.has(email), + phone: (r.phone as string | null) ?? null, + scannedAt: (r.completed_at as string | null) ?? null, + }); + } + + // Same selection the campaign endpoint runs, so this page can't drift from + // what would actually be sent. + const bySegment: Record }> = + {} as never; + for (const seg of ["all", "users", "leads"] as Segment[]) { + const { send, excluded } = selectRecipients(leads, seg); + const counts: Record = {}; + for (const e of excluded) counts[e.reason] = (counts[e.reason] ?? 0) + 1; + bySegment[seg] = { send: send.length, excluded: counts }; + } + + const watches = watchRows ?? []; + const confirmedWatches = watches.filter((w) => w.verified_at && !w.unsubscribed_at); + + return ( +
+
+

Leads

+ + ← Admin + +
+ +
+ + + + l.unsubscribedAt).length} + hint="honoured on every send" + /> +
+ +
+
+ {(["all", "users", "leads"] as Segment[]).map((seg) => ( +
+

{seg}

+

{bySegment[seg].send}

+

would send now

+
    + {Object.entries(bySegment[seg].excluded) + .sort((a, b) => b[1] - a[1]) + .map(([reason, n]) => ( +
  • + {reason.replace(/-/g, " ")}: {n} +
  • + ))} +
+
+ ))} +
+
+ +
+ {sends.length === 0 ? ( + Nothing sent yet. + ) : ( + + {sends.slice(0, 100).map((s, i) => ( + + + + + + ))} +
{String(s.email)}{String(s.subject ?? "—")}{fmt(s.sent_at as string)}
+ )} +
+ +
+ {watches.length === 0 ? ( + No watches yet. They appear once someone confirms the opt-in email. + ) : ( + + {watches.map((w, i) => ( + + + + + + + + + ))} +
{String(w.email)}{String(w.target_url)} + {String(w.cadence)} · {String(w.engine)} + {w.last_score ?? "—"} + {w.unsubscribed_at ? "stopped" : w.verified_at ? fmt(w.verified_at as string) : "pending"} + {fmt(w.next_run_at as string)}
+ )} +
+ +
+ + {leads.slice(0, 200).map((l) => { + const status: ExclusionReason | "queued" | "sent" = l.unsubscribedAt + ? "unsubscribed" + : l.alreadySent + ? "sent" + : (selectRecipients([l], "all").excluded[0]?.reason ?? "queued"); + return ( + + + + + + + + + ); + })} +
{l.email}{l.phone ?? "—"} + {l.reportToken ? ( + + {l.host} + + ) : ( + l.host + )} + {l.score ?? "—"}{l.isCustomer ? "user" : "lead"}{String(status).replace(/-/g, " ")}
+ {leads.length > 200 && ( +

+ Showing 200 of {leads.length}. +

+ )} +
+
+ ); +} + +function Stat({ label, value, hint }: { label: string; value: number; hint?: string }) { + return ( +
+

{label}

+

{value}

+ {hint &&

{hint}

} +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Empty({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function Table({ head, children }: { head: string[]; children: React.ReactNode }) { + return ( +
+ + + + {head.map((h) => ( + + ))} + + + {children} +
+ {h} +
+
+ ); +} + +function Td({ children, muted }: { children: React.ReactNode; muted?: boolean }) { + return ( + {children} + ); +} diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx index 1bace662..41537103 100644 --- a/app/(app)/admin/page.tsx +++ b/app/(app)/admin/page.tsx @@ -69,6 +69,11 @@ export default async function AdminPage() {

Admin

+

+ + Leads & campaigns → + +

Grant or remove credits by email. Every grant is logged in{" "} admin_credit_grants. diff --git a/app/api/admin/lead-campaign/route.ts b/app/api/admin/lead-campaign/route.ts index c2fff274..0e22c6a3 100644 --- a/app/api/admin/lead-campaign/route.ts +++ b/app/api/admin/lead-campaign/route.ts @@ -17,6 +17,10 @@ import { env } from "@/lib/env"; export const runtime = "nodejs"; export const maxDuration = 300; +// Identifies this campaign in campaign_sends. Bump it to deliberately mail a +// previously-contacted audience again. +const CAMPAIGN_ID = "lead-reengagement-2026-07"; + // One-off re-engagement of people who ran a scan and asked for the PDF. // // Why this exists rather than /api/admin/email-broadcast: that route selects @@ -65,10 +69,14 @@ async function loadAudience(): Promise { ); if (emails.length === 0) return []; - const [{ data: contacts }, { data: profiles }] = await Promise.all([ + const [{ data: contacts }, { data: profiles }, { data: alreadySent }] = await Promise.all([ svc.from("marketing_contacts").select("email, unsubscribed_at, consented_at").in("email", emails), svc.from("profiles").select("email").in("email", emails), + svc.from("campaign_sends").select("email").eq("campaign", CAMPAIGN_ID), ]); + const sentAlready = new Set( + (alreadySent ?? []).map((r) => String(r.email ?? "").trim().toLowerCase()), + ); const contactByEmail = new Map( (contacts ?? []).map((c) => [String(c.email).toLowerCase(), c]), @@ -118,6 +126,7 @@ async function loadAudience(): Promise { isCustomer: customerEmails.has(email), unsubscribedAt: (contact?.unsubscribed_at as string | null) ?? null, consentedAt: (contact?.consented_at as string | null) ?? null, + alreadySent: sentAlready.has(email), }); } return out; @@ -225,8 +234,18 @@ export async function POST(req: NextRequest) { unsubscribeToken: contact.unsubscribe_token as string, }); - if (res.sent) sent++; - else { + if (res.sent) { + sent++; + // Log AFTER a confirmed send. Logging first would suppress a retry of a + // message that never actually went out. + if (!body.testTo) { + await svc.from("campaign_sends").insert({ + campaign: CAMPAIGN_ID, + email: row.email, + subject: campaignSubject(row), + }); + } + } else { failed++; if (errors.length < 10) errors.push(`${row.email}: ${res.error}`); } diff --git a/lib/leadCampaign.ts b/lib/leadCampaign.ts index 4260641d..e463a4c2 100644 --- a/lib/leadCampaign.ts +++ b/lib/leadCampaign.ts @@ -19,6 +19,8 @@ export type LeadRow = { isCustomer: boolean; unsubscribedAt: string | null; consentedAt: string | null; + /** True when this campaign already mailed this address. */ + alreadySent?: boolean; }; export type Segment = "users" | "leads" | "all"; @@ -28,11 +30,35 @@ const INTERNAL = /@(profullstack\.com|crawlproof\.com)$/i; const ROLE_LOCALPART = /^(postmaster|abuse|noreply|no-reply|donotreply|mailer-daemon|admin|webmaster|hostmaster)@/i; +/** + * Hosts nobody in our audience owns. Someone who scanned x.com or wikipedia.org + * was trying the tool, not auditing their property — asking "want us to fix + * it?" about a site they can't change reads as a mailshot that didn't look at + * its own data. They stay leads; they're just the wrong audience for THIS + * pitch. + */ +const THIRD_PARTY_HOSTS = [ + "google.com", "share.google", "docs.google.com", "x.com", "twitter.com", + "youtube.com", "facebook.com", "instagram.com", "linkedin.com", "github.com", + "wikipedia.org", "linktr.ee", "sciencedirect.com", "medium.com", "reddit.com", + "amazon.com", "notion.so", "chatgpt.com", "openai.com", "apple.com", + "microsoft.com", "tiktok.com", "pinterest.com", "yahoo.com", "bing.com", +]; + +export function isThirdPartyHost(host: string): boolean { + const h = host.trim().toLowerCase().replace(/^www\./, ""); + if (!h) return false; + const apex = h.split(".").slice(-2).join("."); + return THIRD_PARTY_HOSTS.some((t) => h === t || apex === t || h.endsWith(`.${t}`)); +} + export type ExclusionReason = | "unsubscribed" | "internal" | "role-account" | "no-report" + | "third-party-scan" + | "already-sent" | "wrong-segment"; export function excludeReason(row: LeadRow, segment: Segment): ExclusionReason | null { @@ -44,6 +70,9 @@ export function excludeReason(row: LeadRow, segment: Segment): ExclusionReason | // The whole message is about their report. Without one there is nothing to // say, and it degrades into the generic blast we're avoiding. if (!row.reportToken) return "no-report"; + if (isThirdPartyHost(row.host)) return "third-party-scan"; + // Set by the caller from the campaign_sends log — nobody gets this twice. + if (row.alreadySent) return "already-sent"; if (segment === "users" && !row.isCustomer) return "wrong-segment"; if (segment === "leads" && row.isCustomer) return "wrong-segment"; return null; diff --git a/supabase/migrations/20260726140000_campaign_sends.sql b/supabase/migrations/20260726140000_campaign_sends.sql new file mode 100644 index 00000000..d7030e3a --- /dev/null +++ b/supabase/migrations/20260726140000_campaign_sends.sql @@ -0,0 +1,30 @@ +-- Send log for one-off outbound campaigns. +-- +-- Without this there is no record that an address was mailed, so re-running a +-- campaign silently mails everyone a second time. The unique index is the +-- actual guard — the application check is only an optimisation, and a +-- concurrent second run would race straight past it. + +create table if not exists public.campaign_sends ( + id uuid primary key default gen_random_uuid(), + campaign text not null, + email text not null, + subject text, + sent_at timestamptz not null default now() +); + +-- One send per address per campaign, enforced by the database. +create unique index if not exists campaign_sends_campaign_email_idx + on public.campaign_sends (campaign, lower(trim(email))); + +create index if not exists campaign_sends_sent_at_idx + on public.campaign_sends (campaign, sent_at desc); + +alter table public.campaign_sends enable row level security; + +-- No policies: service-role only. This is an internal audit log of who we +-- mailed, not something anon or authenticated should ever read. + +comment on table public.campaign_sends is + 'Audit log of outbound campaign sends. Unique on (campaign, email) so a ' + 're-run cannot mail the same address twice. Service-role access only.'; diff --git a/tests/lead-campaign.test.ts b/tests/lead-campaign.test.ts index 4afbd9fc..853b4a41 100644 --- a/tests/lead-campaign.test.ts +++ b/tests/lead-campaign.test.ts @@ -3,6 +3,7 @@ import { campaignSubject, excludeReason, isStrongScore, + isThirdPartyHost, hireUrlFor, selectRecipients, type LeadRow, @@ -58,6 +59,42 @@ describe("excludeReason", () => { }); }); +describe("isThirdPartyHost", () => { + it("flags sites the recipient plainly does not own", () => { + // These people were trying the tool, not auditing their property. Asking + // "want us to fix it?" about x.com reads as a mailshot that didn't look + // at its own data. + for (const h of [ + "x.com", "github.com", "wikipedia.org", "linkedin.com", "share.google", + "docs.google.com", "linktr.ee", "www.youtube.com", + ]) { + expect(isThirdPartyHost(h)).toBe(true); + } + }); + + it("leaves ordinary sites alone", () => { + for (const h of ["acme.com", "labautomation101.ai", "kabstreat.com", "mygithub.io"]) { + expect(isThirdPartyHost(h)).toBe(false); + } + }); + + it("excludes a third-party scan from the campaign", () => { + expect(excludeReason(lead({ host: "x.com" }), "all")).toBe("third-party-scan"); + }); +}); + +describe("already-sent guard", () => { + it("never mails the same address twice in one campaign", () => { + expect(excludeReason(lead({ alreadySent: true }), "all")).toBe("already-sent"); + expect(excludeReason(lead({ alreadySent: false }), "all")).toBeNull(); + }); + + it("still lets unsubscribe win over a prior send", () => { + const row = lead({ alreadySent: true, unsubscribedAt: "2026-01-01" }); + expect(excludeReason(row, "all")).toBe("unsubscribed"); + }); +}); + describe("segments", () => { it("'users' keeps only existing customers", () => { expect(excludeReason(lead({ isCustomer: true }), "users")).toBeNull();