From 53809d92e3f482498f1dcf36ac474ba04e31c763 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 21 Apr 2026 15:06:29 -0400 Subject: [PATCH] add newsletter tables --- actions/subscribeToPublicationWithEmail.ts | 14 -- actions/unsubscribeFromPublication.ts | 14 -- .../functions/migrate_user_to_standard.ts | 7 - app/api/rpc/[command]/get_publication_data.ts | 1 + app/lish/Subscribe.tsx | 1 - supabase/database.types.ts | 209 ++++++++++++++--- .../20260421000000_add_newsletter_tables.sql | 214 ++++++++++++++++++ 7 files changed, 391 insertions(+), 69 deletions(-) delete mode 100644 actions/subscribeToPublicationWithEmail.ts delete mode 100644 actions/unsubscribeFromPublication.ts create mode 100644 supabase/migrations/20260421000000_add_newsletter_tables.sql diff --git a/actions/subscribeToPublicationWithEmail.ts b/actions/subscribeToPublicationWithEmail.ts deleted file mode 100644 index ffd77c6a..00000000 --- a/actions/subscribeToPublicationWithEmail.ts +++ /dev/null @@ -1,14 +0,0 @@ -"use server"; -import { supabaseServerClient } from "supabase/serverClient"; -import { getIdentityData } from "./getIdentityData"; - -export async function subscribeToPublicationWithEmail(publication: string) { - let identity = await getIdentityData(); - if (!identity || !identity.email) return null; - //This is an email relation!! - console.log( - await supabaseServerClient - .from("subscribers_to_publications") - .insert({ publication: publication, identity: identity.email }), - ); -} diff --git a/actions/unsubscribeFromPublication.ts b/actions/unsubscribeFromPublication.ts deleted file mode 100644 index cee81156..00000000 --- a/actions/unsubscribeFromPublication.ts +++ /dev/null @@ -1,14 +0,0 @@ -"use server"; -import { supabaseServerClient } from "supabase/serverClient"; -import { getIdentityData } from "./getIdentityData"; - -export async function unsubscribeFromPublication(publication: string) { - let identity = await getIdentityData(); - if (!identity || !identity.email) return null; - //This is an email relation!! - await supabaseServerClient - .from("subscribers_to_publications") - .delete() - .eq("publication", publication) - .eq("identity", identity.email); -} diff --git a/app/api/inngest/functions/migrate_user_to_standard.ts b/app/api/inngest/functions/migrate_user_to_standard.ts index afc51931..ef5abe0b 100644 --- a/app/api/inngest/functions/migrate_user_to_standard.ts +++ b/app/api/inngest/functions/migrate_user_to_standard.ts @@ -496,13 +496,6 @@ export const migrate_user_to_standard = inngest.createFunction( .update({ document: newUri }) .eq("document", oldUri), ), - // Update subscribers_to_publications - publication references - ...pubEntries.map(([oldUri, newUri]) => - supabaseServerClient - .from("subscribers_to_publications") - .update({ publication: newUri }) - .eq("publication", oldUri), - ), // Update publication_subscriptions - publication references ...pubEntries.map(([oldUri, newUri]) => supabaseServerClient diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts index 9fe23804..e0db6852 100644 --- a/app/api/rpc/[command]/get_publication_data.ts +++ b/app/api/rpc/[command]/get_publication_data.ts @@ -45,6 +45,7 @@ export const get_publication_data = makeRoute({ )), publication_subscriptions(*, identities(bsky_profiles(*))), publication_domains(*), + publication_newsletter_settings(enabled), leaflets_in_publications(*, documents(*), permission_tokens(*, diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx index 4b75c478..3e415a28 100644 --- a/app/lish/Subscribe.tsx +++ b/app/lish/Subscribe.tsx @@ -7,7 +7,6 @@ import { confirmEmailAuthToken, requestAuthEmailToken, } from "actions/emailAuth"; -import { subscribeToPublicationWithEmail } from "actions/subscribeToPublicationWithEmail"; import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; import { ShareSmall } from "components/Icons/ShareSmall"; import { Popover } from "components/Popover"; diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 68844690..4210d201 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -1057,6 +1057,182 @@ export type Database = { }, ] } + publication_email_subscriber_events: { + Row: { + event_type: string + id: string + metadata: Json | null + occurred_at: string + publication: string + subscriber: string + } + Insert: { + event_type: string + id?: string + metadata?: Json | null + occurred_at?: string + publication: string + subscriber: string + } + Update: { + event_type?: string + id?: string + metadata?: Json | null + occurred_at?: string + publication?: string + subscriber?: string + } + Relationships: [ + { + foreignKeyName: "publication_email_subscriber_events_publication_fkey" + columns: ["publication"] + isOneToOne: false + referencedRelation: "publications" + referencedColumns: ["uri"] + }, + { + foreignKeyName: "publication_email_subscriber_events_subscriber_fkey" + columns: ["subscriber"] + isOneToOne: false + referencedRelation: "publication_email_subscribers" + referencedColumns: ["id"] + }, + ] + } + publication_email_subscribers: { + Row: { + confirmation_code: string | null + confirmed_at: string | null + created_at: string + email: string + id: string + identity_id: string | null + publication: string + state: string + unsubscribe_token: string + unsubscribed_at: string | null + } + Insert: { + confirmation_code?: string | null + confirmed_at?: string | null + created_at?: string + email: string + id?: string + identity_id?: string | null + publication: string + state?: string + unsubscribe_token?: string + unsubscribed_at?: string | null + } + Update: { + confirmation_code?: string | null + confirmed_at?: string | null + created_at?: string + email?: string + id?: string + identity_id?: string | null + publication?: string + state?: string + unsubscribe_token?: string + unsubscribed_at?: string | null + } + Relationships: [ + { + foreignKeyName: "publication_email_subscribers_identity_id_fkey" + columns: ["identity_id"] + isOneToOne: false + referencedRelation: "identities" + referencedColumns: ["id"] + }, + { + foreignKeyName: "publication_email_subscribers_publication_fkey" + columns: ["publication"] + isOneToOne: false + referencedRelation: "publications" + referencedColumns: ["uri"] + }, + ] + } + publication_newsletter_settings: { + Row: { + created_at: string + enabled: boolean + publication: string + reply_to_email: string | null + reply_to_verified_at: string | null + updated_at: string + } + Insert: { + created_at?: string + enabled?: boolean + publication: string + reply_to_email?: string | null + reply_to_verified_at?: string | null + updated_at?: string + } + Update: { + created_at?: string + enabled?: boolean + publication?: string + reply_to_email?: string | null + reply_to_verified_at?: string | null + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "publication_newsletter_settings_publication_fkey" + columns: ["publication"] + isOneToOne: true + referencedRelation: "publications" + referencedColumns: ["uri"] + }, + ] + } + publication_post_sends: { + Row: { + completed_at: string | null + document: string + error: string | null + publication: string + started_at: string + status: string + subscriber_count: number | null + } + Insert: { + completed_at?: string | null + document: string + error?: string | null + publication: string + started_at?: string + status?: string + subscriber_count?: number | null + } + Update: { + completed_at?: string | null + document?: string + error?: string | null + publication?: string + started_at?: string + status?: string + subscriber_count?: number | null + } + Relationships: [ + { + foreignKeyName: "publication_post_sends_document_fkey" + columns: ["document"] + isOneToOne: false + referencedRelation: "documents" + referencedColumns: ["uri"] + }, + { + foreignKeyName: "publication_post_sends_publication_fkey" + columns: ["publication"] + isOneToOne: false + referencedRelation: "publications" + referencedColumns: ["uri"] + }, + ] + } publication_subscriptions: { Row: { created_at: string @@ -1315,39 +1491,6 @@ export type Database = { }, ] } - subscribers_to_publications: { - Row: { - created_at: string - identity: string - publication: string - } - Insert: { - created_at?: string - identity: string - publication: string - } - Update: { - created_at?: string - identity?: string - publication?: string - } - Relationships: [ - { - foreignKeyName: "subscribers_to_publications_identity_fkey" - columns: ["identity"] - isOneToOne: false - referencedRelation: "identities" - referencedColumns: ["email"] - }, - { - foreignKeyName: "subscribers_to_publications_publication_fkey" - columns: ["publication"] - isOneToOne: false - referencedRelation: "publications" - referencedColumns: ["uri"] - }, - ] - } user_entitlements: { Row: { entitlement_key: string diff --git a/supabase/migrations/20260421000000_add_newsletter_tables.sql b/supabase/migrations/20260421000000_add_newsletter_tables.sql new file mode 100644 index 00000000..9e3a463c --- /dev/null +++ b/supabase/migrations/20260421000000_add_newsletter_tables.sql @@ -0,0 +1,214 @@ +-- Newsletter mode: four new tables and drop the legacy subscribers_to_publications. +-- See specs/2026-04-20-newsletter-mode.md. + +-- Legacy table has no live rows; its writers retired with the feature branch. +drop table if exists "public"."subscribers_to_publications"; + + +-- publication_newsletter_settings --------------------------------------------- +-- Per-publication newsletter config. Separate from publications.metadata because +-- reply_to_email has a real verification lifecycle and we want cheap queries for +-- "newsletter-enabled publications". +create table "public"."publication_newsletter_settings" ( + "publication" text not null, + "enabled" boolean not null default false, + "reply_to_email" text, + "reply_to_verified_at" timestamp with time zone, + "created_at" timestamp with time zone not null default now(), + "updated_at" timestamp with time zone not null default now() +); + +alter table "public"."publication_newsletter_settings" enable row level security; + +CREATE UNIQUE INDEX publication_newsletter_settings_pkey ON public.publication_newsletter_settings USING btree (publication); +CREATE INDEX publication_newsletter_settings_enabled_idx ON public.publication_newsletter_settings USING btree (publication) WHERE enabled; + +alter table "public"."publication_newsletter_settings" add constraint "publication_newsletter_settings_pkey" PRIMARY KEY using index "publication_newsletter_settings_pkey"; + +alter table "public"."publication_newsletter_settings" add constraint "publication_newsletter_settings_publication_fkey" FOREIGN KEY (publication) REFERENCES publications(uri) ON DELETE CASCADE not valid; +alter table "public"."publication_newsletter_settings" validate constraint "publication_newsletter_settings_publication_fkey"; + +grant delete on table "public"."publication_newsletter_settings" to "anon"; +grant insert on table "public"."publication_newsletter_settings" to "anon"; +grant references on table "public"."publication_newsletter_settings" to "anon"; +grant select on table "public"."publication_newsletter_settings" to "anon"; +grant trigger on table "public"."publication_newsletter_settings" to "anon"; +grant truncate on table "public"."publication_newsletter_settings" to "anon"; +grant update on table "public"."publication_newsletter_settings" to "anon"; +grant delete on table "public"."publication_newsletter_settings" to "authenticated"; +grant insert on table "public"."publication_newsletter_settings" to "authenticated"; +grant references on table "public"."publication_newsletter_settings" to "authenticated"; +grant select on table "public"."publication_newsletter_settings" to "authenticated"; +grant trigger on table "public"."publication_newsletter_settings" to "authenticated"; +grant truncate on table "public"."publication_newsletter_settings" to "authenticated"; +grant update on table "public"."publication_newsletter_settings" to "authenticated"; +grant delete on table "public"."publication_newsletter_settings" to "service_role"; +grant insert on table "public"."publication_newsletter_settings" to "service_role"; +grant references on table "public"."publication_newsletter_settings" to "service_role"; +grant select on table "public"."publication_newsletter_settings" to "service_role"; +grant trigger on table "public"."publication_newsletter_settings" to "service_role"; +grant truncate on table "public"."publication_newsletter_settings" to "service_role"; +grant update on table "public"."publication_newsletter_settings" to "service_role"; + + +-- publication_email_subscribers ----------------------------------------------- +-- Per-publication email subscriber list. Handle subscriptions continue to live +-- in publication_subscriptions; cross-flow queries union the two. +create table "public"."publication_email_subscribers" ( + "id" uuid not null default gen_random_uuid(), + "publication" text not null, + "email" text not null, + "identity_id" uuid, + "state" text not null default 'pending', + "confirmation_code" text, + "unsubscribe_token" uuid not null default gen_random_uuid(), + "created_at" timestamp with time zone not null default now(), + "confirmed_at" timestamp with time zone, + "unsubscribed_at" timestamp with time zone +); + +alter table "public"."publication_email_subscribers" enable row level security; + +CREATE UNIQUE INDEX publication_email_subscribers_pkey ON public.publication_email_subscribers USING btree (id); +CREATE UNIQUE INDEX publication_email_subscribers_publication_email_key ON public.publication_email_subscribers USING btree (publication, email); +CREATE UNIQUE INDEX publication_email_subscribers_unsubscribe_token_key ON public.publication_email_subscribers USING btree (unsubscribe_token); +CREATE INDEX publication_email_subscribers_confirmed_idx ON public.publication_email_subscribers USING btree (publication) WHERE state = 'confirmed'; + +alter table "public"."publication_email_subscribers" add constraint "publication_email_subscribers_pkey" PRIMARY KEY using index "publication_email_subscribers_pkey"; +alter table "public"."publication_email_subscribers" add constraint "publication_email_subscribers_publication_email_key" UNIQUE using index "publication_email_subscribers_publication_email_key"; +alter table "public"."publication_email_subscribers" add constraint "publication_email_subscribers_unsubscribe_token_key" UNIQUE using index "publication_email_subscribers_unsubscribe_token_key"; + +alter table "public"."publication_email_subscribers" add constraint "publication_email_subscribers_publication_fkey" FOREIGN KEY (publication) REFERENCES publications(uri) ON DELETE CASCADE not valid; +alter table "public"."publication_email_subscribers" validate constraint "publication_email_subscribers_publication_fkey"; + +alter table "public"."publication_email_subscribers" add constraint "publication_email_subscribers_identity_id_fkey" FOREIGN KEY (identity_id) REFERENCES identities(id) ON DELETE SET NULL not valid; +alter table "public"."publication_email_subscribers" validate constraint "publication_email_subscribers_identity_id_fkey"; + +alter table "public"."publication_email_subscribers" add constraint "publication_email_subscribers_state_check" CHECK (state IN ('pending','confirmed','unsubscribed')) not valid; +alter table "public"."publication_email_subscribers" validate constraint "publication_email_subscribers_state_check"; + +grant delete on table "public"."publication_email_subscribers" to "anon"; +grant insert on table "public"."publication_email_subscribers" to "anon"; +grant references on table "public"."publication_email_subscribers" to "anon"; +grant select on table "public"."publication_email_subscribers" to "anon"; +grant trigger on table "public"."publication_email_subscribers" to "anon"; +grant truncate on table "public"."publication_email_subscribers" to "anon"; +grant update on table "public"."publication_email_subscribers" to "anon"; +grant delete on table "public"."publication_email_subscribers" to "authenticated"; +grant insert on table "public"."publication_email_subscribers" to "authenticated"; +grant references on table "public"."publication_email_subscribers" to "authenticated"; +grant select on table "public"."publication_email_subscribers" to "authenticated"; +grant trigger on table "public"."publication_email_subscribers" to "authenticated"; +grant truncate on table "public"."publication_email_subscribers" to "authenticated"; +grant update on table "public"."publication_email_subscribers" to "authenticated"; +grant delete on table "public"."publication_email_subscribers" to "service_role"; +grant insert on table "public"."publication_email_subscribers" to "service_role"; +grant references on table "public"."publication_email_subscribers" to "service_role"; +grant select on table "public"."publication_email_subscribers" to "service_role"; +grant trigger on table "public"."publication_email_subscribers" to "service_role"; +grant truncate on table "public"."publication_email_subscribers" to "service_role"; +grant update on table "public"."publication_email_subscribers" to "service_role"; + + +-- publication_email_subscriber_events ----------------------------------------- +-- Append-only event log. Timestamp columns on publication_email_subscribers +-- are convenience caches derivable from this. +create table "public"."publication_email_subscriber_events" ( + "id" uuid not null default gen_random_uuid(), + "subscriber" uuid not null, + "publication" text not null, + "event_type" text not null, + "occurred_at" timestamp with time zone not null default now(), + "metadata" jsonb +); + +alter table "public"."publication_email_subscriber_events" enable row level security; + +CREATE UNIQUE INDEX publication_email_subscriber_events_pkey ON public.publication_email_subscriber_events USING btree (id); +CREATE INDEX publication_email_subscriber_events_subscriber_idx ON public.publication_email_subscriber_events USING btree (subscriber, occurred_at DESC); +CREATE INDEX publication_email_subscriber_events_publication_type_idx ON public.publication_email_subscriber_events USING btree (publication, event_type, occurred_at DESC); + +alter table "public"."publication_email_subscriber_events" add constraint "publication_email_subscriber_events_pkey" PRIMARY KEY using index "publication_email_subscriber_events_pkey"; + +alter table "public"."publication_email_subscriber_events" add constraint "publication_email_subscriber_events_subscriber_fkey" FOREIGN KEY (subscriber) REFERENCES publication_email_subscribers(id) ON DELETE CASCADE not valid; +alter table "public"."publication_email_subscriber_events" validate constraint "publication_email_subscriber_events_subscriber_fkey"; + +alter table "public"."publication_email_subscriber_events" add constraint "publication_email_subscriber_events_publication_fkey" FOREIGN KEY (publication) REFERENCES publications(uri) ON DELETE CASCADE not valid; +alter table "public"."publication_email_subscriber_events" validate constraint "publication_email_subscriber_events_publication_fkey"; + +alter table "public"."publication_email_subscriber_events" add constraint "publication_email_subscriber_events_event_type_check" CHECK (event_type IN ('subscribe_requested','confirmation_sent','confirmed','unsubscribe_requested','resubscribed','post_sent','bounce','complaint')) not valid; +alter table "public"."publication_email_subscriber_events" validate constraint "publication_email_subscriber_events_event_type_check"; + +grant delete on table "public"."publication_email_subscriber_events" to "anon"; +grant insert on table "public"."publication_email_subscriber_events" to "anon"; +grant references on table "public"."publication_email_subscriber_events" to "anon"; +grant select on table "public"."publication_email_subscriber_events" to "anon"; +grant trigger on table "public"."publication_email_subscriber_events" to "anon"; +grant truncate on table "public"."publication_email_subscriber_events" to "anon"; +grant update on table "public"."publication_email_subscriber_events" to "anon"; +grant delete on table "public"."publication_email_subscriber_events" to "authenticated"; +grant insert on table "public"."publication_email_subscriber_events" to "authenticated"; +grant references on table "public"."publication_email_subscriber_events" to "authenticated"; +grant select on table "public"."publication_email_subscriber_events" to "authenticated"; +grant trigger on table "public"."publication_email_subscriber_events" to "authenticated"; +grant truncate on table "public"."publication_email_subscriber_events" to "authenticated"; +grant update on table "public"."publication_email_subscriber_events" to "authenticated"; +grant delete on table "public"."publication_email_subscriber_events" to "service_role"; +grant insert on table "public"."publication_email_subscriber_events" to "service_role"; +grant references on table "public"."publication_email_subscriber_events" to "service_role"; +grant select on table "public"."publication_email_subscriber_events" to "service_role"; +grant trigger on table "public"."publication_email_subscriber_events" to "service_role"; +grant truncate on table "public"."publication_email_subscriber_events" to "service_role"; +grant update on table "public"."publication_email_subscriber_events" to "service_role"; + + +-- publication_post_sends ------------------------------------------------------ +-- One row per (publication, document). Primary job is idempotency: publish +-- inserts with ON CONFLICT DO NOTHING before firing a send. +create table "public"."publication_post_sends" ( + "publication" text not null, + "document" text not null, + "status" text not null default 'pending', + "subscriber_count" integer, + "started_at" timestamp with time zone not null default now(), + "completed_at" timestamp with time zone, + "error" text +); + +alter table "public"."publication_post_sends" enable row level security; + +CREATE UNIQUE INDEX publication_post_sends_pkey ON public.publication_post_sends USING btree (publication, document); +CREATE INDEX publication_post_sends_publication_started_idx ON public.publication_post_sends USING btree (publication, started_at DESC); + +alter table "public"."publication_post_sends" add constraint "publication_post_sends_pkey" PRIMARY KEY using index "publication_post_sends_pkey"; + +alter table "public"."publication_post_sends" add constraint "publication_post_sends_publication_fkey" FOREIGN KEY (publication) REFERENCES publications(uri) ON DELETE CASCADE not valid; +alter table "public"."publication_post_sends" validate constraint "publication_post_sends_publication_fkey"; + +alter table "public"."publication_post_sends" add constraint "publication_post_sends_document_fkey" FOREIGN KEY (document) REFERENCES documents(uri) ON DELETE CASCADE not valid; +alter table "public"."publication_post_sends" validate constraint "publication_post_sends_document_fkey"; + +alter table "public"."publication_post_sends" add constraint "publication_post_sends_status_check" CHECK (status IN ('pending','sending','sent','failed')) not valid; +alter table "public"."publication_post_sends" validate constraint "publication_post_sends_status_check"; + +grant delete on table "public"."publication_post_sends" to "anon"; +grant insert on table "public"."publication_post_sends" to "anon"; +grant references on table "public"."publication_post_sends" to "anon"; +grant select on table "public"."publication_post_sends" to "anon"; +grant trigger on table "public"."publication_post_sends" to "anon"; +grant truncate on table "public"."publication_post_sends" to "anon"; +grant update on table "public"."publication_post_sends" to "anon"; +grant delete on table "public"."publication_post_sends" to "authenticated"; +grant insert on table "public"."publication_post_sends" to "authenticated"; +grant references on table "public"."publication_post_sends" to "authenticated"; +grant select on table "public"."publication_post_sends" to "authenticated"; +grant trigger on table "public"."publication_post_sends" to "authenticated"; +grant truncate on table "public"."publication_post_sends" to "authenticated"; +grant update on table "public"."publication_post_sends" to "authenticated"; +grant delete on table "public"."publication_post_sends" to "service_role"; +grant insert on table "public"."publication_post_sends" to "service_role"; +grant references on table "public"."publication_post_sends" to "service_role"; +grant select on table "public"."publication_post_sends" to "service_role"; +grant trigger on table "public"."publication_post_sends" to "service_role"; +grant truncate on table "public"."publication_post_sends" to "service_role"; +grant update on table "public"."publication_post_sends" to "service_role"; -- 2.51.2