permits: admin CRUD (route + UI)

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.
This commit is contained in:
2026-06-15 19:53:03 +02:00
parent c24d99b0f4
commit 3429642edb
7 changed files with 409 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
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();
},
);
}
+4
View File
@@ -18,6 +18,7 @@ import { authRoutes } from "./routes/auth.js";
import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js";
import { payRoutes } from "./routes/pay.js";
import { permitRoutes } from "./routes/permits.js";
import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js";
@@ -118,6 +119,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// the pay station prices against. See wiki/concepts/tariff.md.
await tariffRoutes(app, db);
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
await permitRoutes(app, db);
const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
+2
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { fetchMe, logout, type SessionUser } from "./api.js";
import { Login } from "./Login.js";
import { PermitManager } from "./PermitManager.js";
import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js";
@@ -43,6 +44,7 @@ export function App() {
<>
<SetupWizard />
<TariffComposer />
<PermitManager />
</>
) : (
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useState } from "react";
import {
ApiError,
createPermit,
deletePermit,
fetchPermits,
revokePermit,
updatePermit,
type Permit,
type PermitCredential,
type PermitInput,
} from "./api.js";
// Permit (subscription) admin. Create/edit/revoke/delete permits + their
// credentials (card/QR) and bound plates. A permit is mutable master data; every
// USE of it is a signed ledger event elsewhere. See wiki/entities/permit.md.
interface FormState {
holderName: string;
contact: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
validTo: string;
credentials: PermitCredential[];
platesText: string; // comma/space separated
}
function emptyForm(): FormState {
return { holderName: "", contact: "", carBound: true, maxConcurrent: "1", validFrom: "", validTo: "", credentials: [{ kind: "rf", value: "" }], platesText: "" };
}
function formFrom(p: Permit): FormState {
return {
holderName: p.holderName ?? "",
contact: p.contact ?? "",
carBound: p.maxConcurrent != null,
maxConcurrent: p.maxConcurrent != null ? String(p.maxConcurrent) : "1",
validFrom: p.validFrom ?? "",
validTo: p.validTo ?? "",
credentials: p.credentials.length ? p.credentials : [{ kind: "rf", value: "" }],
platesText: p.plates.join(", "),
};
}
function toInput(f: FormState): PermitInput {
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
validTo: f.validTo.trim() || null,
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
export function PermitManager() {
const [permits, setPermits] = useState<Permit[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
function reload() {
fetchPermits()
.then((r) => setPermits(r.permits))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(reload, []);
function startNew() {
setForm(emptyForm());
setEditing("new");
setMsg(null);
}
function startEdit(p: Permit) {
setForm(formFrom(p));
setEditing(p.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") await createPermit(toInput(form));
else if (editing) await updatePermit(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: "Permit saved." });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doRevoke(p: Permit) {
if (!confirm(`Revoke permit for ${p.holderName ?? p.id}? It will be refused at the barrier.`)) return;
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(p: Permit) {
if (!confirm(`Delete permit for ${p.holderName ?? p.id}? (Past events are kept.)`)) return;
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<PermitCredential>) {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
if (!permits) return null;
return (
<section style={{ marginTop: "2rem" }}>
<h2>Permits</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{permits.map((p) => (
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{p.holderName ?? "(unnamed)"}</strong>
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{p.status}</span>
<span style={{ color: "#666" }}>
{p.maxConcurrent == null ? "unbound" : `${p.maxConcurrent} car${p.maxConcurrent > 1 ? "s" : ""}`} ·{" "}
{p.credentials.length} cred · {p.plates.length} plate(s)
</span>
<span style={{ flex: 1 }} />
<button type="button" onClick={() => startEdit(p)}>Edit</button>
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>Revoke</button>}
<button type="button" onClick={() => doDelete(p)}>Delete</button>
</li>
))}
{permits.length === 0 && <li style={{ color: "#777" }}>No permits yet.</li>}
</ul>
{editing == null ? (
<button type="button" onClick={startNew}>+ Add permit</button>
) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? "New permit" : "Edit permit"}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>Holder name</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>Contact</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>Car limit</label>
<span>
<label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> limit cars in at once
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>Valid from</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder="ISO date (optional)" />
<label>Valid to</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder="ISO date (optional)" />
<label>Bound plates</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder="comma-separated (optional)" />
</div>
<h4 style={{ marginBottom: "0.25rem" }}>Credentials (card / QR)</h4>
{form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="rf">RF card/tag</option>
<option value="qr">QR</option>
</select>
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder="credential value" style={{ flex: 1 }} />
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div>
))}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>+ credential</button>
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
A permit needs at least one credential OR one bound plate.
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>Save</button>
<button type="button" onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}
+37
View File
@@ -240,3 +240,40 @@ export function publishTariffVersion(body: {
}): Promise<TariffVersion> {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
}
// --- Permits --------------------------------------------------------------
export interface PermitCredential {
kind: "rf" | "qr";
value: string;
}
export interface Permit {
id: string;
holderName: string | null;
contact: string | null;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
status: "active" | "suspended" | "revoked";
credentials: PermitCredential[];
plates: string[];
}
export type PermitInput = Omit<Permit, "id" | "status"> & {
status?: Permit["status"];
};
export function fetchPermits(): Promise<{ permits: Permit[] }> {
return apiFetch("/api/permits");
}
export function createPermit(body: PermitInput): Promise<Permit> {
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) });
}
export function updatePermit(id: string, body: PermitInput): Promise<Permit> {
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) });
}
export function revokePermit(id: string): Promise<Permit> {
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" });
}
export function deletePermit(id: string): Promise<void> {
return apiFetch(`/api/permits/${id}`, { method: "DELETE" });
}