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:
2026-06-22 09:33:54 +02:00
parent 3527f48d76
commit 7680d9a0ed
28 changed files with 1095 additions and 42 deletions
+19
View File
@@ -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;
+7
View File
@@ -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
}
]
}
+1 -1
View File
@@ -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
+26
View File
@@ -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.