f31e57b4ae
The live activity feed flagged anomalies with no explanation and showed opaque session keys. Make events self-describing and clickable. - Clickable feed rows → read-only event-detail modal: humanized fields, entry/exit snapshots, and signed-chain provenance collapsed behind an audit disclosure (operator sees the story, auditor expands for crypto). - Localized reason codes (backend i18n): the signed ledger now carries a stable REASON_CODE + params (+ English fallback) instead of free-text English. The UI translates via reason.<code> catalogs in sq/en, so an Albanian operator reads Albanian — from the same immutable event. Adding a language is a catalog change, no re-signing. (@parking/shared REASON_CODES, reasonPayload; entry/exit/subscription flows emit codes.) - Subscriber-name resolution: a SUBSESS-… occurrence now shows the subscription holder's name (fallback "Abonent"/"Subscriber"). Resolved read-time server-side (events API + WS push) as a non-signed subscriberLabel; cached with invalidation on subscription edit/delete. - Failed-snapshot visibility: a camera that was attempted but unreachable now shows a "⚠ camera unreachable" tile instead of a silent gap. The snapshots API returns failures[] from telemetry, filtered so a recovered capture shows no stale warning. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
373 lines
17 KiB
TypeScript
373 lines
17 KiB
TypeScript
import { randomBytes, randomUUID } from "node:crypto";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
|
import { NoPrinterAvailableError } from "@parking/devices";
|
|
import { requirePermission } from "../auth.js";
|
|
import { invalidateHolder } from "../event-enrich.js";
|
|
import { printSubscriptionCard } from "../booth-print.js";
|
|
import type { CredentialCapture } from "../credential-capture.js";
|
|
import { directionOf } from "../device-resolve.js";
|
|
|
|
// Subscription admin CRUD. A subscription 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/subscription.md). A subscription is an
|
|
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
|
|
// them as one unit (create/update replace the child sets; delete removes all).
|
|
//
|
|
// Pricing: priceMinor + period ("monthly") + currency record the recurring plan
|
|
// (e.g. 10,000 ALL / month). Collecting the fee into the ledger/shift is deferred —
|
|
// here we just store the agreed price and the coverage window.
|
|
|
|
interface Credential {
|
|
kind: "rf" | "qr";
|
|
/** For RF: the physical card/tag id (required). For QR: optional — left blank, the
|
|
* server AUTO-GENERATES an unguessable code (the customer never picks it). */
|
|
value?: string;
|
|
}
|
|
interface SubscriptionBody {
|
|
holderName?: string;
|
|
contact?: string;
|
|
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = no price set. */
|
|
priceMinor?: number | null;
|
|
period?: "monthly";
|
|
/** ISO-4217 currency of priceMinor (e.g. "ALL"). */
|
|
currency?: string | null;
|
|
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
|
|
maxConcurrent?: number | null;
|
|
validFrom?: string | null;
|
|
validTo?: string | null;
|
|
/** Months paid for. When set (with validFrom), validTo = validFrom + months — the
|
|
* multi-month case (e.g. 3 months). Takes precedence over an explicit validTo. */
|
|
months?: number | null;
|
|
status?: "active" | "suspended" | "revoked";
|
|
credentials?: Credential[];
|
|
/** Plate binding (optional): bound plates that also serve as identity. */
|
|
plates?: string[];
|
|
}
|
|
|
|
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
|
|
* delivers the full string over TCP/IP (the host-in-the-loop path), so length is
|
|
* free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */
|
|
function newQrCode(): string {
|
|
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
const bytes = randomBytes(15);
|
|
let out = "";
|
|
for (const b of bytes) out += alphabet[b % 32];
|
|
return `SUB-${out}`;
|
|
}
|
|
|
|
/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo →
|
|
* Feb 28/29). Returns ISO. */
|
|
function addMonths(iso: string, months: number): string {
|
|
const d = new Date(iso);
|
|
const day = d.getUTCDate();
|
|
d.setUTCMonth(d.getUTCMonth() + months);
|
|
// If the month rolled past (e.g. day 31 → next month had fewer days), clamp back.
|
|
if (d.getUTCDate() < day) d.setUTCDate(0);
|
|
return d.toISOString();
|
|
}
|
|
|
|
export async function subscriptionRoutes(
|
|
app: FastifyInstance,
|
|
db: Db,
|
|
capture: CredentialCapture,
|
|
): Promise<void> {
|
|
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
|
|
const readGuard = requirePermission("subscription:read");
|
|
const createGuard = requirePermission("subscription:create");
|
|
const updateGuard = requirePermission("subscription:update");
|
|
const deleteGuard = requirePermission("subscription:delete");
|
|
|
|
// Validate the body; returns problems (empty = ok). Shared by create + update.
|
|
function validate(b: SubscriptionBody): 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.priceMinor != null) {
|
|
if (!Number.isInteger(b.priceMinor) || b.priceMinor < 0) {
|
|
errs.push("priceMinor must be a non-negative integer (minor units), or null");
|
|
}
|
|
if (!b.currency?.trim()) {
|
|
errs.push("currency is required when a price is set");
|
|
}
|
|
}
|
|
if (b.period != null && b.period !== "monthly") {
|
|
errs.push("period must be 'monthly' (the only period supported today)");
|
|
}
|
|
if (b.months != null) {
|
|
if (!Number.isInteger(b.months) || b.months < 1) {
|
|
errs.push("months must be a positive integer");
|
|
}
|
|
if (!b.validFrom?.trim()) {
|
|
errs.push("validFrom is required when months is set (validTo = validFrom + months)");
|
|
}
|
|
}
|
|
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") {
|
|
errs.push("each credential needs kind (rf|qr)");
|
|
break;
|
|
}
|
|
// RF must carry the physical card id; QR may be blank (server auto-generates).
|
|
if (c.kind === "rf" && !c.value?.trim()) {
|
|
errs.push("an RF credential needs a non-empty value (the card/tag id)");
|
|
break;
|
|
}
|
|
}
|
|
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
|
|
errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)");
|
|
}
|
|
return errs;
|
|
}
|
|
|
|
function loadAggregate(id: string) {
|
|
const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get();
|
|
if (!sub) return null;
|
|
const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all();
|
|
const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all();
|
|
return {
|
|
...sub,
|
|
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
|
|
plates: plates.map((p) => p.plate),
|
|
};
|
|
}
|
|
|
|
/** Is this credential value already used by ANY subscription? (Global uniqueness —
|
|
* a value is the lane identity, so it must resolve to one subscription.) */
|
|
function valueTaken(value: string): boolean {
|
|
return db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.value, value)).get() != null;
|
|
}
|
|
|
|
/** A fresh, collision-free QR code (retries on the astronomically unlikely clash). */
|
|
function mintQrCode(): string {
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const code = newQrCode();
|
|
if (!valueTaken(code)) return code;
|
|
}
|
|
throw new Error("could not mint a unique QR code");
|
|
}
|
|
|
|
// Replace a subscription's child rows (credentials + plates) from the body. QR
|
|
// credentials with no value are SERVER-GENERATED here (the customer never picks the
|
|
// code). The generated value is returned via loadAggregate so the UI can print it.
|
|
function writeChildren(id: string, b: SubscriptionBody) {
|
|
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
|
|
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
|
|
for (const c of b.credentials ?? []) {
|
|
const supplied = c.value?.trim();
|
|
// QR + blank → auto-generate; otherwise use the supplied value (RF card id, or a
|
|
// QR being preserved on edit).
|
|
const value = supplied && supplied.length > 0 ? supplied : c.kind === "qr" ? mintQrCode() : "";
|
|
if (!value) continue; // guarded by validate(); defensive
|
|
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value }).run();
|
|
}
|
|
for (const p of b.plates ?? []) {
|
|
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
|
|
}
|
|
}
|
|
|
|
/** Resolve the coverage end: months (validFrom + months) wins over an explicit validTo. */
|
|
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
|
|
if (b.months != null && b.validFrom?.trim()) return addMonths(b.validFrom.trim(), b.months);
|
|
if (b.validTo !== undefined) return b.validTo ?? null;
|
|
return fallback;
|
|
}
|
|
|
|
// List all subscriptions (with their credentials + plates).
|
|
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
|
|
const rows = db.select().from(subscriptions).all();
|
|
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
|
|
});
|
|
|
|
// --- Credential capture ("enroll a card") -------------------------------
|
|
// The operator picks a reader and presents an RFID card to it; the next read on
|
|
// that reader is captured for the form instead of opening a barrier. The OTHER
|
|
// reader keeps serving the live flow. Single-shot + TTL. See credential-capture.ts.
|
|
|
|
// The readers the operator can capture on (entry/exit by their bound relay).
|
|
app.get("/api/subscriptions/readers", { preHandler: readGuard }, async () => {
|
|
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
|
|
return {
|
|
readers: rows
|
|
.filter((r) => r.enabled)
|
|
.map((r) => ({ id: r.id, driverId: r.driverId, direction: directionOf(db, r) })),
|
|
};
|
|
});
|
|
|
|
// Arm capture on a reader (by devices.id). Operator-or-admin (booth action).
|
|
app.post<{ Body: { deviceId?: string } }>(
|
|
"/api/subscriptions/capture/arm",
|
|
{ preHandler: readGuard },
|
|
async (req, reply) => {
|
|
const deviceId = (req.body?.deviceId ?? "").trim();
|
|
if (!deviceId) return reply.code(400).send({ error: "deviceId required" });
|
|
const reader = db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
|
if (!reader || reader.category !== "reader" || !reader.enabled) {
|
|
return reply.code(404).send({ error: "no such enabled reader" });
|
|
}
|
|
return capture.arm(deviceId);
|
|
},
|
|
);
|
|
|
|
// Poll the capture state (idle | armed | captured | expired). The form polls this
|
|
// and, on "captured", reads `value` into the credential field then clears it.
|
|
app.get("/api/subscriptions/capture", { preHandler: readGuard }, async () => capture.state());
|
|
|
|
// Operator cancelled / closed the form — disarm and clear any result.
|
|
app.post("/api/subscriptions/capture/cancel", { preHandler: readGuard }, async () => {
|
|
capture.cancel();
|
|
capture.clear();
|
|
return { ok: true };
|
|
});
|
|
|
|
// Create a subscription.
|
|
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
|
|
const b = req.body ?? {};
|
|
const problems = validate(b);
|
|
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
|
const id = randomUUID();
|
|
db.insert(subscriptions)
|
|
.values({
|
|
id,
|
|
holderName: b.holderName ?? null,
|
|
contact: b.contact ?? null,
|
|
priceMinor: b.priceMinor ?? null,
|
|
period: b.period ?? "monthly",
|
|
currency: b.priceMinor != null ? (b.currency ?? null) : null,
|
|
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
|
|
validFrom: b.validFrom ?? null,
|
|
validTo: resolveValidTo(b, null),
|
|
status: b.status ?? "active",
|
|
})
|
|
.run();
|
|
writeChildren(id, b);
|
|
const sub = loadAggregate(id);
|
|
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
|
|
// a print failure NEVER fails the create (the subscription + its code are saved);
|
|
// the response carries { printed, printError } so the UI can warn + offer reprint.
|
|
const printResult = await tryPrintCard(sub);
|
|
return reply.code(201).send({ ...sub, ...printResult });
|
|
});
|
|
|
|
/** The first QR credential's code for a subscription aggregate, or null. */
|
|
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
|
|
const cred = sub?.credentials.find((c) => c.kind === "qr");
|
|
return cred?.value ?? null;
|
|
}
|
|
|
|
/** Best-effort print of a subscription's QR card. Returns a flag + optional error
|
|
* (never throws). No QR credential → nothing to print (printed:false, no error). */
|
|
async function tryPrintCard(
|
|
sub: ReturnType<typeof loadAggregate>,
|
|
): Promise<{ printed: boolean; printedBy?: string; printError?: string }> {
|
|
const code = qrCodeOf(sub);
|
|
if (!sub || !code) return { printed: false };
|
|
try {
|
|
const printedBy = await printSubscriptionCard(
|
|
db,
|
|
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
|
|
app.log,
|
|
);
|
|
return { printed: true, printedBy };
|
|
} catch (err) {
|
|
const printError = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
|
|
app.log.warn(`subscription card print failed for ${sub.id}: ${printError}`);
|
|
return { printed: false, printError };
|
|
}
|
|
}
|
|
|
|
// Update a subscription (replaces fields + child sets).
|
|
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
|
|
"/api/subscriptions/:id",
|
|
{ preHandler: updateGuard },
|
|
async (req, reply) => {
|
|
const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get();
|
|
if (!existing) return reply.code(404).send({ error: "subscription not found" });
|
|
const b = req.body ?? {};
|
|
const problems = validate(b);
|
|
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
|
db.update(subscriptions)
|
|
.set({
|
|
holderName: b.holderName ?? null,
|
|
contact: b.contact ?? null,
|
|
priceMinor: b.priceMinor === undefined ? existing.priceMinor : b.priceMinor,
|
|
period: b.period ?? existing.period,
|
|
currency:
|
|
b.priceMinor === undefined
|
|
? existing.currency
|
|
: b.priceMinor != null
|
|
? (b.currency ?? null)
|
|
: null,
|
|
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
|
validFrom: b.validFrom ?? null,
|
|
validTo: resolveValidTo(b, existing.validTo),
|
|
status: b.status ?? existing.status,
|
|
})
|
|
.where(eq(subscriptions.id, req.params.id))
|
|
.run();
|
|
writeChildren(req.params.id, b);
|
|
// The holder name may have changed — drop the feed-label cache for this sub.
|
|
invalidateHolder(req.params.id);
|
|
return loadAggregate(req.params.id);
|
|
},
|
|
);
|
|
|
|
// Re-print the subscription's QR card (failed auto-print, lost card, re-hand to the
|
|
// customer). Operator-or-admin (it's a booth action, not a master-data edit). 404 if
|
|
// the subscription is gone; 409 if it has no QR credential; 503 if no printer.
|
|
app.post<{ Params: { id: string } }>(
|
|
"/api/subscriptions/:id/print",
|
|
{ preHandler: readGuard },
|
|
async (req, reply) => {
|
|
const sub = loadAggregate(req.params.id);
|
|
if (!sub) return reply.code(404).send({ error: "subscription not found" });
|
|
const code = qrCodeOf(sub);
|
|
if (!code) return reply.code(409).send({ error: "subscription has no QR credential to print" });
|
|
try {
|
|
const printedBy = await printSubscriptionCard(
|
|
db,
|
|
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
|
|
app.log,
|
|
);
|
|
return reply.code(200).send({ ok: true, printedBy });
|
|
} catch (err) {
|
|
if (err instanceof NoPrinterAvailableError) return reply.code(503).send({ error: err.message });
|
|
return reply.code(500).send({ error: (err as Error).message });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Revoke (soft): the common case — keeps the subscription + its history, just bars
|
|
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
|
|
// DELETE only to fully remove one created in error.
|
|
app.post<{ Params: { id: string } }>(
|
|
"/api/subscriptions/:id/revoke",
|
|
{ preHandler: updateGuard },
|
|
async (req, reply) => {
|
|
const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run();
|
|
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
|
|
return loadAggregate(req.params.id);
|
|
},
|
|
);
|
|
|
|
// 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.)
|
|
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();
|
|
invalidateHolder(req.params.id);
|
|
return reply.code(204).send();
|
|
},
|
|
);
|
|
}
|