+
{!isReviewingPub && (
diff --git a/client/containers/Pub/PubDocument/pubArchiveNotice.scss b/client/containers/Pub/PubDocument/pubArchiveNotice.scss
new file mode 100644
index 0000000000..fa60028584
--- /dev/null
+++ b/client/containers/Pub/PubDocument/pubArchiveNotice.scss
@@ -0,0 +1,4 @@
+.pub-archive-notice-component {
+ margin-top: 25px;
+ margin-bottom: 15px;
+}
diff --git a/server/collectionAttribution/model.ts b/server/collectionAttribution/model.ts
index 465a77a6dd..91472c4f3a 100644
--- a/server/collectionAttribution/model.ts
+++ b/server/collectionAttribution/model.ts
@@ -63,7 +63,7 @@ export class CollectionAttribution extends Model<
@Column(DataType.UUID)
declare collectionId: string;
- @BelongsTo(() => User, { onDelete: 'CASCADE', as: 'user', foreignKey: 'userId' })
+ @BelongsTo(() => User, { onDelete: 'SET NULL', as: 'user', foreignKey: 'userId' })
declare user?: User;
@BelongsTo(() => Collection, {
diff --git a/server/community/__tests__/destroyCommunity.test.ts b/server/community/__tests__/destroyCommunity.test.ts
new file mode 100644
index 0000000000..b248415ee3
--- /dev/null
+++ b/server/community/__tests__/destroyCommunity.test.ts
@@ -0,0 +1,262 @@
+/**
+ * Integration tests for community deletion and user account deletion.
+ *
+ * Run with: pnpm test-no-lint -- server/community/__tests__/destroyCommunity.test.ts
+ * Or: pnpm test-no-lint -- server/user/__tests__/destroyUser.test.ts
+ *
+ * (Or run both together — they're independent files.)
+ */
+import {
+ Collection,
+ CollectionAttribution,
+ Community,
+ Discussion,
+ Member,
+ Page,
+ Pub,
+ PubAttribution,
+ Release,
+ Thread,
+ ThreadComment,
+} from 'server/models';
+import { ARCHIVE_COMMUNITY_ID } from 'server/utils/systemEntities';
+import { login, modelize, setup, teardown } from 'stubstub';
+
+// ---------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------
+const models = modelize`
+ Community communityToDelete {
+ Member {
+ permissions: "admin"
+ User communityAdmin {}
+ }
+ Member {
+ permissions: "edit"
+ User communityEditor {}
+ }
+ Page homePage {
+ title: "Home"
+ slug: "home"
+ }
+ Collection collection {
+ CollectionAttribution collectionAttribution {
+ name: "Some Author"
+ isAuthor: true
+ }
+ }
+ Pub pubWithDoi {
+ doi: "10.1234/test-doi-pub"
+ PubAttribution doiPubAttribution {
+ name: "DOI Author"
+ isAuthor: true
+ }
+ Release doiPubRelease {}
+ Discussion doiPubDiscussion {
+ number: 1
+ Visibility {}
+ Thread doiPubThread {
+ ThreadComment doiPubComment {
+ text: "This is a comment on a DOI pub"
+ }
+ }
+ }
+ }
+ Pub pubWithoutDoi {
+ PubAttribution noDotPubAttribution {
+ name: "No DOI Author"
+ isAuthor: true
+ }
+ Discussion noDotPubDiscussion {
+ number: 1
+ Visibility {}
+ Thread noDotPubThread {
+ ThreadComment noDotPubComment {
+ text: "This is a comment on a non-DOI pub"
+ }
+ }
+ }
+ }
+ }
+ Community communityNotDeleted {
+ Member {
+ permissions: "admin"
+ User otherAdmin {}
+ }
+ Pub survivingPub {}
+ }
+ User outsider {}
+`;
+
+setup(beforeAll, async () => {
+ // Ensure the archive community exists for the test.
+ // hooks:false prevents the afterCreate hook from running summarizeCommunity(),
+ // which would fail because findOrCreate's internal transaction hasn't committed yet.
+ await Community.findOrCreate({
+ where: { id: ARCHIVE_COMMUNITY_ID },
+ defaults: {
+ id: ARCHIVE_COMMUNITY_ID,
+ subdomain: 'archive',
+ title: 'PubPub Archive',
+ } as any,
+ hooks: false,
+ });
+ await models.resolve();
+});
+
+teardown(afterAll);
+
+const getHost = (community) => `${community.subdomain}.pubpub.org`;
+
+// ---------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------
+describe('DELETE /api/communities/:id', () => {
+ it('rejects unauthenticated requests', async () => {
+ const { communityToDelete } = models;
+ const agent = await login();
+ await agent
+ .delete(`/api/communities/${communityToDelete.id}`)
+ .set('Host', getHost(communityToDelete))
+ .send({ confirmationTitle: communityToDelete.title })
+ .expect(403);
+ });
+
+ it('rejects non-admin users', async () => {
+ const { communityToDelete, outsider } = models;
+ const agent = await login(outsider);
+ await agent
+ .delete(`/api/communities/${communityToDelete.id}`)
+ .set('Host', getHost(communityToDelete))
+ .send({ confirmationTitle: communityToDelete.title })
+ .expect(403);
+ });
+
+ it('rejects community editors (not admin)', async () => {
+ const { communityToDelete, communityEditor } = models;
+ const agent = await login(communityEditor);
+ await agent
+ .delete(`/api/communities/${communityToDelete.id}`)
+ .set('Host', getHost(communityToDelete))
+ .send({ confirmationTitle: communityToDelete.title })
+ .expect(403);
+ });
+
+ it('rejects if confirmation title does not match', async () => {
+ const { communityToDelete, communityAdmin } = models;
+ const agent = await login(communityAdmin);
+ await agent
+ .delete(`/api/communities/${communityToDelete.id}`)
+ .set('Host', getHost(communityToDelete))
+ .send({ confirmationTitle: 'wrong title' })
+ .expect(400);
+ });
+});
+
+describe('GET /api/communities/:id/deletionAudit', () => {
+ it('returns a correct audit for a community with DOI and non-DOI pubs', async () => {
+ const { communityToDelete, communityAdmin } = models;
+ const agent = await login(communityAdmin);
+ const { body } = await agent
+ .get(`/api/communities/${communityToDelete.id}/deletionAudit`)
+ .set('Host', getHost(communityToDelete))
+ .expect(200);
+
+ expect(body.communityId).toEqual(communityToDelete.id);
+ expect(body.totalPubs).toEqual(2);
+ expect(body.pubsWithDoi).toEqual(1);
+ expect(body.pubsWithoutDoi).toEqual(1);
+ });
+});
+
+describe('Community deletion end-to-end', () => {
+ it('deletes community, archives DOI pubs, hard-deletes non-DOI pubs', async () => {
+ const {
+ communityToDelete,
+ communityAdmin,
+ pubWithDoi,
+ pubWithoutDoi,
+ doiPubAttribution,
+ doiPubRelease,
+ doiPubDiscussion,
+ doiPubThread,
+ doiPubComment,
+ noDotPubAttribution,
+ noDotPubDiscussion,
+ noDotPubThread,
+ noDotPubComment,
+ collection,
+ homePage,
+ communityNotDeleted,
+ survivingPub,
+ } = models;
+
+ const agent = await login(communityAdmin);
+
+ // Perform deletion
+ const { body } = await agent
+ .delete(`/api/communities/${communityToDelete.id}`)
+ .set('Host', getHost(communityToDelete))
+ .send({ confirmationTitle: communityToDelete.title })
+ .expect(200);
+
+ expect(body.success).toBe(true);
+
+ // ---- Community should be gone ----
+ const deletedCommunity = await Community.findByPk(communityToDelete.id);
+ expect(deletedCommunity).toBeNull();
+
+ // ---- DOI pub should be moved to archive community ----
+ const archivedPub = await Pub.findByPk(pubWithDoi.id);
+ expect(archivedPub).not.toBeNull();
+ expect(archivedPub!.communityId).toEqual(ARCHIVE_COMMUNITY_ID);
+ expect(archivedPub!.doi).toEqual('10.1234/test-doi-pub');
+
+ // ---- DOI pub's children should survive ----
+ const archivedAttribution = await PubAttribution.findByPk(doiPubAttribution.id);
+ expect(archivedAttribution).not.toBeNull();
+
+ const archivedRelease = await Release.findByPk(doiPubRelease.id);
+ expect(archivedRelease).not.toBeNull();
+
+ const archivedDiscussion = await Discussion.findByPk(doiPubDiscussion.id);
+ expect(archivedDiscussion).not.toBeNull();
+
+ const archivedThread = await Thread.findByPk(doiPubThread.id);
+ expect(archivedThread).not.toBeNull();
+
+ const archivedComment = await ThreadComment.findByPk(doiPubComment.id);
+ expect(archivedComment).not.toBeNull();
+ expect(archivedComment!.text).toEqual('This is a comment on a DOI pub');
+
+ // ---- Non-DOI pub should be hard deleted ----
+ const deletedPub = await Pub.findByPk(pubWithoutDoi.id);
+ expect(deletedPub).toBeNull();
+
+ const deletedAttribution = await PubAttribution.findByPk(noDotPubAttribution.id);
+ expect(deletedAttribution).toBeNull();
+
+ const deletedDiscussion = await Discussion.findByPk(noDotPubDiscussion.id);
+ expect(deletedDiscussion).toBeNull();
+
+ const deletedThread = await Thread.findByPk(noDotPubThread.id);
+ expect(deletedThread).toBeNull();
+
+ const deletedComment = await ThreadComment.findByPk(noDotPubComment.id);
+ expect(deletedComment).toBeNull();
+
+ // ---- Collection and Page should be gone ----
+ const deletedCollection = await Collection.findByPk(collection.id);
+ expect(deletedCollection).toBeNull();
+
+ const deletedPage = await Page.findByPk(homePage.id);
+ expect(deletedPage).toBeNull();
+
+ // ---- Other community should be completely unaffected ----
+ const otherCommunity = await Community.findByPk(communityNotDeleted.id);
+ expect(otherCommunity).not.toBeNull();
+
+ const otherPub = await Pub.findByPk(survivingPub.id);
+ expect(otherPub).not.toBeNull();
+ });
+});
diff --git a/server/community/api.ts b/server/community/api.ts
index a1f957f41f..2089a105eb 100644
--- a/server/community/api.ts
+++ b/server/community/api.ts
@@ -19,6 +19,7 @@ import {
import { isDevelopment, isDuqDuq, isProd } from 'utils/environment';
import { createGetRequestIds } from 'utils/getRequestIds';
+import { destroyCommunity, getCommunityDeletionAudit } from './destroyCommunity';
import { getPermissions } from './permissions';
import {
CommunityURLAlreadyExistsError,
@@ -218,4 +219,25 @@ export const communityServer = s.router(contract.community, {
status: 200,
};
},
+
+ deletionAudit: async ({ params, req }) => {
+ const community = await ensureUserIsCommunityAdmin({ ...req, id: params.id });
+ const audit = await getCommunityDeletionAudit(community.id);
+ return { status: 200, body: audit };
+ },
+
+ remove: async ({ params, body, req }) => {
+ const community = await ensureUserIsCommunityAdmin({ ...req, id: params.id });
+
+ if (community.title !== body.confirmationTitle) {
+ throw new BadRequestError(
+ new Error(
+ 'Confirmation title does not match. Please type the exact community title to confirm deletion.',
+ ),
+ );
+ }
+
+ await destroyCommunity(community.id, req.user!.id);
+ return { status: 200, body: { success: true } };
+ },
});
diff --git a/server/community/destroyCommunity.ts b/server/community/destroyCommunity.ts
new file mode 100644
index 0000000000..32a3fcbd5f
--- /dev/null
+++ b/server/community/destroyCommunity.ts
@@ -0,0 +1,206 @@
+import type { DoiUrlUpdate } from 'server/doi/updateUrls';
+
+import { Op } from 'sequelize';
+
+import { getCommunityDepositTarget } from 'server/depositTarget/queries';
+import { updateDoiUrlsBestEffort } from 'server/doi/updateUrls';
+import {
+ ActivityItem,
+ Collection,
+ Community,
+ CustomScript,
+ Pub,
+ PublicPermissions,
+ Signup,
+ SubmissionWorkflow,
+ UserScopeVisit,
+} from 'server/models';
+import { sequelize } from 'server/sequelize';
+import { ARCHIVE_COMMUNITY_ID } from 'server/utils/systemEntities';
+import { expect } from 'utils/assert';
+
+/**
+ * Pre-flight audit: returns counts to show the user before confirming deletion.
+ */
+export const getCommunityDeletionAudit = async (communityId: string) => {
+ const community = expect(await Community.findByPk(communityId));
+
+ const [totalPubs, pubsWithDoi, pubsWithReleases] = await Promise.all([
+ Pub.count({ where: { communityId } }),
+ Pub.count({ where: { communityId, doi: { [Op.ne]: null } } }),
+ Pub.count({
+ where: { communityId },
+ include: [{ association: 'releases', required: true }],
+ }),
+ ]);
+
+ return {
+ communityId,
+ communityTitle: community.title,
+ communitySubdomain: community.subdomain,
+ totalPubs,
+ pubsWithDoi,
+ pubsWithReleases,
+ pubsWithoutDoi: totalPubs - pubsWithDoi,
+ };
+};
+
+/**
+ * Destroys a community and all its data.
+ *
+ * Pubs that have a DOI are moved to the archive community (archive.pubpub.org)
+ * instead of being deleted, preserving the scholarly record and keeping DOI
+ * URLs resolvable.
+ *
+ * Pubs without a DOI are hard-deleted along with all their dependent data
+ * (discussions, attributions, releases, etc.) via Sequelize CASCADE.
+ *
+ * The operation runs inside a transaction so it's all-or-nothing.
+ */
+export const destroyCommunity = async (communityId: string, actorId: string) => {
+ const community = expect(await Community.findByPk(communityId));
+
+ if (communityId === ARCHIVE_COMMUNITY_ID) {
+ throw new Error('Cannot delete the archive community');
+ }
+
+ // Read deposit target credentials BEFORE the transaction destroys them.
+ // The DepositTarget row will be cascade-deleted with the community.
+ const depositTarget = (await getCommunityDepositTarget(communityId, true)) ?? null;
+
+ // We'll collect the DOI URL updates inside the transaction, then fire them
+ // after the commit (external API calls shouldn't be inside a DB transaction).
+ let doiUrlUpdates: DoiUrlUpdate[] = [];
+
+ await sequelize.transaction(async (transaction) => {
+ // ---------------------------------------------------------------
+ // 1. Move DOI'd pubs to the archive community
+ // ---------------------------------------------------------------
+ const doiPubs = await Pub.findAll({
+ where: { communityId, doi: { [Op.ne]: null } },
+ attributes: ['id', 'doi', 'slug'],
+ transaction,
+ });
+
+ if (doiPubs.length > 0) {
+ const doiPubIds = doiPubs.map((p) => p.id);
+
+ // Build the list of URL updates to submit after the transaction.
+ doiUrlUpdates = doiPubs.map((p) => ({
+ doi: p.doi!,
+ newUrl: `https://archive.pubpub.org/pub/${p.slug}`,
+ }));
+
+ // Move pubs to archive community. All HasMany children (discussions,
+ // attributions, releases, members, edges, etc.) follow via their
+ // pubId FK -- no communityId column on those tables.
+ await Pub.update(
+ { communityId: ARCHIVE_COMMUNITY_ID },
+ { where: { id: { [Op.in]: doiPubIds } }, transaction },
+ );
+
+ // Strip CollectionPub associations since the collections will be
+ // deleted with the community.
+ await sequelize.query(`DELETE FROM "CollectionPubs" WHERE "pubId" IN (:pubIds)`, {
+ replacements: { pubIds: doiPubIds },
+ transaction,
+ });
+
+ // Strip members from archived pubs -- permissions are meaningless
+ // in the archive community.
+ await sequelize.query(`DELETE FROM "Members" WHERE "pubId" IN (:pubIds)`, {
+ replacements: { pubIds: doiPubIds },
+ transaction,
+ });
+ }
+
+ // ---------------------------------------------------------------
+ // 2. Hard-delete non-DOI pubs and their orphan-prone children.
+ // Hooks are skipped (bulk destroy) because:
+ // a) The Pub.beforeDestroy activity hook creates an ActivityItem
+ // inside the CLS transaction, which conflicts with the
+ // subsequent DELETE of the same pub.
+ // b) We explicitly delete all ActivityItems in step 3 anyway.
+ //
+ // Threads must be deleted explicitly because the FK direction
+ // is Discussion.threadId → Thread.id: deleting a Discussion
+ // does NOT cascade to its Thread. Deleting the Thread first
+ // cascades to ThreadComment, ThreadEvent, and Discussion.
+ // ---------------------------------------------------------------
+ const nonDoiPubFilter = `(SELECT "id" FROM "Pubs" WHERE "communityId" = :communityId AND "doi" IS NULL)`;
+ await sequelize.query(
+ `DELETE FROM "Threads" WHERE "id" IN (
+ SELECT "threadId" FROM "Discussions" WHERE "pubId" IN ${nonDoiPubFilter}
+ UNION
+ SELECT "threadId" FROM "ReviewNews" WHERE "pubId" IN ${nonDoiPubFilter}
+ )`,
+ { replacements: { communityId }, transaction },
+ );
+
+ await Pub.destroy({
+ where: { communityId, doi: null },
+ transaction,
+ });
+
+ // ---------------------------------------------------------------
+ // 3. Clean up community-scoped data without FK cascades
+ // Several BelongsTo associations lack onDelete:'CASCADE' in
+ // their decorators, so the DB FK defaults to RESTRICT.
+ // We must destroy these explicitly before the community.
+ // ---------------------------------------------------------------
+
+ // SubmissionWorkflow → Collection has RESTRICT FK. Destroy first.
+ const collections = await Collection.findAll({
+ attributes: ['id'],
+ where: { communityId },
+ transaction,
+ });
+ const collectionIds = collections.map((collection) => collection.id);
+ await SubmissionWorkflow.destroy({
+ where: {
+ collectionId: {
+ [Op.in]: collectionIds,
+ },
+ },
+ transaction,
+ });
+
+ // Collection → Community has RESTRICT FK. Destroy before community.
+ await Collection.destroy({ where: { communityId }, transaction });
+
+ await Promise.all([
+ ActivityItem.destroy({ where: { communityId }, transaction }),
+ UserScopeVisit.destroy({ where: { communityId }, transaction }),
+ CustomScript.destroy({ where: { communityId }, transaction }),
+ PublicPermissions.destroy({ where: { communityId }, transaction }),
+ Signup.destroy({ where: { communityId }, transaction }),
+ ]);
+
+ // ---------------------------------------------------------------
+ // 4. Delete the community (CASCADE handles Pages, Members,
+ // DepositTargets, FeatureFlagCommunity, LandingPageFeatures,
+ // AuthTokens, CommunityBans)
+ // ---------------------------------------------------------------
+ await community.destroy({ transaction });
+ });
+
+ // ---------------------------------------------------------------
+ // 5. Update DOI resolution URLs (best-effort, after commit)
+ // Uses URL-only updates so all other metadata at Crossref/DataCite
+ // (title, authors, collection/issue context, references, etc.)
+ // is preserved exactly as originally deposited.
+ // ---------------------------------------------------------------
+ if (doiUrlUpdates.length > 0) {
+ const results = await updateDoiUrlsBestEffort(doiUrlUpdates, depositTarget);
+ const failures = results.filter((r) => !r.success);
+ if (failures.length > 0) {
+ console.warn(
+ `[destroyCommunity] ${failures.length}/${results.length} DOI URL updates failed. ` +
+ `These DOIs will resolve to dead links until manually fixed: ` +
+ failures.map((f) => f.doi).join(', '),
+ );
+ }
+ }
+
+ return true;
+};
diff --git a/server/communityBan/model.ts b/server/communityBan/model.ts
index 1c72b35019..b5c07687ca 100644
--- a/server/communityBan/model.ts
+++ b/server/communityBan/model.ts
@@ -76,7 +76,7 @@ export class CommunityBan extends Model<
@BelongsTo(() => Community, { onDelete: 'CASCADE', as: 'community', foreignKey: 'communityId' })
declare community?: Community;
- @BelongsTo(() => User, { onDelete: 'CASCADE', as: 'actor', foreignKey: 'actorId' })
+ @BelongsTo(() => User, { onDelete: 'NO ACTION', as: 'actor', foreignKey: 'actorId' })
/** last user to interact with the ban, not necessarily the one who created it */
declare actor?: User;
diff --git a/server/discussion/model.ts b/server/discussion/model.ts
index 0736ee667e..1416e982be 100644
--- a/server/discussion/model.ts
+++ b/server/discussion/model.ts
@@ -74,7 +74,7 @@ export class Discussion extends Model<
})
declare visibility?: Visibility;
- @BelongsTo(() => User, { onDelete: 'CASCADE', as: 'author', foreignKey: 'userId' })
+ @BelongsTo(() => User, { onDelete: 'NO ACTION', as: 'author', foreignKey: 'userId' })
declare author?: User;
@BelongsTo(() => Commenter, { onDelete: 'CASCADE', as: 'commenter', foreignKey: 'commenterId' })
diff --git a/server/doi/submit.ts b/server/doi/submit.ts
index b7dc956f32..7978d04edd 100644
--- a/server/doi/submit.ts
+++ b/server/doi/submit.ts
@@ -1,3 +1,5 @@
+import type * as types from 'types';
+
import xmlbuilder from 'xmlbuilder';
import { getCommunityDepositTarget } from 'server/depositTarget/queries';
@@ -5,57 +7,93 @@ import { env } from 'server/env';
import { expect } from 'utils/assert';
import { aes256Decrypt } from 'utils/crypto';
-const getDoiLogin = async (communityId: string) => {
- const depositTarget = await getCommunityDepositTarget(communityId, true);
- if (depositTarget) {
- const { username, password, passwordInitVec } = depositTarget;
- if (username && password && passwordInitVec) {
- return {
- login: username,
- password: aes256Decrypt(password, expect(env.AES_ENCRYPTION_KEY), passwordInitVec),
- };
- }
+// ---------------------------------------------------------------------------
+// Shared credential + submission helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolves Crossref credentials from a DepositTarget (or falls back to env).
+ * Exported so that callers with a pre-fetched DepositTarget (e.g. community
+ * deletion, where the community is about to be destroyed) can use the same
+ * credential logic without looking up the community again.
+ */
+export const resolveCrossrefCredentials = (depositTarget: types.DepositTarget | null) => {
+ if (depositTarget?.username && depositTarget?.password && depositTarget?.passwordInitVec) {
+ return {
+ login: depositTarget.username,
+ password: aes256Decrypt(
+ depositTarget.password,
+ expect(env.AES_ENCRYPTION_KEY),
+ depositTarget.passwordInitVec,
+ ),
+ };
}
return {
- login: env.DOI_LOGIN_ID,
- password: env.DOI_LOGIN_PASSWORD,
+ login: env.DOI_LOGIN_ID ?? '',
+ password: env.DOI_LOGIN_PASSWORD ?? '',
};
};
-export const submitDoiData = async (
- json: Record
,
- timestamp: number,
- communityId: string,
-) => {
- const DOI_SUBMISSION_URL = env.DOI_SUBMISSION_URL;
-
- if (!DOI_SUBMISSION_URL) {
+/**
+ * Low-level POST to Crossref's deposit endpoint. Both full-metadata deposits
+ * and bulk URL updates use the same endpoint — they differ only in the
+ * operation parameter and file format.
+ */
+export const postToCrossref = async (opts: {
+ content: string;
+ contentType: string;
+ filename: string;
+ credentials: { login: string; password: string };
+ /** Defaults to doMDUpload (full metadata deposit). */
+ operation?: string;
+}) => {
+ const submissionUrl = env.DOI_SUBMISSION_URL;
+ if (!submissionUrl) {
throw new Error('DOI_SUBMISSION_URL environment variable not set');
}
- const { login, password } = await getDoiLogin(communityId);
- const xmlObject = xmlbuilder.create(json, { headless: true }).end({ pretty: true });
+ const { content, contentType, filename, credentials, operation = 'doMDUpload' } = opts;
const formData = new FormData();
- formData.append('login_id', login ?? '');
- formData.append('login_passwd', password ?? '');
- formData.append(
- 'fname',
- new Blob([xmlObject], { type: 'application/xml' }),
- `${timestamp}.xml`,
- );
-
- const response = await fetch(DOI_SUBMISSION_URL, {
+ formData.append('operation', operation);
+ formData.append('login_id', credentials.login);
+ formData.append('login_passwd', credentials.password);
+ formData.append('fname', new Blob([content], { type: contentType }), filename);
+
+ const response = await fetch(submissionUrl, {
method: 'POST',
body: formData,
- headers: {
- 'user-agent': 'PubPub (mailto:hello@pubpub.org)',
- },
+ headers: { 'user-agent': 'PubPub (mailto:hello@pubpub.org)' },
});
const body = await response.text();
if (!response.ok) {
- throw new Error(`DOI submission failed (${response.status}): ${body}`);
+ throw new Error(`Crossref submission failed (${response.status}): ${body}`);
}
return body;
};
+
+// ---------------------------------------------------------------------------
+// Full metadata deposit
+// ---------------------------------------------------------------------------
+
+const getDoiLogin = async (communityId: string) => {
+ const depositTarget = (await getCommunityDepositTarget(communityId, true)) ?? null;
+ return resolveCrossrefCredentials(depositTarget);
+};
+
+export const submitDoiData = async (
+ json: Record,
+ timestamp: number,
+ communityId: string,
+) => {
+ const credentials = await getDoiLogin(communityId);
+ const xmlContent = xmlbuilder.create(json, { headless: true }).end({ pretty: true });
+
+ return postToCrossref({
+ content: xmlContent,
+ contentType: 'application/xml',
+ filename: `${timestamp}.xml`,
+ credentials,
+ });
+};
diff --git a/server/doi/updateUrls.ts b/server/doi/updateUrls.ts
new file mode 100644
index 0000000000..4412274b12
--- /dev/null
+++ b/server/doi/updateUrls.ts
@@ -0,0 +1,164 @@
+/**
+ * Updates resolution URLs for DOIs at Crossref or DataCite.
+ *
+ * Crossref: uses the "bulk URL update" mechanism — a tab-separated text file
+ * POSTed to the same deposit endpoint used for metadata deposits, but with a
+ * different operation code. This updates ONLY the resolution URL; all other
+ * metadata (title, authors, collection/issue context, references) is untouched.
+ * See: https://www.crossref.org/documentation/register-maintain-records/maintaining-your-metadata/updating-your-metadata/#00172
+ *
+ * DataCite: uses PUT /dois/{id} with only the `url` attribute.
+ */
+import type * as types from 'types';
+
+import { env } from 'server/env';
+import { expect } from 'utils/assert';
+import { aes256Decrypt } from 'utils/crypto';
+
+import { postToCrossref, resolveCrossrefCredentials } from './submit';
+
+// ---------------------------------------------------------------------------
+// DataCite credential helper
+// ---------------------------------------------------------------------------
+
+const getDataciteCredentials = (depositTarget: types.DepositTarget) => {
+ const rawPassword = aes256Decrypt(
+ expect(depositTarget.password),
+ expect(env.AES_ENCRYPTION_KEY),
+ expect(depositTarget.passwordInitVec),
+ );
+ return Buffer.from(`${depositTarget.username}:${rawPassword}`).toString('base64');
+};
+
+// ---------------------------------------------------------------------------
+// Crossref: URL-only update via bulk URL update format
+// ---------------------------------------------------------------------------
+
+/**
+ * Updates the resolution URL for a single Crossref DOI.
+ *
+ * Uses the tab-separated bulk URL update format:
+ * H: email=crossref@pubpub.org;fromPrefix=10.xxxx
+ * 10.xxxx/suffix\thttps://new-url.example.com
+ *
+ * See: https://www.crossref.org/documentation/register-maintain-records/maintaining-your-metadata/updating-your-metadata/#00172
+ */
+const updateCrossrefUrl = async (
+ doi: string,
+ newUrl: string,
+ depositTarget: types.DepositTarget | null,
+) => {
+ const prefix = doi.split('/')[0];
+ const credentials = resolveCrossrefCredentials(depositTarget);
+
+ const fileContent = [
+ `H: email=crossref@pubpub.org;fromPrefix=${prefix}`,
+ `${doi}\t${newUrl}`,
+ ].join('\n');
+
+ return postToCrossref({
+ content: fileContent,
+ contentType: 'text/plain',
+ filename: `url-update-${Date.now()}.txt`,
+ credentials,
+ operation: 'doDOICitUpload',
+ });
+};
+
+// ---------------------------------------------------------------------------
+// DataCite: URL-only update
+// ---------------------------------------------------------------------------
+
+/**
+ * Updates the resolution URL for a single DataCite DOI without touching
+ * any other metadata. Sends a PUT with only the `url` attribute.
+ */
+const updateDataciteUrl = async (
+ doi: string,
+ newUrl: string,
+ depositTarget: types.DepositTarget,
+) => {
+ const dataciteUrl = env.DATACITE_DEPOSIT_URL;
+ if (!dataciteUrl) {
+ throw new Error('DATACITE_DEPOSIT_URL environment variable not set');
+ }
+
+ const encodedCredentials = getDataciteCredentials(depositTarget);
+ const body = {
+ data: {
+ id: doi,
+ type: 'dois',
+ attributes: { url: newUrl },
+ },
+ };
+
+ const response = await fetch(`${dataciteUrl}/${doi}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/vnd.api+json',
+ Authorization: `Basic ${encodedCredentials}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ const result = await response.json();
+ if (result.errors?.length > 0) {
+ throw new Error(`DataCite URL update failed for ${doi}: ${JSON.stringify(result.errors)}`);
+ }
+ return result;
+};
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+export type DoiUrlUpdate = {
+ doi: string;
+ newUrl: string;
+};
+
+/**
+ * Updates the resolution URL for a single DOI at the appropriate registrar.
+ * Determines Crossref vs DataCite from the depositTarget's `service` field.
+ */
+export const updateDoiUrl = async (
+ update: DoiUrlUpdate,
+ depositTarget: types.DepositTarget | null,
+) => {
+ const service = depositTarget?.service ?? 'crossref';
+ if (service === 'datacite') {
+ if (!depositTarget) {
+ throw new Error(`DataCite DOI ${update.doi} has no deposit target with credentials`);
+ }
+ return updateDataciteUrl(update.doi, update.newUrl, depositTarget);
+ }
+ return updateCrossrefUrl(update.doi, update.newUrl, depositTarget);
+};
+
+/**
+ * Best-effort batch update of DOI URLs. Logs failures but does not throw,
+ * since this runs after the community has already been deleted.
+ *
+ * Runs sequentially to avoid overwhelming Crossref's submission queue
+ * (they have a 10,000 pending submission limit and may rate-limit).
+ */
+export const updateDoiUrlsBestEffort = async (
+ updates: DoiUrlUpdate[],
+ depositTarget: types.DepositTarget | null,
+) => {
+ const results: { doi: string; success: boolean; error?: string }[] = [];
+
+ for (const update of updates) {
+ try {
+ // biome-ignore lint: sequential is intentional to avoid overwhelming Crossref's queue
+ await updateDoiUrl(update, depositTarget);
+ results.push({ doi: update.doi, success: true });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error(`[DOI URL update] Failed for ${update.doi}: ${message}`);
+ results.push({ doi: update.doi, success: false, error: message });
+ }
+ }
+
+ return results;
+};
diff --git a/server/pubAttribution/model.ts b/server/pubAttribution/model.ts
index 5ea240f1b6..613f209573 100644
--- a/server/pubAttribution/model.ts
+++ b/server/pubAttribution/model.ts
@@ -73,7 +73,7 @@ export class PubAttribution extends Model<
@Column(DataType.UUID)
declare pubId: string;
- @BelongsTo(() => User, { onDelete: 'CASCADE', as: 'user', foreignKey: 'userId' })
+ @BelongsTo(() => User, { onDelete: 'SET NULL', as: 'user', foreignKey: 'userId' })
declare user?: User;
@BelongsTo(() => Pub, { onDelete: 'CASCADE', as: 'pub', foreignKey: 'pubId' })
diff --git a/server/review/model.ts b/server/review/model.ts
index 02389aa607..07e16171e0 100644
--- a/server/review/model.ts
+++ b/server/review/model.ts
@@ -74,7 +74,7 @@ export class ReviewNew extends Model<
declare visibility?: Visibility;
@BelongsTo(() => User, {
- onDelete: 'CASCADE',
+ onDelete: 'NO ACTION',
as: 'author',
foreignKey: 'userId',
constraints: false,
diff --git a/server/reviewEvent/model.ts b/server/reviewEvent/model.ts
index e3505c694e..bff008a35d 100644
--- a/server/reviewEvent/model.ts
+++ b/server/reviewEvent/model.ts
@@ -45,6 +45,6 @@ export class ReviewEvent extends Model<
@Column(DataType.UUID)
declare reviewId: string;
- @BelongsTo(() => User, { onDelete: 'CASCADE', as: 'user', foreignKey: 'userId' })
+ @BelongsTo(() => User, { onDelete: 'NO ACTION', as: 'user', foreignKey: 'userId' })
declare user?: User;
}
diff --git a/server/threadComment/model.ts b/server/threadComment/model.ts
index d1aa26e43e..3e8b58070c 100644
--- a/server/threadComment/model.ts
+++ b/server/threadComment/model.ts
@@ -47,7 +47,7 @@ export class ThreadComment extends Model<
@Column(DataType.UUID)
declare commenterId: string | null;
- @BelongsTo(() => User, { onDelete: 'CASCADE', as: 'author', foreignKey: 'userId' })
+ @BelongsTo(() => User, { onDelete: 'NO ACTION', as: 'author', foreignKey: 'userId' })
declare author?: User;
@BelongsTo(() => Commenter, { onDelete: 'CASCADE', as: 'commenter', foreignKey: 'commenterId' })
diff --git a/server/threadEvent/model.ts b/server/threadEvent/model.ts
index 36bcce9e5a..eb03b4e9e5 100644
--- a/server/threadEvent/model.ts
+++ b/server/threadEvent/model.ts
@@ -45,6 +45,6 @@ export class ThreadEvent extends Model<
@Index
declare threadId: string;
- @BelongsTo(() => User, { onDelete: 'CASCADE', as: 'user', foreignKey: 'userId' })
+ @BelongsTo(() => User, { onDelete: 'NO ACTION', as: 'user', foreignKey: 'userId' })
declare user?: User;
}
diff --git a/server/user/__tests__/destroyUser.test.ts b/server/user/__tests__/destroyUser.test.ts
new file mode 100644
index 0000000000..0b5b8df91f
--- /dev/null
+++ b/server/user/__tests__/destroyUser.test.ts
@@ -0,0 +1,257 @@
+/**
+ * Integration tests for user account deletion.
+ *
+ * Run with: pnpm test-no-lint -- server/user/__tests__/destroyUser.test.ts
+ */
+import encHex from 'crypto-js/enc-hex';
+import SHA3 from 'crypto-js/sha3';
+
+import {
+ CollectionAttribution,
+ Community,
+ Discussion,
+ Member,
+ Pub,
+ PubAttribution,
+ Release,
+ ThreadComment,
+ User,
+} from 'server/models';
+import { DELETED_USER_ID } from 'server/utils/systemEntities';
+import { login, modelize, setup, teardown } from 'stubstub';
+
+// ---------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------
+const userToDeleteEmail = `delete-me-${crypto.randomUUID()}@example.com`;
+
+const models = modelize`
+ Community community {
+ Member {
+ permissions: "admin"
+ User communityAdmin {}
+ }
+ Member communityMembership {
+ permissions: "edit"
+ User userToDelete {
+ email: ${userToDeleteEmail}
+ password: "password123"
+ }
+ }
+ Pub pubWithAttribution {
+ doi: "10.1234/test-user-delete"
+ PubAttribution userAttribution {
+ isAuthor: true
+ }
+ Release pubRelease {}
+ Discussion userDiscussion {
+ number: 1
+ Visibility {}
+ Thread discussionThread {
+ ThreadComment userComment {
+ text: "A comment by the user being deleted"
+ }
+ ThreadComment otherComment {
+ text: "A comment by someone else"
+ }
+ }
+ }
+ }
+ Pub pubWithoutDoi {
+ PubAttribution userAttributionNoDoi {
+ isAuthor: true
+ }
+ }
+ Collection collection {
+ CollectionAttribution userCollectionAttribution {
+ isAuthor: true
+ }
+ }
+ }
+ User outsider {}
+`;
+
+// Ensure sentinel user exists
+setup(beforeAll, async () => {
+ await User.findOrCreate({
+ where: { id: DELETED_USER_ID },
+ defaults: {
+ id: DELETED_USER_ID,
+ slug: 'deleted-user',
+ firstName: 'Deleted',
+ lastName: 'User',
+ fullName: 'Deleted User',
+ initials: 'DU',
+ email: 'deleted@pubpub.org',
+ hash: '',
+ salt: '',
+ isSuperAdmin: false,
+ gdprConsent: false,
+ } as any,
+
+ hooks: false,
+ });
+ await models.resolve();
+
+ // Manually link attributions/discussion/comments to the user being deleted
+ // (the modelize DSL creates them but may not auto-link userId for attributions
+ // and comments since they're nested under Pub, not directly under User)
+ const { userToDelete, userAttribution, userAttributionNoDoi, userCollectionAttribution } =
+ models;
+ await PubAttribution.update({ userId: userToDelete.id }, { where: { id: userAttribution.id } });
+ await PubAttribution.update(
+ { userId: userToDelete.id },
+ { where: { id: userAttributionNoDoi.id } },
+ );
+ await CollectionAttribution.update(
+ { userId: userToDelete.id },
+ { where: { id: userCollectionAttribution.id } },
+ );
+ await Discussion.update(
+ { userId: userToDelete.id },
+ { where: { id: models.userDiscussion.id } },
+ );
+ await ThreadComment.update(
+ { userId: userToDelete.id },
+ { where: { id: models.userComment.id } },
+ );
+ await Release.update({ userId: userToDelete.id }, { where: { id: models.pubRelease.id } });
+ // Leave otherComment's userId as-is (the modelize builder may have set it to
+ // a pub creator user or left it null — that's fine)
+});
+
+teardown(afterAll);
+
+// ---------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------
+describe('GET /api/account/deletionAudit', () => {
+ it('rejects unauthenticated requests', async () => {
+ const agent = await login();
+ await agent.get('/api/account/deletionAudit').expect(403);
+ });
+
+ it('returns a correct audit for the logged-in user', async () => {
+ const { userToDelete } = models;
+ const agent = await login(userToDelete);
+ const { body } = await agent.get('/api/account/deletionAudit').expect(200);
+
+ expect(body.userId).toEqual(userToDelete.id);
+ expect(body.fullName).toEqual(userToDelete.fullName);
+ expect(body.pubAttributionCount).toBeGreaterThanOrEqual(2);
+ expect(body.discussionCount).toBeGreaterThanOrEqual(1);
+ expect(body.threadCommentCount).toBeGreaterThanOrEqual(1);
+ });
+});
+
+describe('DELETE /api/account', () => {
+ it('rejects unauthenticated requests', async () => {
+ const agent = await login();
+ await agent
+ .delete('/api/account')
+ .send({ password: SHA3('password123').toString(encHex) })
+ .expect(403);
+ });
+
+ it('rejects incorrect password', async () => {
+ const { userToDelete } = models;
+ const agent = await login(userToDelete);
+ await agent
+ .delete('/api/account')
+ .send({ password: SHA3('wrongpassword').toString(encHex) })
+ .expect(403);
+ });
+});
+
+describe('User account deletion end-to-end', () => {
+ it('deletes user, preserves attributions with name, anonymizes discussions', async () => {
+ const {
+ userToDelete,
+ userAttribution,
+ userAttributionNoDoi,
+ userCollectionAttribution,
+ userDiscussion,
+ userComment,
+ otherComment,
+ pubRelease,
+ pubWithAttribution,
+ pubWithoutDoi,
+ communityMembership,
+ community,
+ } = models;
+
+ // Capture user info before deletion
+ const userFullName = userToDelete.fullName;
+
+ const agent = await login(userToDelete);
+
+ // Perform deletion
+ const { body } = await agent
+ .delete('/api/account')
+ .send({ password: SHA3('password123').toString(encHex) })
+ .expect(200);
+
+ expect(body.success).toBe(true);
+
+ // ---- User should be gone ----
+ const deletedUser = await User.findByPk(userToDelete.id);
+ expect(deletedUser).toBeNull();
+
+ // ---- PubAttribution should survive with name copied ----
+ const preservedAttribution = await PubAttribution.findByPk(userAttribution.id);
+ expect(preservedAttribution).not.toBeNull();
+ expect(preservedAttribution!.userId).toBeNull();
+ expect(preservedAttribution!.name).toEqual(userFullName);
+ expect(preservedAttribution!.isAuthor).toBe(true);
+
+ // ---- Attribution on non-DOI pub should ALSO survive ----
+ const preservedAttrNoDoi = await PubAttribution.findByPk(userAttributionNoDoi.id);
+ expect(preservedAttrNoDoi).not.toBeNull();
+ expect(preservedAttrNoDoi!.userId).toBeNull();
+ expect(preservedAttrNoDoi!.name).toEqual(userFullName);
+
+ // ---- CollectionAttribution should survive with name copied ----
+ const preservedCollAttr = await CollectionAttribution.findByPk(
+ userCollectionAttribution.id,
+ );
+ expect(preservedCollAttr).not.toBeNull();
+ expect(preservedCollAttr!.userId).toBeNull();
+ expect(preservedCollAttr!.name).toEqual(userFullName);
+
+ // ---- Discussion should survive but be reassigned to sentinel ----
+ const anonymizedDiscussion = await Discussion.findByPk(userDiscussion.id);
+ expect(anonymizedDiscussion).not.toBeNull();
+ expect(anonymizedDiscussion!.userId).toEqual(DELETED_USER_ID);
+
+ // ---- User's comment should survive but be reassigned to sentinel ----
+ const anonymizedComment = await ThreadComment.findByPk(userComment.id);
+ expect(anonymizedComment).not.toBeNull();
+ expect(anonymizedComment!.userId).toEqual(DELETED_USER_ID);
+ expect(anonymizedComment!.text).toEqual('A comment by the user being deleted');
+
+ // ---- Other user's comment should be completely unaffected ----
+ const untouchedComment = await ThreadComment.findByPk(otherComment.id);
+ expect(untouchedComment).not.toBeNull();
+ expect(untouchedComment!.text).toEqual('A comment by someone else');
+
+ // ---- Release should survive with sentinel userId ----
+ const preservedRelease = await Release.findByPk(pubRelease.id);
+ expect(preservedRelease).not.toBeNull();
+ expect(preservedRelease!.userId).toEqual(DELETED_USER_ID);
+
+ // ---- Pubs themselves should be completely unaffected ----
+ const pub1 = await Pub.findByPk(pubWithAttribution.id);
+ expect(pub1).not.toBeNull();
+
+ const pub2 = await Pub.findByPk(pubWithoutDoi.id);
+ expect(pub2).not.toBeNull();
+
+ // ---- Membership should be gone (CASCADE) ----
+ const deletedMembership = await Member.findByPk(communityMembership.id);
+ expect(deletedMembership).toBeNull();
+
+ // ---- Community should be entirely unaffected ----
+ const untouchedCommunity = await Community.findByPk(community.id);
+ expect(untouchedCommunity).not.toBeNull();
+ });
+});
diff --git a/server/user/account.ts b/server/user/account.ts
index 42920a8374..ea8a250141 100644
--- a/server/user/account.ts
+++ b/server/user/account.ts
@@ -8,6 +8,8 @@ import { logout } from 'server/utils/logout';
import { contract } from 'utils/api/contract';
import { generateHash } from 'utils/hashes';
+import { destroyUser, getUserDeletionAudit } from './destroyUser';
+
const s = initServer();
const ONE_DAY = 1000 * 60 * 60 * 24;
@@ -175,4 +177,51 @@ export const accountServer = s.router(contract.account, {
return { status: 200, body: { success: true, newEmail } };
},
+
+ deletionAudit: async ({ req }) => {
+ const userId = req.user?.id;
+
+ if (!userId) {
+ return {
+ status: 403,
+ body: { message: 'Must be logged in to view account deletion audit' },
+ };
+ }
+
+ const audit = await getUserDeletionAudit(userId);
+ return { status: 200, body: audit };
+ },
+
+ deleteAccount: async ({ req, res, body }) => {
+ const userId = req.user?.id;
+
+ if (!userId) {
+ return {
+ status: 403,
+ body: { message: 'Must be logged in to delete account' },
+ };
+ }
+
+ const userData = await User.findOne({ where: { id: userId } });
+
+ if (!userData) {
+ return {
+ status: 403,
+ body: { message: 'User not found' },
+ };
+ }
+
+ // Require password confirmation
+ try {
+ await authenticate(userData, body.password);
+ } catch (_error) {
+ return { status: 403, body: { message: 'Password is incorrect' } };
+ }
+
+ // Destroy the account first so logout only happens after successful deletion
+ await destroyUser(userId);
+ logout(req, res);
+
+ return { status: 200, body: { success: true } };
+ },
});
diff --git a/server/user/destroyUser.ts b/server/user/destroyUser.ts
new file mode 100644
index 0000000000..d60f91d9be
--- /dev/null
+++ b/server/user/destroyUser.ts
@@ -0,0 +1,183 @@
+import {
+ AuthToken,
+ CollectionAttribution,
+ CommunityBan,
+ Discussion,
+ EmailChangeToken,
+ FeatureFlagUser,
+ Member,
+ PubAttribution,
+ Release,
+ ReviewEvent,
+ ReviewNew,
+ ThreadComment,
+ ThreadEvent,
+ User,
+ UserDismissable,
+ UserNotification,
+ UserNotificationPreferences,
+ UserScopeVisit,
+ UserSubscription,
+ ZoteroIntegration,
+} from 'server/models';
+import { sequelize } from 'server/sequelize';
+import { DELETED_USER_ID } from 'server/utils/systemEntities';
+import { expect } from 'utils/assert';
+
+/**
+ * Pre-flight audit: returns counts to show the user before confirming deletion.
+ */
+export const getUserDeletionAudit = async (userId: string) => {
+ const user = expect(await User.findByPk(userId));
+
+ const [
+ pubAttributionCount,
+ collectionAttributionCount,
+ discussionCount,
+ threadCommentCount,
+ membershipCount,
+ releaseCount,
+ ] = await Promise.all([
+ PubAttribution.count({ where: { userId } }),
+ CollectionAttribution.count({ where: { userId } }),
+ Discussion.count({ where: { userId } }),
+ ThreadComment.count({ where: { userId } }),
+ Member.count({ where: { userId } }),
+ Release.count({ where: { userId } }),
+ ]);
+
+ return {
+ userId,
+ fullName: user.fullName,
+ email: user.email,
+ pubAttributionCount,
+ collectionAttributionCount,
+ discussionCount,
+ threadCommentCount,
+ membershipCount,
+ releaseCount,
+ };
+};
+
+/**
+ * Destroys a user account while preserving the scholarly record.
+ *
+ * The operation:
+ * 1. Decouples PubAttributions & CollectionAttributions — copies the user's
+ * name/avatar/orcid into the standalone fields, then sets userId = NULL.
+ * This preserves authorship credit on all pubs regardless of DOI/release status.
+ * 2. Reassigns discussions, comments, reviews, thread events, review events,
+ * releases, and community ban actor references to the sentinel deleted-user
+ * account. This keeps userId columns NOT NULL and lets the frontend render
+ * "Deleted User" by simply joining on the sentinel's name — no NULL detection
+ * needed.
+ * 3. Explicitly deletes user-owned data with no scholarly value.
+ * 4. Lets CASCADE handle the rest (Member, AuthToken, etc.).
+ * 5. Destroys the User row.
+ *
+ * Runs inside a transaction so it's all-or-nothing.
+ */
+export const destroyUser = async (userId: string) => {
+ if (userId === DELETED_USER_ID) {
+ throw new Error('Cannot delete the system sentinel user');
+ }
+
+ const user = expect(await User.findByPk(userId));
+
+ await sequelize.transaction(async (transaction) => {
+ // ---------------------------------------------------------------
+ // 1. Decouple attributions (preserve scholarly record)
+ // Copy user identity into the standalone fields, then unlink.
+ // ---------------------------------------------------------------
+ // For PubAttributions: set name/avatar/orcid from User, then null userId
+ await sequelize.query(
+ `UPDATE "PubAttributions"
+ SET "name" = COALESCE("PubAttributions"."name", :fullName),
+ "avatar" = COALESCE("PubAttributions"."avatar", :avatar),
+ "orcid" = COALESCE("PubAttributions"."orcid", :orcid),
+ "userId" = NULL
+ WHERE "userId" = :userId`,
+ {
+ replacements: {
+ fullName: user.fullName,
+ avatar: user.avatar,
+ orcid: user.orcid,
+ userId,
+ },
+ transaction,
+ },
+ );
+
+ // For CollectionAttributions: same approach
+ await sequelize.query(
+ `UPDATE "CollectionAttributions"
+ SET "name" = COALESCE("CollectionAttributions"."name", :fullName),
+ "avatar" = COALESCE("CollectionAttributions"."avatar", :avatar),
+ "orcid" = COALESCE("CollectionAttributions"."orcid", :orcid),
+ "userId" = NULL
+ WHERE "userId" = :userId`,
+ {
+ replacements: {
+ fullName: user.fullName,
+ avatar: user.avatar,
+ orcid: user.orcid,
+ userId,
+ },
+ transaction,
+ },
+ );
+
+ // ---------------------------------------------------------------
+ // 2. Reassign all other userId / actorId FKs to sentinel
+ // This keeps columns NOT NULL-safe and lets the frontend
+ // show "Deleted User" via a normal User join.
+ // ---------------------------------------------------------------
+ await Discussion.update({ userId: DELETED_USER_ID }, { where: { userId }, transaction });
+
+ await ThreadComment.update({ userId: DELETED_USER_ID }, { where: { userId }, transaction });
+
+ await ReviewNew.update({ userId: DELETED_USER_ID }, { where: { userId }, transaction });
+
+ await ThreadEvent.update({ userId: DELETED_USER_ID }, { where: { userId }, transaction });
+
+ await ReviewEvent.update({ userId: DELETED_USER_ID }, { where: { userId }, transaction });
+
+ await Release.update({ userId: DELETED_USER_ID }, { where: { userId }, transaction });
+
+ await CommunityBan.update(
+ { actorId: DELETED_USER_ID },
+ { where: { actorId: userId }, transaction },
+ );
+
+ // ---------------------------------------------------------------
+ // 3. Explicitly delete user-owned data without scholarly value
+ // (these lack proper CASCADE or have no FK associations)
+ // ---------------------------------------------------------------
+ await Promise.all([
+ ZoteroIntegration.destroy({ where: { userId }, transaction }),
+ // VisibilityUser is a join table — clean up directly
+ sequelize.query(`DELETE FROM "VisibilityUsers" WHERE "userId" = :userId`, {
+ replacements: { userId },
+ transaction,
+ }),
+ UserScopeVisit.destroy({ where: { userId }, transaction }),
+ UserDismissable.destroy({ where: { userId }, transaction }),
+ ]);
+
+ // ---------------------------------------------------------------
+ // 4. Let CASCADE handle: Member, AuthToken, EmailChangeToken,
+ // UserNotification, UserSubscription, UserNotificationPreferences,
+ // FeatureFlagUser, CommunityBan (userId side)
+ // ---------------------------------------------------------------
+
+ // ActivityItem.actorId has no FK — leave orphaned.
+ // The UI should render missing actor lookups as "[Deleted User]".
+
+ // ---------------------------------------------------------------
+ // 5. Destroy the User row
+ // ---------------------------------------------------------------
+ await user.destroy({ transaction } as any);
+ });
+
+ return true;
+};
diff --git a/server/user/model.ts b/server/user/model.ts
index d1c22a9aae..c2d6fb83b1 100644
--- a/server/user/model.ts
+++ b/server/user/model.ts
@@ -262,13 +262,13 @@ export class User extends ModelWithPassport, InferCreation
declare spamTag?: SpamTag;
@HasMany(() => PubAttribution, {
- onDelete: 'CASCADE',
+ onDelete: 'SET NULL',
as: 'attributions',
foreignKey: 'userId',
})
declare attributions?: PubAttribution[];
- @HasMany(() => Discussion, { onDelete: 'CASCADE', as: 'discussions', foreignKey: 'userId' })
+ @HasMany(() => Discussion, { onDelete: 'NO ACTION', as: 'discussions', foreignKey: 'userId' })
declare discussions?: Discussion[];
@HasMany(() => CommunityBan, {
diff --git a/server/utils/initData.ts b/server/utils/initData.ts
index e6349dab42..15e2ece972 100644
--- a/server/utils/initData.ts
+++ b/server/utils/initData.ts
@@ -12,6 +12,7 @@ import { getAppCommit, isDuqDuq, isProd, isQubQub, shouldForceBasePubPub } from
import { PubPubError } from './errors';
import { getCommunity, getScope, sanitizeCommunity } from './queryHelpers';
+import { ARCHIVE_COMMUNITY_ID } from './systemEntities';
const getNotificationData = async (
userId: null | string,
@@ -112,6 +113,7 @@ export const getInitialData = async (
type: 'default',
credentials: null,
},
+ isArchiveCommunity: false,
} as any,
loginData,
locationData,
@@ -183,7 +185,10 @@ export const getInitialData = async (
);
const result = {
- communityData: cleanedCommunityData,
+ communityData: {
+ ...cleanedCommunityData,
+ isArchiveCommunity: cleanedCommunityData.id === ARCHIVE_COMMUNITY_ID,
+ },
loginData,
locationData,
scopeData,
diff --git a/server/utils/systemEntities.ts b/server/utils/systemEntities.ts
new file mode 100644
index 0000000000..e16f8847a3
--- /dev/null
+++ b/server/utils/systemEntities.ts
@@ -0,0 +1,14 @@
+/**
+ * Well-known IDs for system entities used by account/community deletion.
+ *
+ * These must match the values seeded by the migration
+ * 2026_04_13_prepareAccountAndCommunityDeletion.js
+ */
+
+/** A placeholder User row. Assigned as the author of ThreadEvents, ReviewEvents,
+ * Releases, Discussions, etc. after a real user deletes their account. */
+export const DELETED_USER_ID = '00000000-0000-0000-0000-000000000000';
+
+/** The archive.pubpub.org community. DOI'd pubs are moved here when their
+ * parent community is deleted, so DOI URLs remain resolvable. */
+export const ARCHIVE_COMMUNITY_ID = '00000000-0000-0000-0000-000000000001';
diff --git a/tools/migrations/2026_04_13_prepareAccountAndCommunityDeletion.js b/tools/migrations/2026_04_13_prepareAccountAndCommunityDeletion.js
new file mode 100644
index 0000000000..a283f3391a
--- /dev/null
+++ b/tools/migrations/2026_04_13_prepareAccountAndCommunityDeletion.js
@@ -0,0 +1,125 @@
+/**
+ * Prepare schema for account and community deletion features.
+ *
+ * 1. Change onDelete behavior for user-referencing FKs on scholarly records
+ * from CASCADE to SET NULL (for attributions with backup fields) or
+ * NO ACTION (for all others — these will be reassigned to a sentinel user
+ * before deletion).
+ * 2. Seed the sentinel "[Deleted User]" system account.
+ * 3. Seed the "PubPub Archive" community at archive.pubpub.org.
+ */
+
+const DELETED_USER_ID = '00000000-0000-0000-0000-000000000000';
+const ARCHIVE_COMMUNITY_ID = '00000000-0000-0000-0000-000000000001';
+
+/**
+ * Helper: alter a FK constraint's onDelete behavior.
+ * Drops the existing constraint and recreates it with the new behavior.
+ */
+const alterFkOnDelete = async (queryInterface, { table, column, references, onDelete, constraintName }) => {
+ const name = constraintName || `${table}_${column}_fkey`;
+ await queryInterface.sequelize.query(`
+ ALTER TABLE "${table}" DROP CONSTRAINT IF EXISTS "${name}";
+ `);
+ await queryInterface.sequelize.query(`
+ ALTER TABLE "${table}"
+ ADD CONSTRAINT "${name}"
+ FOREIGN KEY ("${column}")
+ REFERENCES "${references.table}" ("${references.key}")
+ ON DELETE ${onDelete}
+ ON UPDATE CASCADE;
+ `);
+};
+
+const FK_CHANGES = [
+ // PubAttribution.userId: CASCADE → SET NULL
+ { table: 'PubAttributions', column: 'userId', references: { table: 'Users', key: 'id' }, newOnDelete: 'SET NULL', oldOnDelete: 'CASCADE' },
+ // CollectionAttribution.userId: CASCADE → SET NULL
+ { table: 'CollectionAttributions', column: 'userId', references: { table: 'Users', key: 'id' }, newOnDelete: 'SET NULL', oldOnDelete: 'CASCADE' },
+ // Discussion.userId: CASCADE → NO ACTION (reassigned to sentinel before user deletion)
+ { table: 'Discussions', column: 'userId', references: { table: 'Users', key: 'id' }, newOnDelete: 'NO ACTION', oldOnDelete: 'CASCADE' },
+ // ThreadComment.userId: CASCADE → NO ACTION (reassigned to sentinel before user deletion)
+ { table: 'ThreadComments', column: 'userId', references: { table: 'Users', key: 'id' }, newOnDelete: 'NO ACTION', oldOnDelete: 'CASCADE' },
+ // ThreadEvent.userId: CASCADE → NO ACTION (will be reassigned to sentinel before user deletion)
+ { table: 'ThreadEvents', column: 'userId', references: { table: 'Users', key: 'id' }, newOnDelete: 'NO ACTION', oldOnDelete: 'CASCADE' },
+ // ReviewEvent.userId: CASCADE → NO ACTION (will be reassigned to sentinel before user deletion)
+ { table: 'ReviewEvents', column: 'userId', references: { table: 'Users', key: 'id' }, newOnDelete: 'NO ACTION', oldOnDelete: 'CASCADE' },
+ // NOTE: ReviewNew.userId is intentionally unconstrained at the DB level
+ // (model sets constraints: false). We skip it here — destroyUser handles
+ // reassignment to the sentinel user before deleting the User row, so
+ // no DB-level FK enforcement is needed.
+ // CommunityBan.actorId: CASCADE → NO ACTION (reassigned to sentinel before user deletion)
+ { table: 'CommunityBans', column: 'actorId', references: { table: 'Users', key: 'id' }, newOnDelete: 'NO ACTION', oldOnDelete: 'CASCADE' },
+];
+
+export const up = async (queryInterface) => {
+ // 1. Alter FK onDelete behaviors
+ for (const fk of FK_CHANGES) {
+ await alterFkOnDelete(queryInterface, {
+ table: fk.table,
+ column: fk.column,
+ references: fk.references,
+ onDelete: fk.newOnDelete,
+ });
+ }
+
+ // 2. Seed the sentinel "[Deleted User]" system account
+ // Use raw INSERT with ON CONFLICT to be idempotent
+ await queryInterface.sequelize.query(`
+ INSERT INTO "Users" (
+ id, slug, "firstName", "lastName", "fullName", initials, email,
+ hash, salt, "isSuperAdmin", "gdprConsent", "createdAt", "updatedAt"
+ ) VALUES (
+ '${DELETED_USER_ID}',
+ 'deleted-user',
+ 'Deleted',
+ 'User',
+ 'Deleted User',
+ 'DU',
+ 'deleted@pubpub.org',
+ '',
+ '',
+ false,
+ false,
+ NOW(),
+ NOW()
+ ) ON CONFLICT (id) DO NOTHING;
+ `);
+
+ // 3. Seed the "PubPub Archive" community
+ await queryInterface.sequelize.query(`
+ INSERT INTO "Communities" (
+ id, subdomain, title, description,
+ "createdAt", "updatedAt"
+ ) VALUES (
+ '${ARCHIVE_COMMUNITY_ID}',
+ 'archive',
+ 'PubPub Archive',
+ 'Archived publications from deleted PubPub communities. These pages are maintained to preserve the scholarly record.',
+ NOW(),
+ NOW()
+ ) ON CONFLICT (id) DO NOTHING;
+ `);
+};
+
+export const down = async (queryInterface) => {
+ // 1. Remove seeded archive community
+ await queryInterface.sequelize.query(`
+ DELETE FROM "Communities" WHERE id = '${ARCHIVE_COMMUNITY_ID}';
+ `);
+
+ // 2. Remove seeded sentinel user
+ await queryInterface.sequelize.query(`
+ DELETE FROM "Users" WHERE id = '${DELETED_USER_ID}';
+ `);
+
+ // 3. Revert FK onDelete behaviors
+ for (const fk of FK_CHANGES) {
+ await alterFkOnDelete(queryInterface, {
+ table: fk.table,
+ column: fk.column,
+ references: fk.references,
+ onDelete: fk.oldOnDelete,
+ });
+ }
+};
diff --git a/tools/s3Cleanup.ts b/tools/s3Cleanup.ts
index 0aff049119..37b787e9dc 100644
--- a/tools/s3Cleanup.ts
+++ b/tools/s3Cleanup.ts
@@ -1046,6 +1046,19 @@ async function unquarantineKeys(keys: string[]) {
log(`Done: ${restored} restored, ${errors} errors`);
}
+/**
+ * Prompts the user for confirmation via stdin. Returns true if they type 'y' or 'yes'.
+ */
+async function confirm(message: string): Promise {
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
+ return new Promise((resolve) => {
+ rl.question(`${message} [y/N] `, (answer) => {
+ rl.close();
+ resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes');
+ });
+ });
+}
+
async function main() {
// Handle --unquarantine as a standalone command (no DB scan needed)
if (unquarantineIdx !== -1) {
@@ -1069,6 +1082,17 @@ async function main() {
const referencedKeys = await collectReferencedKeys();
if (!skipS3List) {
+ // S3 ListObjectsV2 charges ~$5 per million requests. For a large bucket
+ // with millions of objects, a full listing costs roughly $10+.
+ const ok = await confirm(
+ '\n⚠️ This will list every object in the S3 bucket, which costs ~$10 in LIST request fees.\n' +
+ ' Use --skip-s3-list to reuse a previous orphans.txt instead.\n' +
+ ' Continue?',
+ );
+ if (!ok) {
+ log('Aborted.');
+ process.exit(0);
+ }
// Phase 2: List S3 bucket and write orphans
await listAndCleanS3(referencedKeys);
} else {
diff --git a/types/request.ts b/types/request.ts
index d6b1ab46e5..413c9027f1 100644
--- a/types/request.ts
+++ b/types/request.ts
@@ -89,7 +89,10 @@ export type ScopeData = {
export type InitialCommunityData = DefinitelyHas<
Community,
'collections' | 'pages' | 'scopeSummary'
->;
+> & {
+ /** True when this community is the archive community (archive.pubpub.org). */
+ isArchiveCommunity: boolean;
+};
export type InitialNotificationsData = {
hasNotifications: boolean;
diff --git a/utils/api/contracts/account.ts b/utils/api/contracts/account.ts
index e0c89bcb77..09da3b85dc 100644
--- a/utils/api/contracts/account.ts
+++ b/utils/api/contracts/account.ts
@@ -73,6 +73,52 @@ export const accountRouter = {
400: z.object({ message: z.string() }),
},
},
+ /**
+ * `GET /api/account/deletionAudit`
+ *
+ * Get an audit of what will be affected by deleting the current user's account.
+ */
+ deletionAudit: {
+ method: 'GET',
+ path: '/api/account/deletionAudit',
+ summary: 'Get account deletion audit',
+ description:
+ 'Returns counts of attributions, discussions, etc. that will be affected by deleting this account.',
+ responses: {
+ 200: z.object({
+ userId: z.string().uuid(),
+ fullName: z.string(),
+ email: z.string(),
+ pubAttributionCount: z.number(),
+ collectionAttributionCount: z.number(),
+ discussionCount: z.number(),
+ threadCommentCount: z.number(),
+ membershipCount: z.number(),
+ releaseCount: z.number(),
+ }),
+ 403: z.object({ message: z.string() }),
+ },
+ },
+ /**
+ * `DELETE /api/account`
+ *
+ * Permanently delete the current user's account. Attributions are preserved
+ * with the user's name. Discussions/comments are anonymized.
+ */
+ deleteAccount: {
+ method: 'DELETE',
+ path: '/api/account',
+ summary: 'Delete account',
+ description:
+ 'Permanently delete the current user account. Attributions are preserved with name. Discussions are anonymized.',
+ body: z.object({
+ password: z.string().describe('The SHA3 hash of the user password for confirmation'),
+ }),
+ responses: {
+ 200: z.object({ success: z.boolean() }),
+ 403: z.object({ message: z.string() }),
+ },
+ },
} as const satisfies AppRouter;
type AccountType = typeof accountRouter;
diff --git a/utils/api/contracts/community.ts b/utils/api/contracts/community.ts
index e16eeeacde..b5a8e0e25c 100644
--- a/utils/api/contracts/community.ts
+++ b/utils/api/contracts/community.ts
@@ -114,6 +114,59 @@ export const communityRouter = {
200: communityUpdateSchema.partial(),
},
},
+ /**
+ * `GET /api/communities/:id/deletionAudit`
+ *
+ * Get an audit of what will be affected by deleting this community.
+ *
+ * @access Community admin or super admin.
+ */
+ deletionAudit: {
+ path: '/api/communities/:id/deletionAudit',
+ method: 'GET',
+ summary: 'Get community deletion audit',
+ description:
+ 'Returns counts of pubs, DOI pubs, etc. that will be affected by deleting this community.',
+ pathParams: z.object({
+ id: z.string().uuid(),
+ }),
+ responses: {
+ 200: z.object({
+ communityId: z.string().uuid(),
+ communityTitle: z.string(),
+ communitySubdomain: z.string(),
+ totalPubs: z.number(),
+ pubsWithDoi: z.number(),
+ pubsWithReleases: z.number(),
+ pubsWithoutDoi: z.number(),
+ }),
+ },
+ },
+ /**
+ * `DELETE /api/communities/:id`
+ *
+ * Permanently delete a community. Pubs with DOIs are moved to the
+ * archive community (archive.pubpub.org) to preserve the scholarly record.
+ *
+ * @access Community admin or super admin.
+ */
+ remove: {
+ path: '/api/communities/:id',
+ method: 'DELETE',
+ summary: 'Delete a community',
+ description: 'Permanently delete a community. DOI pubs are moved to the archive community.',
+ pathParams: z.object({
+ id: z.string().uuid(),
+ }),
+ body: z.object({
+ confirmationTitle: z
+ .string()
+ .describe('Must match the community title to confirm deletion'),
+ }),
+ responses: {
+ 200: z.object({ success: z.boolean() }),
+ },
+ },
} as const satisfies AppRouter;
type CommunityRouterType = typeof communityRouter;