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:
@@ -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]].
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]].
|
||||
|
||||
Reference in New Issue
Block a user