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:
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { authRoutes } from "./routes/auth.js";
|
|||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { payRoutes } from "./routes/pay.js";
|
import { payRoutes } from "./routes/pay.js";
|
||||||
|
import { permitRoutes } from "./routes/permits.js";
|
||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
import { setupRoutes } from "./routes/setup.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.
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||||
await tariffRoutes(app, db);
|
await tariffRoutes(app, db);
|
||||||
|
|
||||||
|
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
||||||
|
await permitRoutes(app, db);
|
||||||
|
|
||||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
// 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
|
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchMe, logout, type SessionUser } from "./api.js";
|
import { fetchMe, logout, type SessionUser } from "./api.js";
|
||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
|
import { PermitManager } from "./PermitManager.js";
|
||||||
import { SetupWizard } from "./SetupWizard.js";
|
import { SetupWizard } from "./SetupWizard.js";
|
||||||
import { TariffComposer } from "./TariffComposer.js";
|
import { TariffComposer } from "./TariffComposer.js";
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export function App() {
|
|||||||
<>
|
<>
|
||||||
<SetupWizard />
|
<SetupWizard />
|
||||||
<TariffComposer />
|
<TariffComposer />
|
||||||
|
<PermitManager />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -240,3 +240,40 @@ export function publishTariffVersion(body: {
|
|||||||
}): Promise<TariffVersion> {
|
}): Promise<TariffVersion> {
|
||||||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
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" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ serves both populations ([[entry-exit-readers]]), disambiguated by *what the cre
|
|||||||
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays
|
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays
|
||||||
closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
|
closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
|
||||||
|
|
||||||
|
**Admin CRUD** (`apps/server/src/routes/permits.ts` + `apps/web/src/PermitManager.tsx`): a permit is
|
||||||
|
an **aggregate** (the row + its credentials + bound plates); create/update treat it as one unit
|
||||||
|
(child sets are replaced on update). `GET /api/permits` (any signed-in role — for lookup),
|
||||||
|
`POST/PUT/DELETE /api/permits[/:id]` + `POST /api/permits/:id/revoke` (**admin only**). Validation:
|
||||||
|
`maxConcurrent` is a positive int or `null` (unbound); a permit must have **at least one credential
|
||||||
|
or one bound plate** (else nothing identifies it). Revoke is the soft, common case (keeps history,
|
||||||
|
barred at the barrier); DELETE hard-removes — past ledger events that reference the permit are
|
||||||
|
untouched (the audit trail is append-only and independent). Verified via inject (validation, child
|
||||||
|
replacement, RBAC, revoke/delete).
|
||||||
|
|
||||||
## Resolved (2026-06-15)
|
## Resolved (2026-06-15)
|
||||||
|
|
||||||
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
|
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
|
||||||
|
|||||||
+13
@@ -564,3 +564,16 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
|||||||
F1 exits → F3 enters); plate-bound permit opens; revoked → reject; unknown credential falls through
|
F1 exits → F3 enters); plate-bound permit opens; revoked → reject; unknown credential falls through
|
||||||
to exit-flow reject (not mis-read as permit); verifyChain ok. Full build 5/5.
|
to exit-flow reject (not mis-read as permit); verifyChain ok. Full build 5/5.
|
||||||
- Updated [[permit]] (as-built), [[parking-session]] (read dispatch).
|
- Updated [[permit]] (as-built), [[parking-session]] (read dispatch).
|
||||||
|
|
||||||
|
## [2026-06-15] build | Permit admin CRUD (route + UI)
|
||||||
|
- `apps/server/src/routes/permits.ts`: a permit is an aggregate (row + credentials + bound plates);
|
||||||
|
create/update replace the child sets as one unit. GET (any role, for lookup), POST/PUT/DELETE +
|
||||||
|
POST /:id/revoke (admin only). Validation: maxConcurrent positive-int-or-null; must have ≥1
|
||||||
|
credential OR ≥1 plate. Revoke = soft (keeps history); DELETE = hard (past ledger events untouched).
|
||||||
|
- `apps/web/src/PermitManager.tsx` in the admin shell: list + add/edit (holder, car-bound toggle →
|
||||||
|
maxConcurrent or unbound, validity window, credentials add/remove, plates as a list), revoke, delete.
|
||||||
|
- Makes permits usable without hand-seeding (companion to the tariff composer).
|
||||||
|
- VERIFIED via inject: empty + maxConcurrent=0 → 400 w/ messages; valid → 201; operator LIST 200 but
|
||||||
|
create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then
|
||||||
|
404, children cleaned. Full build 5/5.
|
||||||
|
- Updated [[permit]] (CRUD as-built).
|
||||||
|
|||||||
Reference in New Issue
Block a user