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
+16
View File
@@ -30,6 +30,7 @@ import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js";
import { LogsViewer } from "./LogsViewer.js";
import { RecycleBin } from "./RecycleBin.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
// initial bundle and only downloads when an admin opens /setup/reports.
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
@@ -88,6 +89,7 @@ function SetupLayout() {
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
</nav>
<Outlet />
@@ -387,6 +389,7 @@ function RootLayout() {
show("site:read") ||
show("user:read") ||
show("role:read") ||
show("recyclebin:read") ||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
@@ -513,6 +516,7 @@ const SETUP_TABS: { to: string; perm: Permission }[] = [
{ to: "/setup/site", perm: "site:read" },
{ to: "/setup/users", perm: "user:read" },
{ to: "/setup/roles", perm: "role:read" },
{ to: "/setup/recycle-bin", perm: "recyclebin:read" },
{ to: "/shifts", perm: "shift:read" },
{ to: "/setup/logs", perm: "log:read" },
];
@@ -610,6 +614,17 @@ const rolesRoute = createRoute({
// (Shift history lives at the standalone /shifts route — see shiftRoute. It was
// removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
// Recycle bin — restore/purge soft-deleted master data. Gated by recyclebin:read.
const recycleBinRoute = createRoute({
getParentRoute: () => setupRoute,
path: "recycle-bin",
beforeLoad: ({ context }) => requirePerm("recyclebin:read")(context),
component: function RecycleBinRoute() {
const { user } = rootRoute.useRouteContext();
return <RecycleBin user={user} />;
},
});
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
const logsRoute = createRoute({
getParentRoute: () => setupRoute,
@@ -635,6 +650,7 @@ const routeTree = rootRoute.addChildren([
siteRoute,
usersRoute,
rolesRoute,
recycleBinRoute,
logsRoute,
]),
]);