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
+13 -9
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, rolePermissions, roles, users, type Db } from "@parking/db";
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
// Role management (admin). Roles are DATA: an admin composes a role from the
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
@@ -56,7 +57,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
.where(eq(rolePermissions.roleId, roleId))
.all()
.map((r) => r.permission);
const userCount = db.select().from(users).where(eq(users.roleId, roleId)).all().length;
const userCount = db.select().from(users).where(and(eq(users.roleId, roleId), isNull(users.deletedAt))).all().length;
// The admin role always reports the full grid (it's enforced in code).
return {
id: role.id,
@@ -75,9 +76,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
}
// The full permission grid (for the role-composer checkbox UI) + every role.
// The full permission grid (for the role-composer checkbox UI) + every LIVE role.
// Soft-deleted roles live in the recycle bin, not here.
app.get("/api/roles", { preHandler: readGuard }, async () => {
const all = db.select().from(roles).all();
const all = db.select().from(roles).where(isNull(roles.deletedAt)).all();
return {
catalog: PERMISSIONS,
roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
@@ -143,23 +145,25 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
},
);
// Delete a role. Refused if it's built-in or any user still holds it.
// Delete a role — SOFT (recycle bin). Refused if built-in or any LIVE user still holds
// it. The row is stamped deleted (recoverable), not removed; its permission rows are
// KEPT so a restore brings the role back intact. Restore/purge from the recycle bin.
app.delete<{ Params: { id: string } }>(
"/api/roles/:id",
{ preHandler: deleteGuard },
async (req, reply) => {
const id = req.params.id;
const role = db.select().from(roles).where(eq(roles.id, id)).get();
const role = db.select().from(roles).where(and(eq(roles.id, id), isNull(roles.deletedAt))).get();
if (!role) return reply.code(404).send({ error: "role not found" });
if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" });
}
const holders = db.select().from(users).where(eq(users.roleId, id)).all().length;
// Only LIVE holders block deletion (a soft-deleted user's role assignment is moot).
const holders = db.select().from(users).where(and(eq(users.roleId, id), isNull(users.deletedAt))).all().length;
if (holders > 0) {
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
}
db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)).run();
db.delete(roles).where(eq(roles.id, id)).run();
softDelete(db, "role", id, req.user.sub);
bumpPermsCache();
return { ok: true };
},