From d6b28e6fae2846b8fe2bec8496e9cc5f560c4326 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 11 Jun 2026 16:34:23 +0000 Subject: [PATCH] Add org audience mass-email + org-level read-only viewer role Org audience / mass email: - Connect each project's backing DB (Supabase via service-role auth.users or a public table; Turso via @libsql/client read-only SELECT) as a per-org data source, sync every user email into a deduped org audience, and blast it through the org's existing SMTP/Resend sender config. - Always adds one-click unsubscribe + List-Unsubscribe headers and skips both per-org unsubscribes and globally-unsubscribed marketing_contacts. - Fix: an explicit "resend" org sender config is no longer hijacked by a global SMTP_HOST env (sendOutreachEmail now honors the provider choice). - Migration 20260611140000_org_audience.sql; new lib/audience/*; dashboard panels; tests for connector normalization + query guard. Org-level viewer role: - Read-only org members ("viewer"): see every project in the org but cannot mutate any project or org data. Reads widened in is_org_wide_member + is_project_member; writes unchanged (is_org_owner / is_project_editor still owner/member only). Migration 20260611150000_org_member_viewer_role.sql. - Team-members panel gets a Member/Read-only invite dropdown, a "Make read-only / Make editor" toggle, and viewer badges; role carried through organization_invitations.role and applied on accept. Co-Authored-By: Claude Opus 4.8 --- app/(app)/dashboard/org-controls.tsx | 474 +++++++++++++++++- app/(app)/dashboard/page.tsx | 59 ++- app/actions/org-members.ts | 42 +- app/actions/orgs.ts | 194 +++++++ app/invite/org/[token]/page.tsx | 9 +- app/unsubscribe/org/[token]/page.tsx | 42 ++ lib/audience/blast.ts | 186 +++++++ lib/audience/connectors.ts | 141 ++++++ lib/audience/sync.ts | 145 ++++++ lib/marketing.ts | 17 + lib/orgs.ts | 8 +- lib/outreach.ts | 23 +- package-lock.json | 249 +++++++++ package.json | 1 + .../20260611140000_org_audience.sql | 115 +++++ .../20260611150000_org_member_viewer_role.sql | 72 +++ tests/audience-connectors.test.ts | 62 +++ 17 files changed, 1807 insertions(+), 32 deletions(-) create mode 100644 app/unsubscribe/org/[token]/page.tsx create mode 100644 lib/audience/blast.ts create mode 100644 lib/audience/connectors.ts create mode 100644 lib/audience/sync.ts create mode 100644 supabase/migrations/20260611140000_org_audience.sql create mode 100644 supabase/migrations/20260611150000_org_member_viewer_role.sql create mode 100644 tests/audience-connectors.test.ts 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 ( +
    + +