import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { eq, permitCredentials, permitPlates, permits, type Db } from "@parking/db"; import { requireRole } from "../auth.js"; // Permit (subscription) admin CRUD. A permit is mutable master data — admins // grant/edit/revoke — but every USE of it is a signed ledger event, so the audit // trail stays append-only (see wiki/entities/permit.md). A permit is an aggregate: // the permit row + its credentials (card/QR) + its bound plates. The API treats them // as one unit (create/update replace the child sets; delete removes all). interface Credential { kind: "rf" | "qr"; value: string; } interface PermitBody { holderName?: string; contact?: string; /** Car-count binding: cars inside at once. Default 1; null = unbound. */ maxConcurrent?: number | null; validFrom?: string | null; validTo?: string | null; status?: "active" | "suspended" | "revoked"; credentials?: Credential[]; /** Plate binding (optional): bound plates that also serve as identity. */ plates?: string[]; } export async function permitRoutes(app: FastifyInstance, db: Db): Promise { // Admin manages permits; operator/cashier/readonly may LIST (to look one up). const readGuard = requireRole("admin", "operator", "cashier", "readonly"); const writeGuard = requireRole("admin"); // Validate the body; returns problems (empty = ok). Shared by create + update. function validate(b: PermitBody): string[] { const errs: string[] = []; if (b.maxConcurrent != null) { if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) { errs.push("maxConcurrent must be a positive integer, or null for unbound"); } } if (b.status && !["active", "suspended", "revoked"].includes(b.status)) { errs.push("status must be active|suspended|revoked"); } for (const c of b.credentials ?? []) { if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) { errs.push("each credential needs kind (rf|qr) and a non-empty value"); break; } } if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) { errs.push("a permit needs at least one credential or one bound plate (else nothing identifies it)"); } return errs; } function loadAggregate(id: string) { const permit = db.select().from(permits).where(eq(permits.id, id)).get(); if (!permit) return null; const credentials = db.select().from(permitCredentials).where(eq(permitCredentials.permitId, id)).all(); const plates = db.select().from(permitPlates).where(eq(permitPlates.permitId, id)).all(); return { ...permit, credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })), plates: plates.map((p) => p.plate), }; } // Replace a permit's child rows (credentials + plates) from the body. function writeChildren(id: string, b: PermitBody) { db.delete(permitCredentials).where(eq(permitCredentials.permitId, id)).run(); db.delete(permitPlates).where(eq(permitPlates.permitId, id)).run(); for (const c of b.credentials ?? []) { db.insert(permitCredentials).values({ id: randomUUID(), permitId: id, kind: c.kind, value: c.value.trim() }).run(); } for (const p of b.plates ?? []) { if (p.trim()) db.insert(permitPlates).values({ id: randomUUID(), permitId: id, plate: p.trim() }).run(); } } // List all permits (with their credentials + plates). app.get("/api/permits", { preHandler: readGuard }, async () => { const rows = db.select().from(permits).all(); return { permits: rows.map((r) => loadAggregate(r.id)) }; }); // Create a permit. app.post<{ Body: PermitBody }>("/api/permits", { preHandler: writeGuard }, async (req, reply) => { const b = req.body ?? {}; const problems = validate(b); if (problems.length) return reply.code(400).send({ error: "invalid permit", problems }); const id = randomUUID(); db.insert(permits) .values({ id, holderName: b.holderName ?? null, contact: b.contact ?? null, maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent, validFrom: b.validFrom ?? null, validTo: b.validTo ?? null, status: b.status ?? "active", }) .run(); writeChildren(id, b); return reply.code(201).send(loadAggregate(id)); }); // Update a permit (replaces fields + child sets). app.put<{ Params: { id: string }; Body: PermitBody }>( "/api/permits/:id", { preHandler: writeGuard }, async (req, reply) => { const existing = db.select().from(permits).where(eq(permits.id, req.params.id)).get(); if (!existing) return reply.code(404).send({ error: "permit not found" }); const b = req.body ?? {}; const problems = validate(b); if (problems.length) return reply.code(400).send({ error: "invalid permit", problems }); db.update(permits) .set({ holderName: b.holderName ?? null, contact: b.contact ?? null, maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent, validFrom: b.validFrom ?? null, validTo: b.validTo ?? null, status: b.status ?? existing.status, }) .where(eq(permits.id, req.params.id)) .run(); writeChildren(req.params.id, b); return loadAggregate(req.params.id); }, ); // Revoke (soft): the common case — keeps the permit + its history, just bars it. // A revoked permit fails the entry check (see permit-flow.ts). Use DELETE only to // fully remove a permit created in error. app.post<{ Params: { id: string } }>( "/api/permits/:id/revoke", { preHandler: writeGuard }, async (req, reply) => { const r = db.update(permits).set({ status: "revoked" }).where(eq(permits.id, req.params.id)).run(); if (r.changes === 0) return reply.code(404).send({ error: "permit not found" }); return loadAggregate(req.params.id); }, ); // Hard delete a permit + its child rows. (Past ledger events that reference it // are untouched — the audit trail is append-only and independent of this row.) app.delete<{ Params: { id: string } }>( "/api/permits/:id", { preHandler: writeGuard }, async (req, reply) => { const r = db.delete(permits).where(eq(permits.id, req.params.id)).run(); if (r.changes === 0) return reply.code(404).send({ error: "permit not found" }); db.delete(permitCredentials).where(eq(permitCredentials.permitId, req.params.id)).run(); db.delete(permitPlates).where(eq(permitPlates.permitId, req.params.id)).run(); return reply.code(204).send(); }, ); }