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:
@@ -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=
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).
|
||||
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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
|
||||
// Role management (admin). Roles are DATA: an admin composes a role from the
|
||||
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
|
||||
@@ -56,7 +57,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
.where(eq(rolePermissions.roleId, roleId))
|
||||
.all()
|
||||
.map((r) => r.permission);
|
||||
const userCount = db.select().from(users).where(eq(users.roleId, roleId)).all().length;
|
||||
const userCount = db.select().from(users).where(and(eq(users.roleId, roleId), isNull(users.deletedAt))).all().length;
|
||||
// The admin role always reports the full grid (it's enforced in code).
|
||||
return {
|
||||
id: role.id,
|
||||
@@ -75,9 +76,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// The full permission grid (for the role-composer checkbox UI) + every role.
|
||||
// The full permission grid (for the role-composer checkbox UI) + every LIVE role.
|
||||
// Soft-deleted roles live in the recycle bin, not here.
|
||||
app.get("/api/roles", { preHandler: readGuard }, async () => {
|
||||
const all = db.select().from(roles).all();
|
||||
const all = db.select().from(roles).where(isNull(roles.deletedAt)).all();
|
||||
return {
|
||||
catalog: PERMISSIONS,
|
||||
roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
|
||||
@@ -143,23 +145,25 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a role. Refused if it's built-in or any user still holds it.
|
||||
// Delete a role — SOFT (recycle bin). Refused if built-in or any LIVE user still holds
|
||||
// it. The row is stamped deleted (recoverable), not removed; its permission rows are
|
||||
// KEPT so a restore brings the role back intact. Restore/purge from the recycle bin.
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/roles/:id",
|
||||
{ preHandler: deleteGuard },
|
||||
async (req, reply) => {
|
||||
const id = req.params.id;
|
||||
const role = db.select().from(roles).where(eq(roles.id, id)).get();
|
||||
const role = db.select().from(roles).where(and(eq(roles.id, id), isNull(roles.deletedAt))).get();
|
||||
if (!role) return reply.code(404).send({ error: "role not found" });
|
||||
if (role.builtin === 1) {
|
||||
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" });
|
||||
}
|
||||
const holders = db.select().from(users).where(eq(users.roleId, id)).all().length;
|
||||
// Only LIVE holders block deletion (a soft-deleted user's role assignment is moot).
|
||||
const holders = db.select().from(users).where(and(eq(users.roleId, id), isNull(users.deletedAt))).all().length;
|
||||
if (holders > 0) {
|
||||
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
|
||||
}
|
||||
db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)).run();
|
||||
db.delete(roles).where(eq(roles.id, id)).run();
|
||||
softDelete(db, "role", id, req.user.sub);
|
||||
bumpPermsCache();
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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<void>
|
||||
// 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();
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<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 } }>(
|
||||
"/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<void> {
|
||||
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 };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<FastifyInsta
|
||||
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
|
||||
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
|
||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||
await wsRoutes(app, db, deviceMonitor);
|
||||
@@ -232,6 +238,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
logService.prune(); // once at startup
|
||||
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) => {
|
||||
// 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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<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 {
|
||||
ip: string;
|
||||
iface: string;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||
{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")} />}
|
||||
</nav>
|
||||
<Outlet />
|
||||
@@ -387,6 +389,7 @@ function RootLayout() {
|
||||
show("site:read") ||
|
||||
show("user:read") ||
|
||||
show("role:read") ||
|
||||
show("recyclebin:read") ||
|
||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<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/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 <RecycleBin user={user} />;
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]].
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+16
@@ -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]].
|
||||
|
||||
Reference in New Issue
Block a user