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