7680d9a0ed
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
207 lines
10 KiB
TypeScript
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;
|
|
}
|