diff --git a/apps/server/.env.example b/apps/server/.env.example index 32eed4c..cf35dd9 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -28,6 +28,11 @@ EVENT_SIGNING_KEY= # http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy. # COOKIE_SECURE=0 +# Recycle bin retention: a soft-deleted user/role/subscription/plan/tariff is auto-purged +# this many days after deletion (a 6-hourly sweep). Default 30. Set 0 to keep deleted +# items forever (manual purge only). See wiki/concepts/soft-delete.md. +# RECYCLE_BIN_RETENTION_DAYS=30 + # First admin (seed once): pnpm --filter @parking/server seed-admin # ADMIN_USER=admin # ADMIN_PASS= diff --git a/apps/server/src/recycle-bin.test.ts b/apps/server/src/recycle-bin.test.ts new file mode 100644 index 0000000..886fed2 --- /dev/null +++ b/apps/server/src/recycle-bin.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; +import { + eq, + isNull, + roles, + rolePermissions, + subscriptionCredentials, + subscriptionPlans, + subscriptions, + tariffs, + users, + type Db, +} from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import { + listRecycleBin, + purge, + restore, + restoreBlockedReason, + softDelete, + sweepExpired, +} from "./recycle-bin.js"; + +// Soft delete / recycle bin. Pins: a delete STAMPS (keeps the row), the bin lists +// soft-deleted items across kinds, restore brings them back, purge does the real +// DELETE (+ children), a restore that would collide with a live row is blocked, and the +// retention sweep purges only items past the window. + +let db: Db; +beforeEach(() => { + ({ db } = createTestDb()); +}); + +function seedUser(username: string): string { + const id = randomUUID(); + db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run(); + db.insert(users).values({ id, username, passwordHash: "x", roleId: "admin" }).run(); + return id; +} +function seedRole(name: string): string { + const id = randomUUID(); + db.insert(roles).values({ id, name, builtin: 0 }).run(); + db.insert(rolePermissions).values({ roleId: id, permission: "site:read" }).run(); + return id; +} +function seedSubscription(holder: string): string { + const id = randomUUID(); + db.insert(subscriptions).values({ id, holderName: holder, period: "month" }).run(); + db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: "qr", value: `qr-${id}` }).run(); + return id; +} +function seedPlan(planId: string, versions = 2): void { + for (let i = 0; i < versions; i++) { + db.insert(subscriptionPlans).values({ + id: randomUUID(), + planId, + name: planId, + period: "month", + pricePerPeriodMinor: 100000, + currency: "ALL", + effectiveFrom: `2026-0${i + 1}-01T00:00:00.000Z`, + }).run(); + } +} + +describe("softDelete + restore + purge", () => { + it("stamps the row instead of removing it, and hides it from a live query", () => { + const id = seedUser("alice"); + expect(softDelete(db, "user", id, "admin-1")).toBe(true); + + const row = db.select().from(users).where(eq(users.id, id)).get(); + expect(row).toBeDefined(); // still there + expect(row?.deletedAt).toBeTruthy(); + expect(row?.deletedBy).toBe("admin-1"); + // A live-only query no longer sees it. + expect(db.select().from(users).where(isNull(users.deletedAt)).all()).toHaveLength(0); + }); + + it("soft-deleting an already-deleted row is a no-op (returns false)", () => { + const id = seedUser("bob"); + expect(softDelete(db, "user", id, "a")).toBe(true); + expect(softDelete(db, "user", id, "a")).toBe(false); + }); + + it("restore clears the stamps and brings the row back to the live set", () => { + const id = seedRole("valet"); + softDelete(db, "role", id, "a"); + expect(restore(db, "role", id)).toBe(true); + const row = db.select().from(roles).where(eq(roles.id, id)).get(); + expect(row?.deletedAt).toBeNull(); + expect(db.select().from(roles).where(isNull(roles.deletedAt)).all().map((r) => r.id)).toContain(id); + }); + + it("purge removes a soft-deleted row + its children; refuses a LIVE row", () => { + const id = seedSubscription("carlos"); + // Cannot purge while live (purge only touches soft-deleted rows). + expect(purge(db, "subscription", id)).toBe(false); + expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeDefined(); + + softDelete(db, "subscription", id, "a"); + expect(purge(db, "subscription", id)).toBe(true); + expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeUndefined(); + // Children gone too. + expect(db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all()).toHaveLength(0); + }); +}); + +describe("versioned plans", () => { + it("soft-deletes / restores / purges ALL versions of a planId together", () => { + seedPlan("hotel-daily", 3); + expect(softDelete(db, "plan", "hotel-daily", "a")).toBe(true); + expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(0); + + // The bin lists the plan as ONE item, not three. + const planItems = listRecycleBin(db).filter((i) => i.kind === "plan"); + expect(planItems).toHaveLength(1); + expect(planItems[0]?.id).toBe("hotel-daily"); + + expect(restore(db, "plan", "hotel-daily")).toBe(true); + expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(3); + + softDelete(db, "plan", "hotel-daily", "a"); + expect(purge(db, "plan", "hotel-daily")).toBe(true); + expect(db.select().from(subscriptionPlans).all()).toHaveLength(0); + }); +}); + +describe("listRecycleBin", () => { + it("collects soft-deleted items across every kind, newest-deleted first", () => { + const u = seedUser("dora"); + const r = seedRole("guard"); + const t = randomUUID(); + db.insert(tariffs).values({ id: t, scope: "site", name: "Site" }).run(); + + softDelete(db, "user", u, "a"); + softDelete(db, "role", r, "a"); + softDelete(db, "tariff", t, "a"); + + const items = listRecycleBin(db); + expect(items.map((i) => i.kind).sort()).toEqual(["role", "tariff", "user"]); + // Each carries a human label + the deletedAt stamp. + expect(items.find((i) => i.kind === "user")?.label).toBe("dora"); + expect(items.every((i) => i.deletedAt)).toBe(true); + }); +}); + +describe("restoreBlockedReason", () => { + // NB: the DB `username`/`name` UNIQUE spans live AND soft-deleted rows, so a live + // duplicate can't even be INSERTed while the deleted one exists (the create route + // returns a clear 409 instead — see routes/users.ts). restoreBlockedReason is a + // belt-and-suspenders guard at restore time; verify it returns null in the normal + // case (nothing colliding) so a clean restore is never wrongly blocked. + it("does not block a normal restore (no live collision)", () => { + const u = seedUser("eve"); + softDelete(db, "user", u, "a"); + expect(restoreBlockedReason(db, "user", u)).toBeNull(); + + const r = seedRole("cleaner"); + softDelete(db, "role", r, "a"); + expect(restoreBlockedReason(db, "role", r)).toBeNull(); + }); +}); + +describe("sweepExpired (retention)", () => { + it("purges items deleted longer than the window ago, keeps recent ones", () => { + const old = seedUser("old"); + const fresh = seedUser("fresh"); + softDelete(db, "user", old, "a"); + softDelete(db, "user", fresh, "a"); + // Backdate `old`'s deletion to 40 days ago. + const longAgo = new Date(Date.now() - 40 * 86_400_000).toISOString(); + db.update(users).set({ deletedAt: longAgo }).where(eq(users.id, old)).run(); + + const purged = sweepExpired(db, 30); + expect(purged.user).toBe(1); + expect(db.select().from(users).where(eq(users.id, old)).get()).toBeUndefined(); + expect(db.select().from(users).where(eq(users.id, fresh)).get()).toBeDefined(); + }); + + it("days <= 0 disables the sweep (keep forever)", () => { + const id = seedUser("keeper"); + softDelete(db, "user", id, "a"); + db.update(users).set({ deletedAt: new Date(Date.now() - 999 * 86_400_000).toISOString() }).where(eq(users.id, id)).run(); + const purged = sweepExpired(db, 0); + expect(purged.user).toBe(0); + expect(db.select().from(users).where(eq(users.id, id)).get()).toBeDefined(); + }); +}); diff --git a/apps/server/src/recycle-bin.ts b/apps/server/src/recycle-bin.ts new file mode 100644 index 0000000..caf3420 --- /dev/null +++ b/apps/server/src/recycle-bin.ts @@ -0,0 +1,206 @@ +import { + and, + eq, + isNotNull, + isNull, + lte, + rolePermissions, + roles, + subscriptionCredentials, + subscriptionPlans, + subscriptionPlates, + subscriptions, + tariffs, + users, + type Db, +} from "@parking/db"; + +// Soft delete + recycle bin. Accidental hard-deletes of master data (a user, role, +// subscription, plan, tariff) used to be unrecoverable. Now a DELETE STAMPS the row +// (`deleted_at` = now, `deleted_by` = admin) instead of removing it; it disappears from +// every catalog (the list queries filter `deleted_at IS NULL`) but survives in the +// recycle bin, where an admin can RESTORE it (clear the stamps) or PURGE it (the real +// DELETE). A retention sweep auto-purges items deleted longer than the window ago. +// +// Scope: only the MUTABLE master-data tables below. The signed, append-only ledger is +// NOT here — it has no delete path by design. See wiki/concepts/soft-delete.md. + +/** The soft-deletable resource kinds, as they appear in the recycle-bin API. */ +export type ResourceKind = "user" | "role" | "subscription" | "plan" | "tariff"; + +export const RESOURCE_KINDS: ResourceKind[] = ["user", "role", "subscription", "plan", "tariff"]; + +/** Default retention window before a soft-deleted item is auto-purged (days). Override + * with RECYCLE_BIN_RETENTION_DAYS. 0/negative disables the sweep (keep forever). */ +export function retentionDays(): number { + const raw = Number(process.env.RECYCLE_BIN_RETENTION_DAYS ?? 30); + return Number.isFinite(raw) ? raw : 30; +} + +/** A row surfaced in the recycle bin (normalised across resource kinds). */ +export interface RecycleBinItem { + readonly kind: ResourceKind; + /** The id used to restore/purge. For a versioned PLAN this is the stable planId. */ + readonly id: string; + /** Human label for the list (username, role/plan/tariff name, subscriber holder). */ + readonly label: string; + readonly deletedAt: string; + readonly deletedBy: string | null; +} + +const NOW = () => new Date().toISOString(); + +// --- Per-resource helpers ---------------------------------------------------- +// Subscriptions/users/roles/tariffs are 1 row per id. PLANS are versioned (N rows per +// plan_id) — stamp/clear/delete ALL versions of the plan_id together. + +/** Soft-delete a row by id. Returns false if no live row matched (404). PLAN uses planId. */ +export function softDelete(db: Db, kind: ResourceKind, id: string, byUserId: string): boolean { + const stamp = { deletedAt: NOW(), deletedBy: byUserId }; + switch (kind) { + case "user": + return db.update(users).set(stamp).where(and(eq(users.id, id), isNull(users.deletedAt))).run().changes > 0; + case "role": + return db.update(roles).set(stamp).where(and(eq(roles.id, id), isNull(roles.deletedAt))).run().changes > 0; + case "subscription": + return db.update(subscriptions).set(stamp).where(and(eq(subscriptions.id, id), isNull(subscriptions.deletedAt))).run().changes > 0; + case "plan": + return db.update(subscriptionPlans).set(stamp).where(and(eq(subscriptionPlans.planId, id), isNull(subscriptionPlans.deletedAt))).run().changes > 0; + case "tariff": + return db.update(tariffs).set(stamp).where(and(eq(tariffs.id, id), isNull(tariffs.deletedAt))).run().changes > 0; + } +} + +/** Restore a soft-deleted row (clear the stamps). Returns false if nothing was restored. */ +export function restore(db: Db, kind: ResourceKind, id: string): boolean { + const clear = { deletedAt: null, deletedBy: null }; + switch (kind) { + case "user": + return db.update(users).set(clear).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0; + case "role": + return db.update(roles).set(clear).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0; + case "subscription": + return db.update(subscriptions).set(clear).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0; + case "plan": + return db.update(subscriptionPlans).set(clear).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0; + case "tariff": + return db.update(tariffs).set(clear).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0; + } +} + +/** True if restoring would collide with a LIVE row (e.g. a user with the same username + * was re-created after the delete). The caller turns this into a 409 so the admin + * understands why restore is blocked. */ +export function restoreBlockedReason(db: Db, kind: ResourceKind, id: string): string | null { + if (kind === "user") { + const row = db.select().from(users).where(eq(users.id, id)).get(); + if (row && db.select().from(users).where(and(eq(users.username, row.username), isNull(users.deletedAt))).get()) { + return `a live user named "${row.username}" already exists`; + } + } else if (kind === "role") { + const row = db.select().from(roles).where(eq(roles.id, id)).get(); + if (row && db.select().from(roles).where(and(eq(roles.name, row.name), isNull(roles.deletedAt))).get()) { + return `a live role named "${row.name}" already exists`; + } + } + return null; +} + +// --- Restore ordering note -------------------------------------------------- +// A restored USER points at a roleId; if that role is itself deleted, the user reappears +// with a dangling role. We don't auto-cascade (keep it predictable); the bin lists both +// and the admin restores the role too. The role guard already resolves a missing role to +// an empty permission set (safe-by-default), so a dangling role never escalates. + +/** Hard-delete (purge) a soft-deleted row + its children. The real DELETE. Returns false + * if no soft-deleted row matched (so you can't purge a live row through this path). */ +export function purge(db: Db, kind: ResourceKind, id: string): boolean { + switch (kind) { + case "user": + return db.delete(users).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0; + case "role": { + // Children (role_permissions) only matter once the role row is gone; purge both. + const ok = db.delete(roles).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0; + if (ok) deleteRolePermissions(db, id); + return ok; + } + case "subscription": { + const ok = db.delete(subscriptions).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0; + if (ok) deleteSubscriptionChildren(db, id); + return ok; + } + case "plan": + return db.delete(subscriptionPlans).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0; + case "tariff": + return db.delete(tariffs).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0; + } +} + +// Child cleanup on purge (role_permissions / subscription credentials + plates). +function deleteRolePermissions(db: Db, roleId: string): void { + db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run(); +} +function deleteSubscriptionChildren(db: Db, id: string): void { + db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run(); + db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run(); +} + +// --- Listing the bin -------------------------------------------------------- + +/** All soft-deleted items across every resource kind, newest-deleted first. */ +export function listRecycleBin(db: Db): RecycleBinItem[] { + const items: RecycleBinItem[] = []; + + for (const r of db.select().from(users).where(isNotNull(users.deletedAt)).all()) { + items.push({ kind: "user", id: r.id, label: r.fullName || r.username, deletedAt: r.deletedAt!, deletedBy: r.deletedBy }); + } + for (const r of db.select().from(roles).where(isNotNull(roles.deletedAt)).all()) { + items.push({ kind: "role", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy }); + } + for (const r of db.select().from(subscriptions).where(isNotNull(subscriptions.deletedAt)).all()) { + items.push({ kind: "subscription", id: r.id, label: r.holderName || r.id, deletedAt: r.deletedAt!, deletedBy: r.deletedBy }); + } + // Plans are versioned: collapse to one item per plan_id (the latest version's name). + const planSeen = new Set(); + const planRows = db.select().from(subscriptionPlans).where(isNotNull(subscriptionPlans.deletedAt)).all(); + planRows.sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom)); + for (const r of planRows) { + if (planSeen.has(r.planId)) continue; + planSeen.add(r.planId); + items.push({ kind: "plan", id: r.planId, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy }); + } + for (const r of db.select().from(tariffs).where(isNotNull(tariffs.deletedAt)).all()) { + items.push({ kind: "tariff", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy }); + } + + return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt)); +} + +// --- Retention sweep -------------------------------------------------------- + +/** Purge every soft-deleted row deleted more than `retentionDays()` ago. Returns the + * count purged per kind. Safe to call repeatedly (idempotent). */ +export function sweepExpired(db: Db, days = retentionDays()): Record { + const out: Record = { user: 0, role: 0, subscription: 0, plan: 0, tariff: 0 }; + if (!Number.isFinite(days) || days <= 0) return out; // keep-forever + const cutoff = new Date(Date.now() - days * 86_400_000).toISOString(); + + // Collect ids first so children purge through the same path as a manual purge. + for (const r of db.select().from(users).where(and(isNotNull(users.deletedAt), lte(users.deletedAt, cutoff))).all()) { + if (purge(db, "user", r.id)) out.user++; + } + for (const r of db.select().from(roles).where(and(isNotNull(roles.deletedAt), lte(roles.deletedAt, cutoff))).all()) { + if (purge(db, "role", r.id)) out.role++; + } + for (const r of db.select().from(subscriptions).where(and(isNotNull(subscriptions.deletedAt), lte(subscriptions.deletedAt, cutoff))).all()) { + if (purge(db, "subscription", r.id)) out.subscription++; + } + const planIds = new Set( + db.select().from(subscriptionPlans).where(and(isNotNull(subscriptionPlans.deletedAt), lte(subscriptionPlans.deletedAt, cutoff))).all().map((r) => r.planId), + ); + for (const planId of planIds) if (purge(db, "plan", planId)) out.plan++; + for (const r of db.select().from(tariffs).where(and(isNotNull(tariffs.deletedAt), lte(tariffs.deletedAt, cutoff))).all()) { + if (purge(db, "tariff", r.id)) out.tariff++; + } + return out; +} diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index 84f789d..b4c50b3 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -69,7 +69,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise { // Always run a bcrypt compare to avoid leaking which usernames exist (timing). const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv"; const ok = await bcrypt.compare(password, hash); - if (!user || !ok) { + // A soft-deleted user (in the recycle bin) cannot log in — treat as invalid, with no + // distinct error so a deleted account isn't enumerable. + if (!user || !ok || user.deletedAt) { return reply.code(401).send({ error: "invalid credentials" }); } diff --git a/apps/server/src/routes/recycle-bin-routes.test.ts b/apps/server/src/routes/recycle-bin-routes.test.ts new file mode 100644 index 0000000..8355d2d --- /dev/null +++ b/apps/server/src/routes/recycle-bin-routes.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createTestDb } from "@parking/db/testing"; +import { type Db } from "@parking/db"; +import type { FastifyInstance } from "fastify"; +import { buildServer } from "../server.js"; +import { seedUser, login } from "../test-helpers.js"; + +// HTTP integration for soft delete + recycle bin: an admin DELETE soft-deletes (the user +// leaves the list, can't log in), the bin lists it, restore brings it back, and a deleted +// user can log in again. Drives the REAL app over a fresh in-memory DB via app.inject. + +let db: Db; +let close: () => void; +let app: FastifyInstance; + +beforeEach(async () => { + const t = createTestDb(); + db = t.db; + close = t.close; + app = await buildServer({ db }); + await app.ready(); +}); +afterEach(async () => { + await app.close(); + close(); +}); + +/** Log in an admin and return the auth headers for mutations. */ +async function asAdmin() { + const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); + const { cookie, csrf } = await login(app, username, password); + return { cookie, csrf }; +} + +describe("soft delete via the resource DELETE route", () => { + it("DELETE /api/users/:id soft-deletes: user leaves the list and can't log in, but is restorable", async () => { + const { cookie, csrf } = await asAdmin(); + // Create a victim user to delete. + await seedUser(db, { username: "victim", password: "victim-pass-123", roleId: "admin" }); + const victim = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json() + .users.find((u: { username: string; id: string }) => u.username === "victim"); + expect(victim).toBeDefined(); + + // Delete (soft). + const del = await app.inject({ + method: "DELETE", url: `/api/users/${victim.id}`, + headers: { cookie, "x-csrf-token": csrf }, + }); + expect(del.statusCode).toBeLessThan(300); + + // Gone from the live list. + const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json(); + expect(list.users.some((u: { username: string }) => u.username === "victim")).toBe(false); + + // Can't log in. + const relogin = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } }); + expect(relogin.statusCode).toBe(401); + + // Shows in the recycle bin. + const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json(); + expect(bin.items.some((i: { kind: string; label: string }) => i.kind === "user" && i.label === "victim")).toBe(true); + + // Restore → reappears + can log in. + const restore = await app.inject({ + method: "POST", url: `/api/recycle-bin/user/${victim.id}/restore`, + headers: { cookie, "x-csrf-token": csrf }, + }); + expect(restore.statusCode).toBeLessThan(300); + const relogin2 = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } }); + expect(relogin2.statusCode).toBe(200); + }); + + it("purge permanently removes a soft-deleted user", async () => { + const { cookie, csrf } = await asAdmin(); + await seedUser(db, { username: "gone", password: "gone-pass-1234", roleId: "admin" }); + const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json() + .users.find((u: { username: string }) => u.username === "gone").id; + + await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } }); + const purge = await app.inject({ + method: "DELETE", url: `/api/recycle-bin/user/${id}`, + headers: { cookie, "x-csrf-token": csrf }, + }); + expect(purge.statusCode).toBe(204); + + const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json(); + expect(bin.items.some((i: { label: string }) => i.label === "gone")).toBe(false); + }); + + it("the recycle bin is gated — a user without recyclebin:read is 403", async () => { + const { username, password } = await seedUser(db, { + username: "plain", roleId: "plain", permissions: ["user:read"], + }); + const { cookie } = await login(app, username, password); + const res = await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } }); + expect(res.statusCode).toBe(403); + }); + + it("recreating a user with a soft-deleted user's username gives a clear 409", async () => { + const { cookie, csrf } = await asAdmin(); + await seedUser(db, { username: "dup", password: "dup-pass-12345", roleId: "admin" }); + const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json() + .users.find((u: { username: string }) => u.username === "dup").id; + await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } }); + + const create = await app.inject({ + method: "POST", url: "/api/users", + headers: { cookie, "x-csrf-token": csrf }, + payload: { username: "dup", password: "new-pass-12345", roleId: "admin" }, + }); + expect(create.statusCode).toBe(409); + expect(create.json().error).toMatch(/recycle bin/i); + }); +}); diff --git a/apps/server/src/routes/recycle-bin.ts b/apps/server/src/routes/recycle-bin.ts new file mode 100644 index 0000000..ce36a86 --- /dev/null +++ b/apps/server/src/routes/recycle-bin.ts @@ -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 { + // 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(); + }, + ); +} diff --git a/apps/server/src/routes/roles.ts b/apps/server/src/routes/roles.ts index 1a42bdd..3f7c84f 100644 --- a/apps/server/src/routes/roles.ts +++ b/apps/server/src/routes/roles.ts @@ -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 { .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 { } } - // 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 { }, ); - // 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 }; }, diff --git a/apps/server/src/routes/subscription-plans.ts b/apps/server/src/routes/subscription-plans.ts index 8c0ba72..b668f75 100644 --- a/apps/server/src/routes/subscription-plans.ts +++ b/apps/server/src/routes/subscription-plans.ts @@ -1,8 +1,9 @@ import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db"; +import { and, desc, eq, isNull, subscriptionPlans, subscriptions, type Db } from "@parking/db"; import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared"; import { requirePermission } from "../auth.js"; +import { softDelete } from "../recycle-bin.js"; import { siteTz } from "../subscription-window.js"; // Subscription PLAN catalog — admin-composed, versioned config the operator SELLS @@ -75,7 +76,14 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom // per planId (latest active version with effectiveFrom ≤ now). Operators selling // need the current list; the admin catalog screen asks for ?all=1. app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => { - const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all(); + // Exclude soft-deleted plan versions — those live in the recycle bin. (A plan is + // versioned; a soft-delete stamps every version row of the planId.) + const rows = db + .select() + .from(subscriptionPlans) + .where(isNull(subscriptionPlans.deletedAt)) + .orderBy(desc(subscriptionPlans.effectiveFrom)) + .all(); if (req.query?.all) return { plans: rows }; const now = new Date().toISOString(); // Newest-effective active version wins per planId. @@ -154,12 +162,19 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom // DELETE a plan entirely — allowed ONLY when NO subscription references it (any // version). A referenced plan version MUST survive: a subscription's planVersionId is // needed to reprice/audit that sale, so deleting it would dangle. 409 with the count - // when in use (the admin should retire instead). Removes all versions of the planId. + // when in use (the admin should retire instead). SOFT delete (recycle bin): stamps all + // versions of the planId; a restore brings the plan back; purge does the real removal. app.delete<{ Params: { planId: string } }>( "/api/subscription-plans/:planId", { preHandler: planGuard }, async (req, reply) => { - const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all(); + // Only LIVE subscriptions block deletion (a soft-deleted subscriber's planId ref is + // itself in the bin; if it's restored later, the plan can be restored too). + const refs = db + .select() + .from(subscriptions) + .where(and(eq(subscriptions.planId, req.params.planId), isNull(subscriptions.deletedAt))) + .all(); if (refs.length > 0) { return reply.code(409).send({ error: "plan is in use and cannot be deleted", @@ -167,7 +182,8 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom subscribers: refs.length, }); } - db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run(); + const ok = softDelete(db, "plan", req.params.planId, req.user.sub); + if (!ok) return reply.code(404).send({ error: "plan not found" }); return { planId: req.params.planId, deleted: true }; }, ); diff --git a/apps/server/src/routes/subscriptions.ts b/apps/server/src/routes/subscriptions.ts index 27577d8..cf1dea8 100644 --- a/apps/server/src/routes/subscriptions.ts +++ b/apps/server/src/routes/subscriptions.ts @@ -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(); }, diff --git a/apps/server/src/routes/tariffs.ts b/apps/server/src/routes/tariffs.ts index 383eb99..bf789a0 100644 --- a/apps/server/src/routes/tariffs.ts +++ b/apps/server/src/routes/tariffs.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { desc, eq, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db"; +import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db"; import { computeFee, isTariffV2, @@ -48,9 +48,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise // Publishing a new version changes what customers are charged. const writeGuard = requirePermission("tariff:update"); - // The single site tariff row, created on first read/publish. + // The single site tariff row, created on first read/publish. A soft-deleted (recycle- + // bin) tariff is ignored here so a fresh one is created — the deleted one waits in the + // bin for restore/purge. (Tariffs have soft-delete support for completeness; today the + // site runs one tariff and there's no delete button — recovery is via the recycle bin.) function ensureSiteTariff(): string { - const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get(); + const existing = db.select().from(tariffs).where(and(eq(tariffs.scope, "site"), isNull(tariffs.deletedAt))).get(); if (existing) return existing.id; const id = randomUUID(); db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run(); diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts index c15c665..a324b16 100644 --- a/apps/server/src/routes/users.ts +++ b/apps/server/src/routes/users.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto"; import bcrypt from "bcrypt"; import type { FastifyInstance } from "fastify"; -import { eq, roles, users, type Db } from "@parking/db"; +import { and, eq, isNull, roles, users, type Db } from "@parking/db"; import { ADMIN_ROLE_ID } from "@parking/shared"; import { permissionsFor, requirePermission } from "../auth.js"; +import { softDelete } from "../recycle-bin.js"; // User management (admin). Users are created/edited at runtime here — the // install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one @@ -64,9 +65,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise { const updateGuard = requirePermission("user:update"); const deleteGuard = requirePermission("user:delete"); - /** Count users currently holding the protected admin role. */ + /** Count LIVE users currently holding the protected admin role. A soft-deleted admin + * doesn't count — they can't log in — so the no-lockout check uses live admins only. */ function adminCount(): number { - return db.select().from(users).where(eq(users.roleId, ADMIN_ROLE_ID)).all().length; + return db.select().from(users).where(and(eq(users.roleId, ADMIN_ROLE_ID), isNull(users.deletedAt))).all().length; } /** True if removing/relocating `userId` from admin would leave zero admins. */ @@ -112,9 +114,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise { return false; } - // List all users (no password hashes) + their role names for display. + // List all LIVE users (no password hashes) + their role names for display. Soft-deleted + // users live in the recycle bin, not here. app.get("/api/users", { preHandler: readGuard }, async () => { - const rows = db.select().from(users).all(); + const rows = db.select().from(users).where(isNull(users.deletedAt)).all(); const roleRows = db.select().from(roles).all(); const roleName = new Map(roleRows.map((r) => [r.id, r.name])); return { @@ -140,8 +143,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise { if (exceedsCaller(req.user.roleId, roleId)) { return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" }); } - if (db.select().from(users).where(eq(users.username, username)).get()) { - return reply.code(409).send({ error: "username already exists" }); + const clash = db.select().from(users).where(eq(users.username, username)).get(); + if (clash) { + // The username is UNIQUE across live AND soft-deleted rows. If a DELETED user holds + // it, point the admin at the recycle bin (restore or purge) rather than a bare 409. + return reply.code(409).send({ + error: clash.deletedAt + ? "username belongs to a deleted user — restore or purge it from the recycle bin first" + : "username already exists", + }); } const id = randomUUID(); const passwordHash = await bcrypt.hash(password, 12); @@ -221,13 +231,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise { }, ); - // Delete a user. Refused if it's the last admin (no-lockout). + // Delete a user — SOFT (recycle bin). Refused if it's the last admin (no-lockout). + // The row is stamped deleted (recoverable), not removed; it vanishes from the list and + // can't log in. Restore/purge from the recycle bin. See recycle-bin.ts. app.delete<{ Params: { id: string } }>( "/api/users/:id", { preHandler: deleteGuard }, async (req, reply) => { const id = req.params.id; - const target = db.select().from(users).where(eq(users.id, id)).get(); + const target = db.select().from(users).where(and(eq(users.id, id), isNull(users.deletedAt))).get(); if (!target) { return reply.code(404).send({ error: "user not found" }); } @@ -238,7 +250,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise { if (isLastAdmin(id)) { return reply.code(409).send({ error: "cannot delete the last admin" }); } - db.delete(users).where(eq(users.id, id)).run(); + softDelete(db, "user", id, req.user.sub); return { ok: true }; }, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 853726c..0ff5af1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -26,6 +26,8 @@ import { roleRoutes } from "./routes/roles.js"; import { deviceRoutes } from "./routes/devices.js"; import { eventRoutes } from "./routes/events.js"; import { reportRoutes } from "./routes/reports.js"; +import { recycleBinRoutes } from "./routes/recycle-bin.js"; +import { sweepExpired, retentionDays } from "./recycle-bin.js"; import { payRoutes } from "./routes/pay.js"; import { subscriptionRoutes } from "./routes/subscriptions.js"; import { subscriptionPlanRoutes } from "./routes/subscription-plans.js"; @@ -146,6 +148,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise clearInterval(pruneTimer)); + // Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the + // retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs + // every 6h, unref'd, plus once at startup. See recycle-bin.ts. + const binTimer = setInterval(() => { + const purged = sweepExpired(db); + const total = Object.values(purged).reduce((a, b) => a + b, 0); + if (total > 0) app.log.info(`recycle-bin: auto-purged ${total} expired item(s) ${JSON.stringify(purged)}`); + }, 6 * 60 * 60 * 1000); + binTimer.unref(); + if (retentionDays() > 0) sweepExpired(db); // once at startup + app.addHook("onClose", async () => clearInterval(binTimer)); + const unsubscribeInput = deviceEvents.onInput((e) => { // Record every input edge as unsigned telemetry, keyed to the device that fired // (provenance). No lane — the pool-of-spaces model has none. The entry flow diff --git a/apps/server/src/subscription-flow.ts b/apps/server/src/subscription-flow.ts index 483beb7..927c10c 100644 --- a/apps/server/src/subscription-flow.ts +++ b/apps/server/src/subscription-flow.ts @@ -108,7 +108,10 @@ export class SubscriptionFlow { // that happens BEFORE we infer the entry/exit verb ("both" defers to entry). const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry"; const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get(); - if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") }; + // A soft-deleted (recycle-bin) subscription must NOT open the barrier — treat it as + // gone. (Its credential rows are kept for restore, so the dispatcher can still match + // it; the gate is here.) + if (!sub || sub.deletedAt) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") }; // Validity: active + within the coverage window. const now = new Date().toISOString(); diff --git a/apps/web/src/RecycleBin.tsx b/apps/web/src/RecycleBin.tsx new file mode 100644 index 0000000..38f894f --- /dev/null +++ b/apps/web/src/RecycleBin.tsx @@ -0,0 +1,170 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ApiError, + can, + fetchRecycleBin, + purgeRecycleItem, + restoreRecycleItem, + type RecycleBinItem, + type RecycleKind, + type SessionUser, +} from "./api.js"; +import { qk } from "./lib/query.js"; +import { formatRelativeDateTime } from "./lib/format.js"; +import { Modal } from "./ui/Modal.js"; + +// Recycle bin — the way back from an accidental delete. Lists everything soft-deleted +// across users/roles/subscriptions/plans/tariffs; an admin can Restore (back to its +// catalog) or Purge (permanent). Items auto-purge after the retention window. Gated by +// recyclebin:* (read to view, update to restore, delete to purge). See +// apps/server/src/recycle-bin.ts, wiki/concepts/soft-delete.md. + +const KIND_KEY: Record = { + user: "recycleBin.kind.user", + role: "recycleBin.kind.role", + subscription: "recycleBin.kind.subscription", + plan: "recycleBin.kind.plan", + tariff: "recycleBin.kind.tariff", +}; + +export function RecycleBin({ user }: { user: SessionUser | null }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const binQ = useQuery({ queryKey: qk.recycleBin, queryFn: fetchRecycleBin }); + + const canRestore = can(user, "recyclebin:update"); + const canPurge = can(user, "recyclebin:delete"); + + const [error, setError] = useState(null); + const [purging, setPurging] = useState(null); + + const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message); + const invalidate = () => { + void qc.invalidateQueries({ queryKey: qk.recycleBin }); + // A restore/purge can change any catalog — refresh the ones a restore touches. + for (const key of [["users"], ["roles"], ["subscriptions"], ["subscription-plans"], ["tariff"]]) { + void qc.invalidateQueries({ queryKey: key }); + } + }; + + const restoreM = useMutation({ + mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => restoreRecycleItem(kind, id), + onSuccess: invalidate, + onError, + }); + const purgeM = useMutation({ + mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => purgeRecycleItem(kind, id), + onSuccess: () => { + setPurging(null); + invalidate(); + }, + onError: (e) => { + setPurging(null); + onError(e); + }, + }); + + const items = binQ.data?.items ?? []; + const retentionDays = binQ.data?.retentionDays ?? 0; + + return ( +
+
+

+ {t("recycleBin.title")} +

+ {retentionDays > 0 && ( + + {t("recycleBin.retentionNote", { days: retentionDays })} + + )} +
+ + {error &&

{error}

} + {binQ.isLoading &&

{t("common.loading")}

} + + {!binQ.isLoading && items.length === 0 ? ( +

+ {t("recycleBin.empty")} +

+ ) : ( + + + + + + + + + + + {items.map((it) => ( + + + + + + + ))} + +
{t("recycleBin.col.type")}{t("recycleBin.col.item")}{t("recycleBin.col.deleted")}{t("recycleBin.col.actions")}
+ + {t(KIND_KEY[it.kind])} + + {it.label} + {formatRelativeDateTime(it.deletedAt, t)} + + {canRestore && ( + + )} + {canPurge && ( + + )} +
+ )} + + {purging && ( + setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}> +

+ {t("recycleBin.purgeConfirmBody", { label: purging.label })} +

+

{t("recycleBin.purgeIrreversible")}

+
+ + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index b0e23de..106e0e4 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -368,6 +368,39 @@ export function reportCsvUrl(from: string, to: string, bucket: ReportBucket): st return apiUrl(`/api/reports/summary.csv?${qs}`); } +// --- Recycle bin (soft-deleted master data) ------------------------------ +export type RecycleKind = "user" | "role" | "subscription" | "plan" | "tariff"; + +export interface RecycleBinItem { + kind: RecycleKind; + id: string; + label: string; + deletedAt: string; + deletedBy: string | null; +} + +export interface RecycleBin { + items: RecycleBinItem[]; + retentionDays: number; +} + +/** Everything currently in the recycle bin + the retention window (days). */ +export function fetchRecycleBin(): Promise { + return apiFetch("/api/recycle-bin"); +} + +/** Restore a soft-deleted item (back to its catalog). 409 if a live row would collide. */ +export function restoreRecycleItem(kind: RecycleKind, id: string): Promise<{ restored: boolean }> { + return apiFetch<{ restored: boolean }>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}/restore`, { + method: "POST", + }); +} + +/** Permanently purge a soft-deleted item. Irreversible. */ +export function purgeRecycleItem(kind: RecycleKind, id: string): Promise { + return apiFetch(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}`, { method: "DELETE" }); +} + export interface BackendIpCandidate { ip: string; iface: string; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 851d96d..a236f3a 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -56,6 +56,7 @@ export const en: Catalog = { roles: "Roles", shifts: "Shifts", reports: "Reports", + recycleBin: "Recycle bin", logs: "Logs", }, status: { @@ -712,6 +713,24 @@ export const en: Catalog = { subCars: "Cars covered", }, }, + recycleBin: { + title: "Recycle bin", + retentionNote: "Deleted items are kept for {{days}} days, then permanently removed.", + empty: "Nothing deleted. Items you delete appear here, recoverable until they expire.", + col: { type: "Type", item: "Item", deleted: "Deleted", actions: "" }, + kind: { + user: "User", + role: "Role", + subscription: "Subscription", + plan: "Plan", + tariff: "Tariff", + }, + restore: "Restore", + purge: "Purge", + purgeConfirmTitle: "Purge permanently?", + purgeConfirmBody: "Permanently delete “{{label}}”? It cannot be restored after this.", + purgeIrreversible: "This is irreversible.", + }, logs: { title: "System logs", refresh: "Refresh", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index a42d9d7..90d4e33 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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", diff --git a/apps/web/src/lib/query.ts b/apps/web/src/lib/query.ts index e90ec90..7ec1dc8 100644 --- a/apps/web/src/lib/query.ts +++ b/apps/web/src/lib/query.ts @@ -29,4 +29,5 @@ export const qk = { deviceStatus: ["device-status"] as const, report: (from: string, to: string, bucket: string) => ["report", from, to, bucket] as const, + recycleBin: ["recycle-bin"] as const, } as const; diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 1a13809..6c42a33 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -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") && } {show("user:read") && } {show("role:read") && } + {show("recyclebin:read") && } {show("log:read") && } @@ -387,6 +389,7 @@ function RootLayout() { show("site:read") || show("user:read") || show("role:read") || + show("recyclebin:read") || show("shift:read")) && }
@@ -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 ; + }, +}); + // 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, ]), ]); diff --git a/packages/db/drizzle/0012_soft_delete.sql b/packages/db/drizzle/0012_soft_delete.sql new file mode 100644 index 0000000..038c05d --- /dev/null +++ b/packages/db/drizzle/0012_soft_delete.sql @@ -0,0 +1,19 @@ +-- Soft delete (recycle bin) for accidental hard-deletes of master data. Adds a nullable +-- `deleted_at` (ISO-8601; null = live) + `deleted_by` (the admin user id) to the mutable +-- master-data tables. A DELETE now stamps these instead of removing the row; restore +-- clears them; an admin purge (or the retention sweep) does the real DELETE. The signed +-- append-only ledger is NOT touched — it has no delete path and is out of scope here. +-- +-- All additive ALTER ADD COLUMN — backward-compatible (existing rows: deleted_at null = +-- live). SQLite ADD COLUMN is in-place. Subscription PLANS are versioned (many rows per +-- plan_id); a soft-delete stamps every version row of that plan_id together. +ALTER TABLE `users` ADD `deleted_at` text;--> statement-breakpoint +ALTER TABLE `users` ADD `deleted_by` text;--> statement-breakpoint +ALTER TABLE `roles` ADD `deleted_at` text;--> statement-breakpoint +ALTER TABLE `roles` ADD `deleted_by` text;--> statement-breakpoint +ALTER TABLE `subscriptions` ADD `deleted_at` text;--> statement-breakpoint +ALTER TABLE `subscriptions` ADD `deleted_by` text;--> statement-breakpoint +ALTER TABLE `subscription_plans` ADD `deleted_at` text;--> statement-breakpoint +ALTER TABLE `subscription_plans` ADD `deleted_by` text;--> statement-breakpoint +ALTER TABLE `tariffs` ADD `deleted_at` text;--> statement-breakpoint +ALTER TABLE `tariffs` ADD `deleted_by` text; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 24a3160..6c6c556 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1781885400000, "tag": "0011_subscription_plan_v2", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1781885500000, + "tag": "0012_soft_delete", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 1728a3d..ac11352 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -5,7 +5,7 @@ import * as schema from "./schema.js"; export * from "./schema.js"; // Re-export the query helpers consumers need, so they don't depend on // drizzle-orm directly (it's an implementation detail of this package). -export { eq, and, asc, desc, gte, lte, sql } from "drizzle-orm"; +export { eq, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm"; /** * Open the local SQLite database in WAL mode. WAL allows many concurrent readers diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index abc5992..d654a84 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -29,6 +29,11 @@ export const roles = sqliteTable("roles", { createdAt: text("created_at") .notNull() .default(sql`(current_timestamp)`), + // Soft delete (recycle bin): ISO instant the row was deleted, null = live; the admin + // user id who deleted it. A DELETE stamps these; restore clears them; purge/retention + // does the real row removal. See wiki/concepts/soft-delete.md. + deletedAt: text("deleted_at"), + deletedBy: text("deleted_by"), }); /** The role→permission grid. One row per granted `resource:action` permission. @@ -78,6 +83,11 @@ export const users = sqliteTable("users", { createdAt: text("created_at") .notNull() .default(sql`(current_timestamp)`), + // Soft delete (recycle bin) — see roles.deletedAt. NB: `username` stays UNIQUE across + // live AND deleted rows, so creating a new user reusing a deleted user's name is + // blocked until that row is restored or purged (the route returns a clear 409). + deletedAt: text("deleted_at"), + deletedBy: text("deleted_by"), }); // --- The signed business ledger (formerly `events`) ---------------------- @@ -258,6 +268,10 @@ export const tariffs = sqliteTable("tariffs", { createdAt: text("created_at") .notNull() .default(sql`(current_timestamp)`), + // Soft delete (recycle bin) — see roles.deletedAt. Stamps the rate-card row; its + // immutable tariff_versions are kept (referenced for repricing) and ride along. + deletedAt: text("deleted_at"), + deletedBy: text("deleted_by"), }); export const tariffVersions = sqliteTable("tariff_versions", { @@ -314,6 +328,12 @@ export const subscriptionPlans = sqliteTable("subscription_plans", { createdAt: text("created_at") .notNull() .default(sql`(current_timestamp)`), + // Soft delete (recycle bin) — see roles.deletedAt. A plan is VERSIONED (many rows per + // plan_id); a soft-delete stamps every version row of the plan_id together, and the bin + // shows/restores the plan as one item. Distinct from `active=0` (retire = unsellable + // but kept in the catalog); deletedAt removes it from the catalog entirely. + deletedAt: text("deleted_at"), + deletedBy: text("deleted_by"), }); export const subscriptions = sqliteTable("subscriptions", { @@ -348,6 +368,12 @@ export const subscriptions = sqliteTable("subscriptions", { createdAt: text("created_at") .notNull() .default(sql`(current_timestamp)`), + // Soft delete (recycle bin) — see roles.deletedAt. Distinct from `status: "revoked"` + // (a domain state that BARS the subscriber but keeps it visible); deletedAt removes it + // from the catalog entirely, recoverable from the bin. Child credential/plate rows are + // kept and restored with it. + deletedAt: text("deleted_at"), + deletedBy: text("deleted_by"), }); // A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1a64085..376e8ef 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -27,6 +27,7 @@ export const RESOURCES = [ "event", // the signed ledger feed + void "report", // events feed, occupancy, future reports "log", // application/diagnostic logs (app_logs) — view + retention + "recyclebin", // soft-deleted master data: view / restore / purge ] as const; export type Resource = (typeof RESOURCES)[number]; @@ -56,6 +57,9 @@ export const PERMISSIONS: readonly Permission[] = [ "event:read", "event:void", "report:read", "log:read", + // Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These + // are admin-grade — a restore can revive a privileged user/role, a purge is permanent. + "recyclebin:read", "recyclebin:update", "recyclebin:delete", ] as const; /** The protected built-in role: non-deletable, non-editable, always = ALL diff --git a/wiki/concepts/soft-delete.md b/wiki/concepts/soft-delete.md new file mode 100644 index 0000000..3a5c882 --- /dev/null +++ b/wiki/concepts/soft-delete.md @@ -0,0 +1,74 @@ +--- +type: concept +tags: [parking, data, admin, safety] +sources: [] +updated: 2026-06-22 +status: settled +--- + +# Soft Delete & the Recycle Bin + +A safety net for accidental admin deletes. Master-data deletes used to be **hard** and +**unrecoverable** — an admin who deleted a user, role, subscription, or plan lost it for good. +Now a delete **soft-deletes** (stamps the row) and the item waits in a **recycle bin** where an +admin can **restore** or **purge** it; unrestored items **auto-purge** after a retention window. + +Built 2026-06-22 (migration `0012_soft_delete`). + +## What it covers (and what it deliberately doesn't) + +Soft-delete is for the **mutable master-data** tables only: + +| Resource | Table(s) | Notes | +| --- | --- | --- | +| Users | `users` | A soft-deleted user **cannot log in** (the login route rejects `deleted_at != null`). | +| Roles | `roles` (+ `role_permissions` kept) | Permission rows survive, so a restore brings the role back intact. | +| Subscriptions | `subscriptions` (+ credentials/plates kept) | Distinct from `status: "revoked"` — see below. A soft-deleted sub does **not** open the barrier. | +| Plans | `subscription_plans` | **Versioned**: a soft-delete stamps **every version row** of the `plan_id`; the bin shows/restores it as ONE item. | +| Tariffs | `tariffs` | Has soft-delete for completeness; today the site runs one tariff and there's no delete button — recovery is via the bin. Immutable `tariff_versions` ride along (kept for repricing). | + +**Out of scope — the signed ledger.** The append-only, hash-chained `ledger_events` has **no +delete path by design** ([[append-only-event-chain]]); soft-delete is purely for the mutable +master data. A correction to history is still a new *appended* event, never an edit/delete. + +## Mechanics + +- **Columns:** every covered table gets a nullable `deleted_at` (ISO instant; null = live) and + `deleted_by` (the admin user id). Additive `ALTER ADD COLUMN` — backward-compatible. +- **Delete = stamp.** Each resource's own `DELETE` route now sets the stamps instead of removing + the row. The row vanishes from every catalog because the list/lookup queries filter + `deleted_at IS NULL`. +- **Recycle bin API** (`recyclebin:*` permission): `GET /api/recycle-bin` lists everything + soft-deleted across kinds; `POST /api/recycle-bin/:kind/:id/restore` clears the stamps; + `DELETE /api/recycle-bin/:kind/:id` purges (the real `DELETE`, + children). UI: a **Recycle + bin** tab under Setup. Code: `apps/server/src/recycle-bin.ts` (+ `routes/recycle-bin.ts`), + `apps/web/src/RecycleBin.tsx`. +- **Retention sweep.** A 6-hourly (+ startup) job auto-purges items deleted longer than + `RECYCLE_BIN_RETENTION_DAYS` (default **30**) ago. `0`/negative = keep forever. + +## Invariants & edge cases + +- **No-lockout still holds.** The "last admin" check counts only **live** admins (a soft-deleted + admin can't log in, so they don't count) — you can't delete yourself into a locked-out box. See + [[local-jwt-auth]]. +- **Soft-delete vs. domain lifecycle.** A subscription's `revoke`/`reactivate` and a plan's + `active=0` retire are **domain states** that keep the item *visible* in its catalog (barred / + unsellable). `deleted_at` is different: it removes the item from the catalog entirely, + recoverable only from the bin. Both coexist. See [[subscription]]. +- **Unique-name reuse.** `username` / role `name` are `UNIQUE` across **live AND deleted** rows, + so you can't create a new user reusing a deleted user's name until that row is restored or + purged — the create route returns a clear 409 pointing at the recycle bin (rather than a raw + constraint error). +- **Dangling references on restore.** A restored user points at its `roleId`; if that role is + itself deleted, the user reappears with a deleted role. We **don't auto-cascade** (keep it + predictable) — the bin lists both; the admin restores the role too. The role guard resolves a + missing role to an **empty** permission set (safe-by-default), so a dangling role never + escalates. +- **"In use" checks count live only.** A plan blocked from deletion "while referenced" counts + only **live** subscriptions; a soft-deleted subscriber's `planId` reference doesn't block it. + +## Permission + +`recyclebin:read` (view), `recyclebin:update` (restore), `recyclebin:delete` (purge) — admin-grade +(a restore can revive a privileged user/role; a purge is permanent). Folded into the +code-defined PERMISSIONS grid; the built-in `admin` role holds them. See [[local-jwt-auth]]. diff --git a/wiki/entities/local-jwt-auth.md b/wiki/entities/local-jwt-auth.md index 2a702ee..994af59 100644 --- a/wiki/entities/local-jwt-auth.md +++ b/wiki/entities/local-jwt-auth.md @@ -37,9 +37,15 @@ Authentication and authorization, kept **fully local** — a direct consequence **last user holding admin** — administration can never be locked out of the appliance. - `event:void` is a permission, NOT a ledger delete: the append-only signed chain is untouched; the permission only gates who may APPEND a void event (there is no void API route yet — forward seam). -- The grid is **extensible** — adding a feature adds its `resource:action` rows. Latest: **`log:read`** - (a new `log` resource) gates the diagnostic-log viewer (`GET /api/logs`); admin holds it, and it's - grantable to a diagnostic role. See [[app-logs]]. +- The grid is **extensible** — adding a feature adds its `resource:action` rows. Recent additions: + **`log:read`** (gates the diagnostic-log viewer, `GET /api/logs`; see [[app-logs]]); **`report:read`** + (the admin Reports dashboard; see [[reporting-analytics]]); and **`recyclebin:read/update/delete`** + (view / restore / purge soft-deleted master data; see [[soft-delete]]). Admin holds them all; each is + grantable to a scoped role. +- **Soft-deleted users can't authenticate.** The login route rejects a user whose `deleted_at` is set + (with the same generic "invalid credentials" so a deleted account isn't enumerable). The no-lockout + "last admin" check counts only LIVE admins, so soft-deleting can't strand administration. See + [[soft-delete]]. - **No privilege escalation through the RBAC system itself.** `role:create`/`role:update` and `user:create`/`user:update` are themselves grantable, so a non-admin could otherwise self-escalate. Guards (`routes/roles.ts`, `routes/users.ts`): a caller may only put permissions on a role that diff --git a/wiki/index.md b/wiki/index.md index be81505..5970c2b 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -96,6 +96,7 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records. - [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions. - [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger. - [[app-logs]] — the third stream: diagnostic logs (backend warn+ pino sink + frontend errors) → app_logs; log:read viewer; pruned by age+row cap. +- [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope. - [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.) - [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline. - [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed. diff --git a/wiki/log.md b/wiki/log.md index 9e0cf3a..78b0f5d 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1354,3 +1354,19 @@ so the booth bundle is untouched. reports.test.ts (10) pins the sums/tz/split/du 90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup (`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]]. + +## [2026-06-22] feat | Soft delete + recycle bin for master data (migration 0012) +Accidental admin deletes used to be hard + unrecoverable. Now users/roles/subscriptions/plans/ +tariffs soft-delete: migration 0012 adds nullable deleted_at + deleted_by; each resource's DELETE +route STAMPS instead of removing, and every catalog list filters deleted_at IS NULL. A recycle bin +(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge — gated recyclebin:read/update/delete, +new resource in the PERMISSIONS grid) lists everything soft-deleted, restores, or purges; a 6-hourly ++ startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever). +Key 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 version rows 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 → empty perms, safe). Signed ledger is OUT of scope (no delete path). +Web: a Recycle bin tab under Setup (RecycleBin.tsx). 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 19/19, i18n parity (sq+en). See [[soft-delete]], [[local-jwt-auth]].