diff --git a/app/(app)/audience/[contactId]/page.tsx b/app/(app)/audience/[contactId]/page.tsx new file mode 100644 index 00000000..9e0230bd --- /dev/null +++ b/app/(app)/audience/[contactId]/page.tsx @@ -0,0 +1,262 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; + +export const metadata = { title: "Audience · Contact" }; + +// Contact detail (PRD §15): identity graph, project memberships, event +// timeline, and consent history for one deduped contact. RLS guarantees the +// caller owns the contact's scope. + +type Contact = { + id: string; + email: string; + name: string | null; + status: string; + marketing_consent: boolean; + unsubscribed_at: string | null; + suppressed_at: string | null; + suppression_reason: string | null; + source_project_id: string | null; + first_seen_at: string; + last_seen_at: string; + first_url: string | null; + first_referrer: string | null; + first_utm_source: string | null; + first_utm_medium: string | null; + first_utm_campaign: string | null; + last_url: string | null; + last_utm_source: string | null; + last_utm_campaign: string | null; + tags: string[] | null; + metadata: Record | null; +}; + +function fmt(iso: string | null): string { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +export default async function ContactDetailPage({ + params, +}: { + params: Promise<{ contactId: string }>; +}) { + const { contactId } = await params; + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect("/login"); + + const { data: contactRow } = await supabase + .from("audience_contacts") + .select( + "id, email, name, status, marketing_consent, unsubscribed_at, suppressed_at, suppression_reason, source_project_id, first_seen_at, last_seen_at, first_url, first_referrer, first_utm_source, first_utm_medium, first_utm_campaign, last_url, last_utm_source, last_utm_campaign, tags, metadata", + ) + .eq("id", contactId) + .maybeSingle(); + if (!contactRow) notFound(); + const contact = contactRow as Contact; + + const [{ data: identities }, { data: links }, { data: events }, { data: consents }] = + await Promise.all([ + supabase + .from("audience_identities") + .select("id, provider, external_id, project_id, created_at") + .eq("contact_id", contactId) + .order("created_at", { ascending: true }), + supabase + .from("audience_project_links") + .select("id, project_id, external_user_id, role, plan, first_seen_at, last_seen_at") + .eq("contact_id", contactId), + supabase + .from("audience_events") + .select("id, project_id, event, source, url, utm_source, utm_campaign, occurred_at") + .eq("contact_id", contactId) + .order("occurred_at", { ascending: false }) + .limit(50), + supabase + .from("audience_consent_events") + .select("id, consent_type, consent_value, source, occurred_at") + .eq("contact_id", contactId) + .order("occurred_at", { ascending: false }) + .limit(20), + ]); + + // Resolve project names for the ids we reference. + const projectIds = [ + ...new Set( + [ + ...(links ?? []).map((l) => l.project_id as string), + ...(events ?? []).map((e) => e.project_id as string), + contact.source_project_id, + ].filter(Boolean) as string[], + ), + ]; + const projectNames = new Map(); + if (projectIds.length > 0) { + const { data: projects } = await supabase + .from("projects") + .select("id, name") + .in("id", projectIds); + for (const p of projects ?? []) projectNames.set(p.id as string, p.name as string); + } + const projectLabel = (id: string | null) => + (id && projectNames.get(id)) || (id ? `${id.slice(0, 8)}…` : "—"); + + const attribution: [string, string | null][] = [ + ["First seen", fmt(contact.first_seen_at)], + ["Last seen", fmt(contact.last_seen_at)], + ["First URL", contact.first_url], + ["First referrer", contact.first_referrer], + ["First UTM", [contact.first_utm_source, contact.first_utm_medium, contact.first_utm_campaign].filter(Boolean).join(" / ") || null], + ["Last URL", contact.last_url], + ["Last UTM", [contact.last_utm_source, contact.last_utm_campaign].filter(Boolean).join(" / ") || null], + ["Source project", contact.source_project_id ? projectLabel(contact.source_project_id) : null], + ]; + + return ( +
+
+ + ← Audience Hub + +

{contact.email}

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

Attribution

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

Projects

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

No project links yet.

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

Identities

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

No linked identities.

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

Event timeline

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

No events recorded.

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

Consent history

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

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

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

Audience Hub

+

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

+
+ +
+ +
+ {cards.map((card) => ( +
+
{card.value}
+
+ {card.label} +
+
+ ))} +
+ +
+
+ + +
+ + {contacts.length === 0 ? ( +
+ {q + ? "No contacts match that search." + : "No contacts yet. Install the Audience Hub on a project (Project → Audience tab) and identify users or capture leads via stats.js."} +
+ ) : ( +
+ + + + + + + + + + + + + {contacts.map((contact) => ( + + + + + + + + + ))} + +
EmailNameStatusConsentFirst sourceLast seen
+ + {contact.email} + + {contact.name ?? "—"} + + {contact.status} + + + {contact.suppressed_at + ? "suppressed" + : contact.unsubscribed_at + ? "unsubscribed" + : contact.marketing_consent + ? "yes" + : "no"} + + {contact.first_utm_source ?? contact.last_utm_campaign ?? "—"} + + {fmt(contact.last_seen_at)} +
+
+ )} +
+
+ ); +} diff --git a/app/(app)/dashboard/org-controls.tsx b/app/(app)/dashboard/org-controls.tsx index b49adee4..2089b016 100644 --- a/app/(app)/dashboard/org-controls.tsx +++ b/app/(app)/dashboard/org-controls.tsx @@ -1,21 +1,28 @@ "use client"; -import { FormEvent, useState, useTransition } from "react"; +import { FormEvent, useRef, 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, removeOrgMember, revokeOrgInvitation, + setOrgMemberRole, + type OrgInviteRole, type OrgPendingInvitation, type OrgTeamMember, } from "@/app/actions/org-members"; @@ -23,7 +30,7 @@ import { export type DashboardOrg = { id: string; name: string; - role: "owner" | "member" | "project_member"; + role: "owner" | "member" | "viewer" | "project_member"; }; export type DashboardOrgTeam = { @@ -44,15 +51,34 @@ 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(); @@ -177,6 +203,16 @@ export function OrgDashboardControls({ organizationId={selectedOrgId} senderConfigs={senderConfigs} /> + + ("member"); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); const [pending, startTransition] = useTransition(); @@ -304,7 +342,7 @@ function OrgInviteForm({ orgId }: { orgId: string }) { event.preventDefault(); setMessage(null); startTransition(async () => { - const result = await inviteOrgMember(orgId, email); + const result = await inviteOrgMember(orgId, email, role); if (result.ok) { setEmail(""); setMessage({ ok: true, text: "Invitation sent." }); @@ -318,7 +356,8 @@ function OrgInviteForm({ orgId }: { orgId: string }) { return (

- Invited people can see every project in this org. + Members can see and edit every project in this org. Read-only members + can view everything but can't make changes.

+ @@ -359,6 +407,10 @@ function OrgMemberRow({ const sub = member.profile?.display_name ? member.profile.email : new Date(member.created_at).toLocaleDateString(); + const isViewer = member.role === "viewer"; + // Owners can manage full members and read-only viewers (not other owners + // or project-access markers). + const manageable = member.role === "member" || member.role === "viewer"; function remove() { startTransition(async () => { @@ -367,6 +419,13 @@ function OrgMemberRow({ }); } + function toggleRole() { + startTransition(async () => { + await setOrgMemberRole(orgId, member.user_id, isViewer ? "member" : "viewer"); + router.refresh(); + }); + } + return (
  • @@ -375,21 +434,34 @@ function OrgMemberRow({ {member.role === "owner" && ( owner )} + {isViewer && ( + read-only + )} {member.role === "project_member" && ( project access )}

    {sub &&

    {sub}

    }
    - {isOwner && member.role === "member" && ( - + {isOwner && manageable && ( +
    + + +
    )}
  • ); @@ -416,7 +488,12 @@ function OrgInvitationRow({ return (
  • -

    {invitation.email}

    +

    + {invitation.email} + {invitation.role === "viewer" && ( + read-only + )} +

    {expired ? "Expired" @@ -708,6 +785,375 @@ function Field({ ); } +function DataSourcesPanel({ + organizationId, + dataSources, +}: { + organizationId: string; + dataSources: DashboardDataSource[]; +}) { + const router = useRouter(); + const [message, setMessage] = useState(null); + const [pending, startTransition] = useTransition(); + + function syncAll() { + setMessage(null); + startTransition(async () => { + const result = await syncAllOrganizationAudience({ organizationId }); + if (!result.ok) { + setMessage(result.error); + return; + } + setMessage( + `Synced ${result.imported} emails (${result.added} new)` + + (result.failed ? `, ${result.failed} source(s) failed` : ""), + ); + router.refresh(); + }); + } + + return ( +

    + + User data sources + +
    +

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

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

    No data sources yet.

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

    {message}

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

    + {message} +

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

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

    + + {message &&

    {message}

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