feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling
Rounds out subscriptions across enrollment, the barrier flow, and the booth.
- RFID credentials enabled with a "Read card" enrollment flow: the operator
arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
reader's next read is captured into the form and NOT dispatched to the access
flow — the OTHER reader keeps serving live entry/exit. Routes:
/api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
per-occurrence id (SUBSESS-<short>), not the credential value, with
permitId in the payload. Direction is decided by the barrier the reader sits
at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
pay/exit modal shows a subscription mode (snapshots + a single audited
Open-barrier action) to assist a faulty exit reader / missing card;
reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
"abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.
Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import { eq, devices, type Db } from "@parking/db";
|
||||
import type { DeviceReadEvent } from "../device-events.js";
|
||||
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||
import type { CredentialCapture } from "../credential-capture.js";
|
||||
|
||||
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
||||
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||
@@ -30,6 +31,7 @@ export async function qrReaderRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
dispatcher: ReadDispatcher,
|
||||
capture: CredentialCapture,
|
||||
): Promise<void> {
|
||||
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
||||
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
||||
@@ -59,10 +61,19 @@ export async function qrReaderRoutes(
|
||||
// Map the reader's serial → its assigned lane_devices row id (the dispatcher
|
||||
// resolves the lane from that row). If unassigned, deviceId stays the serial so
|
||||
// the dispatcher simply finds no lane and rejects (status:0) — never crashes.
|
||||
const deviceId = readerRowIdForSerial(serial) ?? serial;
|
||||
const matchedRowId = readerRowIdForSerial(serial);
|
||||
const deviceId = matchedRowId ?? serial;
|
||||
|
||||
let accepted = false;
|
||||
if (cardid) {
|
||||
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
|
||||
// value for the subscription form and do NOT run the access flow (we must not
|
||||
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
|
||||
// Reads from the OTHER reader are untouched and dispatch normally below.
|
||||
if (capture.tryConsume(deviceId, cardid)) {
|
||||
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
|
||||
accepted = true; // beep "ok" so the operator knows the card was read
|
||||
} else {
|
||||
const read: DeviceReadEvent = {
|
||||
driverId: "gee-qr-reader",
|
||||
deviceId,
|
||||
@@ -73,10 +84,18 @@ export async function qrReaderRoutes(
|
||||
try {
|
||||
const outcome = await dispatcher.dispatch(read);
|
||||
accepted = outcome.accepted;
|
||||
if (!accepted) app.log.info(`QR ${cardid} rejected: ${outcome.reason ?? "?"}`);
|
||||
// Per-read diagnostic: which reader (serial) sent it, which configured device
|
||||
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
|
||||
// the logs (e.g. an entry-side scan resolving to the exit relay).
|
||||
app.log.info(
|
||||
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
|
||||
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
||||
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
|
||||
);
|
||||
} catch (err) {
|
||||
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||
import { NoPrinterAvailableError } from "@parking/devices";
|
||||
import { requireRole } from "../auth.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
|
||||
@@ -64,7 +66,11 @@ function addMonths(iso: string, months: number): string {
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
export async function subscriptionRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
capture: CredentialCapture,
|
||||
): Promise<void> {
|
||||
// Admin manages subscriptions; operator/cashier/readonly may LIST (to look one up).
|
||||
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
const writeGuard = requireRole("admin");
|
||||
@@ -175,6 +181,47 @@ export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<
|
||||
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: writeGuard }, async (req, reply) => {
|
||||
const b = req.body ?? {};
|
||||
|
||||
Reference in New Issue
Block a user