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:
@@ -1,9 +1,10 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||
import { NoPrinterAvailableError } from "@parking/devices";
|
||||
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
import { invalidateHolder } from "../event-enrich.js";
|
||||
import { printSubscriptionCard } from "../booth-print.js";
|
||||
import type { CredentialCapture } from "../credential-capture.js";
|
||||
@@ -226,9 +227,10 @@ export async function subscriptionRoutes(
|
||||
return { plan, validFrom, validTo, quantity, quote };
|
||||
}
|
||||
|
||||
// List all subscriptions (with their credentials + plates).
|
||||
// List all LIVE subscriptions (with their credentials + plates). Soft-deleted ones
|
||||
// live in the recycle bin, not here.
|
||||
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
|
||||
const rows = db.select().from(subscriptions).all();
|
||||
const rows = db.select().from(subscriptions).where(isNull(subscriptions.deletedAt)).all();
|
||||
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
|
||||
});
|
||||
|
||||
@@ -532,16 +534,17 @@ export async function subscriptionRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// Hard delete a subscription + its child rows. (Past ledger events that reference it
|
||||
// are untouched — the audit trail is append-only and independent of this row.)
|
||||
// Delete a subscription — SOFT (recycle bin). The row + its credential/plate children
|
||||
// are KEPT (stamped deleted) so a restore brings the subscriber back intact; it leaves
|
||||
// the catalog and stops opening the barrier (the entry flow filters deleted). Past
|
||||
// ledger events that reference it are untouched (append-only). Restore/purge from the
|
||||
// recycle bin. (Distinct from /revoke, which BARS but keeps the subscriber visible.)
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/subscriptions/:id",
|
||||
{ preHandler: deleteGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
|
||||
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
|
||||
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
|
||||
const ok = softDelete(db, "subscription", req.params.id, req.user.sub);
|
||||
if (!ok) return reply.code(404).send({ error: "subscription not found" });
|
||||
invalidateHolder(req.params.id);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user