7680d9a0ed
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
75 lines
4.4 KiB
Markdown
75 lines
4.4 KiB
Markdown
---
|
|
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]].
|