Files
julian 7680d9a0ed feat(recycle-bin): soft delete + restore for master data
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
2026-06-22 09:33:54 +02:00

4.4 KiB

type, tags, sources, updated, status
type tags sources updated status
concept
parking
data
admin
safety
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) and deleted_by (the admin user id). Additive ALTER ADD COLUMN — backward-compatible.
  • Delete = stamp. Each resource's own DELETE route now sets the stamps instead of removing the row. The row vanishes from every catalog because the list/lookup queries filter deleted_at IS NULL.
  • Recycle bin API (recyclebin:* permission): GET /api/recycle-bin lists everything soft-deleted across kinds; POST /api/recycle-bin/:kind/:id/restore clears the stamps; DELETE /api/recycle-bin/:kind/:id purges (the real DELETE, + 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/reactivate and a plan's active=0 retire are domain states that keep the item visible in its catalog (barred / unsellable). deleted_at is different: it removes the item from the catalog entirely, recoverable only from the bin. Both coexist. See subscription.
  • Unique-name reuse. username / role name are UNIQUE across 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 planId reference 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.