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
This commit is contained in:
2026-06-22 09:33:54 +02:00
parent 3527f48d76
commit 7680d9a0ed
28 changed files with 1095 additions and 42 deletions
+74
View File
@@ -0,0 +1,74 @@
---
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]].
+9 -3
View File
@@ -37,9 +37,15 @@ Authentication and authorization, kept **fully local** — a direct consequence
**last user holding admin** — administration can never be locked out of the appliance.
- `event:void` is a permission, NOT a ledger delete: the append-only signed chain is untouched; the
permission only gates who may APPEND a void event (there is no void API route yet — forward seam).
- The grid is **extensible** — adding a feature adds its `resource:action` rows. Latest: **`log:read`**
(a new `log` resource) gates the diagnostic-log viewer (`GET /api/logs`); admin holds it, and it's
grantable to a diagnostic role. See [[app-logs]].
- The grid is **extensible** — adding a feature adds its `resource:action` rows. Recent additions:
**`log:read`** (gates the diagnostic-log viewer, `GET /api/logs`; see [[app-logs]]); **`report:read`**
(the admin Reports dashboard; see [[reporting-analytics]]); and **`recyclebin:read/update/delete`**
(view / restore / purge soft-deleted master data; see [[soft-delete]]). Admin holds them all; each is
grantable to a scoped role.
- **Soft-deleted users can't authenticate.** The login route rejects a user whose `deleted_at` is set
(with the same generic "invalid credentials" so a deleted account isn't enumerable). The no-lockout
"last admin" check counts only LIVE admins, so soft-deleting can't strand administration. See
[[soft-delete]].
- **No privilege escalation through the RBAC system itself.** `role:create`/`role:update` and
`user:create`/`user:update` are themselves grantable, so a non-admin could otherwise self-escalate.
Guards (`routes/roles.ts`, `routes/users.ts`): a caller may only put permissions on a role that
+1
View File
@@ -96,6 +96,7 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
- [[app-logs]] — the third stream: diagnostic logs (backend warn+ pino sink + frontend errors) → app_logs; log:read viewer; pruned by age+row cap.
- [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope.
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline.
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
+16
View File
@@ -1354,3 +1354,19 @@ so the booth bundle is untouched. reports.test.ts (10) pins the sums/tz/split/du
90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup
(`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR
opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]].
## [2026-06-22] feat | Soft delete + recycle bin for master data (migration 0012)
Accidental admin deletes used to be hard + unrecoverable. Now users/roles/subscriptions/plans/
tariffs soft-delete: migration 0012 adds nullable deleted_at + deleted_by; each resource's DELETE
route STAMPS instead of removing, and every catalog list filters deleted_at IS NULL. A recycle bin
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge — gated recyclebin:read/update/delete,
new resource in the PERMISSIONS grid) lists everything soft-deleted, restores, or purges; a 6-hourly
+ startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever).
Key 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 version rows 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 → empty perms, safe). Signed ledger is OUT of scope (no delete path).
Web: a Recycle bin tab under Setup (RecycleBin.tsx). 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 19/19, i18n parity (sq+en). See [[soft-delete]], [[local-jwt-auth]].