From 1db849a032b5c6758c8bd4be117ad35724cbf72d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 18 Jun 2026 09:57:21 +0000 Subject: [PATCH] Remove the Audience feature (org mass-email + Audience Hub) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire the Audience feature entirely — it collected contact PII (emails, consent, identities) that was unused and an information-handling risk. Deletes all audience code: lib/audience/*, the /audience and /projects/[id]/audience UI, /api/events ingest, /api/audience/export, the GitHub installer + create-audience-pr route, the org-unsubscribe route, audience tests, and docs/audience-hub.md. Surgically strips audience from shared, still-live code while keeping core analytics intact: - stats.js + /api/track: drop identify/consent/lead-capture; pageview and behavioral-event stats still work, window.crawlproof is track-only - dashboard: remove DataSources + Mass-email panels (org viewer role and Team-members panel kept) - actions/orgs.ts: remove org-audience actions (outreach sender config kept) - lib/marketing.ts: drop orphaned unsubscribeOrgAudienceByToken Adds migration 20260618120000_drop_audience.sql to DROP all 9 audience tables CASCADE (apply with `supabase db push` to delete the data). typecheck clean; 318 tests pass. Co-Authored-By: Claude Opus 4.8 --- app/(app)/audience/[contactId]/page.tsx | 262 --------- app/(app)/audience/page.tsx | 209 ------- app/(app)/dashboard/org-controls.tsx | 405 +------------- app/(app)/dashboard/page.tsx | 47 -- app/(app)/layout.tsx | 1 - app/(app)/projects/[id]/audience/client.tsx | 360 ------------ app/(app)/projects/[id]/audience/page.tsx | 262 --------- app/actions/audience.ts | 93 ---- app/actions/orgs.ts | 194 ------- app/api/audience/export/route.ts | 83 --- app/api/events/route.ts | 124 ----- .../[id]/github/create-audience-pr/route.ts | 143 ----- app/api/track/route.ts | 98 ---- app/stats.js/route.ts | 82 +-- app/unsubscribe/org/[token]/page.tsx | 42 -- components/project-tabs-nav.tsx | 6 - docs/audience-hub.md | 101 ---- lib/audience/blast.ts | 186 ------- lib/audience/connectors.ts | 141 ----- lib/audience/hub.ts | 374 ------------- lib/audience/projectKeys.ts | 93 ---- lib/audience/sync.ts | 145 ----- lib/github/install-audience.ts | 515 ------------------ lib/marketing.ts | 17 - lib/outreach.ts | 1 - .../20260618120000_drop_audience.sql | 20 + tests/audience-connectors.test.ts | 62 --- tests/audience-hub.test.ts | 179 ------ tests/contract/stats-js.test.ts | 23 +- 29 files changed, 36 insertions(+), 4232 deletions(-) delete mode 100644 app/(app)/audience/[contactId]/page.tsx delete mode 100644 app/(app)/audience/page.tsx delete mode 100644 app/(app)/projects/[id]/audience/client.tsx delete mode 100644 app/(app)/projects/[id]/audience/page.tsx delete mode 100644 app/actions/audience.ts delete mode 100644 app/api/audience/export/route.ts delete mode 100644 app/api/events/route.ts delete mode 100644 app/api/projects/[id]/github/create-audience-pr/route.ts delete mode 100644 app/unsubscribe/org/[token]/page.tsx delete mode 100644 docs/audience-hub.md delete mode 100644 lib/audience/blast.ts delete mode 100644 lib/audience/connectors.ts delete mode 100644 lib/audience/hub.ts delete mode 100644 lib/audience/projectKeys.ts delete mode 100644 lib/audience/sync.ts delete mode 100644 lib/github/install-audience.ts create mode 100644 supabase/migrations/20260618120000_drop_audience.sql delete mode 100644 tests/audience-connectors.test.ts delete mode 100644 tests/audience-hub.test.ts diff --git a/app/(app)/audience/[contactId]/page.tsx b/app/(app)/audience/[contactId]/page.tsx deleted file mode 100644 index 9e0230bd..00000000 --- a/app/(app)/audience/[contactId]/page.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import Link from "next/link"; -import { notFound, redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; - -export const metadata = { title: "Audience · Contact" }; - -// Contact detail (PRD §15): identity graph, project memberships, event -// timeline, and consent history for one deduped contact. RLS guarantees the -// caller owns the contact's scope. - -type Contact = { - id: string; - email: string; - name: string | null; - status: string; - marketing_consent: boolean; - unsubscribed_at: string | null; - suppressed_at: string | null; - suppression_reason: string | null; - source_project_id: string | null; - first_seen_at: string; - last_seen_at: string; - first_url: string | null; - first_referrer: string | null; - first_utm_source: string | null; - first_utm_medium: string | null; - first_utm_campaign: string | null; - last_url: string | null; - last_utm_source: string | null; - last_utm_campaign: string | null; - tags: string[] | null; - metadata: Record | null; -}; - -function fmt(iso: string | null): string { - if (!iso) return "—"; - try { - return new Date(iso).toLocaleString(); - } catch { - return iso; - } -} - -export default async function ContactDetailPage({ - params, -}: { - params: Promise<{ contactId: string }>; -}) { - const { contactId } = await params; - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) redirect("/login"); - - const { data: contactRow } = await supabase - .from("audience_contacts") - .select( - "id, email, name, status, marketing_consent, unsubscribed_at, suppressed_at, suppression_reason, source_project_id, first_seen_at, last_seen_at, first_url, first_referrer, first_utm_source, first_utm_medium, first_utm_campaign, last_url, last_utm_source, last_utm_campaign, tags, metadata", - ) - .eq("id", contactId) - .maybeSingle(); - if (!contactRow) notFound(); - const contact = contactRow as Contact; - - const [{ data: identities }, { data: links }, { data: events }, { data: consents }] = - await Promise.all([ - supabase - .from("audience_identities") - .select("id, provider, external_id, project_id, created_at") - .eq("contact_id", contactId) - .order("created_at", { ascending: true }), - supabase - .from("audience_project_links") - .select("id, project_id, external_user_id, role, plan, first_seen_at, last_seen_at") - .eq("contact_id", contactId), - supabase - .from("audience_events") - .select("id, project_id, event, source, url, utm_source, utm_campaign, occurred_at") - .eq("contact_id", contactId) - .order("occurred_at", { ascending: false }) - .limit(50), - supabase - .from("audience_consent_events") - .select("id, consent_type, consent_value, source, occurred_at") - .eq("contact_id", contactId) - .order("occurred_at", { ascending: false }) - .limit(20), - ]); - - // Resolve project names for the ids we reference. - const projectIds = [ - ...new Set( - [ - ...(links ?? []).map((l) => l.project_id as string), - ...(events ?? []).map((e) => e.project_id as string), - contact.source_project_id, - ].filter(Boolean) as string[], - ), - ]; - const projectNames = new Map(); - if (projectIds.length > 0) { - const { data: projects } = await supabase - .from("projects") - .select("id, name") - .in("id", projectIds); - for (const p of projects ?? []) projectNames.set(p.id as string, p.name as string); - } - const projectLabel = (id: string | null) => - (id && projectNames.get(id)) || (id ? `${id.slice(0, 8)}…` : "—"); - - const attribution: [string, string | null][] = [ - ["First seen", fmt(contact.first_seen_at)], - ["Last seen", fmt(contact.last_seen_at)], - ["First URL", contact.first_url], - ["First referrer", contact.first_referrer], - ["First UTM", [contact.first_utm_source, contact.first_utm_medium, contact.first_utm_campaign].filter(Boolean).join(" / ") || null], - ["Last URL", contact.last_url], - ["Last UTM", [contact.last_utm_source, contact.last_utm_campaign].filter(Boolean).join(" / ") || null], - ["Source project", contact.source_project_id ? projectLabel(contact.source_project_id) : null], - ]; - - return ( -
-
- - ← Audience Hub - -

{contact.email}

-
- {contact.name && {contact.name}} - {contact.status} - {contact.suppressed_at ? ( - - suppressed{contact.suppression_reason ? ` · ${contact.suppression_reason}` : ""} - - ) : contact.unsubscribed_at ? ( - unsubscribed {fmt(contact.unsubscribed_at)} - ) : ( - - {contact.marketing_consent ? "marketing consent" : "no marketing consent"} - - )} - {(contact.tags ?? []).map((tag) => ( - {tag} - ))} -
-
- -
-
-

Attribution

-
- {attribution.map(([label, value]) => ( -
-
{label}
-
{value ?? "—"}
-
- ))} -
-
- -
-

Projects

- {(links ?? []).length === 0 ? ( -

No project links yet.

- ) : ( -
    - {(links ?? []).map((link) => ( -
  • - {projectLabel(link.project_id as string)} - {link.plan ? {String(link.plan)} : null} - {link.role ? {String(link.role)} : null} - {link.external_user_id ? ( - - {String(link.external_user_id)} - - ) : null} - - last seen {fmt(link.last_seen_at as string)} - -
  • - ))} -
- )} - -

Identities

- {(identities ?? []).length === 0 ? ( -

No linked identities.

- ) : ( -
    - {(identities ?? []).map((identity) => ( -
  • - {String(identity.provider)} - {String(identity.external_id)} -
  • - ))} -
- )} -
-
- -
-

Event timeline

- {(events ?? []).length === 0 ? ( -

No events recorded.

- ) : ( -
- - - - - - - - - - - - {(events ?? []).map((event) => ( - - - - - - - - ))} - -
EventProjectSourceCampaignWhen
{String(event.event)}{projectLabel(event.project_id as string)}{String(event.source)} - {[event.utm_source, event.utm_campaign].filter(Boolean).join(" / ") || "—"} - {fmt(event.occurred_at as string)}
-
- )} -
- -
-

Consent history

- {(consents ?? []).length === 0 ? ( -

- No explicit consent events. Consent is only recorded from explicit - signals — never inferred from account creation. -

- ) : ( -
    - {(consents ?? []).map((consent) => ( -
  • - - {consent.consent_value ? "opt-in" : "opt-out"} - - {String(consent.consent_type)} - - via {String(consent.source ?? "unknown")} · {fmt(consent.occurred_at as string)} - -
  • - ))} -
- )} -
-
- ); -} diff --git a/app/(app)/audience/page.tsx b/app/(app)/audience/page.tsx deleted file mode 100644 index 9114044e..00000000 --- a/app/(app)/audience/page.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; - -export const metadata = { title: "Audience Hub" }; - -// Account-level Audience Hub: every contact captured across the caller's -// properties, deduped by normalized email. RLS scopes all queries to -// contacts the user owns directly or through an organization they own. - -type ContactRow = { - id: string; - email: string; - name: string | null; - status: string; - marketing_consent: boolean; - unsubscribed_at: string | null; - suppressed_at: string | null; - last_seen_at: string; - first_utm_source: string | null; - last_utm_campaign: string | null; -}; - -const STATUS_BADGE: Record = { - customer: "badge-pass", - user: "badge-pass", - subscriber: "badge-pass", - lead: "badge-warn", - unknown: "badge-warn", - unsubscribed: "badge-fail", - suppressed: "badge-fail", - deleted: "badge-fail", -}; - -function fmt(iso: string | null): string { - if (!iso) return "—"; - try { - return new Date(iso).toLocaleDateString(); - } catch { - return iso; - } -} - -export default async function AudienceHubPage({ - searchParams, -}: { - searchParams: Promise<{ q?: string }>; -}) { - const { q } = await searchParams; - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) redirect("/login"); - - const head = { count: "exact" as const, head: true }; - const [totalRes, consentedRes, usersRes, customersRes, unsubscribedRes] = - await Promise.all([ - supabase.from("audience_contacts").select("id", head), - supabase - .from("audience_contacts") - .select("id", head) - .eq("marketing_consent", true) - .is("unsubscribed_at", null) - .is("suppressed_at", null), - supabase.from("audience_contacts").select("id", head).eq("status", "user"), - supabase.from("audience_contacts").select("id", head).eq("status", "customer"), - supabase - .from("audience_contacts") - .select("id", head) - .in("status", ["unsubscribed", "suppressed", "deleted"]), - ]); - const total = totalRes.count ?? 0; - const consented = consentedRes.count ?? 0; - const users = usersRes.count ?? 0; - const customers = customersRes.count ?? 0; - const unsubscribed = unsubscribedRes.count ?? 0; - - let contactsQuery = supabase - .from("audience_contacts") - .select( - "id, email, name, status, marketing_consent, unsubscribed_at, suppressed_at, last_seen_at, first_utm_source, last_utm_campaign", - ) - .order("last_seen_at", { ascending: false }) - .limit(100); - if (q?.trim()) { - contactsQuery = contactsQuery.ilike("normalized_email", `%${q.trim().toLowerCase()}%`); - } - const { data } = await contactsQuery; - const contacts = (data ?? []) as ContactRow[]; - - const cards: { label: string; value: number }[] = [ - { label: "Contacts", value: total }, - { label: "Marketing consent", value: consented }, - { label: "Users", value: users }, - { label: "Customers", value: customers }, - { label: "Unsubscribed / suppressed", value: unsubscribed }, - ]; - - return ( -
-
-
-

Audience Hub

-

- One deduplicated, consent-aware contact list across all your - connected properties. Capture leads via stats.js, - confirm lifecycle events server-side, export when you need to send. -

-
- -
- -
- {cards.map((card) => ( -
-
{card.value}
-
- {card.label} -
-
- ))} -
- -
-
- - -
- - {contacts.length === 0 ? ( -
- {q - ? "No contacts match that search." - : "No contacts yet. Install the Audience Hub on a project (Project → Audience tab) and identify users or capture leads via stats.js."} -
- ) : ( -
- - - - - - - - - - - - - {contacts.map((contact) => ( - - - - - - - - - ))} - -
EmailNameStatusConsentFirst sourceLast seen
- - {contact.email} - - {contact.name ?? "—"} - - {contact.status} - - - {contact.suppressed_at - ? "suppressed" - : contact.unsubscribed_at - ? "unsubscribed" - : contact.marketing_consent - ? "yes" - : "no"} - - {contact.first_utm_source ?? contact.last_utm_campaign ?? "—"} - - {fmt(contact.last_seen_at)} -
-
- )} -
-
- ); -} diff --git a/app/(app)/dashboard/org-controls.tsx b/app/(app)/dashboard/org-controls.tsx index 2089b016..965ac1ce 100644 --- a/app/(app)/dashboard/org-controls.tsx +++ b/app/(app)/dashboard/org-controls.tsx @@ -1,21 +1,16 @@ "use client"; -import { FormEvent, useRef, useState, useTransition } from "react"; +import { FormEvent, useState, useTransition } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { createOrganization, deleteOrganization, - deleteOrganizationDataSource, deleteOrganizationOutreachConfig, mergeOrganization, moveProjectToOrganization, renameOrganization, - saveOrganizationDataSource, saveOrganizationOutreachConfig, - sendOrganizationAudienceBlast, setDefaultOrganization, - syncAllOrganizationAudience, - syncOrganizationDataSource, } from "@/app/actions/orgs"; import { inviteOrgMember, @@ -51,34 +46,15 @@ export type DashboardSenderConfig = { created_at: string; }; -export type DashboardDataSource = { - id: string; - label: string; - kind: "supabase" | "turso"; - enabled: boolean; - last_synced_at: string | null; - last_sync_count: number | null; - last_sync_error: string | null; -}; - -export type DashboardAudienceStats = { - total: number; - unsubscribed: number; -}; - export function OrgDashboardControls({ orgs, selectedOrgId, senderConfigs, - dataSources, - audienceStats, orgTeam, }: { orgs: DashboardOrg[]; selectedOrgId: string | null; senderConfigs: DashboardSenderConfig[]; - dataSources: DashboardDataSource[]; - audienceStats: DashboardAudienceStats; orgTeam: DashboardOrgTeam | null; }) { const router = useRouter(); @@ -203,16 +179,6 @@ export function OrgDashboardControls({ organizationId={selectedOrgId} senderConfigs={senderConfigs} /> - - (null); - const [pending, startTransition] = useTransition(); - - function syncAll() { - setMessage(null); - startTransition(async () => { - const result = await syncAllOrganizationAudience({ organizationId }); - if (!result.ok) { - setMessage(result.error); - return; - } - setMessage( - `Synced ${result.imported} emails (${result.added} new)` + - (result.failed ? `, ${result.failed} source(s) failed` : ""), - ); - router.refresh(); - }); - } - - return ( -
- - User data sources - -
-

- Connect each project's database (Supabase or Turso). Syncing pulls - every user email into this org's deduplicated audience. -

- {dataSources.length === 0 ? ( -

No data sources yet.

- ) : ( -
    - {dataSources.map((source) => ( -
  • -
    -
    -
    {source.label}
    -
    - {source.kind} - {!source.enabled && off} -
    -
    - {source.last_sync_error ? ( - {source.last_sync_error} - ) : source.last_synced_at ? ( - <> - {source.last_sync_count ?? 0} emails ·{" "} - {new Date(source.last_synced_at).toLocaleString()} - - ) : ( - "Never synced" - )} -
    -
    - -
    -
  • - ))} -
- )} -
- - {message && ( - {message} - )} -
- -
-
- ); -} - -function DataSourceActions({ - organizationId, - sourceId, -}: { - organizationId: string; - sourceId: string; -}) { - const router = useRouter(); - const [message, setMessage] = useState(null); - const [pending, startTransition] = useTransition(); - - function sync() { - setMessage(null); - startTransition(async () => { - const result = await syncOrganizationDataSource({ organizationId, sourceId }); - setMessage(result.ok ? `+${result.added} new` : result.error); - if (result.ok) router.refresh(); - }); - } - - function remove() { - setMessage(null); - startTransition(async () => { - const result = await deleteOrganizationDataSource({ organizationId, sourceId }); - if (!result.ok) { - setMessage(result.error); - return; - } - router.refresh(); - }); - } - - return ( -
- - - {message &&

{message}

} -
- ); -} - -type DataSourceKind = "supabase" | "turso"; - -function DataSourceForm({ organizationId }: { organizationId: string }) { - const router = useRouter(); - const [kind, setKind] = useState("supabase"); - const [mode, setMode] = useState<"auth_users" | "table">("auth_users"); - const [message, setMessage] = useState(null); - const [pending, startTransition] = useTransition(); - - function submit(event: FormEvent) { - event.preventDefault(); - const formEl = event.currentTarget; - const form = new FormData(formEl); - setMessage(null); - startTransition(async () => { - const result = await saveOrganizationDataSource({ - organizationId, - label: String(form.get("label") ?? ""), - kind, - supabaseUrl: String(form.get("supabaseUrl") ?? ""), - serviceRoleKey: String(form.get("serviceRoleKey") ?? ""), - sourceMode: mode, - tableName: String(form.get("tableName") ?? ""), - emailColumn: String(form.get("emailColumn") ?? ""), - tursoUrl: String(form.get("tursoUrl") ?? ""), - authToken: String(form.get("authToken") ?? ""), - emailQuery: String(form.get("emailQuery") ?? ""), - }); - setMessage(result.ok ? "Source saved." : result.error); - if (result.ok) { - formEl.reset(); - router.refresh(); - } - }); - } - - return ( -
- - - {kind === "supabase" ? ( - <> - - - - {mode === "table" && ( - <> - - - - )} - - ) : ( - <> - - - - - )} -
- - {message && ( -

- {message} -

- )} -
- - ); -} - -function AudiencePanel({ - organizationId, - audienceStats, -}: { - organizationId: string; - audienceStats: DashboardAudienceStats; -}) { - const [message, setMessage] = useState(null); - const [pending, startTransition] = useTransition(); - const active = audienceStats.total - audienceStats.unsubscribed; - - function send(previewTo?: string) { - return (event: FormEvent) => { - event.preventDefault(); - const form = new FormData(event.currentTarget); - const subject = String(form.get("subject") ?? ""); - const html = String(form.get("html") ?? ""); - setMessage(null); - startTransition(async () => { - const result = await sendOrganizationAudienceBlast({ - organizationId, - subject, - html, - previewTo, - }); - if (!result.ok) { - setMessage(result.error); - return; - } - setMessage( - previewTo - ? `Preview sent (${result.sent} sent, ${result.skipped} skipped).` - : `Done — ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped of ${result.total}.`, - ); - }); - }; - } - - return ( -
- - Mass email ({active} contacts) - -
-

- Sends to {active} active contacts via this org's default email sender. - {audienceStats.unsubscribed > 0 && - ` ${audienceStats.unsubscribed} unsubscribed are excluded.`}{" "} - Every message includes a one-click unsubscribe link. -

- - {message &&

{message}

} -
-
- ); -} - -function CampaignForm({ - onSubmitSend, - pending, -}: { - onSubmitSend: (previewTo?: string) => (event: FormEvent) => void; - pending: boolean; -}) { - const [previewTo, setPreviewTo] = useState(""); - const formRef = useRef(null); - - return ( -
- -