[Backend] Add friction and a soft-delete window to campaign hard-delete #405
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: MEDIUM
Found in the August 2026 session lifecycle review (#319).
Deleting a campaign — every session, every note, every wiki entry, every bit of recorded history the group has ever built — is one browser
confirm()dialog away from any GM on the campaign (not just its creator), with an immediate, irreversible cascade delete and no soft-delete window, despite the app already having both an archive feature and an export endpoint that this destructive action bypasses entirely.Evidence
webapp/backend/app/routers/campaigns.py:930-953(delete_campaignendpoint) andwebapp/backend/app/services/campaign_service.py:189-193(db.delete(campaign)followed immediately byawait db.commit()) — an unconditional cascade delete with no soft-delete/grace period.webapp/frontend/src/pages/CampaignDetail.jsx:747— the only guard is a nativeconfirm()dialog reading "Delete {campaign name}? This cannot be undone." — no typed confirmation of the campaign name.campaigns.py:3505) and the existing archive mode already in the schema are both available features that this flow does not route through or even mention.Failure scenario
A GM meaning to archive an inactive campaign for the season instead clicks "Delete" by mistake (or a co-GM does it without realising the scope), confirms the browser dialog on reflex, and every session, transcript, summary, wiki entry, and note the group has produced over months of play is gone in one request — with no undo, no grace period, and no prompt to export first.
Proposed fix
Route campaign deletion through the existing archive feature plus a delayed purge (7-30 days) rather than an immediate cascade — archive now, hard-delete later via a scheduled job, with an "undo" available in that window. Require typing the campaign's name to confirm (matching the weight of the action), and auto-offer the existing export endpoint before the delete is finalised.
Acceptance criteria
Implemented in PR #478 (awaiting CI).
One correction to this issue worth recording, because it changed the design. The proposed fix was to "route campaign deletion through the existing archive feature plus a delayed purge". That does not work:
is_archivedis not a read filter anywhere in the backend — it appears in the model, the schemas,archive_campaign/restore_campaign, and one guard on join-by-invite, and nothing else. Archiving a campaign today does not stop its reminders, its beats, or its bot surface, so it gave soft delete nothing to build on.deleted_atis a separate column and the two states stay distinct: collapsing them would make "archive for the season" and "destroy everything" the same database state.The other design point is why this does not reuse #408's convention. A lore entry is only ever reached by selecting one, so filtering each of its 13 reads works. A campaign's children are routinely selected without the query mentioning
Campaignat all — 35select(Session)sites alone, across 17 CASCADE'd tables — so "filter every read" would mean auditing every session, note, ledger and lore query and would still miss the next one written. The gate is instead at the four authorisation dependencies every campaign- and session-scoped route already passes through, plus explicit filters on the five doors that do not open with a session cookie (public analytics link, bot guild lookup, invite join, dashboard list, scheduled tasks).Against the acceptance criteria:
purge_trashed_campaignspermanently deletes after a configurable grace, floored at 7 days and defaulting to 30.enforce_retentionis exempted in all three of its passes so nothing erodes underneath it while it waits.Line numbers in the evidence had drifted (endpoint 930-953 → 967, service 189-193 → 190, frontend
confirm()747 → 839); the substance held.