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,67 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import { requirePermission, bumpPermsCache } from "../auth.js";
|
||||
import {
|
||||
listRecycleBin,
|
||||
purge,
|
||||
restore,
|
||||
restoreBlockedReason,
|
||||
retentionDays,
|
||||
RESOURCE_KINDS,
|
||||
type ResourceKind,
|
||||
} from "../recycle-bin.js";
|
||||
|
||||
// Recycle bin API — view / restore / purge soft-deleted master data. The actual
|
||||
// soft-delete STAMP happens in each resource's own DELETE route (users/roles/
|
||||
// subscriptions/plans/tariffs); this is the way back. Admin-grade (recyclebin:*).
|
||||
// See recycle-bin.ts, wiki/concepts/soft-delete.md.
|
||||
|
||||
function isKind(s: string): s is ResourceKind {
|
||||
return (RESOURCE_KINDS as string[]).includes(s);
|
||||
}
|
||||
|
||||
export async function recycleBinRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// List everything in the bin (+ the retention window so the UI can warn how long
|
||||
// items survive before auto-purge).
|
||||
app.get(
|
||||
"/api/recycle-bin",
|
||||
{ preHandler: requirePermission("recyclebin:read") },
|
||||
async () => ({ items: listRecycleBin(db), retentionDays: retentionDays() }),
|
||||
);
|
||||
|
||||
// Restore a soft-deleted item (clear the stamps → it reappears in its catalog).
|
||||
// Blocked with a 409 when a live row would collide (e.g. the username was reused).
|
||||
app.post<{ Params: { kind: string; id: string } }>(
|
||||
"/api/recycle-bin/:kind/:id/restore",
|
||||
{ preHandler: requirePermission("recyclebin:update") },
|
||||
async (req, reply) => {
|
||||
const { kind, id } = req.params;
|
||||
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
|
||||
|
||||
const blocked = restoreBlockedReason(db, kind, id);
|
||||
if (blocked) return reply.code(409).send({ error: `cannot restore: ${blocked}` });
|
||||
|
||||
const ok = restore(db, kind, id);
|
||||
if (!ok) return reply.code(404).send({ error: "no deleted item to restore" });
|
||||
// A restored role/user changes the authz picture — drop the permission cache.
|
||||
if (kind === "role" || kind === "user") bumpPermsCache();
|
||||
app.log.info(`recycle-bin: restored ${kind} ${id}`);
|
||||
return { kind, id, restored: true };
|
||||
},
|
||||
);
|
||||
|
||||
// Purge (permanently delete) a soft-deleted item + its children. Irreversible.
|
||||
app.delete<{ Params: { kind: string; id: string } }>(
|
||||
"/api/recycle-bin/:kind/:id",
|
||||
{ preHandler: requirePermission("recyclebin:delete") },
|
||||
async (req, reply) => {
|
||||
const { kind, id } = req.params;
|
||||
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
|
||||
const ok = purge(db, kind, id);
|
||||
if (!ok) return reply.code(404).send({ error: "no deleted item to purge" });
|
||||
if (kind === "role" || kind === "user") bumpPermsCache();
|
||||
app.log.warn(`recycle-bin: PURGED ${kind} ${id} (permanent)`);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user