Accidental admin deletes of users/roles/subscriptions/plans/tariffs were hard and unrecoverable. Now they soft-delete into a recycle bin. Schema (migration 0012): nullable deleted_at + deleted_by on users, roles, subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified against a copy of the live DB. Backend: each resource's DELETE route STAMPS instead of removing; every catalog list filters deleted_at IS NULL. New recycle-bin module + routes (GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a new recyclebin:read/update/delete permission. A 6-hourly + startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever). Invariants: soft-deleted users can't log in (login rejects deleted_at; no-lockout counts live admins only); a soft-deleted subscription doesn't open the barrier; plans are versioned so a delete stamps all versions of the plan_id (bin shows one item); username/role-name UNIQUE spans deleted rows so reuse returns a clear 409 pointing at the bin; restore doesn't auto-cascade a dangling role (guard resolves missing role to empty perms). The signed append-only ledger is OUT of scope (no delete path). Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge + purge confirm; api client + i18n (sq + en parity). Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4 integration: delete -> can't-login -> restore -> login, purge, gating, 409 reuse). server 103/103; build+lint+test 19/19. Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
4.4 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||
|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-06-22 | settled |
Soft Delete & the Recycle Bin
A safety net for accidental admin deletes. Master-data deletes used to be hard and unrecoverable — an admin who deleted a user, role, subscription, or plan lost it for good. Now a delete soft-deletes (stamps the row) and the item waits in a recycle bin where an admin can restore or purge it; unrestored items auto-purge after a retention window.
Built 2026-06-22 (migration 0012_soft_delete).
What it covers (and what it deliberately doesn't)
Soft-delete is for the mutable master-data tables only:
| Resource | Table(s) | Notes |
|---|---|---|
| Users | users |
A soft-deleted user cannot log in (the login route rejects deleted_at != null). |
| Roles | roles (+ role_permissions kept) |
Permission rows survive, so a restore brings the role back intact. |
| Subscriptions | subscriptions (+ credentials/plates kept) |
Distinct from status: "revoked" — see below. A soft-deleted sub does not open the barrier. |
| Plans | subscription_plans |
Versioned: a soft-delete stamps every version row of the plan_id; the bin shows/restores it as ONE item. |
| Tariffs | tariffs |
Has soft-delete for completeness; today the site runs one tariff and there's no delete button — recovery is via the bin. Immutable tariff_versions ride along (kept for repricing). |
Out of scope — the signed ledger. The append-only, hash-chained ledger_events has no
delete path by design (append-only-event-chain); soft-delete is purely for the mutable
master data. A correction to history is still a new appended event, never an edit/delete.
Mechanics
- Columns: every covered table gets a nullable
deleted_at(ISO instant; null = live) anddeleted_by(the admin user id). AdditiveALTER ADD COLUMN— backward-compatible. - Delete = stamp. Each resource's own
DELETEroute now sets the stamps instead of removing the row. The row vanishes from every catalog because the list/lookup queries filterdeleted_at IS NULL. - Recycle bin API (
recyclebin:*permission):GET /api/recycle-binlists everything soft-deleted across kinds;POST /api/recycle-bin/:kind/:id/restoreclears the stamps;DELETE /api/recycle-bin/:kind/:idpurges (the realDELETE, + children). UI: a Recycle bin tab under Setup. Code:apps/server/src/recycle-bin.ts(+routes/recycle-bin.ts),apps/web/src/RecycleBin.tsx. - Retention sweep. A 6-hourly (+ startup) job auto-purges items deleted longer than
RECYCLE_BIN_RETENTION_DAYS(default 30) ago.0/negative = keep forever.
Invariants & edge cases
- No-lockout still holds. The "last admin" check counts only live admins (a soft-deleted admin can't log in, so they don't count) — you can't delete yourself into a locked-out box. See local-jwt-auth.
- Soft-delete vs. domain lifecycle. A subscription's
revoke/reactivateand a plan'sactive=0retire are domain states that keep the item visible in its catalog (barred / unsellable).deleted_atis different: it removes the item from the catalog entirely, recoverable only from the bin. Both coexist. See subscription. - Unique-name reuse.
username/ rolenameareUNIQUEacross live AND deleted rows, so you can't create a new user reusing a deleted user's name until that row is restored or purged — the create route returns a clear 409 pointing at the recycle bin (rather than a raw constraint error). - Dangling references on restore. A restored user points at its
roleId; if that role is itself deleted, the user reappears with a deleted role. We don't auto-cascade (keep it predictable) — the bin lists both; the admin restores the role too. The role guard resolves a missing role to an empty permission set (safe-by-default), so a dangling role never escalates. - "In use" checks count live only. A plan blocked from deletion "while referenced" counts
only live subscriptions; a soft-deleted subscriber's
planIdreference doesn't block it.
Permission
recyclebin:read (view), recyclebin:update (restore), recyclebin:delete (purge) — admin-grade
(a restore can revive a privileged user/role; a purge is permanent). Folded into the
code-defined PERMISSIONS grid; the built-in admin role holds them. See local-jwt-auth.