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
+19
View File
@@ -58,6 +58,7 @@ export const sq = {
roles: "Rolet",
shifts: "Turnet",
reports: "Raportet",
recycleBin: "Koshi",
logs: "Loget",
},
status: {
@@ -726,6 +727,24 @@ export const sq = {
subCars: "Makina të mbuluara",
},
},
recycleBin: {
title: "Koshi",
retentionNote: "Artikujt e fshirë mbahen për {{days}} ditë, pastaj hiqen përgjithmonë.",
empty: "Asgjë e fshirë. Artikujt që fshini shfaqen këtu, të rikuperueshëm derisa të skadojnë.",
col: { type: "Lloji", item: "Artikulli", deleted: "Fshirë", actions: "" },
kind: {
user: "Përdorues",
role: "Rol",
subscription: "Abonim",
plan: "Plan",
tariff: "Tarifë",
},
restore: "Rikthe",
purge: "Fshi përfundimisht",
purgeConfirmTitle: "Të fshihet përfundimisht?",
purgeConfirmBody: "Të fshihet përgjithmonë “{{label}}”? Nuk mund të rikthehet pas kësaj.",
purgeIrreversible: "Ky veprim është i pakthyeshëm.",
},
logs: {
title: "Loget e sistemit",
refresh: "Rifresko",