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
+5
View File
@@ -28,6 +28,11 @@ EVENT_SIGNING_KEY=
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy. # http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
# COOKIE_SECURE=0 # 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 # First admin (seed once): pnpm --filter @parking/server seed-admin
# ADMIN_USER=admin # ADMIN_USER=admin
# ADMIN_PASS= # ADMIN_PASS=
+189
View File
@@ -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();
});
});
+206
View File
@@ -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<string>();
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<ResourceKind, number> {
const out: Record<ResourceKind, number> = { 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;
}
+3 -1
View File
@@ -69,7 +69,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Always run a bcrypt compare to avoid leaking which usernames exist (timing). // Always run a bcrypt compare to avoid leaking which usernames exist (timing).
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv"; const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
const ok = await bcrypt.compare(password, hash); 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" }); return reply.code(401).send({ error: "invalid credentials" });
} }
@@ -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);
});
});
+67
View File
@@ -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();
},
);
}
+13 -9
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; 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 { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js"; 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 // Role management (admin). Roles are DATA: an admin composes a role from the
// code-defined PERMISSIONS grid (resource:action), and users are assigned one // 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)) .where(eq(rolePermissions.roleId, roleId))
.all() .all()
.map((r) => r.permission); .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). // The admin role always reports the full grid (it's enforced in code).
return { return {
id: role.id, 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 () => { 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 { return {
catalog: PERMISSIONS, catalog: PERMISSIONS,
roles: all.map((r) => roleView(r.id)).filter((r) => r != null), 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 } }>( app.delete<{ Params: { id: string } }>(
"/api/roles/:id", "/api/roles/:id",
{ preHandler: deleteGuard }, { preHandler: deleteGuard },
async (req, reply) => { async (req, reply) => {
const id = req.params.id; 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) return reply.code(404).send({ error: "role not found" });
if (role.builtin === 1) { if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" }); 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) { if (holders > 0) {
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` }); 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(); softDelete(db, "role", id, req.user.sub);
db.delete(roles).where(eq(roles.id, id)).run();
bumpPermsCache(); bumpPermsCache();
return { ok: true }; return { ok: true };
}, },
+21 -5
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; 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 { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
import { siteTz } from "../subscription-window.js"; import { siteTz } from "../subscription-window.js";
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS // 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 // per planId (latest active version with effectiveFrom ≤ now). Operators selling
// need the current list; the admin catalog screen asks for ?all=1. // need the current list; the admin catalog screen asks for ?all=1.
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => { 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 }; if (req.query?.all) return { plans: rows };
const now = new Date().toISOString(); const now = new Date().toISOString();
// Newest-effective active version wins per planId. // 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 // DELETE a plan entirely — allowed ONLY when NO subscription references it (any
// version). A referenced plan version MUST survive: a subscription's planVersionId is // 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 // 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 } }>( app.delete<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId", "/api/subscription-plans/:planId",
{ preHandler: planGuard }, { preHandler: planGuard },
async (req, reply) => { 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) { if (refs.length > 0) {
return reply.code(409).send({ return reply.code(409).send({
error: "plan is in use and cannot be deleted", 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, 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 }; return { planId: req.params.planId, deleted: true };
}, },
); );
+12 -9
View File
@@ -1,9 +1,10 @@
import { randomBytes, randomUUID } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; 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 { NoPrinterAvailableError } from "@parking/devices";
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared"; import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
import { requirePermission, roleHasPermissions } from "../auth.js"; import { requirePermission, roleHasPermissions } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
import { invalidateHolder } from "../event-enrich.js"; import { invalidateHolder } from "../event-enrich.js";
import { printSubscriptionCard } from "../booth-print.js"; import { printSubscriptionCard } from "../booth-print.js";
import type { CredentialCapture } from "../credential-capture.js"; import type { CredentialCapture } from "../credential-capture.js";
@@ -226,9 +227,10 @@ export async function subscriptionRoutes(
return { plan, validFrom, validTo, quantity, quote }; 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 () => { 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)) }; 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 // Delete a subscription — SOFT (recycle bin). The row + its credential/plate children
// are untouched — the audit trail is append-only and independent of this row.) // 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 } }>( app.delete<{ Params: { id: string } }>(
"/api/subscriptions/:id", "/api/subscriptions/:id",
{ preHandler: deleteGuard }, { preHandler: deleteGuard },
async (req, reply) => { async (req, reply) => {
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run(); const ok = softDelete(db, "subscription", req.params.id, req.user.sub);
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" }); if (!ok) 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();
invalidateHolder(req.params.id); invalidateHolder(req.params.id);
return reply.code(204).send(); return reply.code(204).send();
}, },
+6 -3
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; 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 { import {
computeFee, computeFee,
isTariffV2, isTariffV2,
@@ -48,9 +48,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
// Publishing a new version changes what customers are charged. // Publishing a new version changes what customers are charged.
const writeGuard = requirePermission("tariff:update"); 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 { 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; if (existing) return existing.id;
const id = randomUUID(); const id = randomUUID();
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run(); db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
+22 -10
View File
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify"; 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 { ADMIN_ROLE_ID } from "@parking/shared";
import { permissionsFor, requirePermission } from "../auth.js"; import { permissionsFor, requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
// User management (admin). Users are created/edited at runtime here — the // 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 // 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<void> {
const updateGuard = requirePermission("user:update"); const updateGuard = requirePermission("user:update");
const deleteGuard = requirePermission("user:delete"); 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 { 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. */ /** True if removing/relocating `userId` from admin would leave zero admins. */
@@ -112,9 +114,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
return false; 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 () => { 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 roleRows = db.select().from(roles).all();
const roleName = new Map(roleRows.map((r) => [r.id, r.name])); const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
return { return {
@@ -140,8 +143,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (exceedsCaller(req.user.roleId, roleId)) { if (exceedsCaller(req.user.roleId, roleId)) {
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" }); 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()) { const clash = db.select().from(users).where(eq(users.username, username)).get();
return reply.code(409).send({ error: "username already exists" }); 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 id = randomUUID();
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
@@ -221,13 +231,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
}, },
); );
// 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 } }>( app.delete<{ Params: { id: string } }>(
"/api/users/:id", "/api/users/:id",
{ preHandler: deleteGuard }, { preHandler: deleteGuard },
async (req, reply) => { async (req, reply) => {
const id = req.params.id; 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) { if (!target) {
return reply.code(404).send({ error: "user not found" }); return reply.code(404).send({ error: "user not found" });
} }
@@ -238,7 +250,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (isLastAdmin(id)) { if (isLastAdmin(id)) {
return reply.code(409).send({ error: "cannot delete the last admin" }); 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 }; return { ok: true };
}, },
); );
+18
View File
@@ -26,6 +26,8 @@ import { roleRoutes } from "./routes/roles.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js"; import { eventRoutes } from "./routes/events.js";
import { reportRoutes } from "./routes/reports.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 { payRoutes } from "./routes/pay.js";
import { subscriptionRoutes } from "./routes/subscriptions.js"; import { subscriptionRoutes } from "./routes/subscriptions.js";
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js"; import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
@@ -146,6 +148,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts. // (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
await reportRoutes(app, db); await reportRoutes(app, db);
// Recycle bin: view / restore / purge soft-deleted master data (users/roles/subs/
// plans/tariffs). Gated on recyclebin:*. See routes/recycle-bin.ts, recycle-bin.ts.
await recycleBinRoutes(app, db);
// Live booth feed: server-pushed ledger + occupancy + printer-status over a // Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts. // single authenticated WebSocket (/api/ws). See routes/ws.ts.
await wsRoutes(app, db, deviceMonitor); await wsRoutes(app, db, deviceMonitor);
@@ -232,6 +238,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
logService.prune(); // once at startup logService.prune(); // once at startup
app.addHook("onClose", async () => clearInterval(pruneTimer)); app.addHook("onClose", async () => 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) => { const unsubscribeInput = deviceEvents.onInput((e) => {
// Record every input edge as unsigned telemetry, keyed to the device that fired // 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 // (provenance). No lane — the pool-of-spaces model has none. The entry flow
+4 -1
View File
@@ -108,7 +108,10 @@ export class SubscriptionFlow {
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry). // that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry"; const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get(); 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. // Validity: active + within the coverage window.
const now = new Date().toISOString(); const now = new Date().toISOString();
+170
View File
@@ -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<RecycleKind, string> = {
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<string | null>(null);
const [purging, setPurging] = useState<RecycleBinItem | null>(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 (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center gap-3">
<h1 className="text-base font-bold uppercase tracking-widest text-term-amber">
{t("recycleBin.title")}
</h1>
{retentionDays > 0 && (
<span className="text-[12px] text-term-muted">
{t("recycleBin.retentionNote", { days: retentionDays })}
</span>
)}
</div>
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
{!binQ.isLoading && items.length === 0 ? (
<p className="rounded-term border border-term-border bg-term-panel p-6 text-center text-term-muted">
{t("recycleBin.empty")}
</p>
) : (
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
<th className="py-1.5 text-right">{t("recycleBin.col.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
<td className="py-1.5 pr-3">
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted">
{t(KIND_KEY[it.kind])}
</span>
</td>
<td className="py-1.5 pr-3 text-term-text">{it.label}</td>
<td className="py-1.5 pr-3 text-term-muted">
{formatRelativeDateTime(it.deletedAt, t)}
</td>
<td className="py-1.5 text-right">
{canRestore && (
<button
type="button"
className="btn btn-sm"
disabled={restoreM.isPending}
onClick={() => {
setError(null);
restoreM.mutate({ kind: it.kind, id: it.id });
}}
>
{t("recycleBin.restore")}
</button>
)}
{canPurge && (
<button
type="button"
className="btn btn-sm btn-ghost ml-1 text-term-red"
onClick={() => {
setError(null);
setPurging(it);
}}
>
{t("recycleBin.purge")}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{purging && (
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
<p className="text-[13px] text-term-text">
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
</p>
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
{t("common.cancel")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
disabled={purgeM.isPending}
onClick={() => purgeM.mutate({ kind: purging.kind, id: purging.id })}
>
{t("recycleBin.purge")}
</button>
</div>
</Modal>
)}
</div>
);
}
+33
View File
@@ -368,6 +368,39 @@ export function reportCsvUrl(from: string, to: string, bucket: ReportBucket): st
return apiUrl(`/api/reports/summary.csv?${qs}`); 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<RecycleBin> {
return apiFetch<RecycleBin>("/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<void> {
return apiFetch<void>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}`, { method: "DELETE" });
}
export interface BackendIpCandidate { export interface BackendIpCandidate {
ip: string; ip: string;
iface: string; iface: string;
+19
View File
@@ -56,6 +56,7 @@ export const en: Catalog = {
roles: "Roles", roles: "Roles",
shifts: "Shifts", shifts: "Shifts",
reports: "Reports", reports: "Reports",
recycleBin: "Recycle bin",
logs: "Logs", logs: "Logs",
}, },
status: { status: {
@@ -712,6 +713,24 @@ export const en: Catalog = {
subCars: "Cars covered", 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: { logs: {
title: "System logs", title: "System logs",
refresh: "Refresh", refresh: "Refresh",
+19
View File
@@ -58,6 +58,7 @@ export const sq = {
roles: "Rolet", roles: "Rolet",
shifts: "Turnet", shifts: "Turnet",
reports: "Raportet", reports: "Raportet",
recycleBin: "Koshi",
logs: "Loget", logs: "Loget",
}, },
status: { status: {
@@ -726,6 +727,24 @@ export const sq = {
subCars: "Makina të mbuluara", 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: { logs: {
title: "Loget e sistemit", title: "Loget e sistemit",
refresh: "Rifresko", refresh: "Rifresko",
+1
View File
@@ -29,4 +29,5 @@ export const qk = {
deviceStatus: ["device-status"] as const, deviceStatus: ["device-status"] as const,
report: (from: string, to: string, bucket: string) => report: (from: string, to: string, bucket: string) =>
["report", from, to, bucket] as const, ["report", from, to, bucket] as const,
recycleBin: ["recycle-bin"] as const,
} as const; } as const;
+16
View File
@@ -30,6 +30,7 @@ import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js"; import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js"; import { ShiftsHistory } from "./ShiftsHistory.js";
import { LogsViewer } from "./LogsViewer.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 // 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. // initial bundle and only downloads when an admin opens /setup/reports.
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.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("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />} {show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />} {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")} />} {show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
</nav> </nav>
<Outlet /> <Outlet />
@@ -387,6 +389,7 @@ function RootLayout() {
show("site:read") || show("site:read") ||
show("user:read") || show("user:read") ||
show("role:read") || show("role:read") ||
show("recyclebin:read") ||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />} show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <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/site", perm: "site:read" },
{ to: "/setup/users", perm: "user:read" }, { to: "/setup/users", perm: "user:read" },
{ to: "/setup/roles", perm: "role:read" }, { to: "/setup/roles", perm: "role:read" },
{ to: "/setup/recycle-bin", perm: "recyclebin:read" },
{ to: "/shifts", perm: "shift:read" }, { to: "/shifts", perm: "shift:read" },
{ to: "/setup/logs", perm: "log: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 // (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.) // 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). // Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
const logsRoute = createRoute({ const logsRoute = createRoute({
getParentRoute: () => setupRoute, getParentRoute: () => setupRoute,
@@ -635,6 +650,7 @@ const routeTree = rootRoute.addChildren([
siteRoute, siteRoute,
usersRoute, usersRoute,
rolesRoute, rolesRoute,
recycleBinRoute,
logsRoute, logsRoute,
]), ]),
]); ]);
+19
View File
@@ -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;
+7
View File
@@ -85,6 +85,13 @@
"when": 1781885400000, "when": 1781885400000,
"tag": "0011_subscription_plan_v2", "tag": "0011_subscription_plan_v2",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1781885500000,
"tag": "0012_soft_delete",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
export * from "./schema.js"; export * from "./schema.js";
// Re-export the query helpers consumers need, so they don't depend on // Re-export the query helpers consumers need, so they don't depend on
// drizzle-orm directly (it's an implementation detail of this package). // 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 * Open the local SQLite database in WAL mode. WAL allows many concurrent readers
+26
View File
@@ -29,6 +29,11 @@ export const roles = sqliteTable("roles", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .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. /** The role→permission grid. One row per granted `resource:action` permission.
@@ -78,6 +83,11 @@ export const users = sqliteTable("users", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .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`) ---------------------- // --- The signed business ledger (formerly `events`) ----------------------
@@ -258,6 +268,10 @@ export const tariffs = sqliteTable("tariffs", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .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", { export const tariffVersions = sqliteTable("tariff_versions", {
@@ -314,6 +328,12 @@ export const subscriptionPlans = sqliteTable("subscription_plans", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .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", { export const subscriptions = sqliteTable("subscriptions", {
@@ -348,6 +368,12 @@ export const subscriptions = sqliteTable("subscriptions", {
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .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. // A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
+4
View File
@@ -27,6 +27,7 @@ export const RESOURCES = [
"event", // the signed ledger feed + void "event", // the signed ledger feed + void
"report", // events feed, occupancy, future reports "report", // events feed, occupancy, future reports
"log", // application/diagnostic logs (app_logs) — view + retention "log", // application/diagnostic logs (app_logs) — view + retention
"recyclebin", // soft-deleted master data: view / restore / purge
] as const; ] as const;
export type Resource = (typeof RESOURCES)[number]; export type Resource = (typeof RESOURCES)[number];
@@ -56,6 +57,9 @@ export const PERMISSIONS: readonly Permission[] = [
"event:read", "event:void", "event:read", "event:void",
"report:read", "report:read",
"log: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; ] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL /** The protected built-in role: non-deletable, non-editable, always = ALL
+74
View File
@@ -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]].
+9 -3
View File
@@ -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. **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 - `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). 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`** - The grid is **extensible** — adding a feature adds its `resource:action` rows. Recent additions:
(a new `log` resource) gates the diagnostic-log viewer (`GET /api/logs`); admin holds it, and it's **`log:read`** (gates the diagnostic-log viewer, `GET /api/logs`; see [[app-logs]]); **`report:read`**
grantable to a diagnostic role. See [[app-logs]]. (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 - **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. `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 Guards (`routes/roles.ts`, `routes/users.ts`): a caller may only put permissions on a role that
+1
View File
@@ -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. - [[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. - [[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. - [[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.) - [[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. - [[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. - [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
+16
View File
@@ -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 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 (`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]]. 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]].