diff --git a/appview/index.ts b/appview/index.ts --- a/appview/index.ts +++ b/appview/index.ts @@ -118,6 +118,7 @@ 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 @@ data: { document_uri: evt.uri.toString(), bsky_post_uri: record.value.bskyPostRef?.uri, + event_type: evt.event, }, }); diff --git a/app/api/inngest/client.ts b/app/api/inngest/client.ts --- a/app/api/inngest/client.ts +++ b/app/api/inngest/client.ts @@ -42,7 +42,11 @@ 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 --- a/app/api/inngest/functions/sync_document_metadata.ts +++ b/app/api/inngest/functions/sync_document_metadata.ts @@ -20,7 +20,7 @@ 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 @@ .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 }; + // 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: 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, + 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) {