Files
parking_solution/apps/server/src/recycle-bin.ts
T
julian 7680d9a0ed 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
2026-06-22 09:33:54 +02:00

207 lines
10 KiB
TypeScript

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;
}