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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user