diff --git a/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx b/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx index b78ba15b..4b15aa8c 100644 --- a/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx +++ b/app/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx @@ -20,6 +20,12 @@ import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; import { InlineUpgradeToPro, UpgradeToProButton } from "../../UpgradeModal"; import { Modal } from "components/Modal"; import { Input } from "components/Input"; +import { deletePublication } from "./deletePublication"; +import Link from "next/link"; +import { + isOAuthSessionError, + OAuthErrorMessage, +} from "components/OAuthError"; type SettingsView = "all" | "theme"; @@ -276,48 +282,116 @@ export const DashboardContainer = (props: { ); }; +let pluralize = (n: number, word: string) => + `${n} ${word}${n === 1 ? "" : "s"}`; + const DeletePublication = () => { + let [open, setOpen] = useState(false); let [value, setValue] = useState(""); + let [deleting, setDeleting] = useState(false); + let [deleted, setDeleted] = useState(false); let record = useNormalizedPublicationRecord(); let { data: pub } = usePublicationData(); - let postCount = pub?.documents?.length; - let subCount = pub?.publication?.publication_subscriptions?.length; + let postCount = pub?.documents?.length ?? 0; + let draftCount = pub?.drafts?.length ?? 0; + let subCount = pub?.publication?.publication_subscriptions?.length ?? 0; + let toaster = useToaster(); + let pubUri = pub?.publication?.uri; + + let onDelete = async () => { + if (!pubUri || record?.name !== value || deleting) return; + setDeleting(true); + let result = await deletePublication(pubUri); + if (!result.success) { + setDeleting(false); + toaster({ + type: "error", + content: isOAuthSessionError(result.error) ? ( + + ) : ( + typeof result.error === "string" + ? result.error + : "Failed to delete publication" + ), + }); + return; + } + setDeleting(false); + setDeleted(true); + }; + return ( { + // Once deleted, the underlying page is stale — keep the modal open + // so the only way out is the "back to home" link. + if (deleted && !next) return; + setOpen(next); + if (!next) { + setValue(""); + } + }} trigger={Delete Publication} - title="Are you sure?" + title={deleted ? "Publication deleted" : "Are you sure?"} > -
-
- You will permanently lose
- {postCount !== 0 - ? `${pub?.documents?.length} post${postCount !== 1 && "s"}` - : null} -
- {subCount !== 0 - ? `${pub?.documents?.length} subscriber${subCount !== 1 && "s"}` - : null} -
-
- Enter the name of this publication to confirm + {deleted ? ( +
+
+ {record?.name} and + all its posts, drafts, and associated records have been deleted. +
+ + Back to home → +
+ ) : ( +
+
+ This will permanently delete: +
    +
  • This publication and its settings
  • +
  • + {pluralize(postCount, "published post")} + {postCount > 0 ? " (removed from your PDS)" : ""} +
  • +
  • {pluralize(draftCount, "draft")}
  • +
  • All associated records on your PDS
  • +
+ {subCount > 0 && ( +
+ {pluralize(subCount, "subscriber")} will lose access. +
+ )} +
+ This cannot be undone. +
+
+
+ Enter the name of this publication to confirm +
- setValue(e.currentTarget.value)} - /> - - Delete Publication - -
+ setValue(e.currentTarget.value)} + /> + + {deleting ? : "Delete Publication"} + +
+ )} ); }; diff --git a/app/lish/[did]/[publication]/dashboard/settings/deletePublication.ts b/app/lish/[did]/[publication]/dashboard/settings/deletePublication.ts new file mode 100644 index 00000000..90547613 --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/settings/deletePublication.ts @@ -0,0 +1,146 @@ +"use server"; + +import { AtpBaseClient } from "lexicons/api"; +import { getIdentityData } from "actions/getIdentityData"; +import { + restoreOAuthSession, + OAuthSessionError, +} from "src/atproto-oauth"; +import { AtUri } from "@atproto/syntax"; +import { supabaseServerClient } from "supabase/serverClient"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { + entities, + permission_tokens, + permission_token_rights, +} from "drizzle/schema"; +import { eq, inArray } from "drizzle-orm"; +import { pool } from "supabase/pool"; +import { revalidatePath } from "next/cache"; + +export async function deletePublication( + publication_uri: string, +): Promise< + { success: true } | { success: false; error: string | OAuthSessionError } +> { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) { + return { success: false, error: "Not authenticated" }; + } + + let pubUri = new AtUri(publication_uri); + if (pubUri.host !== identity.atp_did) { + return { success: false, error: "Not authorized" }; + } + + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + + // Collect these BEFORE deleting the publication row — cascading deletes would remove the join rows. + let [legacyDocs, siteDocs, drafts] = await Promise.all([ + supabaseServerClient + .from("documents_in_publications") + .select("document") + .eq("publication", publication_uri), + supabaseServerClient + .from("site_standard_documents_in_publications") + .select("document") + .eq("publication", publication_uri), + supabaseServerClient + .from("leaflets_in_publications") + .select("leaflet") + .eq("publication", publication_uri), + ]); + let documentUris = Array.from( + new Set([ + ...(legacyDocs.data ?? []).map((r) => r.document), + ...(siteDocs.data ?? []).map((r) => r.document), + ]), + ); + let draftTokenIds = (drafts.data ?? []).map((r) => r.leaflet); + + let pdsDeletes = Promise.all([ + ...documentUris.flatMap((docUri) => { + let u = new AtUri(docUri); + if (u.host !== credentialSession.did) return []; + return [ + agent.pub.leaflet.document + .delete({ repo: credentialSession.did, rkey: u.rkey }) + .catch(() => {}), + agent.site.standard.document + .delete({ repo: credentialSession.did, rkey: u.rkey }) + .catch(() => {}), + ]; + }), + agent.pub.leaflet.publication + .delete({ repo: credentialSession.did, rkey: pubUri.rkey }) + .catch(() => {}), + agent.site.standard.publication + .delete({ repo: credentialSession.did, rkey: pubUri.rkey }) + .catch(() => {}), + ]); + + let draftDeletes = async () => { + if (draftTokenIds.length === 0) return; + const client = await pool.connect(); + try { + const db = drizzle(client); + await db.transaction(async (tx) => { + let tokens = await tx + .select() + .from(permission_tokens) + .leftJoin( + permission_token_rights, + eq(permission_tokens.id, permission_token_rights.token), + ) + .where(inArray(permission_tokens.id, draftTokenIds)); + let entitySets = tokens + .map((t) => t.permission_token_rights?.entity_set) + .filter((s): s is string => !!s); + if (entitySets.length > 0) { + await tx.delete(entities).where(inArray(entities.set, entitySets)); + } + await tx + .delete(permission_tokens) + .where(inArray(permission_tokens.id, draftTokenIds)); + }); + } finally { + client.release(); + } + }; + + await Promise.all([pdsDeletes, draftDeletes()]); + + // Delete document rows before publication rows — publication cascade would leave orphaned docs. + if (documentUris.length > 0) { + await Promise.all([ + supabaseServerClient + .from("documents") + .delete() + .in("uri", documentUris), + supabaseServerClient + .from("site_standard_documents") + .delete() + .in("uri", documentUris), + ]); + } + await Promise.all([ + supabaseServerClient + .from("publications") + .delete() + .eq("uri", publication_uri), + supabaseServerClient + .from("site_standard_publications") + .delete() + .eq("uri", publication_uri), + ]); + + revalidatePath("/lish/[did]/[publication]", "layout"); + return { success: true }; +}