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.
- ) : (
-
-
-
-
- Event
- Project
- Source
- Campaign
- When
-
-
-
- {(events ?? []).map((event) => (
-
- {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."}
-
- ) : (
-
-
-
-
- Email
- Name
- Status
- Consent
- First source
- Last seen
-
-
-
- {contacts.map((contact) => (
-
-
-
- {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"
- )}
-
-
-
-
-
- ))}
-
- )}
-
-
- {pending ? "Syncing..." : "Sync all"}
-
- {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 (
-
-
- {pending ? "..." : "Sync now"}
-
-
- Delete
-
- {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 (
-
- );
-}
-
-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 (
-
- );
-}
-
function DangerZonePanel({
orgId,
orgs,
diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx
index a48e789b..6268a82f 100644
--- a/app/(app)/dashboard/page.tsx
+++ b/app/(app)/dashboard/page.tsx
@@ -9,8 +9,6 @@ import {
OrgDashboardControls,
ProjectOrgMoveControl,
type DashboardSenderConfig,
- type DashboardDataSource,
- type DashboardAudienceStats,
type DashboardOrg,
type DashboardOrgTeam,
} from "./org-controls";
@@ -100,8 +98,6 @@ export default async function DashboardPage({
{ data: audits },
counts,
senderConfigs,
- dataSources,
- audienceStats,
orgTeam,
] = await Promise.all([
scopedProjectsQuery,
@@ -113,8 +109,6 @@ export default async function DashboardPage({
.limit(10),
countByStatus(supabase, user!.id, accessFilter, selectedOrgId),
fetchSenderConfigs(supabase, ownerOrgId),
- fetchDataSources(supabase, ownerOrgId),
- fetchAudienceStats(supabase, ownerOrgId),
fetchOrgTeam(selectedOrg && isOrgWideRole(selectedOrg.role) ? selectedOrgId : null),
]);
const projects = ((projectsRaw ?? []) as unknown) as DashboardProject[];
@@ -154,8 +148,6 @@ export default async function DashboardPage({
orgs={orgs as DashboardOrg[]}
selectedOrgId={selectedOrgId}
senderConfigs={senderConfigs}
- dataSources={dataSources}
- audienceStats={audienceStats}
orgTeam={orgTeam}
/>
)}
@@ -535,42 +527,3 @@ async function fetchSenderConfigs(
return ((data ?? []) as unknown) as DashboardSenderConfig[];
}
-async function fetchDataSources(
- supabase: Awaited>,
- organizationId: string | null,
-): Promise {
- if (!organizationId) return [];
- const { data, error } = await supabase
- .from("organization_data_sources")
- .select("id,label,kind,enabled,last_synced_at,last_sync_count,last_sync_error")
- .eq("organization_id", organizationId)
- .order("created_at", { ascending: false });
- if (error) {
- if (missingOrgSchema(error)) return [];
- throw error;
- }
- return ((data ?? []) as unknown) as DashboardDataSource[];
-}
-
-async function fetchAudienceStats(
- supabase: Awaited>,
- organizationId: string | null,
-): Promise {
- if (!organizationId) return { total: 0, unsubscribed: 0 };
- const [{ count: total, error: totalErr }, { count: unsub, error: unsubErr }] =
- await Promise.all([
- supabase
- .from("organization_audience_contacts")
- .select("id", { count: "exact", head: true })
- .eq("organization_id", organizationId),
- supabase
- .from("organization_audience_contacts")
- .select("id", { count: "exact", head: true })
- .eq("organization_id", organizationId)
- .not("unsubscribed_at", "is", null),
- ]);
- if (totalErr || unsubErr) {
- if (missingOrgSchema(totalErr ?? unsubErr)) return { total: 0, unsubscribed: 0 };
- }
- return { total: total ?? 0, unsubscribed: unsub ?? 0 };
-}
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index 6fb8d51c..64fa74b8 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -35,7 +35,6 @@ export default async function AppLayout({ children }: { children: React.ReactNod
Dashboard
- Audience
Recent
New
GitHub
diff --git a/app/(app)/projects/[id]/audience/client.tsx b/app/(app)/projects/[id]/audience/client.tsx
deleted file mode 100644
index 0b70205c..00000000
--- a/app/(app)/projects/[id]/audience/client.tsx
+++ /dev/null
@@ -1,360 +0,0 @@
-"use client";
-
-import { useState, useTransition } from "react";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
-import {
- createProjectApiKey,
- revealProjectApiKey,
- revokeProjectApiKey,
-} from "@/app/actions/audience";
-
-type KeyRow = {
- id: string;
- name: string;
- key_prefix: string;
- last_used_at: string | null;
- revoked_at: string | null;
- created_at: string;
- can_reveal: boolean;
-};
-
-type RepoRow = {
- installation_id: number;
- repo_owner: string;
- repo_name: string;
- default_branch: string | null;
-};
-
-function fmt(iso: string | null): string {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleString();
- } catch {
- return iso;
- }
-}
-
-function KeyValue({ value }: { value: string }) {
- const [copied, setCopied] = useState(false);
- return (
-
-
- {value}
-
- {
- void navigator.clipboard.writeText(value).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- });
- }}
- >
- {copied ? "Copied ✓" : "Copy"}
-
-
- );
-}
-
-export function AudienceKeysClient({
- projectId,
- keys,
-}: {
- projectId: string;
- keys: KeyRow[];
-}) {
- const router = useRouter();
- const [pending, start] = useTransition();
- const [name, setName] = useState("");
- const [justMinted, setJustMinted] = useState<{ name: string; key: string } | null>(null);
- const [revealed, setRevealed] = useState>({});
- const [error, setError] = useState(null);
-
- function submit(e: React.FormEvent) {
- e.preventDefault();
- setError(null);
- start(async () => {
- const r = await createProjectApiKey({ projectId, name });
- if (!r.ok) {
- setError(r.error);
- return;
- }
- setJustMinted({ name, key: r.key });
- setName("");
- router.refresh();
- });
- }
-
- function revoke(keyId: string) {
- setError(null);
- start(async () => {
- const r = await revokeProjectApiKey({ projectId, keyId });
- if (!r.ok) setError(r.error);
- router.refresh();
- });
- }
-
- function reveal(keyId: string) {
- setError(null);
- start(async () => {
- const r = await revealProjectApiKey({ projectId, keyId });
- if (!r.ok) {
- setError(r.error);
- return;
- }
- setRevealed((prev) => ({ ...prev, [keyId]: r.key }));
- });
- }
-
- function hide(keyId: string) {
- setRevealed((prev) => {
- const next = { ...prev };
- delete next[keyId];
- return next;
- });
- }
-
- return (
-
-
-
-
- Key name
-
- setName(e.target.value)}
- />
-
-
- {pending ? "Minting…" : "Generate key"}
-
-
- {error &&
{error}
}
-
- {justMinted && (
-
-
- “{justMinted.name}” is ready — you can reveal it again from this
- list anytime.
-
-
-
- )}
-
- {keys.length === 0 ? (
-
No keys yet.
- ) : (
-
- {keys.map((key) => (
-
-
-
- {key.name}
- {key.key_prefix}…
- {key.revoked_at ? (
- revoked
- ) : (
-
- last used {fmt(key.last_used_at)}
-
- )}
-
- {!key.revoked_at && (
-
- {key.can_reveal &&
- (revealed[key.id] ? (
- hide(key.id)}
- >
- Hide
-
- ) : (
- reveal(key.id)}
- >
- Reveal
-
- ))}
- revoke(key.id)}
- >
- Revoke
-
-
- )}
-
- {revealed[key.id] && !key.revoked_at && (
-
- )}
-
- ))}
-
- )}
-
- );
-}
-
-type PrResult = {
- status: "opened" | "noop";
- prUrl?: string;
- stackDetected?: string;
- filesChanged?: string[];
- detail?: string;
-};
-
-export function CreateAudiencePrClient({
- projectId,
- repos,
-}: {
- projectId: string;
- repos: RepoRow[];
-}) {
- const router = useRouter();
- const [pending, setPending] = useState(false);
- const [repoKey, setRepoKey] = useState(
- repos.length > 0 ? `${repos[0].repo_owner}/${repos[0].repo_name}` : "",
- );
- const [mode, setMode] = useState<"browser_and_server" | "browser_only" | "server_only">(
- "browser_and_server",
- );
- const [result, setResult] = useState(null);
- const [error, setError] = useState(null);
-
- if (repos.length === 0) {
- return (
-
- No GitHub repo connected to this project yet. Connect one on the{" "}
-
- Repos tab
- {" "}
- first, then come back to create the install PR.
-
- );
- }
-
- async function createPr() {
- const repo = repos.find((r) => `${r.repo_owner}/${r.repo_name}` === repoKey);
- if (!repo) return;
- setPending(true);
- setError(null);
- setResult(null);
- try {
- const res = await fetch(
- `/api/projects/${projectId}/github/create-audience-pr`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- owner: repo.repo_owner,
- repo: repo.repo_name,
- installation_id: repo.installation_id,
- default_branch: repo.default_branch ?? undefined,
- install_mode: mode,
- }),
- },
- );
- const body = (await res.json()) as { data?: PrResult; error?: string };
- if (!res.ok || body.error) {
- setError(body.error ?? `Request failed (${res.status})`);
- } else {
- setResult(body.data ?? null);
- router.refresh();
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err));
- } finally {
- setPending(false);
- }
- }
-
- return (
-
-
-
-
- Repository
-
- setRepoKey(e.target.value)}
- >
- {repos.map((repo) => (
-
- {repo.repo_owner}/{repo.repo_name}
-
- ))}
-
-
-
-
- Install
-
- setMode(e.target.value as typeof mode)}
- >
- Browser + server
- Browser only
- Server only
-
-
-
- {pending ? "Opening PR…" : "Create PR"}
-
-
-
- {error &&
{error}
}
- {result && (
-
- {result.status === "opened" ? (
-
- ✅ PR opened{result.stackDetected ? ` (stack: ${result.stackDetected})` : ""}:{" "}
-
- {result.prUrl}
-
-
- ) : (
-
{result.detail ?? "Nothing to change — already installed."}
- )}
- {result.filesChanged && result.filesChanged.length > 0 && (
-
- Files: {result.filesChanged.join(", ")}
-
- )}
-
- )}
-
- );
-}
diff --git a/app/(app)/projects/[id]/audience/page.tsx b/app/(app)/projects/[id]/audience/page.tsx
deleted file mode 100644
index d2023119..00000000
--- a/app/(app)/projects/[id]/audience/page.tsx
+++ /dev/null
@@ -1,262 +0,0 @@
-import Link from "next/link";
-import { notFound } from "next/navigation";
-import { createClient } from "@/lib/supabase/server";
-import { env } from "@/lib/env";
-import { AudienceKeysClient, CreateAudiencePrClient } from "./client";
-
-export const metadata = { title: "Project · Audience" };
-
-// Project integration page for the Audience Hub (PRD §15): install status,
-// snippets, server ingest keys, and the owner-initiated Create PR flow.
-
-type KeyRow = {
- id: string;
- name: string;
- key_prefix: string;
- last_used_at: string | null;
- revoked_at: string | null;
- created_at: string;
-};
-
-// Raw select row; the ciphertext itself never leaves the server — the
-// client component only gets a can_reveal flag.
-type KeySelectRow = KeyRow & { key_ciphertext: string | null };
-
-type RepoRow = {
- installation_id: number;
- repo_owner: string;
- repo_name: string;
- default_branch: string | null;
- root_path?: string | null;
-};
-
-type RunRow = {
- id: string;
- status: string;
- pr_url: string | null;
- repo_owner: string;
- repo_name: string;
- created_at: string;
-};
-
-function fmt(iso: string | null): string {
- if (!iso) return "—";
- try {
- return new Date(iso).toLocaleString();
- } catch {
- return iso;
- }
-}
-
-export default async function ProjectAudiencePage({
- params,
-}: {
- params: Promise<{ id: string }>;
-}) {
- const { id: projectId } = await params;
- const supabase = await createClient();
- const { data: project } = await supabase
- .from("projects")
- .select("id, name, url, tracker_enabled")
- .eq("id", projectId)
- .maybeSingle();
- if (!project) notFound();
-
- const siteUrl = env.siteUrl.replace(/\/$/, "");
-
- const [keysRes, reposRes, runsRes, lastBrowserRes, lastServerRes, contactCountRes] =
- await Promise.all([
- supabase
- .from("project_api_keys")
- .select("id, name, key_prefix, last_used_at, revoked_at, created_at, key_ciphertext")
- .eq("project_id", projectId)
- .order("created_at", { ascending: false }),
- supabase
- .from("project_repos")
- .select("installation_id, repo_owner, repo_name, default_branch")
- .eq("project_id", projectId),
- supabase
- .from("project_pr_runs")
- .select("id, status, pr_url, repo_owner, repo_name, created_at")
- .eq("project_id", projectId)
- .eq("kind", "audience_hub")
- .order("created_at", { ascending: false })
- .limit(5),
- supabase
- .from("audience_events")
- .select("occurred_at")
- .eq("project_id", projectId)
- .eq("source", "browser")
- .order("occurred_at", { ascending: false })
- .limit(1),
- supabase
- .from("audience_events")
- .select("occurred_at")
- .eq("project_id", projectId)
- .eq("source", "server")
- .order("occurred_at", { ascending: false })
- .limit(1),
- supabase
- .from("audience_project_links")
- .select("id", { count: "exact", head: true })
- .eq("project_id", projectId),
- ]);
-
- const keys = ((keysRes.data ?? []) as KeySelectRow[]).map(
- ({ key_ciphertext, ...rest }) => ({ ...rest, can_reveal: !!key_ciphertext }),
- );
- const repos = (reposRes.data ?? []) as RepoRow[];
- const runs = (runsRes.data ?? []) as RunRow[];
- const lastBrowser = (lastBrowserRes.data?.[0]?.occurred_at as string | undefined) ?? null;
- const lastServer = (lastServerRes.data?.[0]?.occurred_at as string | undefined) ?? null;
- const contactCount = contactCountRes.count ?? 0;
-
- const scriptSnippet = ``;
- const identifySnippet = `window.crawlproof("identify", {
- email: user.email,
- name: user.name,
- user_id: user.id,
- marketing_consent: Boolean(user.marketingConsent)
-});`;
- const curlSnippet = `curl -X POST ${siteUrl}/api/events \\
- -H "Authorization: Bearer $CRAWLPROOF_PROJECT_KEY" \\
- -H "Content-Type: application/json" \\
- -d '{"event":"user.created","email":"user@example.com","marketing_consent":true}'`;
-
- const statusCards: { label: string; value: string; ok: boolean }[] = [
- {
- label: "stats.js tracker",
- value: project.tracker_enabled ? "enabled" : "disabled",
- ok: !!project.tracker_enabled,
- },
- {
- label: "Last browser event",
- value: fmt(lastBrowser),
- ok: !!lastBrowser,
- },
- {
- label: "Last server event",
- value: fmt(lastServer),
- ok: !!lastServer,
- },
- {
- label: "Contacts from this project",
- value: String(contactCount),
- ok: contactCount > 0,
- },
- ];
-
- return (
-
-
-
-
Audience Hub
-
- Capture leads, signups and customers from this property into your
- central audience. Browser events ride the existing{" "}
- stats.js install; trusted lifecycle events use a
- server API key.
-
-
-
- View all contacts →
-
-
-
-
- {statusCards.map((card) => (
-
-
- {card.value}
-
-
- {card.label}
-
-
- ))}
-
-
-
- Install via GitHub PR
-
- CrawlProof opens a small, reviewable pull request: the{" "}
- stats.js snippet, a generated server helper for{" "}
- /api/events where the stack supports it, and{" "}
- .env.example docs. Nothing is pushed to your default
- branch — you review and merge.
-
-
-
-
- {runs.length > 0 && (
-
- )}
-
-
-
- Server API keys
-
- Authenticate POST {siteUrl}/api/events with{" "}
- Authorization: Bearer cpk_…. Keys are encrypted at rest
- — reveal one again anytime from the list below.
-
-
-
-
-
- Manual install
-
-
-
1. Tracker snippet (before </body>)
-
- {scriptSnippet}
-
-
-
-
2. Identify logged-in users / capture leads
-
- {identifySnippet}
-
-
- Also available: window.crawlproof("track", "lead.captured", {"{ email, source }"}),{" "}
- window.crawlproof("consent", {"{ email, marketing_consent: true }"}).
-
-
-
-
3. Trusted server events
-
- {curlSnippet}
-
-
-
-
-
- );
-}
diff --git a/app/actions/audience.ts b/app/actions/audience.ts
deleted file mode 100644
index 12171ad1..00000000
--- a/app/actions/audience.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-"use server";
-
-// Audience Hub server actions: per-project ingest key management.
-// Keys authenticate POST /api/events; the plaintext is stored encrypted
-// (AES-256-GCM, lib/sp/vault.ts) so members can re-reveal it on demand.
-
-import { serviceClient } from "@/lib/supabase/service";
-import { requireProjectAccess } from "@/lib/lx/currentSite";
-import { mintProjectKey } from "@/lib/audience/projectKeys";
-import { decryptSecret } from "@/lib/sp/vault";
-
-export async function createProjectApiKey(input: {
- projectId: string;
- name: string;
-}): Promise<{ ok: true; key: string; prefix: string } | { ok: false; error: string }> {
- const name = input.name?.trim().slice(0, 120);
- if (!name) return { ok: false, error: "Name is required." };
-
- const access = await requireProjectAccess(input.projectId);
- if (!access.ok) return { ok: false, error: "Not found." };
- if (access.isViewer) return { ok: false, error: "Viewers can't create API keys." };
-
- let minted;
- try {
- minted = mintProjectKey();
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Could not mint key." };
- }
-
- const svc = serviceClient();
- const { error } = await svc.from("project_api_keys").insert({
- project_id: input.projectId,
- name,
- key_prefix: minted.prefix,
- key_hash: minted.hash,
- key_ciphertext: minted.ciphertext,
- created_by: access.userId,
- });
- if (error) return { ok: false, error: error.message };
-
- return { ok: true, key: minted.plaintext, prefix: minted.prefix };
-}
-
-export async function revealProjectApiKey(input: {
- projectId: string;
- keyId: string;
-}): Promise<{ ok: true; key: string } | { ok: false; error: string }> {
- const access = await requireProjectAccess(input.projectId);
- if (!access.ok) return { ok: false, error: "Not found." };
- if (access.isViewer) return { ok: false, error: "Viewers can't reveal API keys." };
-
- const svc = serviceClient();
- const { data: row, error } = await svc
- .from("project_api_keys")
- .select("key_ciphertext, revoked_at")
- .eq("id", input.keyId)
- .eq("project_id", input.projectId)
- .maybeSingle();
- if (error) return { ok: false, error: error.message };
- if (!row) return { ok: false, error: "Not found." };
- if (row.revoked_at) return { ok: false, error: "Key has been revoked." };
- if (!row.key_ciphertext) {
- return {
- ok: false,
- error:
- "This key predates recoverable storage and can't be shown again — generate a new one.",
- };
- }
- try {
- return { ok: true, key: decryptSecret(row.key_ciphertext as string) };
- } catch {
- return { ok: false, error: "Could not decrypt this key." };
- }
-}
-
-export async function revokeProjectApiKey(input: {
- projectId: string;
- keyId: string;
-}): Promise<{ ok: true } | { ok: false; error: string }> {
- const access = await requireProjectAccess(input.projectId);
- if (!access.ok) return { ok: false, error: "Not found." };
- if (access.isViewer) return { ok: false, error: "Viewers can't revoke API keys." };
-
- const svc = serviceClient();
- const { error } = await svc
- .from("project_api_keys")
- .update({ revoked_at: new Date().toISOString() })
- .eq("id", input.keyId)
- .eq("project_id", input.projectId)
- .is("revoked_at", null);
- if (error) return { ok: false, error: error.message };
- return { ok: true };
-}
diff --git a/app/actions/orgs.ts b/app/actions/orgs.ts
index 2ee50357..2fbbbd1a 100644
--- a/app/actions/orgs.ts
+++ b/app/actions/orgs.ts
@@ -5,9 +5,6 @@ import { createClient } from "@/lib/supabase/server";
import { serviceClient } from "@/lib/supabase/service";
import { getOrCreateDefaultOrg } from "@/lib/orgs";
import { encryptSecret } from "@/lib/sp/vault";
-import { syncDataSource, syncAllForOrg } from "@/lib/audience/sync";
-import { sendOrgAudienceBlast } from "@/lib/audience/blast";
-import { assertReadOnlySelect } from "@/lib/audience/connectors";
type Ok = { ok: true } & (T extends undefined ? {} : T);
type Err = { ok: false; error: string };
@@ -304,197 +301,6 @@ export async function deleteOrganizationOutreachConfig(input: {
return { ok: true };
}
-// --- Org audience: connected project databases + mass email ----------------
-
-async function requireOrgOwner(
- organizationId: string,
-): Promise<{ ok: true; userId: string } | Err> {
- const supabase = await createClient();
- const {
- data: { user },
- } = await supabase.auth.getUser();
- if (!user) return { ok: false, error: "Not authenticated." };
- const svc = serviceClient();
- const { data: member } = await svc
- .from("organization_members")
- .select("id")
- .eq("organization_id", organizationId)
- .eq("user_id", user.id)
- .eq("role", "owner")
- .maybeSingle();
- if (!member) return { ok: false, error: "You must own this org." };
- return { ok: true, userId: user.id };
-}
-
-export async function saveOrganizationDataSource(input: {
- organizationId: string;
- label: string;
- kind: "supabase" | "turso";
- // Supabase
- supabaseUrl?: string;
- serviceRoleKey?: string;
- sourceMode?: "auth_users" | "table";
- tableName?: string;
- emailColumn?: string;
- // Turso
- tursoUrl?: string;
- authToken?: string;
- emailQuery?: string;
-}): Promise | Err> {
- const owner = await requireOrgOwner(input.organizationId);
- if (!owner.ok) return owner;
-
- const label = input.label.trim().replace(/\s+/g, " ").slice(0, 80);
- if (!label) return { ok: false, error: "Label is required." };
-
- const patch: Record = {
- organization_id: input.organizationId,
- created_by: owner.userId,
- label,
- kind: input.kind,
- enabled: true,
- supabase_url: null,
- enc_service_role_key: null,
- source_mode: null,
- table_name: null,
- email_column: null,
- turso_url: null,
- enc_auth_token: null,
- email_query: null,
- };
-
- try {
- if (input.kind === "supabase") {
- const url = clean(input.supabaseUrl);
- if (!url) return { ok: false, error: "Supabase URL is required." };
- const mode = input.sourceMode === "table" ? "table" : "auth_users";
- patch.supabase_url = url;
- patch.source_mode = mode;
- if (mode === "table") {
- const table = clean(input.tableName);
- const column = clean(input.emailColumn);
- if (!table || !column) {
- return { ok: false, error: "Table name and email column are required for table mode." };
- }
- patch.table_name = table;
- patch.email_column = column;
- }
- const key = clean(input.serviceRoleKey);
- if (key) patch.enc_service_role_key = encryptSecret(key);
- else return { ok: false, error: "Service role key is required." };
- } else {
- const url = clean(input.tursoUrl);
- const query = clean(input.emailQuery);
- if (!url) return { ok: false, error: "Turso URL is required." };
- if (!query) return { ok: false, error: "Email query is required." };
- const guard = assertReadOnlySelect(query);
- if (guard) return { ok: false, error: guard };
- patch.turso_url = url;
- patch.email_query = query;
- const token = clean(input.authToken);
- if (token) patch.enc_auth_token = encryptSecret(token);
- }
- } catch (error) {
- return {
- ok: false,
- error: error instanceof Error ? error.message : "Could not encrypt source credentials.",
- };
- }
-
- const svc = serviceClient();
- const { data, error } = await svc
- .from("organization_data_sources")
- .insert(patch)
- .select("id")
- .single();
- if (error) return { ok: false, error: error.message };
-
- revalidatePath("/dashboard");
- return { ok: true, id: data.id as string };
-}
-
-export async function deleteOrganizationDataSource(input: {
- organizationId: string;
- sourceId: string;
-}): Promise {
- const owner = await requireOrgOwner(input.organizationId);
- if (!owner.ok) return owner;
-
- const svc = serviceClient();
- const { error } = await svc
- .from("organization_data_sources")
- .delete()
- .eq("id", input.sourceId)
- .eq("organization_id", input.organizationId);
- if (error) return { ok: false, error: error.message };
-
- revalidatePath("/dashboard");
- return { ok: true };
-}
-
-export async function syncOrganizationDataSource(input: {
- organizationId: string;
- sourceId: string;
-}): Promise | Err> {
- const owner = await requireOrgOwner(input.organizationId);
- if (!owner.ok) return owner;
-
- const result = await syncDataSource(input.organizationId, input.sourceId);
- if (!result.ok) return { ok: false, error: result.error ?? "Sync failed." };
-
- revalidatePath("/dashboard");
- return { ok: true, imported: result.imported, added: result.added };
-}
-
-export async function syncAllOrganizationAudience(input: {
- organizationId: string;
-}): Promise | Err> {
- const owner = await requireOrgOwner(input.organizationId);
- if (!owner.ok) return owner;
-
- const results = await syncAllForOrg(input.organizationId);
- const imported = results.reduce((n, r) => n + r.imported, 0);
- const added = results.reduce((n, r) => n + r.added, 0);
- const failed = results.filter((r) => !r.ok).length;
-
- revalidatePath("/dashboard");
- return { ok: true, imported, added, failed };
-}
-
-export async function sendOrganizationAudienceBlast(input: {
- organizationId: string;
- subject: string;
- html: string;
- previewTo?: string;
-}): Promise | Err> {
- const owner = await requireOrgOwner(input.organizationId);
- if (!owner.ok) return owner;
-
- const subject = input.subject.trim();
- const html = input.html.trim();
- if (!subject) return { ok: false, error: "Subject is required." };
- if (!html) return { ok: false, error: "Message body is required." };
-
- const preview = clean(input.previewTo);
- const result = await sendOrgAudienceBlast({
- organizationId: input.organizationId,
- subject,
- html,
- createdBy: owner.userId,
- previewTo: preview ?? undefined,
- });
- if (!result.ok) return { ok: false, error: result.error ?? "Send failed." };
-
- revalidatePath("/dashboard");
- return {
- ok: true,
- total: result.total,
- sent: result.sent,
- failed: result.failed,
- skipped: result.skipped,
- };
-}
-
export async function deleteOrganization(input: {
orgId: string;
}): Promise {
diff --git a/app/api/audience/export/route.ts b/app/api/audience/export/route.ts
deleted file mode 100644
index e8e9ee5a..00000000
--- a/app/api/audience/export/route.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-// GET /api/audience/export[?consented=1]
-// Session-authenticated CSV export of the caller's Audience Hub contacts.
-// RLS does the scoping: the select policy returns contacts owned by the
-// user plus contacts in orgs the user owns.
-
-import { NextRequest, NextResponse } from "next/server";
-import { createClient } from "@/lib/supabase/server";
-
-export const runtime = "nodejs";
-
-const COLUMNS = [
- "email",
- "name",
- "status",
- "marketing_consent",
- "unsubscribed_at",
- "suppressed_at",
- "first_seen_at",
- "last_seen_at",
- "first_utm_source",
- "first_utm_campaign",
- "last_utm_source",
- "last_utm_campaign",
- "first_url",
- "last_url",
- "tags",
-] as const;
-
-function csvCell(value: unknown): string {
- if (value === null || value === undefined) return "";
- const s = Array.isArray(value) ? value.join(";") : String(value);
- // Defang spreadsheet formula injection, then quote.
- const safe = /^[=+\-@\t]/.test(s) ? `'${s}` : s;
- return `"${safe.replace(/"/g, '""')}"`;
-}
-
-export async function GET(request: NextRequest) {
- const supabase = await createClient();
- const {
- data: { user },
- } = await supabase.auth.getUser();
- if (!user) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- const consentedOnly =
- new URL(request.url).searchParams.get("consented") === "1";
-
- const lines: string[] = [COLUMNS.join(",")];
- const pageSize = 1000;
- for (let from = 0; ; from += pageSize) {
- let q = supabase
- .from("audience_contacts")
- .select(COLUMNS.join(", "))
- .order("last_seen_at", { ascending: false })
- .range(from, from + pageSize - 1);
- if (consentedOnly) {
- // Marketing destinations only get opted-in, non-suppressed contacts.
- q = q
- .eq("marketing_consent", true)
- .is("unsubscribed_at", null)
- .is("suppressed_at", null);
- }
- const { data, error } = await q;
- if (error) {
- return NextResponse.json({ error: error.message }, { status: 500 });
- }
- const rows = (data ?? []) as unknown as Record[];
- for (const record of rows) {
- lines.push(COLUMNS.map((col) => csvCell(record[col])).join(","));
- }
- if (!data || data.length < pageSize) break;
- }
-
- const date = new Date().toISOString().slice(0, 10);
- return new NextResponse(lines.join("\n") + "\n", {
- headers: {
- "content-type": "text/csv; charset=utf-8",
- "content-disposition": `attachment; filename="crawlproof-audience-${date}${consentedOnly ? "-consented" : ""}.csv"`,
- "cache-control": "no-store",
- },
- });
-}
diff --git a/app/api/events/route.ts b/app/api/events/route.ts
deleted file mode 100644
index cf8f27e0..00000000
--- a/app/api/events/route.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-// Authenticated server-side ingest for the Audience Hub (PRD §2/§16).
-//
-// POST /api/events
-// Authorization: Bearer cpk_... (per-project key, hashed at rest)
-//
-// Trusted lifecycle events (user.created, customer.created, plan.changed,
-// newsletter.unsubscribed, ...) flow through the same contact pipeline as the
-// browser beacon, but with source="server". Unlike /api/track this endpoint
-// is not a black hole: callers get real status codes (202/400/401/429).
-
-import { NextRequest, NextResponse } from "next/server";
-import { z } from "zod";
-import { ingestAudienceEvent } from "@/lib/audience/hub";
-import { verifyProjectKey } from "@/lib/audience/projectKeys";
-
-export const runtime = "nodejs";
-
-const EVENT_NAME_RE = /^[a-z0-9_.:-]{1,80}$/;
-
-const bodySchema = z.object({
- event: z.string().regex(EVENT_NAME_RE, "invalid event name"),
- /** Informational project slug/domain; the key already pins the project. */
- project: z.string().max(255).optional(),
- email: z.string().max(320).optional(),
- name: z.string().max(255).optional(),
- user_id: z.union([z.string().max(255), z.number()]).optional(),
- anonymous_id: z.string().max(128).optional(),
- source: z.string().max(80).optional(),
- marketing_consent: z.boolean().optional(),
- consent_type: z.string().max(64).optional(),
- plan: z.string().max(80).optional(),
- role: z.string().max(80).optional(),
- tags: z.array(z.string().max(80)).max(20).optional(),
- url: z.string().max(2048).optional(),
- referrer: z.string().max(2048).optional(),
- utm_source: z.string().max(255).optional(),
- utm_medium: z.string().max(255).optional(),
- utm_campaign: z.string().max(255).optional(),
- utm_content: z.string().max(255).optional(),
- utm_term: z.string().max(255).optional(),
- occurred_at: z.string().max(64).optional(),
- created_at: z.string().max(64).optional(),
- metadata: z
- .record(z.unknown())
- .optional()
- .refine((v) => v === undefined || JSON.stringify(v).length <= 8_192, {
- message: "metadata too large",
- }),
-});
-
-// Best-effort per-key rate limit. In-memory is fine for a single Railway
-// instance; the worst case under multiple instances is a proportionally
-// higher cap, not an open door.
-const RATE_LIMIT = 600; // events per key per minute
-const buckets = new Map();
-
-function rateLimited(keyId: string): boolean {
- const now = Date.now();
- const bucket = buckets.get(keyId);
- if (!bucket || now - bucket.windowStart >= 60_000) {
- buckets.set(keyId, { windowStart: now, count: 1 });
- if (buckets.size > 10_000) buckets.clear(); // bound memory
- return false;
- }
- bucket.count += 1;
- return bucket.count > RATE_LIMIT;
-}
-
-export async function POST(request: NextRequest) {
- const auth = request.headers.get("authorization") ?? "";
- const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
- const verified = token ? await verifyProjectKey(token) : null;
- if (!verified) {
- return NextResponse.json({ error: "Invalid or revoked API key" }, { status: 401 });
- }
-
- if (rateLimited(verified.keyId)) {
- return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
- }
-
- let body: z.infer;
- try {
- body = bodySchema.parse(await request.json());
- } catch (err) {
- const detail = err instanceof z.ZodError ? err.issues[0]?.message : "invalid JSON";
- return NextResponse.json({ error: `Invalid payload: ${detail}` }, { status: 400 });
- }
-
- try {
- const result = await ingestAudienceEvent({
- project: verified.project,
- event: body.event.toLowerCase(),
- source: "server",
- email: body.email,
- name: body.name,
- externalUserId: body.user_id != null ? String(body.user_id) : undefined,
- anonymousId: body.anonymous_id,
- url: body.url,
- referrer: body.referrer,
- utmSource: body.utm_source ?? (body.metadata?.utm_source as string | undefined),
- utmMedium: body.utm_medium ?? (body.metadata?.utm_medium as string | undefined),
- utmCampaign: body.utm_campaign ?? (body.metadata?.utm_campaign as string | undefined),
- utmContent: body.utm_content,
- utmTerm: body.utm_term,
- marketingConsent: body.marketing_consent,
- consentType: body.consent_type,
- plan: body.plan ?? (body.metadata?.plan as string | undefined),
- role: body.role,
- tags: body.tags,
- metadata: body.metadata,
- occurredAt: body.occurred_at ?? body.created_at,
- });
- if (!result.ok) {
- return NextResponse.json({ error: result.error }, { status: 500 });
- }
- return NextResponse.json(
- { status: "accepted", contact_id: result.contactId },
- { status: 202 },
- );
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- return NextResponse.json({ error: msg }, { status: 500 });
- }
-}
diff --git a/app/api/projects/[id]/github/create-audience-pr/route.ts b/app/api/projects/[id]/github/create-audience-pr/route.ts
deleted file mode 100644
index 4b382d94..00000000
--- a/app/api/projects/[id]/github/create-audience-pr/route.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-// POST /api/projects/[id]/github/create-audience-pr
-// Body: { owner, repo, installation_id, root_path?, install_mode? }
-// Opens the Audience Hub installation PR (stats.js + server helper + env
-// docs) on the chosen repo. Owner-initiated only — never runs silently.
-
-import { NextRequest, NextResponse } from "next/server";
-import { z } from "zod";
-import { createClient } from "@/lib/supabase/server";
-import { serviceClient } from "@/lib/supabase/service";
-import { requireProjectAccess } from "@/lib/lx/currentSite";
-import { getOrMintInstallationToken } from "@/lib/github/installations";
-import { installAudienceHub } from "@/lib/github/install-audience";
-
-export const runtime = "nodejs";
-
-const bodySchema = z.object({
- owner: z.string().min(1),
- repo: z.string().min(1),
- installation_id: z.number().int().positive(),
- root_path: z.string().max(500).optional(),
- install_mode: z
- .enum(["browser_only", "server_only", "browser_and_server"])
- .optional(),
- default_branch: z.string().max(200).optional(),
-});
-
-export async function POST(
- request: NextRequest,
- ctx: { params: Promise<{ id: string }> },
-) {
- const { id: projectId } = await ctx.params;
- const supabase = await createClient();
- const {
- data: { user },
- } = await supabase.auth.getUser();
- if (!user) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- let body;
- try {
- body = bodySchema.parse(await request.json());
- } catch {
- return NextResponse.json({ error: "Bad request" }, { status: 400 });
- }
-
- const projectAccess = await requireProjectAccess(projectId);
- if (!projectAccess.ok) {
- return NextResponse.json({ error: "Not found" }, { status: 404 });
- }
- if (projectAccess.isViewer) {
- return NextResponse.json({ error: "Viewers can't open PRs" }, { status: 403 });
- }
-
- const { data: installation } = await supabase
- .from("github_installations")
- .select("installation_id")
- .eq("installation_id", body.installation_id)
- .eq("user_id", user.id)
- .maybeSingle();
- if (!installation) {
- return NextResponse.json(
- { error: "Installation not connected to this account" },
- { status: 403 },
- );
- }
-
- const svc = serviceClient();
- const { data: project } = await svc
- .from("projects")
- .select("id, url")
- .eq("id", projectId)
- .maybeSingle();
- const projectDomain = domainFromUrl(project?.url as string | undefined);
-
- // Audit-log the run (PRD §13: all GitHub write actions are logged).
- const { data: run } = await svc
- .from("project_pr_runs")
- .insert({
- project_id: projectId,
- owner_id: user.id,
- kind: "audience_hub",
- installation_id: body.installation_id,
- repo_owner: body.owner,
- repo_name: body.repo,
- status: "running",
- })
- .select("id")
- .single();
- const runId = run?.id as string | undefined;
-
- async function finalize(patch: Record) {
- if (!runId) return;
- await svc
- .from("project_pr_runs")
- .update({ ...patch, updated_at: new Date().toISOString() })
- .eq("id", runId);
- }
-
- try {
- const token = await getOrMintInstallationToken(body.installation_id);
- const result = await installAudienceHub({
- token,
- owner: body.owner,
- repo: body.repo,
- projectId,
- projectDomain,
- rootPath: body.root_path,
- installMode: body.install_mode,
- });
- await finalize({
- status: result.status,
- pr_url: result.prUrl ?? null,
- pr_number: result.prNumber ?? null,
- branch_name: result.branch ?? null,
- });
- await svc.from("project_repos").upsert(
- {
- project_id: projectId,
- installation_id: body.installation_id,
- repo_owner: body.owner,
- repo_name: body.repo,
- default_branch: body.default_branch ?? null,
- added_by: user.id,
- },
- { onConflict: "project_id,repo_owner,repo_name" },
- );
- return NextResponse.json({ data: result });
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- await finalize({ status: "failed", error: msg });
- return NextResponse.json({ error: msg }, { status: 500 });
- }
-}
-
-function domainFromUrl(url: string | undefined): string | undefined {
- if (!url) return undefined;
- try {
- return new URL(url).hostname.replace(/^www\./, "");
- } catch {
- return undefined;
- }
-}
diff --git a/app/api/track/route.ts b/app/api/track/route.ts
index 38e8fa8b..3c520d9f 100644
--- a/app/api/track/route.ts
+++ b/app/api/track/route.ts
@@ -2,14 +2,12 @@
// JSON payload, categorizes the event, and bumps the matching counter row
// in tracker_daily_stats. Idempotent under load via ON CONFLICT.
-import crypto from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { serviceClient } from "@/lib/supabase/service";
import { categorize } from "@/lib/tracker/categorize";
import { clientIpFromHeaders, lookupGeo } from "@/lib/tracker/geo";
import { enqueuePostHogEvent } from "@/lib/posthog/events";
-import { AUDIENCE_BROWSER_EVENTS, ingestAudienceEvent } from "@/lib/audience/hub";
export const runtime = "nodejs";
@@ -37,27 +35,6 @@ const bodySchema = z.object({
.optional(),
screenWidth: z.number().int().nonnegative().optional(),
screenHeight: z.number().int().nonnegative().optional(),
- // Audience Hub fields (identify / consent / lead capture from stats.js).
- email: z.string().max(320).optional(),
- name: z.string().max(255).optional(),
- userId: z.string().max(255).optional(),
- previousId: z.string().max(128).optional(),
- marketingConsent: z.boolean().optional(),
- consentType: z.string().max(64).optional(),
- plan: z.string().max(80).optional(),
- role: z.string().max(80).optional(),
- tags: z.array(z.string().max(80)).max(20).optional(),
- utmSource: z.string().max(255).optional(),
- utmMedium: z.string().max(255).optional(),
- utmCampaign: z.string().max(255).optional(),
- utmContent: z.string().max(255).optional(),
- utmTerm: z.string().max(255).optional(),
- payload: z
- .record(z.unknown())
- .optional()
- .refine((v) => v === undefined || JSON.stringify(v).length <= 8_192, {
- message: "payload too large",
- }),
});
function corsHeaders(request: Request) {
@@ -127,41 +104,6 @@ function hostFromUrl(value: string | null | undefined) {
}
}
-function sha256Short(value: string) {
- return crypto
- .createHash("sha256")
- .update(`crawlproof:${value}`)
- .digest("hex")
- .slice(0, 32);
-}
-
-// Keys already promoted to first-class contact fields; keeping them out of
-// event metadata avoids duplicating PII in the jsonb blob.
-const PROMOTED_PAYLOAD_KEYS = new Set([
- "email",
- "name",
- "user_id",
- "userId",
- "previous_id",
- "marketing_consent",
- "consent",
- "consent_type",
- "plan",
- "role",
- "tags",
-]);
-
-function sanitizeAudiencePayload(
- payload: Record | undefined,
-): Record | undefined {
- if (!payload) return undefined;
- const out: Record = {};
- for (const [key, value] of Object.entries(payload)) {
- if (!PROMOTED_PAYLOAD_KEYS.has(key)) out[key] = value;
- }
- return out;
-}
-
function payloadFromUrl(request: Request) {
const url = new URL(request.url);
return bodySchema.safeParse({
@@ -223,46 +165,6 @@ async function ingest(request: NextRequest, parseBody: boolean) {
.maybeSingle();
if (!project || !project.tracker_enabled) return ok(request);
- // Audience Hub: forward identity / consent / lead events into the contact
- // pipeline. Fire-and-forget — the beacon response never waits on it, and
- // plain pageviews stay out of the audience tables.
- if (parsed.data.email || AUDIENCE_BROWSER_EVENTS.has(event)) {
- const d = parsed.data;
- const ip = clientIpFromHeaders(request.headers);
- void ingestAudienceEvent({
- project: {
- id: project.id,
- owner_id: project.owner_id,
- organization_id: project.organization_id ?? null,
- },
- event,
- source: "browser",
- email: d.email,
- name: d.name,
- externalUserId: d.userId,
- anonymousId: d.visitorId,
- previousAnonymousId: d.previousId,
- sessionId: d.sessionId,
- url: pageUrl,
- referrer,
- utmSource: d.utmSource,
- utmMedium: d.utmMedium,
- utmCampaign: d.utmCampaign,
- utmContent: d.utmContent,
- utmTerm: d.utmTerm,
- marketingConsent: d.marketingConsent,
- consentType: d.consentType,
- plan: d.plan,
- role: d.role,
- tags: d.tags,
- metadata: sanitizeAudiencePayload(d.payload),
- ipHash: ip ? sha256Short(ip) : null,
- userAgentHash: userAgent ? sha256Short(userAgent) : null,
- }).catch(() => {
- // Audience writes must never break the beacon response.
- });
- }
-
const { bucket, isAi } = categorize({ referrer, userAgent });
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD UTC
diff --git a/app/stats.js/route.ts b/app/stats.js/route.ts
index 8d723188..97273a2d 100644
--- a/app/stats.js/route.ts
+++ b/app/stats.js/route.ts
@@ -112,96 +112,22 @@ const snippet = `(function(){
}).catch(function(){});
} catch (_) {}
}
- // ── Audience Hub: identity, lead capture, consent (richer payloads). ──
- function utmParams() {
- var out = {};
- try {
- var sp = new URLSearchParams(location.search);
- var keys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'];
- for (var i = 0; i < keys.length; i++) {
- var v = sp.get(keys[i]);
- if (v) out[keys[i]] = v.slice(0, 255);
- }
- } catch (_) {}
- return out;
- }
- function sendAudience(eventName, data) {
- try {
- data = data || {};
- var utm = utmParams();
- var body = {
- websiteId: siteId,
- site: siteId,
- domain: location.hostname,
- href: pageUrl(),
- referrer: document.referrer || null,
- visitorId: visitorId,
- sessionId: getSessionId(),
- type: eventName || 'custom',
- target: '',
- email: data.email || undefined,
- name: data.name || undefined,
- userId: data.user_id != null ? String(data.user_id) : (data.userId != null ? String(data.userId) : undefined),
- previousId: data.previous_id != null ? String(data.previous_id) : undefined,
- marketingConsent: typeof data.marketing_consent === 'boolean' ? data.marketing_consent
- : (typeof data.consent === 'boolean' ? data.consent : undefined),
- consentType: data.consent_type || undefined,
- plan: data.plan || undefined,
- role: data.role || undefined,
- tags: Array.isArray(data.tags) ? data.tags.slice(0, 20) : undefined,
- utmSource: utm.utm_source,
- utmMedium: utm.utm_medium,
- utmCampaign: utm.utm_campaign,
- utmContent: utm.utm_content,
- utmTerm: utm.utm_term,
- payload: data
- };
- fetch(endpoint, {
- method: 'POST',
- keepalive: true,
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- credentials: 'omit',
- mode: 'cors',
- cache: 'no-store'
- }).catch(function(){});
- } catch (_) {}
- }
function cpTrack(name, arg) {
- // Back-compat: a string second arg is the old behavioral "target";
- // an object carries an Audience Hub payload (email, consent, ...).
- if (arg && typeof arg === 'object') sendAudience(name || 'custom', arg);
- else send(name || 'custom', arg || '');
- }
- function cpIdentify(p) { sendAudience('identify', p || {}); }
- function cpConsent(p) {
- p = p || {};
- if (typeof p.marketing_consent !== 'boolean' && typeof p.consent === 'boolean') p.marketing_consent = p.consent;
- sendAudience('consent', p);
- }
- function cpAlias(prevId, extra) {
- var d = (extra && typeof extra === 'object') ? extra : {};
- d.previous_id = prevId;
- sendAudience('alias', d);
+ // Custom behavioral event with an optional string "target" label.
+ send(name || 'custom', typeof arg === 'string' ? arg : '');
}
// Callable API so the async stub pattern works:
- // window.crawlproof('identify', { email: ... })
- // plus method form: window.crawlproof.identify({ email: ... }).
+ // window.crawlproof('track', 'signup_click')
+ // plus method form: window.crawlproof.track('signup_click').
var prev = window.crawlproof;
function api(method) {
var args = Array.prototype.slice.call(arguments, 1);
try {
- if (method === 'identify') return cpIdentify(args[0]);
- if (method === 'consent') return cpConsent(args[0]);
- if (method === 'alias') return cpAlias(args[0], args[1]);
if (method === 'track') return cpTrack(args[0], args[1]);
return cpTrack(method, args[0]);
} catch (_) {}
}
api.track = cpTrack;
- api.identify = cpIdentify;
- api.consent = cpConsent;
- api.alias = cpAlias;
window.crawlproof = api;
// Drain calls queued before the script loaded.
try {
diff --git a/app/unsubscribe/org/[token]/page.tsx b/app/unsubscribe/org/[token]/page.tsx
deleted file mode 100644
index 8dac4984..00000000
--- a/app/unsubscribe/org/[token]/page.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import Link from "next/link";
-import { unsubscribeOrgAudienceByToken } from "@/lib/marketing";
-
-export const metadata = { title: "Unsubscribe" };
-export const dynamic = "force-dynamic";
-
-export default async function OrgUnsubscribePage({
- params,
-}: {
- params: Promise<{ token: string }>;
-}) {
- const { token } = await params;
- const result = await unsubscribeOrgAudienceByToken(token);
-
- return (
-
-
- {result.ok ? "You're unsubscribed" : "Unsubscribe link not recognized"}
-
- {result.ok ? (
-
- {result.email ? (
- <>
- {result.email} won't receive any more emails
- from us.
- >
- ) : (
- <>You won't receive any more emails from us.>
- )}
-
- ) : (
-
- We couldn't find a subscription for that link. It may have
- already been unsubscribed, or the link may be malformed.
-
- )}
-
- ← Back to CrawlProof
-
-
- );
-}
diff --git a/components/project-tabs-nav.tsx b/components/project-tabs-nav.tsx
index 5147f1d6..f057e81c 100644
--- a/components/project-tabs-nav.tsx
+++ b/components/project-tabs-nav.tsx
@@ -48,12 +48,6 @@ const TABS: ProjectTab[] = [
href: (id) => `/projects/${id}/stats`,
matches: (p, id) => p.startsWith(`/projects/${id}/stats`),
},
- {
- id: "audience",
- label: "Audience",
- href: (id) => `/projects/${id}/audience`,
- matches: (p, id) => p.startsWith(`/projects/${id}/audience`),
- },
{
id: "autoblog",
label: "Autoblog",
diff --git a/docs/audience-hub.md b/docs/audience-hub.md
deleted file mode 100644
index f6c6c963..00000000
--- a/docs/audience-hub.md
+++ /dev/null
@@ -1,101 +0,0 @@
-# Audience Hub
-
-Centralized, deduplicated, consent-aware contacts across all connected
-properties. Hybrid ingest model:
-
-```txt
-stats.js → browser identity, lead capture, attribution (public beacon)
-POST /api/events → trusted account/customer lifecycle (per-project bearer key)
-Create PR button → owner-initiated GitHub install (never silent)
-Supabase importer → optional backfill only (existing org audience feature)
-```
-
-## Data model (migration `20260612120000_audience_hub.sql`)
-
-- `audience_contacts` — one row per normalized email per scope. Scope = the
- project's organization when it has one, otherwise the project owner
- (`organization_id` / `owner_id`, enforced by two partial unique indexes).
- Carries lifecycle `status`, `marketing_consent`, unsubscribe/suppression,
- first/last-touch attribution, tags, metadata.
-- `audience_identities` — provider/external-id pairs (`project_user`,
- `anonymous`, …) linking visitor ids and app user ids to a contact.
-- `audience_project_links` — which properties a contact belongs to (+ plan/role).
-- `audience_events` — append-only event log (`browser` | `server` | `import`).
-- `audience_consent_events` — explicit consent audit trail (type, value,
- source, hashed IP/UA).
-- `project_api_keys` — server ingest keys; sha256(plaintext + SP_TOKEN_PEPPER)
- at rest, plaintext shown once (`cpk_…`, see `lib/audience/projectKeys.ts`).
-
-All tables are RLS select-only for the owning user / org owner; writes go
-through the service client inside `lib/audience/hub.ts`.
-
-## Pipeline (`lib/audience/hub.ts`)
-
-`ingestAudienceEvent` implements PRD §17: normalize email → resolve contact
-(email, then `project_user`/`anonymous` identity) → create if new → upgrade
-lifecycle status (upgrade-only ladder; unsubscribe/suppress/delete are
-terminal and override) → first-touch fills holes, last-touch advances →
-upsert identities + project link → append event → log explicit consent.
-
-Consent rules: an account email is **never** auto-subscribed. Only explicit
-`marketing_consent` booleans (or `newsletter.unsubscribed`) touch consent.
-Suppression overrides everything, including later opt-ins.
-
-## Browser API (served by `/stats.js`)
-
-`window.crawlproof` is callable and has methods; calls made before the
-script loads can be queued via the standard stub (`window.crawlproof.q`).
-
-```js
-crawlproof("identify", { email, name, user_id, marketing_consent: true });
-crawlproof("track", "lead.captured", { email, source: "pricing-page" });
-crawlproof("consent", { email, marketing_consent: false });
-crawlproof("alias", previousAnonymousId);
-crawlproof.track("button_click", "cta"); // legacy behavioral form still works
-```
-
-Audience payloads ride the existing `/api/track` beacon with UTM params
-captured from the page URL. Plain pageviews never enter the audience tables —
-only events with an email or in `AUDIENCE_BROWSER_EVENTS`.
-
-## Server API
-
-```http
-POST /api/events
-Authorization: Bearer cpk_...
-Content-Type: application/json
-
-{ "event": "user.created", "email": "user@example.com",
- "user_id": "abc123", "marketing_consent": true,
- "metadata": { "plan": "free" } }
-```
-
-Responses: `202` accepted, `400` invalid payload, `401` bad/revoked key,
-`429` rate limited (600 events/key/min, in-memory).
-
-## Surfaces
-
-- `/audience` — account-level hub: counts, search, contact table, CSV export
- (`/api/audience/export`, `?consented=1` for marketing-safe export).
-- `/audience/[contactId]` — attribution, projects, identities, event
- timeline, consent history.
-- `/projects/[id]/audience` — install status, GitHub **Create PR** flow,
- server API keys, manual snippets.
-
-## Create PR flow
-
-`POST /api/projects/[id]/github/create-audience-pr` →
-`lib/github/install-audience.ts`. Detects the stack (Next app/pages, Vite,
-Hono, Express, static), reuses the tracker installer's snippet
-discovery/injection, adds a generated server helper
-(`lib/crawlproof/server.ts` or `src/lib/crawlproof.ts`) plus `.env.example`
-entries (`CRAWLPROOF_PROJECT_ID`, `CRAWLPROOF_PROJECT_KEY`,
-`CRAWLPROOF_INGEST_URL`), and opens a PR on a
-`crawlproof/audience-hub-` branch. Runs are audit-logged in
-`project_pr_runs` (`kind = 'audience_hub'`).
-
-## Deferred (per PRD MVP cut)
-
-Resend/provider sync, advanced segments, Supabase backfill into the contact
-graph, CoinPay DID identities, per-project unsubscribe preferences,
-payload-driven email field auto-detection.
diff --git a/lib/audience/blast.ts b/lib/audience/blast.ts
deleted file mode 100644
index 422cc068..00000000
--- a/lib/audience/blast.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-import "server-only";
-import { serviceClient } from "@/lib/supabase/service";
-import { env } from "@/lib/env";
-import { sendOutreachEmail, type OutreachConfig } from "@/lib/outreach";
-
-export type BlastResult = {
- ok: boolean;
- total: number;
- sent: number;
- failed: number;
- skipped: number;
- error?: string;
- campaignId?: string;
-};
-
-// Mass-email an org's deduped audience through its configured email sender
-// (SMTP or Resend — whichever is the org's default email config). Every
-// message carries a one-click unsubscribe footer + List-Unsubscribe headers,
-// and any globally-unsubscribed address (marketing_contacts) is skipped.
-export async function sendOrgAudienceBlast(input: {
- organizationId: string;
- subject: string;
- html: string;
- createdBy?: string | null;
- // Send only to this address (must be a real, active audience contact).
- // Used to preview a campaign before going live.
- previewTo?: string;
- perSecond?: number;
-}): Promise {
- const svc = serviceClient();
- const limitPerSec = Math.max(1, Math.min(input.perSecond ?? 5, 20));
- const delayMs = Math.ceil(1000 / limitPerSec);
-
- // 1. Resolve the org's default email sender config.
- const { data: configRow, error: configErr } = await svc
- .from("organization_outreach_configs")
- .select(
- "id,provider,from_email,reply_to,smtp_host,smtp_port,smtp_secure,enc_smtp_user,enc_smtp_pass,enc_api_key",
- )
- .eq("organization_id", input.organizationId)
- .eq("channel", "email")
- .eq("enabled", true)
- .eq("is_default", true)
- .maybeSingle();
- if (configErr) return zero("Could not load sender config: " + configErr.message);
- if (!configRow) {
- return zero("No default email sender configured. Add an SMTP or Resend sender first.");
- }
- const config = configRow as unknown as OutreachConfig;
-
- // 2. Build the suppression set: globally-unsubscribed marketing contacts.
- const suppressed = await loadSuppressed(svc);
-
- // 3. Page through active audience contacts.
- const contacts = await loadActiveContacts(svc, input.organizationId, input.previewTo);
- if (contacts.length === 0) {
- return { ok: true, total: 0, sent: 0, failed: 0, skipped: 0 };
- }
-
- let sent = 0;
- let failed = 0;
- let skipped = 0;
-
- for (const c of contacts) {
- if (suppressed.has(c.email)) {
- skipped += 1;
- continue;
- }
- const unsubUrl = `${env.siteUrl}/unsubscribe/org/${c.unsubscribe_token}`;
- const html = `${input.html}${unsubscribeFooter(unsubUrl)}`;
- const res = await sendOutreachEmail({
- to: c.email,
- subject: input.subject,
- body: htmlToText(input.html) + `\n\nUnsubscribe: ${unsubUrl}`,
- html,
- headers: {
- "List-Unsubscribe": `<${unsubUrl}>`,
- "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
- },
- config,
- });
- if (res.sent) sent += 1;
- else {
- failed += 1;
- console.warn("[audience.blast] send failed", { to: c.email, error: res.error });
- }
- if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
- }
-
- // 4. Record the campaign (skip the audit row for previews).
- let campaignId: string | undefined;
- if (!input.previewTo) {
- const { data: campaign } = await svc
- .from("organization_email_campaigns")
- .insert({
- organization_id: input.organizationId,
- created_by: input.createdBy ?? null,
- sender_config_id: configRow.id,
- subject: input.subject,
- sent_count: sent,
- failed_count: failed,
- skipped_count: skipped,
- })
- .select("id")
- .maybeSingle();
- campaignId = campaign?.id as string | undefined;
- }
-
- return { ok: true, total: contacts.length, sent, failed, skipped, campaignId };
-}
-
-function zero(error: string): BlastResult {
- return { ok: false, total: 0, sent: 0, failed: 0, skipped: 0, error };
-}
-
-async function loadActiveContacts(
- svc: ReturnType,
- organizationId: string,
- previewTo?: string,
-): Promise> {
- if (previewTo) {
- const email = previewTo.trim().toLowerCase();
- const { data } = await svc
- .from("organization_audience_contacts")
- .select("email,unsubscribe_token,unsubscribed_at")
- .eq("organization_id", organizationId)
- .ilike("email", email)
- .maybeSingle();
- if (!data || data.unsubscribed_at) return [];
- return [{ email: data.email as string, unsubscribe_token: data.unsubscribe_token as string }];
- }
-
- const out: Array<{ email: string; unsubscribe_token: string }> = [];
- const pageSize = 1000;
- for (let from = 0; ; from += pageSize) {
- const { data, error } = await svc
- .from("organization_audience_contacts")
- .select("email,unsubscribe_token")
- .eq("organization_id", organizationId)
- .is("unsubscribed_at", null)
- .range(from, from + pageSize - 1);
- if (error || !data || data.length === 0) break;
- for (const r of data) {
- out.push({ email: r.email as string, unsubscribe_token: r.unsubscribe_token as string });
- }
- if (data.length < pageSize) break;
- }
- return out;
-}
-
-async function loadSuppressed(svc: ReturnType): Promise> {
- const set = new Set();
- const pageSize = 1000;
- for (let from = 0; ; from += pageSize) {
- const { data, error } = await svc
- .from("marketing_contacts")
- .select("email")
- .not("unsubscribed_at", "is", null)
- .range(from, from + pageSize - 1);
- if (error || !data || data.length === 0) break;
- for (const r of data) set.add(String(r.email).trim().toLowerCase());
- if (data.length < pageSize) break;
- }
- return set;
-}
-
-function unsubscribeFooter(unsubUrl: string): string {
- return `
-
- You're receiving this because you have an account on one of our products.
- Unsubscribe .
-
`;
-}
-
-function htmlToText(html: string): string {
- return html
- .replace(/<\s*br\s*\/?>/gi, "\n")
- .replace(/<\/(p|div|h[1-6]|li)>/gi, "\n")
- .replace(/<[^>]+>/g, "")
- .replace(/ /g, " ")
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/\n{3,}/g, "\n\n")
- .trim();
-}
diff --git a/lib/audience/connectors.ts b/lib/audience/connectors.ts
deleted file mode 100644
index 790d2166..00000000
--- a/lib/audience/connectors.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import { createClient as createSb } from "@supabase/supabase-js";
-import { createClient as createTurso } from "@libsql/client";
-
-// Connectors pull every user email out of a project's own backing database.
-// Supabase and Turso are the two hosted stores used across the CrawlProof
-// org's projects. Each connector returns a flat, normalized email list; the
-// caller (lib/audience/sync.ts) is responsible for dedup + persistence.
-
-export type DataSourceRow = {
- id: string;
- organization_id: string;
- kind: "supabase" | "turso";
- // Supabase
- supabase_url: string | null;
- source_mode: "auth_users" | "table" | null;
- table_name: string | null;
- email_column: string | null;
- // Turso
- turso_url: string | null;
- email_query: string | null;
- // Decrypted at call time by the caller.
- serviceRoleKey?: string | null;
- authToken?: string | null;
-};
-
-export type FetchResult = { emails: string[]; error?: string };
-
-// Normalize a raw value to a deliverable email or null. Lowercase + trim,
-// require a single "@" with something either side, drop anything obviously
-// junk. Mirrors lib/marketing.ts#normalize, slightly stricter.
-export function normalizeEmail(value: unknown): string | null {
- if (typeof value !== "string") return null;
- const e = value.trim().toLowerCase();
- if (!e || e.length > 254) return null;
- const at = e.indexOf("@");
- if (at <= 0 || at !== e.lastIndexOf("@") || at === e.length - 1) return null;
- if (/\s/.test(e)) return null;
- if (!e.slice(at + 1).includes(".")) return null;
- return e;
-}
-
-function dedupeNormalize(values: unknown[]): string[] {
- const seen = new Set();
- for (const v of values) {
- const e = normalizeEmail(v);
- if (e) seen.add(e);
- }
- return [...seen];
-}
-
-// Guard a user-supplied Turso query: must be a single read-only SELECT.
-// The DB belongs to the org owner, so this is defense-in-depth — it stops a
-// fat-fingered destructive statement, not a determined attacker.
-export function assertReadOnlySelect(query: string): string | null {
- const q = query.trim().replace(/;+\s*$/, ""); // allow one trailing semicolon
- if (!q) return "Query is empty.";
- if (q.includes(";")) return "Query must be a single statement (no semicolons).";
- if (!/^\s*(with|select)\b/i.test(q)) return "Query must be a SELECT.";
- if (/\b(insert|update|delete|drop|alter|create|attach|detach|pragma|replace|truncate|vacuum|reindex)\b/i.test(q)) {
- return "Query may only read data (no write/DDL keywords).";
- }
- return null;
-}
-
-export async function fetchSupabaseEmails(src: DataSourceRow): Promise {
- if (!src.supabase_url) return { emails: [], error: "Supabase URL is required." };
- if (!src.serviceRoleKey) return { emails: [], error: "Service role key is required." };
-
- const sb = createSb(src.supabase_url, src.serviceRoleKey, {
- auth: { autoRefreshToken: false, persistSession: false },
- });
-
- if (src.source_mode === "table") {
- if (!src.table_name || !src.email_column) {
- return { emails: [], error: "Table name and email column are required for table mode." };
- }
- const collected: unknown[] = [];
- const pageSize = 1000;
- for (let from = 0; ; from += pageSize) {
- const { data, error } = await sb
- .from(src.table_name)
- .select(src.email_column)
- .range(from, from + pageSize - 1);
- if (error) return { emails: [], error: error.message };
- if (!data || data.length === 0) break;
- for (const row of data) {
- collected.push((row as unknown as Record)[src.email_column]);
- }
- if (data.length < pageSize) break;
- }
- return { emails: dedupeNormalize(collected) };
- }
-
- // Default: read auth.users via the admin API — uniform across every
- // Supabase project regardless of its public schema.
- const collected: unknown[] = [];
- const perPage = 1000;
- for (let page = 1; ; page += 1) {
- const { data, error } = await sb.auth.admin.listUsers({ page, perPage });
- if (error) return { emails: [], error: error.message };
- const users = data?.users ?? [];
- if (users.length === 0) break;
- for (const u of users) collected.push(u.email);
- if (users.length < perPage) break;
- }
- return { emails: dedupeNormalize(collected) };
-}
-
-export async function fetchTursoEmails(src: DataSourceRow): Promise {
- if (!src.turso_url) return { emails: [], error: "Turso URL is required." };
- if (!src.email_query) return { emails: [], error: "Email query is required." };
- const guardError = assertReadOnlySelect(src.email_query);
- if (guardError) return { emails: [], error: guardError };
-
- const client = createTurso({
- url: src.turso_url,
- authToken: src.authToken ?? undefined,
- });
- try {
- const result = await client.execute(src.email_query);
- // Prefer an "email" column if present, else the first column.
- const cols = result.columns ?? [];
- const emailIdx = cols.findIndex((c) => c?.toLowerCase() === "email");
- const values: unknown[] = result.rows.map((row) => {
- if (emailIdx >= 0) return (row as unknown as unknown[])[emailIdx];
- const arr = row as unknown as unknown[];
- return arr[0];
- });
- return { emails: dedupeNormalize(values) };
- } catch (error) {
- return { emails: [], error: error instanceof Error ? error.message : "Turso query failed." };
- } finally {
- client.close();
- }
-}
-
-export async function fetchEmailsForSource(src: DataSourceRow): Promise {
- if (src.kind === "supabase") return fetchSupabaseEmails(src);
- if (src.kind === "turso") return fetchTursoEmails(src);
- return { emails: [], error: `Unknown data source kind: ${src.kind}` };
-}
diff --git a/lib/audience/hub.ts b/lib/audience/hub.ts
deleted file mode 100644
index 0c453045..00000000
--- a/lib/audience/hub.ts
+++ /dev/null
@@ -1,374 +0,0 @@
-import { serviceClient } from "@/lib/supabase/service";
-import { normalizeEmail } from "./connectors";
-
-export { normalizeEmail };
-
-// Audience Hub ingest pipeline (PRD §17). Both ingest doors — the public
-// browser beacon (/api/track) and the authenticated server endpoint
-// (/api/events) — funnel through ingestAudienceEvent. Contacts dedupe by
-// normalized email inside an account scope: the project's organization when
-// it has one, otherwise the project owner.
-
-export type AudienceScopeProject = {
- id: string;
- owner_id: string;
- organization_id: string | null;
-};
-
-export type AudienceIngestInput = {
- project: AudienceScopeProject;
- event: string;
- source: "browser" | "server" | "import";
- email?: string | null;
- name?: string | null;
- /** The property's own user id (their auth user id, customer id, …). */
- externalUserId?: string | null;
- anonymousId?: string | null;
- /** A prior anonymous id being aliased onto this contact (crawlproof.alias). */
- previousAnonymousId?: string | null;
- sessionId?: string | null;
- url?: string | null;
- referrer?: string | null;
- utmSource?: string | null;
- utmMedium?: string | null;
- utmCampaign?: string | null;
- utmContent?: string | null;
- utmTerm?: string | null;
- /** Explicit consent signal. undefined = no signal; never inferred. */
- marketingConsent?: boolean;
- consentType?: string | null;
- plan?: string | null;
- role?: string | null;
- tags?: string[];
- metadata?: Record;
- occurredAt?: string | null;
- ipHash?: string | null;
- userAgentHash?: string | null;
-};
-
-export type AudienceIngestResult =
- | { ok: true; contactId: string | null }
- | { ok: false; error: string };
-
-// Contact lifecycle states only ever upgrade through this ladder; the
-// terminal states (unsubscribed/suppressed/deleted) are handled separately
-// because they must override upgrades.
-const STATUS_RANK: Record = {
- unknown: 0,
- lead: 1,
- subscriber: 2,
- user: 3,
- customer: 4,
-};
-
-const TERMINAL_STATUSES = new Set(["unsubscribed", "suppressed", "deleted"]);
-
-/** Map an event name to the lifecycle status it implies, if any. */
-export function statusForEvent(event: string): string | null {
- switch (event) {
- case "identify":
- case "lead.captured":
- return "lead";
- case "newsletter.subscribed":
- return "subscriber";
- case "user.created":
- case "user.updated":
- return "user";
- case "customer.created":
- case "customer.updated":
- case "plan.changed":
- case "payment.succeeded":
- return "customer";
- case "newsletter.unsubscribed":
- return "unsubscribed";
- case "user.deleted":
- case "account.deleted":
- return "deleted";
- default:
- return null;
- }
-}
-
-/** Events the browser beacon forwards into the audience pipeline even
- * without an email, as long as the visitor was previously identified. */
-export const AUDIENCE_BROWSER_EVENTS = new Set([
- "identify",
- "consent",
- "alias",
- "lead.captured",
- "newsletter.subscribed",
- "newsletter.unsubscribed",
-]);
-
-function textOrNull(v: string | null | undefined, max = 2048): string | null {
- if (!v || typeof v !== "string") return null;
- const t = v.trim();
- return t ? t.slice(0, max) : null;
-}
-
-type ContactRow = {
- id: string;
- status: string;
- name: string | null;
- marketing_consent: boolean;
- unsubscribed_at: string | null;
- suppressed_at: string | null;
- tags: string[] | null;
- first_url: string | null;
- first_referrer: string | null;
- first_utm_source: string | null;
- first_utm_medium: string | null;
- first_utm_campaign: string | null;
-};
-
-const CONTACT_COLS =
- "id, status, name, marketing_consent, unsubscribed_at, suppressed_at, tags, first_url, first_referrer, first_utm_source, first_utm_medium, first_utm_campaign";
-
-export async function ingestAudienceEvent(
- input: AudienceIngestInput,
-): Promise {
- const svc = serviceClient();
- const scope = input.project.organization_id
- ? { organization_id: input.project.organization_id, owner_id: input.project.owner_id }
- : { organization_id: null, owner_id: input.project.owner_id };
-
- const email = normalizeEmail(input.email);
- const anonymousId = textOrNull(input.anonymousId, 128);
- const previousAnonymousId = textOrNull(input.previousAnonymousId, 128);
- const externalUserId = textOrNull(input.externalUserId, 255);
- const occurredAt = input.occurredAt && !Number.isNaN(Date.parse(input.occurredAt))
- ? new Date(input.occurredAt).toISOString()
- : new Date().toISOString();
-
- // ── Resolve the contact: email first, then known identities. ────────────
- let contact: ContactRow | null = null;
-
- if (email) {
- let q = svc.from("audience_contacts").select(CONTACT_COLS).eq("normalized_email", email);
- q = scope.organization_id
- ? q.eq("organization_id", scope.organization_id)
- : q.eq("owner_id", scope.owner_id).is("organization_id", null);
- const { data } = await q.maybeSingle();
- contact = (data as ContactRow | null) ?? null;
- }
-
- if (!contact && (externalUserId || anonymousId || previousAnonymousId)) {
- contact = await resolveByIdentity(svc, scope, [
- externalUserId ? { provider: "project_user", externalId: externalUserId } : null,
- anonymousId ? { provider: "anonymous", externalId: anonymousId } : null,
- previousAnonymousId ? { provider: "anonymous", externalId: previousAnonymousId } : null,
- ]);
- }
-
- if (!contact && email) {
- const { data: inserted, error: insErr } = await svc
- .from("audience_contacts")
- .insert({
- owner_id: scope.owner_id,
- organization_id: scope.organization_id,
- email: input.email!.trim().slice(0, 320),
- normalized_email: email,
- name: textOrNull(input.name, 255),
- source_project_id: input.project.id,
- first_seen_at: occurredAt,
- last_seen_at: occurredAt,
- first_url: textOrNull(input.url),
- first_referrer: textOrNull(input.referrer),
- first_utm_source: textOrNull(input.utmSource, 255),
- first_utm_medium: textOrNull(input.utmMedium, 255),
- first_utm_campaign: textOrNull(input.utmCampaign, 255),
- })
- .select(CONTACT_COLS)
- .maybeSingle();
- if (inserted) {
- contact = inserted as ContactRow;
- } else if (insErr) {
- // Unique-index race: another request created the contact between our
- // select and insert. Re-select instead of failing the event.
- let q = svc.from("audience_contacts").select(CONTACT_COLS).eq("normalized_email", email);
- q = scope.organization_id
- ? q.eq("organization_id", scope.organization_id)
- : q.eq("owner_id", scope.owner_id).is("organization_id", null);
- const { data: retry } = await q.maybeSingle();
- contact = (retry as ContactRow | null) ?? null;
- if (!contact) return { ok: false, error: insErr.message };
- }
- }
-
- // No email and no known identity: nothing to attach this event to.
- if (!contact) return { ok: true, contactId: null };
-
- // ── Update contact lifecycle, consent, attribution, traits. ─────────────
- const patch: Record = { last_seen_at: occurredAt };
-
- const impliedStatus = statusForEvent(input.event);
- const suppressed = contact.suppressed_at != null || contact.status === "suppressed";
- if (impliedStatus && !suppressed) {
- if (TERMINAL_STATUSES.has(impliedStatus)) {
- patch.status = impliedStatus;
- if (impliedStatus === "unsubscribed" || impliedStatus === "deleted") {
- patch.marketing_consent = false;
- patch.unsubscribed_at = occurredAt;
- }
- if (impliedStatus === "deleted") {
- patch.suppressed_at = occurredAt;
- patch.suppression_reason = "account_deleted";
- }
- } else if (
- !TERMINAL_STATUSES.has(contact.status) &&
- (STATUS_RANK[impliedStatus] ?? 0) > (STATUS_RANK[contact.status] ?? 0)
- ) {
- patch.status = impliedStatus;
- }
- }
-
- // Explicit consent only — an account email is never auto-subscribed
- // (PRD §9). Suppression overrides any opt-in.
- if (typeof input.marketingConsent === "boolean" && !suppressed) {
- patch.marketing_consent = input.marketingConsent;
- if (input.marketingConsent) {
- patch.unsubscribed_at = null;
- if (TERMINAL_STATUSES.has((patch.status as string) ?? contact.status) && contact.status !== "deleted") {
- patch.status = "subscriber";
- }
- } else {
- patch.unsubscribed_at = occurredAt;
- }
- }
-
- if (input.name && textOrNull(input.name, 255) && input.name !== contact.name) {
- patch.name = textOrNull(input.name, 255);
- }
-
- // First-touch only fills holes; last-touch always advances.
- if (!contact.first_url && input.url) patch.first_url = textOrNull(input.url);
- if (!contact.first_referrer && input.referrer) patch.first_referrer = textOrNull(input.referrer);
- if (!contact.first_utm_source && input.utmSource) patch.first_utm_source = textOrNull(input.utmSource, 255);
- if (!contact.first_utm_medium && input.utmMedium) patch.first_utm_medium = textOrNull(input.utmMedium, 255);
- if (!contact.first_utm_campaign && input.utmCampaign) patch.first_utm_campaign = textOrNull(input.utmCampaign, 255);
- if (input.url) patch.last_url = textOrNull(input.url);
- if (input.referrer) patch.last_referrer = textOrNull(input.referrer);
- if (input.utmSource) patch.last_utm_source = textOrNull(input.utmSource, 255);
- if (input.utmMedium) patch.last_utm_medium = textOrNull(input.utmMedium, 255);
- if (input.utmCampaign) patch.last_utm_campaign = textOrNull(input.utmCampaign, 255);
-
- if (input.tags && input.tags.length > 0) {
- const existing = Array.isArray(contact.tags) ? contact.tags : [];
- const merged = [...new Set([...existing, ...input.tags.map((t) => String(t).slice(0, 80))])];
- if (merged.length !== existing.length) patch.tags = merged.slice(0, 100);
- }
-
- await svc.from("audience_contacts").update(patch).eq("id", contact.id);
-
- // ── Identities + project link. ───────────────────────────────────────────
- const identityRows = [
- externalUserId
- ? { contact_id: contact.id, provider: "project_user", external_id: externalUserId, project_id: input.project.id }
- : null,
- anonymousId
- ? { contact_id: contact.id, provider: "anonymous", external_id: anonymousId, project_id: input.project.id }
- : null,
- previousAnonymousId
- ? { contact_id: contact.id, provider: "anonymous", external_id: previousAnonymousId, project_id: input.project.id }
- : null,
- ].filter(Boolean) as Record[];
- if (identityRows.length > 0) {
- await svc
- .from("audience_identities")
- .upsert(identityRows, { onConflict: "contact_id,provider,external_id", ignoreDuplicates: true });
- }
-
- const linkPatch: Record = {
- contact_id: contact.id,
- project_id: input.project.id,
- last_seen_at: occurredAt,
- };
- if (externalUserId) linkPatch.external_user_id = externalUserId;
- if (input.plan) linkPatch.plan = textOrNull(input.plan, 80);
- if (input.role) linkPatch.role = textOrNull(input.role, 80);
- await svc
- .from("audience_project_links")
- .upsert(linkPatch, { onConflict: "contact_id,project_id" });
-
- // ── Append the event row. ────────────────────────────────────────────────
- await svc.from("audience_events").insert({
- contact_id: contact.id,
- anonymous_id: anonymousId ?? "",
- session_id: textOrNull(input.sessionId, 128) ?? "",
- project_id: input.project.id,
- event: input.event.slice(0, 80),
- source: input.source,
- url: textOrNull(input.url),
- referrer: textOrNull(input.referrer),
- utm_source: textOrNull(input.utmSource, 255),
- utm_medium: textOrNull(input.utmMedium, 255),
- utm_campaign: textOrNull(input.utmCampaign, 255),
- utm_content: textOrNull(input.utmContent, 255),
- utm_term: textOrNull(input.utmTerm, 255),
- metadata: input.metadata ?? {},
- occurred_at: occurredAt,
- });
-
- // ── Consent audit log (explicit signals + unsubscribe events). ──────────
- const consentValue =
- typeof input.marketingConsent === "boolean"
- ? input.marketingConsent
- : input.event === "newsletter.unsubscribed"
- ? false
- : null;
- if (consentValue !== null) {
- await svc.from("audience_consent_events").insert({
- contact_id: contact.id,
- email: email ?? "",
- project_id: input.project.id,
- consent_type: validConsentType(input.consentType) ?? "marketing_email",
- consent_value: consentValue,
- source: input.source,
- ip_hash: textOrNull(input.ipHash, 64),
- user_agent_hash: textOrNull(input.userAgentHash, 64),
- occurred_at: occurredAt,
- });
- }
-
- return { ok: true, contactId: contact.id };
-}
-
-const CONSENT_TYPES = new Set([
- "marketing_email",
- "transactional_email",
- "product_updates",
- "newsletter",
- "cross_property_updates",
-]);
-
-function validConsentType(t: string | null | undefined): string | null {
- return t && CONSENT_TYPES.has(t) ? t : null;
-}
-
-async function resolveByIdentity(
- svc: ReturnType,
- scope: { organization_id: string | null; owner_id: string },
- lookups: ({ provider: string; externalId: string } | null)[],
-): Promise {
- for (const lookup of lookups) {
- if (!lookup) continue;
- const { data: identities } = await svc
- .from("audience_identities")
- .select("contact_id")
- .eq("provider", lookup.provider)
- .eq("external_id", lookup.externalId)
- .limit(10);
- for (const row of identities ?? []) {
- let q = svc
- .from("audience_contacts")
- .select(CONTACT_COLS)
- .eq("id", (row as { contact_id: string }).contact_id);
- q = scope.organization_id
- ? q.eq("organization_id", scope.organization_id)
- : q.eq("owner_id", scope.owner_id).is("organization_id", null);
- const { data } = await q.maybeSingle();
- if (data) return data as ContactRow;
- }
- }
- return null;
-}
diff --git a/lib/audience/projectKeys.ts b/lib/audience/projectKeys.ts
deleted file mode 100644
index 0878cdf5..00000000
--- a/lib/audience/projectKeys.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-// Per-project server ingest keys for POST /api/events.
-//
-// Token shape: `cpk_` prefix + 43 base64url chars from 32 bytes of
-// crypto-random. Verification mirrors lib/sp/apiToken.ts: lookup is by
-// sha256(plaintext + pepper), which is fine because the plaintext
-// carries 256 bits of entropy; the shared SP_TOKEN_PEPPER means a DB
-// leak alone is useless. We additionally persist the plaintext
-// AES-256-GCM-encrypted under SOCIAL_VAULT_KEY (lib/sp/vault.ts) so
-// owners can re-reveal a key from the dashboard instead of show-once.
-
-import crypto from "node:crypto";
-import { env } from "@/lib/env";
-import { encryptSecret } from "@/lib/sp/vault";
-import { serviceClient } from "@/lib/supabase/service";
-
-const PREFIX = "cpk_";
-const PREFIX_DISPLAY_LEN = 8;
-
-export type MintedProjectKey = {
- plaintext: string;
- prefix: string;
- hash: string; // verification lookup
- ciphertext: string; // at-rest encrypted copy for later reveal
-};
-
-export function mintProjectKey(): MintedProjectKey {
- if (!env.spTokenPepper) {
- throw new Error(
- "SP_TOKEN_PEPPER not set. Generate with `openssl rand -base64 32`.",
- );
- }
- const random = crypto.randomBytes(32).toString("base64url");
- const plaintext = `${PREFIX}${random}`;
- return {
- plaintext,
- prefix: plaintext.slice(0, PREFIX_DISPLAY_LEN),
- hash: hashProjectKey(plaintext),
- ciphertext: encryptSecret(plaintext),
- };
-}
-
-export function hashProjectKey(plaintext: string): string {
- if (!env.spTokenPepper) {
- throw new Error("SP_TOKEN_PEPPER not set.");
- }
- return crypto
- .createHash("sha256")
- .update(plaintext + env.spTokenPepper, "utf8")
- .digest("hex");
-}
-
-export function isProjectKeyShape(s: string | null | undefined): boolean {
- if (!s) return false;
- return s.startsWith(PREFIX) && s.length >= 32 && s.length <= 128;
-}
-
-export type VerifiedProjectKey = {
- keyId: string;
- project: { id: string; owner_id: string; organization_id: string | null };
-};
-
-/** Resolve a bearer key to its project, or null if unknown/revoked. */
-export async function verifyProjectKey(
- plaintext: string,
-): Promise {
- if (!isProjectKeyShape(plaintext)) return null;
- const svc = serviceClient();
- const { data: key } = await svc
- .from("project_api_keys")
- .select("id, project_id, revoked_at")
- .eq("key_hash", hashProjectKey(plaintext))
- .maybeSingle();
- if (!key || key.revoked_at) return null;
-
- const { data: project } = await svc
- .from("projects")
- .select("id, owner_id, organization_id")
- .eq("id", key.project_id)
- .maybeSingle();
- if (!project) return null;
-
- // Best-effort usage stamp; never blocks ingest.
- void svc
- .from("project_api_keys")
- .update({ last_used_at: new Date().toISOString() })
- .eq("id", key.id)
- .then(undefined, () => {});
-
- return {
- keyId: key.id as string,
- project: project as VerifiedProjectKey["project"],
- };
-}
diff --git a/lib/audience/sync.ts b/lib/audience/sync.ts
deleted file mode 100644
index 0bb1ecba..00000000
--- a/lib/audience/sync.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import "server-only";
-import { serviceClient } from "@/lib/supabase/service";
-import { decryptSecret } from "@/lib/sp/vault";
-import { fetchEmailsForSource, type DataSourceRow } from "./connectors";
-
-export type SyncResult = {
- ok: boolean;
- sourceId: string;
- imported: number; // distinct emails returned by the connector
- added: number; // rows newly inserted into the audience
- error?: string;
-};
-
-// Pull emails from one connected data source and upsert them into the org's
-// deduped audience. Existing rows (incl. their unsubscribe state) are
-// preserved — re-syncing never resurrects an unsubscribed contact.
-export async function syncDataSource(
- organizationId: string,
- sourceId: string,
-): Promise {
- const svc = serviceClient();
- const { data: row, error } = await svc
- .from("organization_data_sources")
- .select(
- "id,organization_id,kind,enabled,supabase_url,enc_service_role_key,source_mode,table_name,email_column,turso_url,enc_auth_token,email_query",
- )
- .eq("id", sourceId)
- .eq("organization_id", organizationId)
- .maybeSingle();
-
- if (error || !row) {
- return { ok: false, sourceId, imported: 0, added: 0, error: error?.message ?? "Source not found." };
- }
-
- const src: DataSourceRow = {
- id: row.id,
- organization_id: row.organization_id,
- kind: row.kind,
- supabase_url: row.supabase_url,
- source_mode: row.source_mode,
- table_name: row.table_name,
- email_column: row.email_column,
- turso_url: row.turso_url,
- email_query: row.email_query,
- };
-
- try {
- if (row.enc_service_role_key) src.serviceRoleKey = decryptSecret(row.enc_service_role_key);
- if (row.enc_auth_token) src.authToken = decryptSecret(row.enc_auth_token);
- } catch {
- const msg = "Could not decrypt source credentials (check SOCIAL_VAULT_KEY).";
- await recordSyncError(svc, sourceId, msg);
- return { ok: false, sourceId, imported: 0, added: 0, error: msg };
- }
-
- const fetched = await fetchEmailsForSource(src);
- if (fetched.error) {
- await recordSyncError(svc, sourceId, fetched.error);
- return { ok: false, sourceId, imported: 0, added: 0, error: fetched.error };
- }
-
- let added = 0;
- if (fetched.emails.length > 0) {
- // Which of these already exist for this org? Page through to avoid an
- // unbounded IN list, then insert only the new ones. The unique index on
- // (organization_id, lower(email)) is the final safety net under races.
- const existing = await loadExistingEmails(svc, organizationId);
- const fresh = fetched.emails.filter((e) => !existing.has(e));
- for (let i = 0; i < fresh.length; i += 500) {
- const chunk = fresh.slice(i, i + 500).map((email) => ({
- organization_id: organizationId,
- source_id: sourceId,
- email,
- }));
- const { error: insErr, count } = await svc
- .from("organization_audience_contacts")
- .upsert(chunk, { onConflict: "organization_id,email", ignoreDuplicates: true, count: "exact" });
- if (insErr) {
- // Fall back to per-row insert ignoring conflicts if the bulk upsert
- // conflict target doesn't match the functional index.
- for (const r of chunk) {
- const { error: rowErr } = await svc.from("organization_audience_contacts").insert(r);
- if (!rowErr) added += 1;
- }
- continue;
- }
- added += count ?? chunk.length;
- }
- }
-
- await svc
- .from("organization_data_sources")
- .update({
- last_synced_at: new Date().toISOString(),
- last_sync_count: fetched.emails.length,
- last_sync_error: null,
- })
- .eq("id", sourceId);
-
- return { ok: true, sourceId, imported: fetched.emails.length, added };
-}
-
-export async function syncAllForOrg(organizationId: string): Promise {
- const svc = serviceClient();
- const { data } = await svc
- .from("organization_data_sources")
- .select("id")
- .eq("organization_id", organizationId)
- .eq("enabled", true);
- const results: SyncResult[] = [];
- for (const s of data ?? []) {
- results.push(await syncDataSource(organizationId, s.id as string));
- }
- return results;
-}
-
-async function loadExistingEmails(
- svc: ReturnType,
- organizationId: string,
-): Promise> {
- const emails = new Set();
- const pageSize = 1000;
- for (let from = 0; ; from += pageSize) {
- const { data, error } = await svc
- .from("organization_audience_contacts")
- .select("email")
- .eq("organization_id", organizationId)
- .range(from, from + pageSize - 1);
- if (error || !data || data.length === 0) break;
- for (const r of data) emails.add(String(r.email).toLowerCase());
- if (data.length < pageSize) break;
- }
- return emails;
-}
-
-async function recordSyncError(
- svc: ReturnType,
- sourceId: string,
- message: string,
-): Promise {
- await svc
- .from("organization_data_sources")
- .update({ last_synced_at: new Date().toISOString(), last_sync_error: message.slice(0, 500) })
- .eq("id", sourceId);
-}
diff --git a/lib/github/install-audience.ts b/lib/github/install-audience.ts
deleted file mode 100644
index 855fa7e5..00000000
--- a/lib/github/install-audience.ts
+++ /dev/null
@@ -1,515 +0,0 @@
-// Audience Hub installer (PRD §3/§14): opens a project-owner-initiated PR
-// that wires a property up to the CrawlProof Audience Hub. Deterministic —
-// no LLM. The PR:
-// 1. installs/updates the stats.js snippet (reuses the tracker installer's
-// discovery + injection),
-// 2. adds a small generated server helper for POST /api/events where the
-// stack supports it (we favor generated code over a package in v1 so
-// the diff is fully inspectable),
-// 3. documents the required env vars in .env.example.
-//
-// CrawlProof never pushes to the default branch and never opens PRs without
-// an explicit owner action (the route requires a session + project access).
-
-import { env } from "@/lib/env";
-import {
- createBranch,
- getFileContent,
- getRepo,
- openPullRequest,
- putFile,
-} from "./repos";
-import {
- findInstallCandidates,
- hasTrackerReference,
- previewInstallAtPath,
-} from "./install-tracker";
-
-const SITE_ORIGIN = env.siteUrl.replace(/\/$/, "");
-const BRANCH_PREFIX = "crawlproof/audience-hub";
-
-export type DetectedStack =
- | "nextjs-app"
- | "nextjs-pages"
- | "vite"
- | "hono"
- | "express"
- | "static"
- | "unknown";
-
-export type InstallMode = "browser_only" | "server_only" | "browser_and_server";
-
-export interface AudienceInstallInput {
- token: string;
- owner: string;
- repo: string;
- projectId: string;
- /** Project domain, used in generated examples. */
- projectDomain?: string;
- rootPath?: string;
- installMode?: InstallMode;
-}
-
-export interface AudienceInstallResult {
- status: "opened" | "noop";
- prUrl?: string;
- prNumber?: number;
- branch?: string;
- stackDetected: DetectedStack;
- filesChanged: string[];
- detail: string;
-}
-
-/** Which stacks can host the generated Node server helper. */
-const SERVER_CAPABLE: ReadonlySet = new Set([
- "nextjs-app",
- "nextjs-pages",
- "hono",
- "express",
-]);
-
-function joinRoot(root: string, path: string): string {
- return root ? `${root}/${path}` : path;
-}
-
-function normalizeRoot(p: string | undefined): string {
- if (!p) return "";
- return p.replace(/^\/+/, "").replace(/\/+$/, "");
-}
-
-/** Pure: classify a repo from its package.json text. Exported for tests. */
-export function detectStackFromPackageJson(
- pkgJsonText: string | null,
- hints: { hasAppLayout?: boolean; hasPagesApp?: boolean; hasIndexHtml?: boolean } = {},
-): DetectedStack {
- if (!pkgJsonText) {
- return hints.hasIndexHtml ? "static" : "unknown";
- }
- let deps: Record = {};
- try {
- const parsed = JSON.parse(pkgJsonText) as {
- dependencies?: Record;
- devDependencies?: Record;
- };
- deps = { ...parsed.dependencies, ...parsed.devDependencies };
- } catch {
- return "unknown";
- }
- if (deps.next) {
- if (hints.hasPagesApp && !hints.hasAppLayout) return "nextjs-pages";
- return "nextjs-app";
- }
- if (deps.hono) return "hono";
- if (deps.express) return "express";
- if (deps.vite) return "vite";
- if (hints.hasIndexHtml) return "static";
- return "unknown";
-}
-
-export async function detectStack(input: {
- token: string;
- owner: string;
- repo: string;
- ref: string;
- root: string;
-}): Promise {
- const read = (path: string) =>
- getFileContent({
- token: input.token,
- owner: input.owner,
- repo: input.repo,
- path: joinRoot(input.root, path),
- ref: input.ref,
- });
-
- const pkg = await read("package.json");
- const [appLayout, srcAppLayout, pagesApp, srcPagesApp, indexHtml] = await Promise.all([
- read("app/layout.tsx"),
- read("src/app/layout.tsx"),
- read("pages/_app.tsx"),
- read("src/pages/_app.tsx"),
- read("index.html"),
- ]);
-
- return detectStackFromPackageJson(pkg?.content ?? null, {
- hasAppLayout: !!(appLayout || srcAppLayout),
- hasPagesApp: !!(pagesApp || srcPagesApp),
- hasIndexHtml: !!indexHtml,
- });
-}
-
-/** Where the generated helper lives per stack. */
-export function serverHelperPath(stack: DetectedStack): string | null {
- switch (stack) {
- case "nextjs-app":
- case "nextjs-pages":
- return "lib/crawlproof/server.ts";
- case "hono":
- case "express":
- return "src/lib/crawlproof.ts";
- default:
- return null;
- }
-}
-
-/** Generated server helper (PRD §6) — plain fetch, no dependency. */
-export function generatedServerHelper(projectDomain: string | undefined): string {
- const domain = projectDomain ?? "your-project.example";
- return `// Generated by CrawlProof Audience Hub — safe to edit.
-// Sends trusted server-side events (user/customer lifecycle) to CrawlProof.
-// Docs: ${SITE_ORIGIN}/docs/stats-tracker
-
-type CrawlProofEvent = {
- event: string;
- project?: string;
- email?: string;
- name?: string;
- user_id?: string;
- marketing_consent?: boolean;
- plan?: string;
- metadata?: Record;
-};
-
-export async function sendCrawlProofEvent(event: CrawlProofEvent): Promise {
- const ingestUrl =
- process.env.CRAWLPROOF_INGEST_URL || "${SITE_ORIGIN}/api/events";
- const projectKey = process.env.CRAWLPROOF_PROJECT_KEY;
-
- if (!projectKey) {
- console.warn("CRAWLPROOF_PROJECT_KEY is not set; skipping CrawlProof event");
- return;
- }
-
- try {
- const response = await fetch(ingestUrl, {
- method: "POST",
- headers: {
- Authorization: \`Bearer \${projectKey}\`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ project: "${domain}", ...event }),
- });
- if (!response.ok) {
- console.warn("CrawlProof ingest failed", response.status, await response.text());
- }
- } catch (err) {
- // Audience events are best-effort; never break the caller.
- console.warn("CrawlProof ingest error", err);
- }
-}
-
-// Example:
-// await sendCrawlProofEvent({
-// event: "user.created",
-// email: user.email,
-// name: user.name,
-// user_id: user.id,
-// marketing_consent: Boolean(user.marketingConsent),
-// });
-`;
-}
-
-/** Generated browser helper for Vite/React SPAs (queues until stats.js loads). */
-export function generatedClientHelper(): string {
- return `// Generated by CrawlProof Audience Hub — safe to edit.
-// Thin wrapper over the stats.js queue: calls made before the script loads
-// are buffered and replayed. Docs: ${SITE_ORIGIN}/docs/stats-tracker
-
-type CrawlProofPayload = Record;
-
-declare global {
- interface Window {
- crawlproof?: ((method: string, ...args: unknown[]) => void) & { q?: unknown[][] };
- }
-}
-
-function cp(method: string, ...args: unknown[]): void {
- if (typeof window === "undefined") return;
- if (typeof window.crawlproof === "function" && !window.crawlproof.q) {
- window.crawlproof(method, ...args);
- return;
- }
- const stub = (window.crawlproof ??= Object.assign(
- (...queued: unknown[]) => {
- (stub.q ??= []).push(queued as unknown[]);
- },
- { q: [] as unknown[][] },
- ));
- stub.q!.push([method, ...args]);
-}
-
-export const crawlproof = {
- track: (event: string, payload?: CrawlProofPayload) => cp("track", event, payload),
- identify: (payload: CrawlProofPayload) => cp("identify", payload),
- consent: (payload: CrawlProofPayload) => cp("consent", payload),
- alias: (previousId: string) => cp("alias", previousId),
-};
-
-// Example:
-// crawlproof.identify({ email: user.email, user_id: user.id, marketing_consent: true });
-`;
-}
-
-export function envExampleBlock(projectId: string): string {
- return `# CrawlProof Audience Hub (${SITE_ORIGIN})
-CRAWLPROOF_PROJECT_ID=${projectId}
-CRAWLPROOF_PROJECT_KEY=
-CRAWLPROOF_INGEST_URL=${SITE_ORIGIN}/api/events
-`;
-}
-
-export async function installAudienceHub(
- input: AudienceInstallInput,
-): Promise {
- const mode: InstallMode = input.installMode ?? "browser_and_server";
- const repoMeta = await getRepo({
- token: input.token,
- owner: input.owner,
- repo: input.repo,
- });
- const base = repoMeta.default_branch;
- const root = normalizeRoot(input.rootPath);
-
- const stack = await detectStack({
- token: input.token,
- owner: input.owner,
- repo: input.repo,
- ref: base,
- root,
- });
-
- // ── Compute browser-side change (stats.js snippet). ─────────────────────
- let trackerEdit: { path: string; sha: string; after: string } | null = null;
- let trackerAlready: string | null = null;
- if (mode !== "server_only") {
- const candidates = await findInstallCandidates({
- token: input.token,
- owner: input.owner,
- repo: input.repo,
- rootPath: input.rootPath,
- });
-
- // Pass 1: if ANY candidate file already references stats.js, the
- // tracker is installed — even when it lives in a lower-ranked file or
- // is formatted across multiple lines. Never add a duplicate tag.
- const contents = new Map();
- for (const candidate of candidates) {
- const file = await getFileContent({
- token: input.token,
- owner: input.owner,
- repo: input.repo,
- path: candidate.path,
- ref: base,
- });
- if (!file) continue;
- contents.set(file.path, { sha: file.sha, content: file.content });
- if (hasTrackerReference(file.content)) {
- trackerAlready = file.path;
- break;
- }
- }
-
- // Pass 2: only when no candidate has it do we inject into the best one.
- if (!trackerAlready) {
- for (const candidate of candidates) {
- const preview = await previewInstallAtPath({
- token: input.token,
- owner: input.owner,
- repo: input.repo,
- path: candidate.path,
- projectId: input.projectId,
- });
- if (preview.status === "already_installed") {
- trackerAlready = preview.path;
- break;
- }
- if (preview.status === "ready") {
- const file = contents.get(preview.path);
- if (file) {
- trackerEdit = { path: preview.path, sha: file.sha, after: preview.after };
- }
- break;
- }
- }
- }
- }
-
- // ── Compute server-side changes (helper + .env.example). ────────────────
- const wantServer = mode !== "browser_only" && SERVER_CAPABLE.has(stack);
- const wantClientHelper = mode !== "server_only" && stack === "vite";
-
- const helperRelPath = wantServer
- ? serverHelperPath(stack)!
- : wantClientHelper
- ? "src/lib/crawlproof.ts"
- : null;
- const helperPath = helperRelPath ? joinRoot(root, helperRelPath) : null;
- const helperContent = wantServer
- ? generatedServerHelper(input.projectDomain)
- : wantClientHelper
- ? generatedClientHelper()
- : null;
-
- let helperEdit: { path: string; sha?: string; content: string } | null = null;
- if (helperPath && helperContent) {
- const existing = await getFileContent({
- token: input.token,
- owner: input.owner,
- repo: input.repo,
- path: helperPath,
- ref: base,
- });
- if (!existing) {
- helperEdit = { path: helperPath, content: helperContent };
- } else if (existing.content !== helperContent) {
- helperEdit = { path: helperPath, sha: existing.sha, content: helperContent };
- }
- }
-
- let envEdit: { path: string; sha?: string; content: string } | null = null;
- if (wantServer) {
- const envPath = joinRoot(root, ".env.example");
- const existing = await getFileContent({
- token: input.token,
- owner: input.owner,
- repo: input.repo,
- path: envPath,
- ref: base,
- });
- if (!existing) {
- envEdit = { path: envPath, content: envExampleBlock(input.projectId) };
- } else if (!existing.content.includes("CRAWLPROOF_PROJECT_KEY")) {
- const sep = existing.content.endsWith("\n") ? "\n" : "\n\n";
- envEdit = {
- path: envPath,
- sha: existing.sha,
- content: existing.content + sep + envExampleBlock(input.projectId),
- };
- }
- }
-
- const edits = [
- trackerEdit
- ? { path: trackerEdit.path, sha: trackerEdit.sha, content: trackerEdit.after }
- : null,
- helperEdit,
- envEdit,
- ].filter(Boolean) as { path: string; sha?: string; content: string }[];
-
- if (edits.length === 0) {
- return {
- status: "noop",
- stackDetected: stack,
- filesChanged: [],
- detail: trackerAlready
- ? `Audience Hub already installed (tracker at ${trackerAlready}; helper and env docs up to date).`
- : "Nothing to change — Audience Hub files are already up to date.",
- };
- }
-
- // ── Branch, commit, PR. ──────────────────────────────────────────────────
- const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
- const branch = `${BRANCH_PREFIX}-${ts}`;
- await createBranch({
- token: input.token,
- owner: input.owner,
- repo: input.repo,
- newBranch: branch,
- fromBranch: base,
- });
-
- for (const edit of edits) {
- await putFile({
- token: input.token,
- owner: input.owner,
- repo: input.repo,
- path: edit.path,
- branch,
- message: "Install CrawlProof Audience Hub",
- contentUtf8: edit.content,
- sha: edit.sha,
- });
- }
-
- const pr = await openPullRequest({
- token: input.token,
- owner: input.owner,
- repo: input.repo,
- head: branch,
- base,
- title: "Install CrawlProof Audience Hub tracking and ingest",
- body: prBody({
- stack,
- trackerPath: trackerEdit?.path ?? trackerAlready,
- helperPath: helperEdit?.path ?? null,
- envPath: envEdit?.path ?? null,
- serverEnabled: wantServer,
- }),
- });
-
- return {
- status: "opened",
- prUrl: pr.html_url,
- prNumber: pr.number,
- branch,
- stackDetected: stack,
- filesChanged: edits.map((edit) => edit.path),
- detail: `Opened PR touching ${edits.map((edit) => edit.path).join(", ")} (stack: ${stack}).`,
- };
-}
-
-function prBody(input: {
- stack: DetectedStack;
- trackerPath: string | null;
- helperPath: string | null;
- envPath: string | null;
- serverEnabled: boolean;
-}): string {
- const changes = [
- input.trackerPath
- ? `- Browser-side identity and lead capture via \`stats.js\` (\`${input.trackerPath}\`)`
- : null,
- input.helperPath
- ? input.serverEnabled
- ? `- Server-side event forwarding helper (\`${input.helperPath}\`)`
- : `- Typed browser helper for identify/track/consent (\`${input.helperPath}\`)`
- : null,
- input.envPath ? `- Environment variable documentation (\`${input.envPath}\`)` : null,
- ]
- .filter(Boolean)
- .join("\n");
-
- const envSection = input.serverEnabled
- ? `
-## Required Environment Variables
-
-- \`CRAWLPROOF_PROJECT_ID\` — already filled in \`.env.example\`
-- \`CRAWLPROOF_PROJECT_KEY\` — mint one on the project's Audience tab in CrawlProof
-- \`CRAWLPROOF_INGEST_URL\` — defaults to \`${SITE_ORIGIN}/api/events\`
-`
- : "";
-
- return `## Summary
-
-This PR installs the CrawlProof Audience Hub integration for this property (detected stack: \`${input.stack}\`).
-
-It adds or updates:
-
-${changes}
-
-## Events you can now send
-
-- \`page_view\` (automatic)
-- \`lead.captured\`
-- \`newsletter.subscribed\`
-- \`user.created\`
-- \`customer.created\`
-${envSection}
-## Notes
-
-CrawlProof uses these events to build a centralized, consent-aware audience
-list across connected properties. Marketing consent is only ever recorded
-from explicit signals — an account email is never auto-subscribed.
-
-Docs: ${SITE_ORIGIN}/docs/stats-tracker`;
-}
diff --git a/lib/marketing.ts b/lib/marketing.ts
index eafd4edc..5c0492c0 100644
--- a/lib/marketing.ts
+++ b/lib/marketing.ts
@@ -103,20 +103,3 @@ export async function unsubscribeByToken(
if (error || !data) return { ok: false };
return { ok: true, email: data.email as string };
}
-
-// Unsubscribe an imported org-audience contact (mass-email recipient). Marks
-// the per-org row suppressed; the contact won't receive future org blasts.
-export async function unsubscribeOrgAudienceByToken(
- token: string,
-): Promise<{ ok: boolean; email?: string }> {
- if (!token || token.length < 8) return { ok: false };
- const svc = serviceClient();
- const { data, error } = await svc
- .from("organization_audience_contacts")
- .update({ unsubscribed_at: new Date().toISOString() })
- .eq("unsubscribe_token", token)
- .select("email")
- .maybeSingle();
- if (error || !data) return { ok: false };
- return { ok: true, email: data.email as string };
-}
diff --git a/lib/outreach.ts b/lib/outreach.ts
index d9444b03..ee406297 100644
--- a/lib/outreach.ts
+++ b/lib/outreach.ts
@@ -39,7 +39,6 @@ export async function sendOutreachEmail(input: {
subject: string;
body: string;
// Optional pre-rendered HTML. When omitted, HTML is derived from `body`.
- // Used by audience campaigns to send real HTML + unsubscribe footer.
html?: string;
// Extra mail headers (e.g. List-Unsubscribe). Passed to both SMTP and
// Resend so native unsubscribe buttons render.
diff --git a/supabase/migrations/20260618120000_drop_audience.sql b/supabase/migrations/20260618120000_drop_audience.sql
new file mode 100644
index 00000000..298663b8
--- /dev/null
+++ b/supabase/migrations/20260618120000_drop_audience.sql
@@ -0,0 +1,20 @@
+-- Remove the Audience feature (org mass-email import lists + the event-driven
+-- Audience Hub contact graph). The feature is being retired: it collected
+-- contact PII (emails, consent, identities) that is no longer used and is an
+-- information-handling risk. This drops all tables and their data.
+--
+-- CASCADE handles the inter-table foreign keys (identities/links/events/consent
+-- referencing audience_contacts, etc.) and any dependent policies/indexes.
+
+-- Audience Hub (event-driven contact graph)
+drop table if exists public.audience_consent_events cascade;
+drop table if exists public.audience_events cascade;
+drop table if exists public.audience_project_links cascade;
+drop table if exists public.audience_identities cascade;
+drop table if exists public.audience_contacts cascade;
+drop table if exists public.project_api_keys cascade;
+
+-- Org mass-email (data-source import lists + campaigns)
+drop table if exists public.organization_email_campaigns cascade;
+drop table if exists public.organization_audience_contacts cascade;
+drop table if exists public.organization_data_sources cascade;
diff --git a/tests/audience-connectors.test.ts b/tests/audience-connectors.test.ts
deleted file mode 100644
index 7c41b184..00000000
--- a/tests/audience-connectors.test.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { normalizeEmail, assertReadOnlySelect } from "@/lib/audience/connectors";
-
-describe("normalizeEmail", () => {
- it("lowercases and trims", () => {
- expect(normalizeEmail(" User@Example.COM ")).toBe("user@example.com");
- });
-
- it("rejects non-strings and blanks", () => {
- expect(normalizeEmail(null)).toBeNull();
- expect(normalizeEmail(undefined)).toBeNull();
- expect(normalizeEmail(42)).toBeNull();
- expect(normalizeEmail("")).toBeNull();
- expect(normalizeEmail(" ")).toBeNull();
- });
-
- it("requires a single @ with a dotted domain", () => {
- expect(normalizeEmail("nope")).toBeNull();
- expect(normalizeEmail("a@b")).toBeNull(); // no dot in domain
- expect(normalizeEmail("a@@b.com")).toBeNull();
- expect(normalizeEmail("@example.com")).toBeNull();
- expect(normalizeEmail("user@")).toBeNull();
- expect(normalizeEmail("user@example.com")).toBe("user@example.com");
- });
-
- it("rejects internal whitespace and overlong values", () => {
- expect(normalizeEmail("us er@example.com")).toBeNull();
- expect(normalizeEmail("a".repeat(250) + "@example.com")).toBeNull();
- });
-});
-
-describe("assertReadOnlySelect", () => {
- it("accepts a plain SELECT", () => {
- expect(assertReadOnlySelect("select email from users")).toBeNull();
- });
-
- it("accepts a trailing semicolon and a CTE", () => {
- expect(assertReadOnlySelect("select email from users;")).toBeNull();
- expect(
- assertReadOnlySelect("with a as (select email from users) select email from a"),
- ).toBeNull();
- });
-
- it("rejects empty queries", () => {
- expect(assertReadOnlySelect(" ")).toMatch(/empty/i);
- });
-
- it("rejects multiple statements", () => {
- expect(assertReadOnlySelect("select 1; drop table users")).toMatch(/single statement/i);
- });
-
- it("rejects non-SELECT statements", () => {
- expect(assertReadOnlySelect("update users set email='x'")).toMatch(/SELECT/i);
- });
-
- it("rejects write/DDL keywords even inside a SELECT", () => {
- expect(assertReadOnlySelect("select email from users where x in (delete from t)")).toMatch(
- /only read/i,
- );
- expect(assertReadOnlySelect("select email from users; pragma table_info(users)")).not.toBeNull();
- });
-});
diff --git a/tests/audience-hub.test.ts b/tests/audience-hub.test.ts
deleted file mode 100644
index 1996fdfa..00000000
--- a/tests/audience-hub.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { statusForEvent, AUDIENCE_BROWSER_EVENTS } from "@/lib/audience/hub";
-import {
- detectStackFromPackageJson,
- envExampleBlock,
- generatedServerHelper,
- generatedClientHelper,
- serverHelperPath,
-} from "@/lib/github/install-audience";
-import { hasTrackerReference } from "@/lib/github/install-tracker";
-
-describe("statusForEvent", () => {
- it("maps lifecycle events to lifecycle statuses", () => {
- expect(statusForEvent("identify")).toBe("lead");
- expect(statusForEvent("lead.captured")).toBe("lead");
- expect(statusForEvent("newsletter.subscribed")).toBe("subscriber");
- expect(statusForEvent("user.created")).toBe("user");
- expect(statusForEvent("customer.created")).toBe("customer");
- expect(statusForEvent("payment.succeeded")).toBe("customer");
- expect(statusForEvent("plan.changed")).toBe("customer");
- });
-
- it("maps opt-out and deletion to terminal statuses", () => {
- expect(statusForEvent("newsletter.unsubscribed")).toBe("unsubscribed");
- expect(statusForEvent("user.deleted")).toBe("deleted");
- expect(statusForEvent("account.deleted")).toBe("deleted");
- });
-
- it("ignores behavioral / unknown events", () => {
- expect(statusForEvent("pageview")).toBeNull();
- expect(statusForEvent("button_click")).toBeNull();
- expect(statusForEvent("totally.custom")).toBeNull();
- });
-});
-
-describe("AUDIENCE_BROWSER_EVENTS", () => {
- it("forwards identity events but not plain pageviews", () => {
- expect(AUDIENCE_BROWSER_EVENTS.has("identify")).toBe(true);
- expect(AUDIENCE_BROWSER_EVENTS.has("consent")).toBe(true);
- expect(AUDIENCE_BROWSER_EVENTS.has("alias")).toBe(true);
- expect(AUDIENCE_BROWSER_EVENTS.has("lead.captured")).toBe(true);
- expect(AUDIENCE_BROWSER_EVENTS.has("pageview")).toBe(false);
- expect(AUDIENCE_BROWSER_EVENTS.has("scroll_50")).toBe(false);
- });
-});
-
-describe("detectStackFromPackageJson", () => {
- const pkg = (deps: Record) =>
- JSON.stringify({ dependencies: deps });
-
- it("detects Next.js app vs pages router from layout hints", () => {
- expect(
- detectStackFromPackageJson(pkg({ next: "^16.0.0" }), { hasAppLayout: true }),
- ).toBe("nextjs-app");
- expect(
- detectStackFromPackageJson(pkg({ next: "^16.0.0" }), { hasPagesApp: true }),
- ).toBe("nextjs-pages");
- // App router wins when both exist (hybrid repos).
- expect(
- detectStackFromPackageJson(pkg({ next: "^16.0.0" }), {
- hasAppLayout: true,
- hasPagesApp: true,
- }),
- ).toBe("nextjs-app");
- });
-
- it("detects hono, express, and vite", () => {
- expect(detectStackFromPackageJson(pkg({ hono: "^4.0.0" }))).toBe("hono");
- expect(detectStackFromPackageJson(pkg({ express: "^4.18.0" }))).toBe("express");
- expect(
- detectStackFromPackageJson(JSON.stringify({ devDependencies: { vite: "^6.0.0" } })),
- ).toBe("vite");
- });
-
- it("next takes precedence over vite tooling in the same repo", () => {
- expect(detectStackFromPackageJson(pkg({ next: "16.0.0", vite: "6.0.0" }))).toBe(
- "nextjs-app",
- );
- });
-
- it("falls back to static / unknown", () => {
- expect(detectStackFromPackageJson(null, { hasIndexHtml: true })).toBe("static");
- expect(detectStackFromPackageJson(null)).toBe("unknown");
- expect(detectStackFromPackageJson("not json")).toBe("unknown");
- expect(detectStackFromPackageJson(pkg({}), { hasIndexHtml: true })).toBe("static");
- });
-});
-
-describe("serverHelperPath", () => {
- it("places the helper per stack conventions", () => {
- expect(serverHelperPath("nextjs-app")).toBe("lib/crawlproof/server.ts");
- expect(serverHelperPath("nextjs-pages")).toBe("lib/crawlproof/server.ts");
- expect(serverHelperPath("hono")).toBe("src/lib/crawlproof.ts");
- expect(serverHelperPath("express")).toBe("src/lib/crawlproof.ts");
- expect(serverHelperPath("vite")).toBeNull();
- expect(serverHelperPath("static")).toBeNull();
- });
-});
-
-describe("generated files", () => {
- it("env block documents all three variables with the project id filled", () => {
- const block = envExampleBlock("proj-123");
- expect(block).toContain("CRAWLPROOF_PROJECT_ID=proj-123");
- expect(block).toContain("CRAWLPROOF_PROJECT_KEY=");
- expect(block).toContain("CRAWLPROOF_INGEST_URL=");
- expect(block).toContain("/api/events");
- });
-
- it("server helper reads env, never throws, and posts the project domain", () => {
- const code = generatedServerHelper("qaaas.dev");
- expect(code).toContain("process.env.CRAWLPROOF_PROJECT_KEY");
- expect(code).toContain("process.env.CRAWLPROOF_INGEST_URL");
- expect(code).toContain('project: "qaaas.dev"');
- expect(code).toContain("sendCrawlProofEvent");
- // No secrets baked into generated code.
- expect(code).not.toContain("cpk_");
- });
-
- it("client helper queues calls before stats.js loads", () => {
- const code = generatedClientHelper();
- expect(code).toContain("window.crawlproof");
- expect(code).toContain("identify");
- expect(code).toContain("consent");
- });
-});
-
-describe("hasTrackerReference (duplicate-install guard)", () => {
- // env.siteUrl is stubbed to http://localhost:3000 in tests/setup.ts.
- const origin = "http://localhost:3000";
-
- it("detects a single-line script tag", () => {
- expect(
- hasTrackerReference(
- ``,
- ),
- ).toBe(true);
- });
-
- it("detects a prettier-formatted multi-line tag", () => {
- const layout = `
-
hi