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

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]].