From 3e9583ca44883a77a09f220ff526c12f12a0f342 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 19 May 2026 15:27:15 -0700 Subject: [PATCH] don't trigger newsletter sends on firehose update events (#302) The firehose UPDATE path was also running the newsletter claim/send logic. Combined with the fact that the idempotency row in publication_post_sends is only inserted when newsletters are enabled at upsert time, this meant that editing an old post after newsletters were turned on for the publication would mail the post out to subscribers retroactively. Gate the newsletter block on event_type === "create" so only firehose creates can fire sends. Metadata sync (indexed flag, bsky like counts, bridgy deletion) still runs on updates as before. Co-authored-by: Claude --- app/api/inngest/client.ts | 6 +- .../functions/sync_document_metadata.ts | 101 +++++++++--------- appview/index.ts | 2 + 3 files changed, 59 insertions(+), 50 deletions(-) diff --git a/app/api/inngest/client.ts b/app/api/inngest/client.ts index 0cc7ff97..19352ac3 100644 --- a/app/api/inngest/client.ts +++ b/app/api/inngest/client.ts @@ -42,7 +42,11 @@ export const events = { schema: staticSchema<{ documentUris?: string[] }>(), }), appviewSyncDocumentMetadata: eventType("appview/sync-document-metadata", { - schema: staticSchema<{ document_uri: string; bsky_post_uri?: string }>(), + schema: staticSchema<{ + document_uri: string; + bsky_post_uri?: string; + event_type?: "create" | "update"; + }>(), }), userWriteRecordsToPds: eventType("user/write-records-to-pds", { schema: staticSchema<{ diff --git a/app/api/inngest/functions/sync_document_metadata.ts b/app/api/inngest/functions/sync_document_metadata.ts index 8a0348bd..1ab7965f 100644 --- a/app/api/inngest/functions/sync_document_metadata.ts +++ b/app/api/inngest/functions/sync_document_metadata.ts @@ -20,7 +20,7 @@ export const sync_document_metadata = inngest.createFunction( triggers: [events.appviewSyncDocumentMetadata], }, async ({ event, step }) => { - const { document_uri, bsky_post_uri } = event.data; + const { document_uri, bsky_post_uri, event_type } = event.data; const did = new AtUri(document_uri).host; @@ -57,55 +57,58 @@ export const sync_document_metadata = inngest.createFunction( .select(); }); - const broadcast = await step.run( - "maybe-claim-newsletter-broadcast", - async () => { - const { data: docInPub } = await supabaseServerClient - .from("documents_in_publications") - .select("publication") - .eq("document", document_uri) - .maybeSingle(); - const publication_uri = docInPub?.publication; - if (!publication_uri) return { skipped: "no_publication" as const }; - - const { data: settings } = await supabaseServerClient - .from("publication_newsletter_settings") - .select("enabled") - .eq("publication", publication_uri) - .maybeSingle(); - if (!settings?.enabled) { - return { skipped: "newsletter_not_enabled" as const }; - } - - // Composite PK on (publication, document) is the idempotency guard: - // re-runs on document updates and races with publishToPublication - // both no-op here. - const { data: inserted } = await supabaseServerClient - .from("publication_post_sends") - .upsert( - { - publication: publication_uri, - document: document_uri, - status: "pending", - }, - { onConflict: "publication,document", ignoreDuplicates: true }, - ) - .select(); - if (!inserted || inserted.length === 0) { - return { skipped: "already_sent_or_pending" as const }; - } - return { claimed: true as const, publication_uri }; - }, - ); - - if ("claimed" in broadcast) { - await step.sendEvent("send-newsletter-broadcast", { - name: "newsletter/post.send.requested", - data: { - publication_uri: broadcast.publication_uri, - document_uri, + // Only fire newsletter broadcasts on first-time document creation. An + // update to an existing post must never trigger a send — otherwise editing + // an old post after newsletters were enabled would mail subscribers a post + // they never signed up for. + if (event_type === "create") { + const broadcast = await step.run( + "maybe-claim-newsletter-broadcast", + async () => { + const { data: docInPub } = await supabaseServerClient + .from("documents_in_publications") + .select("publication") + .eq("document", document_uri) + .maybeSingle(); + const publication_uri = docInPub?.publication; + if (!publication_uri) return { skipped: "no_publication" as const }; + + const { data: settings } = await supabaseServerClient + .from("publication_newsletter_settings") + .select("enabled") + .eq("publication", publication_uri) + .maybeSingle(); + if (!settings?.enabled) { + return { skipped: "newsletter_not_enabled" as const }; + } + + const { data: inserted } = await supabaseServerClient + .from("publication_post_sends") + .upsert( + { + publication: publication_uri, + document: document_uri, + status: "pending", + }, + { onConflict: "publication,document", ignoreDuplicates: true }, + ) + .select(); + if (!inserted || inserted.length === 0) { + return { skipped: "already_sent_or_pending" as const }; + } + return { claimed: true as const, publication_uri }; }, - }); + ); + + if ("claimed" in broadcast) { + await step.sendEvent("send-newsletter-broadcast", { + name: "newsletter/post.send.requested", + data: { + publication_uri: broadcast.publication_uri, + document_uri, + }, + }); + } } if (!bsky_post_uri) { diff --git a/appview/index.ts b/appview/index.ts index 782ad144..5b9b0fc5 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -118,6 +118,7 @@ async function handleEvent(evt: Event) { data: { document_uri: evt.uri.toString(), bsky_post_uri: record.value.postRef?.uri, + event_type: evt.event, }, }); if (record.value.publication) { @@ -285,6 +286,7 @@ async function handleEvent(evt: Event) { data: { document_uri: evt.uri.toString(), bsky_post_uri: record.value.bskyPostRef?.uri, + event_type: evt.event, }, }); -- 2.51.2