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:
@@ -28,6 +28,11 @@ EVENT_SIGNING_KEY=
|
||||
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
|
||||
# COOKIE_SECURE=0
|
||||
|
||||
# Recycle bin retention: a soft-deleted user/role/subscription/plan/tariff is auto-purged
|
||||
# this many days after deletion (a 6-hourly sweep). Default 30. Set 0 to keep deleted
|
||||
# items forever (manual purge only). See wiki/concepts/soft-delete.md.
|
||||
# RECYCLE_BIN_RETENTION_DAYS=30
|
||||
|
||||
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
||||
# ADMIN_USER=admin
|
||||
# ADMIN_PASS=
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
eq,
|
||||
isNull,
|
||||
roles,
|
||||
rolePermissions,
|
||||
subscriptionCredentials,
|
||||
subscriptionPlans,
|
||||
subscriptions,
|
||||
tariffs,
|
||||
users,
|
||||
type Db,
|
||||
} from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import {
|
||||
listRecycleBin,
|
||||
purge,
|
||||
restore,
|
||||
restoreBlockedReason,
|
||||
softDelete,
|
||||
sweepExpired,
|
||||
} from "./recycle-bin.js";
|
||||
|
||||
// Soft delete / recycle bin. Pins: a delete STAMPS (keeps the row), the bin lists
|
||||
// soft-deleted items across kinds, restore brings them back, purge does the real
|
||||
// DELETE (+ children), a restore that would collide with a live row is blocked, and the
|
||||
// retention sweep purges only items past the window.
|
||||
|
||||
let db: Db;
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
});
|
||||
|
||||
function seedUser(username: string): string {
|
||||
const id = randomUUID();
|
||||
db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run();
|
||||
db.insert(users).values({ id, username, passwordHash: "x", roleId: "admin" }).run();
|
||||
return id;
|
||||
}
|
||||
function seedRole(name: string): string {
|
||||
const id = randomUUID();
|
||||
db.insert(roles).values({ id, name, builtin: 0 }).run();
|
||||
db.insert(rolePermissions).values({ roleId: id, permission: "site:read" }).run();
|
||||
return id;
|
||||
}
|
||||
function seedSubscription(holder: string): string {
|
||||
const id = randomUUID();
|
||||
db.insert(subscriptions).values({ id, holderName: holder, period: "month" }).run();
|
||||
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: "qr", value: `qr-${id}` }).run();
|
||||
return id;
|
||||
}
|
||||
function seedPlan(planId: string, versions = 2): void {
|
||||
for (let i = 0; i < versions; i++) {
|
||||
db.insert(subscriptionPlans).values({
|
||||
id: randomUUID(),
|
||||
planId,
|
||||
name: planId,
|
||||
period: "month",
|
||||
pricePerPeriodMinor: 100000,
|
||||
currency: "ALL",
|
||||
effectiveFrom: `2026-0${i + 1}-01T00:00:00.000Z`,
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
|
||||
describe("softDelete + restore + purge", () => {
|
||||
it("stamps the row instead of removing it, and hides it from a live query", () => {
|
||||
const id = seedUser("alice");
|
||||
expect(softDelete(db, "user", id, "admin-1")).toBe(true);
|
||||
|
||||
const row = db.select().from(users).where(eq(users.id, id)).get();
|
||||
expect(row).toBeDefined(); // still there
|
||||
expect(row?.deletedAt).toBeTruthy();
|
||||
expect(row?.deletedBy).toBe("admin-1");
|
||||
// A live-only query no longer sees it.
|
||||
expect(db.select().from(users).where(isNull(users.deletedAt)).all()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("soft-deleting an already-deleted row is a no-op (returns false)", () => {
|
||||
const id = seedUser("bob");
|
||||
expect(softDelete(db, "user", id, "a")).toBe(true);
|
||||
expect(softDelete(db, "user", id, "a")).toBe(false);
|
||||
});
|
||||
|
||||
it("restore clears the stamps and brings the row back to the live set", () => {
|
||||
const id = seedRole("valet");
|
||||
softDelete(db, "role", id, "a");
|
||||
expect(restore(db, "role", id)).toBe(true);
|
||||
const row = db.select().from(roles).where(eq(roles.id, id)).get();
|
||||
expect(row?.deletedAt).toBeNull();
|
||||
expect(db.select().from(roles).where(isNull(roles.deletedAt)).all().map((r) => r.id)).toContain(id);
|
||||
});
|
||||
|
||||
it("purge removes a soft-deleted row + its children; refuses a LIVE row", () => {
|
||||
const id = seedSubscription("carlos");
|
||||
// Cannot purge while live (purge only touches soft-deleted rows).
|
||||
expect(purge(db, "subscription", id)).toBe(false);
|
||||
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeDefined();
|
||||
|
||||
softDelete(db, "subscription", id, "a");
|
||||
expect(purge(db, "subscription", id)).toBe(true);
|
||||
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeUndefined();
|
||||
// Children gone too.
|
||||
expect(db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("versioned plans", () => {
|
||||
it("soft-deletes / restores / purges ALL versions of a planId together", () => {
|
||||
seedPlan("hotel-daily", 3);
|
||||
expect(softDelete(db, "plan", "hotel-daily", "a")).toBe(true);
|
||||
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(0);
|
||||
|
||||
// The bin lists the plan as ONE item, not three.
|
||||
const planItems = listRecycleBin(db).filter((i) => i.kind === "plan");
|
||||
expect(planItems).toHaveLength(1);
|
||||
expect(planItems[0]?.id).toBe("hotel-daily");
|
||||
|
||||
expect(restore(db, "plan", "hotel-daily")).toBe(true);
|
||||
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(3);
|
||||
|
||||
softDelete(db, "plan", "hotel-daily", "a");
|
||||
expect(purge(db, "plan", "hotel-daily")).toBe(true);
|
||||
expect(db.select().from(subscriptionPlans).all()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listRecycleBin", () => {
|
||||
it("collects soft-deleted items across every kind, newest-deleted first", () => {
|
||||
const u = seedUser("dora");
|
||||
const r = seedRole("guard");
|
||||
const t = randomUUID();
|
||||
db.insert(tariffs).values({ id: t, scope: "site", name: "Site" }).run();
|
||||
|
||||
softDelete(db, "user", u, "a");
|
||||
softDelete(db, "role", r, "a");
|
||||
softDelete(db, "tariff", t, "a");
|
||||
|
||||
const items = listRecycleBin(db);
|
||||
expect(items.map((i) => i.kind).sort()).toEqual(["role", "tariff", "user"]);
|
||||
// Each carries a human label + the deletedAt stamp.
|
||||
expect(items.find((i) => i.kind === "user")?.label).toBe("dora");
|
||||
expect(items.every((i) => i.deletedAt)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreBlockedReason", () => {
|
||||
// NB: the DB `username`/`name` UNIQUE spans live AND soft-deleted rows, so a live
|
||||
// duplicate can't even be INSERTed while the deleted one exists (the create route
|
||||
// returns a clear 409 instead — see routes/users.ts). restoreBlockedReason is a
|
||||
// belt-and-suspenders guard at restore time; verify it returns null in the normal
|
||||
// case (nothing colliding) so a clean restore is never wrongly blocked.
|
||||
it("does not block a normal restore (no live collision)", () => {
|
||||
const u = seedUser("eve");
|
||||
softDelete(db, "user", u, "a");
|
||||
expect(restoreBlockedReason(db, "user", u)).toBeNull();
|
||||
|
||||
const r = seedRole("cleaner");
|
||||
softDelete(db, "role", r, "a");
|
||||
expect(restoreBlockedReason(db, "role", r)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sweepExpired (retention)", () => {
|
||||
it("purges items deleted longer than the window ago, keeps recent ones", () => {
|
||||
const old = seedUser("old");
|
||||
const fresh = seedUser("fresh");
|
||||
softDelete(db, "user", old, "a");
|
||||
softDelete(db, "user", fresh, "a");
|
||||
// Backdate `old`'s deletion to 40 days ago.
|
||||
const longAgo = new Date(Date.now() - 40 * 86_400_000).toISOString();
|
||||
db.update(users).set({ deletedAt: longAgo }).where(eq(users.id, old)).run();
|
||||
|
||||
const purged = sweepExpired(db, 30);
|
||||
expect(purged.user).toBe(1);
|
||||
expect(db.select().from(users).where(eq(users.id, old)).get()).toBeUndefined();
|
||||
expect(db.select().from(users).where(eq(users.id, fresh)).get()).toBeDefined();
|
||||
});
|
||||
|
||||
it("days <= 0 disables the sweep (keep forever)", () => {
|
||||
const id = seedUser("keeper");
|
||||
softDelete(db, "user", id, "a");
|
||||
db.update(users).set({ deletedAt: new Date(Date.now() - 999 * 86_400_000).toISOString() }).where(eq(users.id, id)).run();
|
||||
const purged = sweepExpired(db, 0);
|
||||
expect(purged.user).toBe(0);
|
||||
expect(db.select().from(users).where(eq(users.id, id)).get()).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
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;
|
||||
}
|
||||
@@ -69,7 +69,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Always run a bcrypt compare to avoid leaking which usernames exist (timing).
|
||||
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
||||
const ok = await bcrypt.compare(password, hash);
|
||||
if (!user || !ok) {
|
||||
// A soft-deleted user (in the recycle bin) cannot log in — treat as invalid, with no
|
||||
// distinct error so a deleted account isn't enumerable.
|
||||
if (!user || !ok || user.deletedAt) {
|
||||
return reply.code(401).send({ error: "invalid credentials" });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// HTTP integration for soft delete + recycle bin: an admin DELETE soft-deletes (the user
|
||||
// leaves the list, can't log in), the bin lists it, restore brings it back, and a deleted
|
||||
// user can log in again. Drives the REAL app over a fresh in-memory DB via app.inject.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
/** Log in an admin and return the auth headers for mutations. */
|
||||
async function asAdmin() {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
return { cookie, csrf };
|
||||
}
|
||||
|
||||
describe("soft delete via the resource DELETE route", () => {
|
||||
it("DELETE /api/users/:id soft-deletes: user leaves the list and can't log in, but is restorable", async () => {
|
||||
const { cookie, csrf } = await asAdmin();
|
||||
// Create a victim user to delete.
|
||||
await seedUser(db, { username: "victim", password: "victim-pass-123", roleId: "admin" });
|
||||
const victim = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
|
||||
.users.find((u: { username: string; id: string }) => u.username === "victim");
|
||||
expect(victim).toBeDefined();
|
||||
|
||||
// Delete (soft).
|
||||
const del = await app.inject({
|
||||
method: "DELETE", url: `/api/users/${victim.id}`,
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
});
|
||||
expect(del.statusCode).toBeLessThan(300);
|
||||
|
||||
// Gone from the live list.
|
||||
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json();
|
||||
expect(list.users.some((u: { username: string }) => u.username === "victim")).toBe(false);
|
||||
|
||||
// Can't log in.
|
||||
const relogin = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
|
||||
expect(relogin.statusCode).toBe(401);
|
||||
|
||||
// Shows in the recycle bin.
|
||||
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
|
||||
expect(bin.items.some((i: { kind: string; label: string }) => i.kind === "user" && i.label === "victim")).toBe(true);
|
||||
|
||||
// Restore → reappears + can log in.
|
||||
const restore = await app.inject({
|
||||
method: "POST", url: `/api/recycle-bin/user/${victim.id}/restore`,
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
});
|
||||
expect(restore.statusCode).toBeLessThan(300);
|
||||
const relogin2 = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
|
||||
expect(relogin2.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("purge permanently removes a soft-deleted user", async () => {
|
||||
const { cookie, csrf } = await asAdmin();
|
||||
await seedUser(db, { username: "gone", password: "gone-pass-1234", roleId: "admin" });
|
||||
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
|
||||
.users.find((u: { username: string }) => u.username === "gone").id;
|
||||
|
||||
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
|
||||
const purge = await app.inject({
|
||||
method: "DELETE", url: `/api/recycle-bin/user/${id}`,
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
});
|
||||
expect(purge.statusCode).toBe(204);
|
||||
|
||||
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
|
||||
expect(bin.items.some((i: { label: string }) => i.label === "gone")).toBe(false);
|
||||
});
|
||||
|
||||
it("the recycle bin is gated — a user without recyclebin:read is 403", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "plain", roleId: "plain", permissions: ["user:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("recreating a user with a soft-deleted user's username gives a clear 409", async () => {
|
||||
const { cookie, csrf } = await asAdmin();
|
||||
await seedUser(db, { username: "dup", password: "dup-pass-12345", roleId: "admin" });
|
||||
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
|
||||
.users.find((u: { username: string }) => u.username === "dup").id;
|
||||
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST", url: "/api/users",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { username: "dup", password: "new-pass-12345", roleId: "admin" },
|
||||
});
|
||||
expect(create.statusCode).toBe(409);
|
||||
expect(create.json().error).toMatch(/recycle bin/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import { requirePermission, bumpPermsCache } from "../auth.js";
|
||||
import {
|
||||
listRecycleBin,
|
||||
purge,
|
||||
restore,
|
||||
restoreBlockedReason,
|
||||
retentionDays,
|
||||
RESOURCE_KINDS,
|
||||
type ResourceKind,
|
||||
} from "../recycle-bin.js";
|
||||
|
||||
// Recycle bin API — view / restore / purge soft-deleted master data. The actual
|
||||
// soft-delete STAMP happens in each resource's own DELETE route (users/roles/
|
||||
// subscriptions/plans/tariffs); this is the way back. Admin-grade (recyclebin:*).
|
||||
// See recycle-bin.ts, wiki/concepts/soft-delete.md.
|
||||
|
||||
function isKind(s: string): s is ResourceKind {
|
||||
return (RESOURCE_KINDS as string[]).includes(s);
|
||||
}
|
||||
|
||||
export async function recycleBinRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// List everything in the bin (+ the retention window so the UI can warn how long
|
||||
// items survive before auto-purge).
|
||||
app.get(
|
||||
"/api/recycle-bin",
|
||||
{ preHandler: requirePermission("recyclebin:read") },
|
||||
async () => ({ items: listRecycleBin(db), retentionDays: retentionDays() }),
|
||||
);
|
||||
|
||||
// Restore a soft-deleted item (clear the stamps → it reappears in its catalog).
|
||||
// Blocked with a 409 when a live row would collide (e.g. the username was reused).
|
||||
app.post<{ Params: { kind: string; id: string } }>(
|
||||
"/api/recycle-bin/:kind/:id/restore",
|
||||
{ preHandler: requirePermission("recyclebin:update") },
|
||||
async (req, reply) => {
|
||||
const { kind, id } = req.params;
|
||||
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
|
||||
|
||||
const blocked = restoreBlockedReason(db, kind, id);
|
||||
if (blocked) return reply.code(409).send({ error: `cannot restore: ${blocked}` });
|
||||
|
||||
const ok = restore(db, kind, id);
|
||||
if (!ok) return reply.code(404).send({ error: "no deleted item to restore" });
|
||||
// A restored role/user changes the authz picture — drop the permission cache.
|
||||
if (kind === "role" || kind === "user") bumpPermsCache();
|
||||
app.log.info(`recycle-bin: restored ${kind} ${id}`);
|
||||
return { kind, id, restored: true };
|
||||
},
|
||||
);
|
||||
|
||||
// Purge (permanently delete) a soft-deleted item + its children. Irreversible.
|
||||
app.delete<{ Params: { kind: string; id: string } }>(
|
||||
"/api/recycle-bin/:kind/:id",
|
||||
{ preHandler: requirePermission("recyclebin:delete") },
|
||||
async (req, reply) => {
|
||||
const { kind, id } = req.params;
|
||||
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
|
||||
const ok = purge(db, kind, id);
|
||||
if (!ok) return reply.code(404).send({ error: "no deleted item to purge" });
|
||||
if (kind === "role" || kind === "user") bumpPermsCache();
|
||||
app.log.warn(`recycle-bin: PURGED ${kind} ${id} (permanent)`);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
|
||||
// Role management (admin). Roles are DATA: an admin composes a role from the
|
||||
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
|
||||
@@ -56,7 +57,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
.where(eq(rolePermissions.roleId, roleId))
|
||||
.all()
|
||||
.map((r) => r.permission);
|
||||
const userCount = db.select().from(users).where(eq(users.roleId, roleId)).all().length;
|
||||
const userCount = db.select().from(users).where(and(eq(users.roleId, roleId), isNull(users.deletedAt))).all().length;
|
||||
// The admin role always reports the full grid (it's enforced in code).
|
||||
return {
|
||||
id: role.id,
|
||||
@@ -75,9 +76,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// The full permission grid (for the role-composer checkbox UI) + every role.
|
||||
// The full permission grid (for the role-composer checkbox UI) + every LIVE role.
|
||||
// Soft-deleted roles live in the recycle bin, not here.
|
||||
app.get("/api/roles", { preHandler: readGuard }, async () => {
|
||||
const all = db.select().from(roles).all();
|
||||
const all = db.select().from(roles).where(isNull(roles.deletedAt)).all();
|
||||
return {
|
||||
catalog: PERMISSIONS,
|
||||
roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
|
||||
@@ -143,23 +145,25 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a role. Refused if it's built-in or any user still holds it.
|
||||
// Delete a role — SOFT (recycle bin). Refused if built-in or any LIVE user still holds
|
||||
// it. The row is stamped deleted (recoverable), not removed; its permission rows are
|
||||
// KEPT so a restore brings the role back intact. Restore/purge from the recycle bin.
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/roles/:id",
|
||||
{ preHandler: deleteGuard },
|
||||
async (req, reply) => {
|
||||
const id = req.params.id;
|
||||
const role = db.select().from(roles).where(eq(roles.id, id)).get();
|
||||
const role = db.select().from(roles).where(and(eq(roles.id, id), isNull(roles.deletedAt))).get();
|
||||
if (!role) return reply.code(404).send({ error: "role not found" });
|
||||
if (role.builtin === 1) {
|
||||
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" });
|
||||
}
|
||||
const holders = db.select().from(users).where(eq(users.roleId, id)).all().length;
|
||||
// Only LIVE holders block deletion (a soft-deleted user's role assignment is moot).
|
||||
const holders = db.select().from(users).where(and(eq(users.roleId, id), isNull(users.deletedAt))).all().length;
|
||||
if (holders > 0) {
|
||||
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
|
||||
}
|
||||
db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)).run();
|
||||
db.delete(roles).where(eq(roles.id, id)).run();
|
||||
softDelete(db, "role", id, req.user.sub);
|
||||
bumpPermsCache();
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db";
|
||||
import { and, desc, eq, isNull, subscriptionPlans, subscriptions, type Db } from "@parking/db";
|
||||
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
import { siteTz } from "../subscription-window.js";
|
||||
|
||||
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
|
||||
@@ -75,7 +76,14 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
// per planId (latest active version with effectiveFrom ≤ now). Operators selling
|
||||
// need the current list; the admin catalog screen asks for ?all=1.
|
||||
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => {
|
||||
const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all();
|
||||
// Exclude soft-deleted plan versions — those live in the recycle bin. (A plan is
|
||||
// versioned; a soft-delete stamps every version row of the planId.)
|
||||
const rows = db
|
||||
.select()
|
||||
.from(subscriptionPlans)
|
||||
.where(isNull(subscriptionPlans.deletedAt))
|
||||
.orderBy(desc(subscriptionPlans.effectiveFrom))
|
||||
.all();
|
||||
if (req.query?.all) return { plans: rows };
|
||||
const now = new Date().toISOString();
|
||||
// Newest-effective active version wins per planId.
|
||||
@@ -154,12 +162,19 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
|
||||
// version). A referenced plan version MUST survive: a subscription's planVersionId is
|
||||
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
|
||||
// when in use (the admin should retire instead). Removes all versions of the planId.
|
||||
// when in use (the admin should retire instead). SOFT delete (recycle bin): stamps all
|
||||
// versions of the planId; a restore brings the plan back; purge does the real removal.
|
||||
app.delete<{ Params: { planId: string } }>(
|
||||
"/api/subscription-plans/:planId",
|
||||
{ preHandler: planGuard },
|
||||
async (req, reply) => {
|
||||
const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all();
|
||||
// Only LIVE subscriptions block deletion (a soft-deleted subscriber's planId ref is
|
||||
// itself in the bin; if it's restored later, the plan can be restored too).
|
||||
const refs = db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(and(eq(subscriptions.planId, req.params.planId), isNull(subscriptions.deletedAt)))
|
||||
.all();
|
||||
if (refs.length > 0) {
|
||||
return reply.code(409).send({
|
||||
error: "plan is in use and cannot be deleted",
|
||||
@@ -167,7 +182,8 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
subscribers: refs.length,
|
||||
});
|
||||
}
|
||||
db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run();
|
||||
const ok = softDelete(db, "plan", req.params.planId, req.user.sub);
|
||||
if (!ok) return reply.code(404).send({ error: "plan not found" });
|
||||
return { planId: req.params.planId, deleted: true };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||
import { NoPrinterAvailableError } from "@parking/devices";
|
||||
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
import { invalidateHolder } from "../event-enrich.js";
|
||||
import { printSubscriptionCard } from "../booth-print.js";
|
||||
import type { CredentialCapture } from "../credential-capture.js";
|
||||
@@ -226,9 +227,10 @@ export async function subscriptionRoutes(
|
||||
return { plan, validFrom, validTo, quantity, quote };
|
||||
}
|
||||
|
||||
// List all subscriptions (with their credentials + plates).
|
||||
// List all LIVE subscriptions (with their credentials + plates). Soft-deleted ones
|
||||
// live in the recycle bin, not here.
|
||||
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
|
||||
const rows = db.select().from(subscriptions).all();
|
||||
const rows = db.select().from(subscriptions).where(isNull(subscriptions.deletedAt)).all();
|
||||
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
|
||||
});
|
||||
|
||||
@@ -532,16 +534,17 @@ export async function subscriptionRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// Hard delete a subscription + its child rows. (Past ledger events that reference it
|
||||
// are untouched — the audit trail is append-only and independent of this row.)
|
||||
// Delete a subscription — SOFT (recycle bin). The row + its credential/plate children
|
||||
// are KEPT (stamped deleted) so a restore brings the subscriber back intact; it leaves
|
||||
// the catalog and stops opening the barrier (the entry flow filters deleted). Past
|
||||
// ledger events that reference it are untouched (append-only). Restore/purge from the
|
||||
// recycle bin. (Distinct from /revoke, which BARS but keeps the subscriber visible.)
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/subscriptions/:id",
|
||||
{ preHandler: deleteGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
|
||||
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
|
||||
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
|
||||
const ok = softDelete(db, "subscription", req.params.id, req.user.sub);
|
||||
if (!ok) return reply.code(404).send({ error: "subscription not found" });
|
||||
invalidateHolder(req.params.id);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import {
|
||||
computeFee,
|
||||
isTariffV2,
|
||||
@@ -48,9 +48,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
// Publishing a new version changes what customers are charged.
|
||||
const writeGuard = requirePermission("tariff:update");
|
||||
|
||||
// The single site tariff row, created on first read/publish.
|
||||
// The single site tariff row, created on first read/publish. A soft-deleted (recycle-
|
||||
// bin) tariff is ignored here so a fresh one is created — the deleted one waits in the
|
||||
// bin for restore/purge. (Tariffs have soft-delete support for completeness; today the
|
||||
// site runs one tariff and there's no delete button — recovery is via the recycle bin.)
|
||||
function ensureSiteTariff(): string {
|
||||
const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||
const existing = db.select().from(tariffs).where(and(eq(tariffs.scope, "site"), isNull(tariffs.deletedAt))).get();
|
||||
if (existing) return existing.id;
|
||||
const id = randomUUID();
|
||||
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import bcrypt from "bcrypt";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, roles, users, type Db } from "@parking/db";
|
||||
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID } from "@parking/shared";
|
||||
import { permissionsFor, requirePermission } from "../auth.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
|
||||
// User management (admin). Users are created/edited at runtime here — the
|
||||
// install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one
|
||||
@@ -64,9 +65,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
const updateGuard = requirePermission("user:update");
|
||||
const deleteGuard = requirePermission("user:delete");
|
||||
|
||||
/** Count users currently holding the protected admin role. */
|
||||
/** Count LIVE users currently holding the protected admin role. A soft-deleted admin
|
||||
* doesn't count — they can't log in — so the no-lockout check uses live admins only. */
|
||||
function adminCount(): number {
|
||||
return db.select().from(users).where(eq(users.roleId, ADMIN_ROLE_ID)).all().length;
|
||||
return db.select().from(users).where(and(eq(users.roleId, ADMIN_ROLE_ID), isNull(users.deletedAt))).all().length;
|
||||
}
|
||||
|
||||
/** True if removing/relocating `userId` from admin would leave zero admins. */
|
||||
@@ -112,9 +114,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
return false;
|
||||
}
|
||||
|
||||
// List all users (no password hashes) + their role names for display.
|
||||
// List all LIVE users (no password hashes) + their role names for display. Soft-deleted
|
||||
// users live in the recycle bin, not here.
|
||||
app.get("/api/users", { preHandler: readGuard }, async () => {
|
||||
const rows = db.select().from(users).all();
|
||||
const rows = db.select().from(users).where(isNull(users.deletedAt)).all();
|
||||
const roleRows = db.select().from(roles).all();
|
||||
const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
|
||||
return {
|
||||
@@ -140,8 +143,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
if (exceedsCaller(req.user.roleId, roleId)) {
|
||||
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
|
||||
}
|
||||
if (db.select().from(users).where(eq(users.username, username)).get()) {
|
||||
return reply.code(409).send({ error: "username already exists" });
|
||||
const clash = db.select().from(users).where(eq(users.username, username)).get();
|
||||
if (clash) {
|
||||
// The username is UNIQUE across live AND soft-deleted rows. If a DELETED user holds
|
||||
// it, point the admin at the recycle bin (restore or purge) rather than a bare 409.
|
||||
return reply.code(409).send({
|
||||
error: clash.deletedAt
|
||||
? "username belongs to a deleted user — restore or purge it from the recycle bin first"
|
||||
: "username already exists",
|
||||
});
|
||||
}
|
||||
const id = randomUUID();
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
@@ -221,13 +231,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a user. Refused if it's the last admin (no-lockout).
|
||||
// Delete a user — SOFT (recycle bin). Refused if it's the last admin (no-lockout).
|
||||
// The row is stamped deleted (recoverable), not removed; it vanishes from the list and
|
||||
// can't log in. Restore/purge from the recycle bin. See recycle-bin.ts.
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/users/:id",
|
||||
{ preHandler: deleteGuard },
|
||||
async (req, reply) => {
|
||||
const id = req.params.id;
|
||||
const target = db.select().from(users).where(eq(users.id, id)).get();
|
||||
const target = db.select().from(users).where(and(eq(users.id, id), isNull(users.deletedAt))).get();
|
||||
if (!target) {
|
||||
return reply.code(404).send({ error: "user not found" });
|
||||
}
|
||||
@@ -238,7 +250,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
if (isLastAdmin(id)) {
|
||||
return reply.code(409).send({ error: "cannot delete the last admin" });
|
||||
}
|
||||
db.delete(users).where(eq(users.id, id)).run();
|
||||
softDelete(db, "user", id, req.user.sub);
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@ import { roleRoutes } from "./routes/roles.js";
|
||||
import { deviceRoutes } from "./routes/devices.js";
|
||||
import { eventRoutes } from "./routes/events.js";
|
||||
import { reportRoutes } from "./routes/reports.js";
|
||||
import { recycleBinRoutes } from "./routes/recycle-bin.js";
|
||||
import { sweepExpired, retentionDays } from "./recycle-bin.js";
|
||||
import { payRoutes } from "./routes/pay.js";
|
||||
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||
@@ -146,6 +148,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
|
||||
await reportRoutes(app, db);
|
||||
|
||||
// Recycle bin: view / restore / purge soft-deleted master data (users/roles/subs/
|
||||
// plans/tariffs). Gated on recyclebin:*. See routes/recycle-bin.ts, recycle-bin.ts.
|
||||
await recycleBinRoutes(app, db);
|
||||
|
||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||
await wsRoutes(app, db, deviceMonitor);
|
||||
@@ -232,6 +238,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
logService.prune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(pruneTimer));
|
||||
|
||||
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
|
||||
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
|
||||
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
|
||||
const binTimer = setInterval(() => {
|
||||
const purged = sweepExpired(db);
|
||||
const total = Object.values(purged).reduce((a, b) => a + b, 0);
|
||||
if (total > 0) app.log.info(`recycle-bin: auto-purged ${total} expired item(s) ${JSON.stringify(purged)}`);
|
||||
}, 6 * 60 * 60 * 1000);
|
||||
binTimer.unref();
|
||||
if (retentionDays() > 0) sweepExpired(db); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(binTimer));
|
||||
|
||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||
// Record every input edge as unsigned telemetry, keyed to the device that fired
|
||||
// (provenance). No lane — the pool-of-spaces model has none. The entry flow
|
||||
|
||||
@@ -108,7 +108,10 @@ export class SubscriptionFlow {
|
||||
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
|
||||
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
|
||||
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
|
||||
if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
|
||||
// A soft-deleted (recycle-bin) subscription must NOT open the barrier — treat it as
|
||||
// gone. (Its credential rows are kept for restore, so the dispatcher can still match
|
||||
// it; the gate is here.)
|
||||
if (!sub || sub.deletedAt) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
|
||||
|
||||
// Validity: active + within the coverage window.
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user