import { and, eq, isNotNull, isNull, lte, rolePermissions, roles, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, tariffs, users, type Db, } from "@parking/db"; // Soft delete + recycle bin. Accidental hard-deletes of master data (a user, role, // subscription, plan, tariff) used to be unrecoverable. Now a DELETE STAMPS the row // (`deleted_at` = now, `deleted_by` = admin) instead of removing it; it disappears from // every catalog (the list queries filter `deleted_at IS NULL`) but survives in the // recycle bin, where an admin can RESTORE it (clear the stamps) or PURGE it (the real // DELETE). A retention sweep auto-purges items deleted longer than the window ago. // // Scope: only the MUTABLE master-data tables below. The signed, append-only ledger is // NOT here — it has no delete path by design. See wiki/concepts/soft-delete.md. /** The soft-deletable resource kinds, as they appear in the recycle-bin API. */ export type ResourceKind = "user" | "role" | "subscription" | "plan" | "tariff"; export const RESOURCE_KINDS: ResourceKind[] = ["user", "role", "subscription", "plan", "tariff"]; /** Default retention window before a soft-deleted item is auto-purged (days). Override * with RECYCLE_BIN_RETENTION_DAYS. 0/negative disables the sweep (keep forever). */ export function retentionDays(): number { const raw = Number(process.env.RECYCLE_BIN_RETENTION_DAYS ?? 30); return Number.isFinite(raw) ? raw : 30; } /** A row surfaced in the recycle bin (normalised across resource kinds). */ export interface RecycleBinItem { readonly kind: ResourceKind; /** The id used to restore/purge. For a versioned PLAN this is the stable planId. */ readonly id: string; /** Human label for the list (username, role/plan/tariff name, subscriber holder). */ readonly label: string; readonly deletedAt: string; readonly deletedBy: string | null; } const NOW = () => new Date().toISOString(); // --- Per-resource helpers ---------------------------------------------------- // Subscriptions/users/roles/tariffs are 1 row per id. PLANS are versioned (N rows per // plan_id) — stamp/clear/delete ALL versions of the plan_id together. /** Soft-delete a row by id. Returns false if no live row matched (404). PLAN uses planId. */ export function softDelete(db: Db, kind: ResourceKind, id: string, byUserId: string): boolean { const stamp = { deletedAt: NOW(), deletedBy: byUserId }; switch (kind) { case "user": return db.update(users).set(stamp).where(and(eq(users.id, id), isNull(users.deletedAt))).run().changes > 0; case "role": return db.update(roles).set(stamp).where(and(eq(roles.id, id), isNull(roles.deletedAt))).run().changes > 0; case "subscription": return db.update(subscriptions).set(stamp).where(and(eq(subscriptions.id, id), isNull(subscriptions.deletedAt))).run().changes > 0; case "plan": return db.update(subscriptionPlans).set(stamp).where(and(eq(subscriptionPlans.planId, id), isNull(subscriptionPlans.deletedAt))).run().changes > 0; case "tariff": return db.update(tariffs).set(stamp).where(and(eq(tariffs.id, id), isNull(tariffs.deletedAt))).run().changes > 0; } } /** Restore a soft-deleted row (clear the stamps). Returns false if nothing was restored. */ export function restore(db: Db, kind: ResourceKind, id: string): boolean { const clear = { deletedAt: null, deletedBy: null }; switch (kind) { case "user": return db.update(users).set(clear).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0; case "role": return db.update(roles).set(clear).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0; case "subscription": return db.update(subscriptions).set(clear).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0; case "plan": return db.update(subscriptionPlans).set(clear).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0; case "tariff": return db.update(tariffs).set(clear).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0; } } /** True if restoring would collide with a LIVE row (e.g. a user with the same username * was re-created after the delete). The caller turns this into a 409 so the admin * understands why restore is blocked. */ export function restoreBlockedReason(db: Db, kind: ResourceKind, id: string): string | null { if (kind === "user") { const row = db.select().from(users).where(eq(users.id, id)).get(); if (row && db.select().from(users).where(and(eq(users.username, row.username), isNull(users.deletedAt))).get()) { return `a live user named "${row.username}" already exists`; } } else if (kind === "role") { const row = db.select().from(roles).where(eq(roles.id, id)).get(); if (row && db.select().from(roles).where(and(eq(roles.name, row.name), isNull(roles.deletedAt))).get()) { return `a live role named "${row.name}" already exists`; } } return null; } // --- Restore ordering note -------------------------------------------------- // A restored USER points at a roleId; if that role is itself deleted, the user reappears // with a dangling role. We don't auto-cascade (keep it predictable); the bin lists both // and the admin restores the role too. The role guard already resolves a missing role to // an empty permission set (safe-by-default), so a dangling role never escalates. /** Hard-delete (purge) a soft-deleted row + its children. The real DELETE. Returns false * if no soft-deleted row matched (so you can't purge a live row through this path). */ export function purge(db: Db, kind: ResourceKind, id: string): boolean { switch (kind) { case "user": return db.delete(users).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0; case "role": { // Children (role_permissions) only matter once the role row is gone; purge both. const ok = db.delete(roles).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0; if (ok) deleteRolePermissions(db, id); return ok; } case "subscription": { const ok = db.delete(subscriptions).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0; if (ok) deleteSubscriptionChildren(db, id); return ok; } case "plan": return db.delete(subscriptionPlans).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0; case "tariff": return db.delete(tariffs).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0; } } // Child cleanup on purge (role_permissions / subscription credentials + plates). function deleteRolePermissions(db: Db, roleId: string): void { db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run(); } function deleteSubscriptionChildren(db: Db, id: string): void { db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run(); db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run(); } // --- Listing the bin -------------------------------------------------------- /** All soft-deleted items across every resource kind, newest-deleted first. */ export function listRecycleBin(db: Db): RecycleBinItem[] { const items: RecycleBinItem[] = []; for (const r of db.select().from(users).where(isNotNull(users.deletedAt)).all()) { items.push({ kind: "user", id: r.id, label: r.fullName || r.username, deletedAt: r.deletedAt!, deletedBy: r.deletedBy }); } for (const r of db.select().from(roles).where(isNotNull(roles.deletedAt)).all()) { items.push({ kind: "role", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy }); } for (const r of db.select().from(subscriptions).where(isNotNull(subscriptions.deletedAt)).all()) { items.push({ kind: "subscription", id: r.id, label: r.holderName || r.id, deletedAt: r.deletedAt!, deletedBy: r.deletedBy }); } // Plans are versioned: collapse to one item per plan_id (the latest version's name). const planSeen = new Set(); const planRows = db.select().from(subscriptionPlans).where(isNotNull(subscriptionPlans.deletedAt)).all(); planRows.sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom)); for (const r of planRows) { if (planSeen.has(r.planId)) continue; planSeen.add(r.planId); items.push({ kind: "plan", id: r.planId, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy }); } for (const r of db.select().from(tariffs).where(isNotNull(tariffs.deletedAt)).all()) { items.push({ kind: "tariff", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy }); } return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt)); } // --- Retention sweep -------------------------------------------------------- /** Purge every soft-deleted row deleted more than `retentionDays()` ago. Returns the * count purged per kind. Safe to call repeatedly (idempotent). */ export function sweepExpired(db: Db, days = retentionDays()): Record { const out: Record = { user: 0, role: 0, subscription: 0, plan: 0, tariff: 0 }; if (!Number.isFinite(days) || days <= 0) return out; // keep-forever const cutoff = new Date(Date.now() - days * 86_400_000).toISOString(); // Collect ids first so children purge through the same path as a manual purge. for (const r of db.select().from(users).where(and(isNotNull(users.deletedAt), lte(users.deletedAt, cutoff))).all()) { if (purge(db, "user", r.id)) out.user++; } for (const r of db.select().from(roles).where(and(isNotNull(roles.deletedAt), lte(roles.deletedAt, cutoff))).all()) { if (purge(db, "role", r.id)) out.role++; } for (const r of db.select().from(subscriptions).where(and(isNotNull(subscriptions.deletedAt), lte(subscriptions.deletedAt, cutoff))).all()) { if (purge(db, "subscription", r.id)) out.subscription++; } const planIds = new Set( db.select().from(subscriptionPlans).where(and(isNotNull(subscriptionPlans.deletedAt), lte(subscriptionPlans.deletedAt, cutoff))).all().map((r) => r.planId), ); for (const planId of planIds) if (purge(db, "plan", planId)) out.plan++; for (const r of db.select().from(tariffs).where(and(isNotNull(tariffs.deletedAt), lte(tariffs.deletedAt, cutoff))).all()) { if (purge(db, "tariff", r.id)) out.tariff++; } return out; }