3429642edb
A permit is an aggregate (row + credentials + bound plates); create/update treat it as one unit (child sets replaced on update). GET /api/permits (any signed-in role, for lookup); POST/PUT/DELETE + POST /:id/revoke (admin only). Validation: maxConcurrent positive-int-or-null (unbound); a permit must have at least one credential OR one bound plate. Revoke is the soft common case (keeps history, barred at the barrier); DELETE hard-removes — past ledger events that reference it are untouched (append-only audit trail, independent of this row). Web PermitManager in the admin shell: list + add/edit (holder, car-bound toggle, validity, credentials, plates), revoke, delete. Makes permits usable without hand-seeding (companion to the tariff composer). Verified via inject: validation (empty / maxConcurrent=0 -> 400), create -> 201, operator can LIST but not write (403), update replaces child rows, revoke -> revoked, delete -> 204 then 404 with children cleaned.
161 lines
6.6 KiB
TypeScript
161 lines
6.6 KiB
TypeScript
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<void> {
|
|
// 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();
|
|
},
|
|
);
|
|
}
|