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
+170
View File
@@ -0,0 +1,170 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ApiError,
can,
fetchRecycleBin,
purgeRecycleItem,
restoreRecycleItem,
type RecycleBinItem,
type RecycleKind,
type SessionUser,
} from "./api.js";
import { qk } from "./lib/query.js";
import { formatRelativeDateTime } from "./lib/format.js";
import { Modal } from "./ui/Modal.js";
// Recycle bin — the way back from an accidental delete. Lists everything soft-deleted
// across users/roles/subscriptions/plans/tariffs; an admin can Restore (back to its
// catalog) or Purge (permanent). Items auto-purge after the retention window. Gated by
// recyclebin:* (read to view, update to restore, delete to purge). See
// apps/server/src/recycle-bin.ts, wiki/concepts/soft-delete.md.
const KIND_KEY: Record<RecycleKind, string> = {
user: "recycleBin.kind.user",
role: "recycleBin.kind.role",
subscription: "recycleBin.kind.subscription",
plan: "recycleBin.kind.plan",
tariff: "recycleBin.kind.tariff",
};
export function RecycleBin({ user }: { user: SessionUser | null }) {
const { t } = useTranslation();
const qc = useQueryClient();
const binQ = useQuery({ queryKey: qk.recycleBin, queryFn: fetchRecycleBin });
const canRestore = can(user, "recyclebin:update");
const canPurge = can(user, "recyclebin:delete");
const [error, setError] = useState<string | null>(null);
const [purging, setPurging] = useState<RecycleBinItem | null>(null);
const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message);
const invalidate = () => {
void qc.invalidateQueries({ queryKey: qk.recycleBin });
// A restore/purge can change any catalog — refresh the ones a restore touches.
for (const key of [["users"], ["roles"], ["subscriptions"], ["subscription-plans"], ["tariff"]]) {
void qc.invalidateQueries({ queryKey: key });
}
};
const restoreM = useMutation({
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => restoreRecycleItem(kind, id),
onSuccess: invalidate,
onError,
});
const purgeM = useMutation({
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => purgeRecycleItem(kind, id),
onSuccess: () => {
setPurging(null);
invalidate();
},
onError: (e) => {
setPurging(null);
onError(e);
},
});
const items = binQ.data?.items ?? [];
const retentionDays = binQ.data?.retentionDays ?? 0;
return (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center gap-3">
<h1 className="text-base font-bold uppercase tracking-widest text-term-amber">
{t("recycleBin.title")}
</h1>
{retentionDays > 0 && (
<span className="text-[12px] text-term-muted">
{t("recycleBin.retentionNote", { days: retentionDays })}
</span>
)}
</div>
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
{!binQ.isLoading && items.length === 0 ? (
<p className="rounded-term border border-term-border bg-term-panel p-6 text-center text-term-muted">
{t("recycleBin.empty")}
</p>
) : (
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
<th className="py-1.5 text-right">{t("recycleBin.col.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
<td className="py-1.5 pr-3">
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted">
{t(KIND_KEY[it.kind])}
</span>
</td>
<td className="py-1.5 pr-3 text-term-text">{it.label}</td>
<td className="py-1.5 pr-3 text-term-muted">
{formatRelativeDateTime(it.deletedAt, t)}
</td>
<td className="py-1.5 text-right">
{canRestore && (
<button
type="button"
className="btn btn-sm"
disabled={restoreM.isPending}
onClick={() => {
setError(null);
restoreM.mutate({ kind: it.kind, id: it.id });
}}
>
{t("recycleBin.restore")}
</button>
)}
{canPurge && (
<button
type="button"
className="btn btn-sm btn-ghost ml-1 text-term-red"
onClick={() => {
setError(null);
setPurging(it);
}}
>
{t("recycleBin.purge")}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{purging && (
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
<p className="text-[13px] text-term-text">
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
</p>
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
{t("common.cancel")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
disabled={purgeM.isPending}
onClick={() => purgeM.mutate({ kind: purging.kind, id: purging.id })}
>
{t("recycleBin.purge")}
</button>
</div>
</Modal>
)}
</div>
);
}
+33
View File
@@ -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;
+19
View File
@@ -56,6 +56,7 @@ export const en: Catalog = {
roles: "Roles",
shifts: "Shifts",
reports: "Reports",
recycleBin: "Recycle bin",
logs: "Logs",
},
status: {
@@ -712,6 +713,24 @@ export const en: Catalog = {
subCars: "Cars covered",
},
},
recycleBin: {
title: "Recycle bin",
retentionNote: "Deleted items are kept for {{days}} days, then permanently removed.",
empty: "Nothing deleted. Items you delete appear here, recoverable until they expire.",
col: { type: "Type", item: "Item", deleted: "Deleted", actions: "" },
kind: {
user: "User",
role: "Role",
subscription: "Subscription",
plan: "Plan",
tariff: "Tariff",
},
restore: "Restore",
purge: "Purge",
purgeConfirmTitle: "Purge permanently?",
purgeConfirmBody: "Permanently delete “{{label}}”? It cannot be restored after this.",
purgeIrreversible: "This is irreversible.",
},
logs: {
title: "System logs",
refresh: "Refresh",
+19
View File
@@ -58,6 +58,7 @@ export const sq = {
roles: "Rolet",
shifts: "Turnet",
reports: "Raportet",
recycleBin: "Koshi",
logs: "Loget",
},
status: {
@@ -726,6 +727,24 @@ export const sq = {
subCars: "Makina të mbuluara",
},
},
recycleBin: {
title: "Koshi",
retentionNote: "Artikujt e fshirë mbahen për {{days}} ditë, pastaj hiqen përgjithmonë.",
empty: "Asgjë e fshirë. Artikujt që fshini shfaqen këtu, të rikuperueshëm derisa të skadojnë.",
col: { type: "Lloji", item: "Artikulli", deleted: "Fshirë", actions: "" },
kind: {
user: "Përdorues",
role: "Rol",
subscription: "Abonim",
plan: "Plan",
tariff: "Tarifë",
},
restore: "Rikthe",
purge: "Fshi përfundimisht",
purgeConfirmTitle: "Të fshihet përfundimisht?",
purgeConfirmBody: "Të fshihet përgjithmonë “{{label}}”? Nuk mund të rikthehet pas kësaj.",
purgeIrreversible: "Ky veprim është i pakthyeshëm.",
},
logs: {
title: "Loget e sistemit",
refresh: "Rifresko",
+1
View File
@@ -29,4 +29,5 @@ export const qk = {
deviceStatus: ["device-status"] as const,
report: (from: string, to: string, bucket: string) =>
["report", from, to, bucket] as const,
recycleBin: ["recycle-bin"] as const,
} as const;
+16
View File
@@ -30,6 +30,7 @@ import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js";
import { LogsViewer } from "./LogsViewer.js";
import { RecycleBin } from "./RecycleBin.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
// initial bundle and only downloads when an admin opens /setup/reports.
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
@@ -88,6 +89,7 @@ function SetupLayout() {
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
</nav>
<Outlet />
@@ -387,6 +389,7 @@ function RootLayout() {
show("site:read") ||
show("user:read") ||
show("role:read") ||
show("recyclebin:read") ||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
@@ -513,6 +516,7 @@ const SETUP_TABS: { to: string; perm: Permission }[] = [
{ to: "/setup/site", perm: "site:read" },
{ to: "/setup/users", perm: "user:read" },
{ to: "/setup/roles", perm: "role:read" },
{ to: "/setup/recycle-bin", perm: "recyclebin:read" },
{ to: "/shifts", perm: "shift:read" },
{ to: "/setup/logs", perm: "log:read" },
];
@@ -610,6 +614,17 @@ const rolesRoute = createRoute({
// (Shift history lives at the standalone /shifts route — see shiftRoute. It was
// removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
// Recycle bin — restore/purge soft-deleted master data. Gated by recyclebin:read.
const recycleBinRoute = createRoute({
getParentRoute: () => setupRoute,
path: "recycle-bin",
beforeLoad: ({ context }) => requirePerm("recyclebin:read")(context),
component: function RecycleBinRoute() {
const { user } = rootRoute.useRouteContext();
return <RecycleBin user={user} />;
},
});
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
const logsRoute = createRoute({
getParentRoute: () => setupRoute,
@@ -635,6 +650,7 @@ const routeTree = rootRoute.addChildren([
siteRoute,
usersRoute,
rolesRoute,
recycleBinRoute,
logsRoute,
]),
]);