--- type: concept tags: [parking, data, admin, safety] sources: [] updated: 2026-06-22 status: 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]].