Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
7.3 kB · 208 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209import { supabaseServerClient } from "supabase/serverClient";import { inngest } from "app/api/inngest/client";import { isConfirmedContributor } from "src/contributorPermissions";import { Ok, Err, type Result } from "src/result";import { emailOnlyIneligibleReason, type EmailOnlyIneligibleReason,} from "src/emailPosts/eligibility";import { draftContributorDids, draftPagesForEmail } from "src/emailPosts/draft";import { EMAIL_POST_SUMMARY_COLUMNS, isEmailPostFinal, isValidSendAt, UNSENT_EMAIL_POST_STATUSES, type EmailPostAudience, type EmailPostStatus, type EmailPostSummary,} from "src/emailPosts/types";import type { Json } from "supabase/database.types";
export type EmailPostSend = | { mode: "now" } | { mode: "scheduled"; send_at: string } | { mode: "on_subscribe" };
export type SaveEmailPostError = | "unauthorized" | EmailOnlyIneligibleReason | "not_a_draft" | "already_sent" | "invalid_send_at" | "on_subscribe_taken" | "database_error";
// The publication, if the actor owns it or is a confirmed contributor.export async function loadActablePublication( publicationUri: string, actorDid: string,) { const { data } = await supabaseServerClient .from("publications") .select("identity_did, publication_membership_settings(enabled)") .eq("uri", publicationUri) .maybeSingle(); if (!data) return null; if ( data.identity_did === actorDid || (await isConfirmedContributor(publicationUri, actorDid)) ) return data; return null;}
// Creates or updates the email-only post for a publication draft, snapshotting// the draft's current content. Called with the signed-in actor's DID.export async function saveEmailPost( args: { publication_uri: string; leaflet_id: string; title: string; description: string; send: EmailPostSend; audience: EmailPostAudience; }, actorDid: string,): Promise<Result<EmailPostSummary, SaveEmailPostError>> { const pub = await loadActablePublication(args.publication_uri, actorDid); if (!pub) return Err("unauthorized");
// Only this publication's email-only drafts go out this way; a post that's // on the web, or on its way there, goes out through the normal publish. const { data: draft } = await supabaseServerClient .from("leaflets_in_publications") .select("doc, email_only, permission_tokens(root_entity)") .eq("publication", args.publication_uri) .eq("leaflet", args.leaflet_id) .maybeSingle(); const rootEntity = draft?.permission_tokens?.root_entity; if (!draft || draft.doc || !draft.email_only || !rootEntity) return Err("not_a_draft");
const ineligible = await emailOnlyIneligibleReason(args.publication_uri); if (ineligible) return Err(ineligible);
const { data: existing } = await supabaseServerClient .from("publication_email_posts") .select("id, publication, status, revision, send_mode, subscriber_count") .eq("leaflet", args.leaflet_id) .maybeSingle(); if (existing && existing.publication !== args.publication_uri) return Err("not_a_draft"); if (existing && isEmailPostFinal(existing.status as EmailPostStatus)) return Err("already_sent");
let sendAt: string | null = null; if (args.send.mode === "now") sendAt = new Date().toISOString(); if (args.send.mode === "scheduled") { const at = new Date(args.send.send_at); if (!isValidSendAt(at)) return Err("invalid_send_at"); sendAt = at.toISOString(); } const onSubscribe = args.send.mode === "on_subscribe";
const audience = pub.publication_membership_settings?.enabled ? args.audience : "all";
// Free and paid can each have their own on-subscribe email; an "all" one // would reach the same readers, so it can't sit alongside either. if (onSubscribe) { let others = supabaseServerClient .from("publication_email_posts") .select("id", { count: "exact", head: true }) .eq("publication", args.publication_uri) .eq("send_mode", "on_subscribe") .neq("leaflet", args.leaflet_id); if (audience !== "all") others = others.in("audience", ["all", audience]); const { count } = await others; if (count) return Err("on_subscribe_taken"); }
const [{ pages, imagePaths }, bylineDids] = await Promise.all([ draftPagesForEmail(rootEntity), draftContributorDids(args.publication_uri, args.leaflet_id), ]);
const revision = (existing?.revision ?? -1) + 1; const row = { publication: args.publication_uri, leaflet: args.leaflet_id, created_by: actorDid, title: args.title, description: args.description, pages: pages as unknown as Json, byline_dids: bylineDids, image_paths: imagePaths, send_mode: onSubscribe ? "on_subscribe" : "scheduled", send_at: sendAt, audience, status: onSubscribe ? "active" : "scheduled", revision, // An on-subscribe email's count is a running total across saves. subscriber_count: onSubscribe && existing?.send_mode === "on_subscribe" ? existing.subscriber_count : null, error: null, updated_at: new Date().toISOString(), }; // Updates are guarded on the row still being unsent, so a save can't // rewrite an email a scheduled send has just claimed. const { data: saved, error } = existing ? await supabaseServerClient .from("publication_email_posts") .update(row) .eq("id", existing.id) .in("status", UNSENT_EMAIL_POST_STATUSES) .select(EMAIL_POST_SUMMARY_COLUMNS) .maybeSingle() : await supabaseServerClient .from("publication_email_posts") .insert(row) .select(EMAIL_POST_SUMMARY_COLUMNS) .single(); if (error?.message.includes("publication_email_posts_one_on_subscribe")) return Err("on_subscribe_taken"); if (error) { console.error("[saveEmailPost] save failed:", error); return Err("database_error"); } if (!saved) return Err("already_sent");
if (!onSubscribe) await inngest.send({ name: "newsletter/email-post.send.requested", data: { email_post_id: saved.id, revision }, });
return Ok(saved as EmailPostSummary);}
// Returns an unsent email-only post to being a plain draft.export async function cancelEmailPost( leafletId: string, actorDid: string,): Promise<Result<null, "unauthorized" | "already_sent" | "database_error">> { const { data: existing } = await supabaseServerClient .from("publication_email_posts") .select("id, publication, status") .eq("leaflet", leafletId) .maybeSingle(); if (!existing) return Ok(null); if (!(await loadActablePublication(existing.publication, actorDid))) return Err("unauthorized"); if (isEmailPostFinal(existing.status as EmailPostStatus)) return Err("already_sent"); // The status guard loses a race with a scheduled send claiming the row, so // a send that already started is never silently orphaned. const { data: deleted, error } = await supabaseServerClient .from("publication_email_posts") .delete() .eq("id", existing.id) .in("status", UNSENT_EMAIL_POST_STATUSES) .select("id"); if (error) return Err("database_error"); if (!deleted?.length) return Err("already_sent"); return Ok(null);}