ticket: site metadata header + scannable Albanian ticket; widen barcode
- site_config gains optional park identity (park_name, operator_name, nius, address, phone, email); additive Drizzle migration 0001. GET/PUT /api/site-config read/write the full config (PUT partial patch, admin only); SiteSettings + SetupWizard expose the fields. - renderTicket() prints an Albanian header sourced from site_config, the all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits, and a lost-ticket footer. CP852 codepage so ë/ç render. - Widen the Code128 module width 2->3 and height 80->100 dots so the short-range "Simple" QR/barcode reader decodes reliably (was barely reading at module width 2 on the 80mm head). See wiki/concepts/site-metadata.md and ticket-encoding.md.
This commit is contained in:
@@ -8,6 +8,13 @@
|
||||
# Generate one with: openssl rand -hex 32
|
||||
JWT_SECRET=
|
||||
|
||||
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
|
||||
# Generate with: openssl rand -hex 32
|
||||
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
|
||||
# dev, but set a dedicated key before production. Events store the key that signed
|
||||
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
||||
EVENT_SIGNING_KEY=
|
||||
|
||||
# Optional ----------------------------------------------------------------
|
||||
# PORT=3000
|
||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sessions, type Db, type DeviceRow } from "@parking/db";
|
||||
import { randomInt } from "node:crypto";
|
||||
import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||
import {
|
||||
NoPrinterAvailableError,
|
||||
printWithFailover,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type PrinterDevice,
|
||||
type PrinterInstance,
|
||||
type TicketData,
|
||||
type TicketHeader,
|
||||
} from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
@@ -91,7 +92,7 @@ export class EntryFlow {
|
||||
const printers = this.#loadPrinters();
|
||||
|
||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||
const ticket: TicketData = { ticketId, issuedAt };
|
||||
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||
try {
|
||||
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
||||
d.printTicket(ticket),
|
||||
@@ -182,9 +183,67 @@ export class EntryFlow {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Park identity for the ticket header, from site_config (all fields optional;
|
||||
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
|
||||
#ticketHeader(): TicketHeader | undefined {
|
||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
parkName: row.parkName,
|
||||
operatorName: row.operatorName,
|
||||
nius: row.nius,
|
||||
address: row.address,
|
||||
phone: row.phone,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). */
|
||||
/**
|
||||
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
|
||||
*
|
||||
* Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check
|
||||
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
|
||||
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
|
||||
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
|
||||
* — the anti-fraud property the wiki settles. 12 random digits = 10^12 space, so
|
||||
* collisions are negligible at lot scale; the unique constraints on
|
||||
* ledger_events.index / sessions.id are the backstop. The Luhn digit lets a manual
|
||||
* entry reject a typo (validateTicketCode) instead of failing as "session not found".
|
||||
*/
|
||||
function newTicketId(): string {
|
||||
return `T-${randomUUID()}`;
|
||||
let body = "";
|
||||
for (let i = 0; i < 12; i += 1) body += String(randomInt(10));
|
||||
return body + luhnCheckDigit(body);
|
||||
}
|
||||
|
||||
/** The Luhn (mod-10) check digit for an all-digit string. */
|
||||
function luhnCheckDigit(digits: string): string {
|
||||
let sum = 0;
|
||||
// Walk right-to-left; the check digit sits at position 0 from the right, so the
|
||||
// last body digit is an "even" position that gets doubled.
|
||||
let double = true;
|
||||
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
||||
let d = digits.charCodeAt(i) - 48;
|
||||
if (double) {
|
||||
d *= 2;
|
||||
if (d > 9) d -= 9;
|
||||
}
|
||||
sum += d;
|
||||
double = !double;
|
||||
}
|
||||
return String((10 - (sum % 10)) % 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
|
||||
* Lets a manual-entry path (operator types the code off the ticket when readers are
|
||||
* down) reject a typo up front. A scanned/looked-up id that predates this format
|
||||
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
|
||||
* never reject an id that already exists in the ledger. See ticket-encoding.md.
|
||||
*/
|
||||
export function validateTicketCode(code: string): boolean {
|
||||
if (!/^\d{13}$/.test(code)) return false;
|
||||
const body = code.slice(0, 12);
|
||||
return luhnCheckDigit(body) === code[12];
|
||||
}
|
||||
|
||||
@@ -81,15 +81,24 @@ export function hashEvent(canonical: string): string {
|
||||
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
|
||||
* Returns undefined when the key that signed an event is not available. */
|
||||
export type SignerResolver = (keyId: string) => Signer | undefined;
|
||||
|
||||
export class EventLog {
|
||||
readonly #db: Db;
|
||||
readonly #signer: Signer;
|
||||
/** Picks the verifying signer per event keyId; lets a chain span key rotations
|
||||
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
|
||||
* callers that don't pass one (single-key chains, tests). */
|
||||
readonly #resolveVerifier: SignerResolver;
|
||||
/** Serialize appends: each waits for the previous to finish. */
|
||||
#tail: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(db: Db, signer: Signer) {
|
||||
constructor(db: Db, signer: Signer, resolveVerifier?: SignerResolver) {
|
||||
this.#db = db;
|
||||
this.#signer = signer;
|
||||
this.#resolveVerifier = resolveVerifier ?? (() => signer);
|
||||
}
|
||||
|
||||
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
||||
@@ -146,7 +155,13 @@ export class EventLog {
|
||||
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
|
||||
* first detected break, or { ok: true }. This is what reconciliation and an
|
||||
* integrity self-check call. Catches: tampered content, reordering, a deleted
|
||||
* row (index gap), and a forged/invalid signature.
|
||||
* row (index gap), a forged/invalid signature, and an event signed under a key
|
||||
* that is no longer configured.
|
||||
*
|
||||
* Each row is verified against the signer for ITS OWN `keyId`, not the current
|
||||
* append signer — so a chain that spans a key rotation (e.g. early events under
|
||||
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
|
||||
* verifies end to end. See signer.buildVerifier.
|
||||
*/
|
||||
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
@@ -159,8 +174,16 @@ export class EventLog {
|
||||
if ((row.prevHash ?? null) !== prevHash) {
|
||||
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
|
||||
}
|
||||
const verifier = this.#resolveVerifier(row.keyId);
|
||||
if (!verifier) {
|
||||
return {
|
||||
ok: false,
|
||||
index: row.index,
|
||||
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
|
||||
};
|
||||
}
|
||||
const canonical = canonicalize(row);
|
||||
if (!this.#signer.verify(canonical, row.signature)) {
|
||||
if (!verifier.verify(canonical, row.signature)) {
|
||||
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
|
||||
}
|
||||
prevHash = hashEvent(canonical);
|
||||
|
||||
+172
-82
@@ -51,6 +51,127 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
||||
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
||||
type ConfigureOutcome =
|
||||
| { config: Record<string, unknown>; warnings: string[] }
|
||||
| { error: { code: number; message: string } };
|
||||
|
||||
/**
|
||||
* Validate + configure a device, returning the config to persist. Runs the same
|
||||
* pipeline for both create and edit: validate the driver config, fix
|
||||
* preconditions, harden (relay password + protocol lockdown), and set up input
|
||||
* push (Digest creds + push URLs). Each step is a device write (the device
|
||||
* reboots on apply). The caller owns the DB row; this never touches the DB.
|
||||
*
|
||||
* `id` is the assignment id (stable across an edit) — it's baked into the push
|
||||
* URL, so editing in place keeps the device pushing to the same path.
|
||||
* `existingConfig` carries forward secrets the client never sees on edit
|
||||
* (push/relay passwords), so a PATCH that omits them doesn't wipe them.
|
||||
*/
|
||||
async function configureDevice(
|
||||
app: FastifyInstance,
|
||||
args: {
|
||||
id: string;
|
||||
driverId: string;
|
||||
config: DeviceConfig;
|
||||
backendIp?: string;
|
||||
existingConfig?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<ConfigureOutcome> {
|
||||
const { id, driverId, config, backendIp, existingConfig } = args;
|
||||
|
||||
// Start from any machine-only secrets already on the row (push/relay passwords
|
||||
// are redacted out of the client's copy, so an edit would otherwise drop them),
|
||||
// then layer the submitted config on top.
|
||||
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
|
||||
// The web password the admin typed is a DESIRED value, not a stored fact:
|
||||
// it's passed to the driver (via create(config) below) as the rotation
|
||||
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
|
||||
// secrets.webPassword gets saved — otherwise a failed rotation would leave
|
||||
// the DB claiming a password the device never accepted (login stays old).
|
||||
delete fullConfig.webPassword;
|
||||
// webPasswordCurrent is an input-only credential (the OLD password used to
|
||||
// authorize the change) — never persist it as typed.
|
||||
delete fullConfig.webPasswordCurrent;
|
||||
// Residual-risk warnings from device hardening (shown to the admin; the
|
||||
// save still succeeds — these are "configured, but note X" advisories).
|
||||
const hardenWarnings: string[] = [];
|
||||
|
||||
let device;
|
||||
try {
|
||||
device = registry.create(driverId, config); // validates required fields
|
||||
} catch (err) {
|
||||
return { error: { code: 400, message: (err as Error).message } };
|
||||
}
|
||||
|
||||
// Configure the device on save (before persisting, so we don't store a row
|
||||
// for a device we couldn't configure):
|
||||
// 1. fix preconditions (e.g. disable input_link_relay so a button press
|
||||
// doesn't auto-fire its relay — host must decide first),
|
||||
// 2. harden (relay password + disable unused protocol channels), and
|
||||
// 3. set up input push (Digest creds + push URLs).
|
||||
// Each step is a device config write (the device reboots on apply).
|
||||
try {
|
||||
if (hasPreconditions(device)) {
|
||||
const fixed = await device.fixPreconditions();
|
||||
if (!fixed.ok) {
|
||||
const unfixable = fixed.issues.find((i) => !i.fixable);
|
||||
return {
|
||||
error: {
|
||||
code: 502,
|
||||
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isHardenable(device)) {
|
||||
const { secrets, warnings } = await device.harden();
|
||||
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
||||
// Surface residual-risk warnings (e.g. firmware that won't disable the
|
||||
// password-less string protocol) so the admin can act (web-UI step).
|
||||
for (const w of warnings ?? []) {
|
||||
app.log.warn(`harden(${driverId} ${id}): ${w}`);
|
||||
hardenWarnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPushConfig(device)) {
|
||||
const host = String(config.host ?? "");
|
||||
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
|
||||
const pushHost = backendIp ?? backendIpForDevice(host);
|
||||
if (!pushHost) {
|
||||
return {
|
||||
error: {
|
||||
code: 400,
|
||||
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const pushUser = "dingtian";
|
||||
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
||||
// (longer is silently truncated → auth mismatch), so keep it short.
|
||||
const pushPassword = randomBytes(12).toString("hex");
|
||||
await device.configureInputPush({
|
||||
host: pushHost,
|
||||
port: backendPort(),
|
||||
pathBase: `/api/devices/${driverId}/${id}/input`,
|
||||
auth: { user: pushUser, password: pushPassword },
|
||||
});
|
||||
fullConfig.pushUser = pushUser;
|
||||
fullConfig.pushPassword = pushPassword;
|
||||
// Record the backend IP the device was told to push to — lets us detect
|
||||
// a later mismatch if the host's IP changes.
|
||||
fullConfig.backendIp = pushHost;
|
||||
}
|
||||
} catch (err) {
|
||||
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
|
||||
}
|
||||
|
||||
return { config: fullConfig, warnings: hardenWarnings };
|
||||
}
|
||||
|
||||
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
@@ -166,100 +287,69 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const fullConfig: Record<string, unknown> = { ...config };
|
||||
// The web password the admin typed is a DESIRED value, not a stored fact:
|
||||
// it's passed to the driver (via create(config) below) as the rotation
|
||||
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
|
||||
// secrets.webPassword gets saved — otherwise a failed rotation would leave
|
||||
// the DB claiming a password the device never accepted (login stays old).
|
||||
delete fullConfig.webPassword;
|
||||
// webPasswordCurrent is an input-only credential (the OLD password used to
|
||||
// authorize the change) — never persist it as typed.
|
||||
delete fullConfig.webPasswordCurrent;
|
||||
// Residual-risk warnings from device hardening (shown to the admin; the
|
||||
// save still succeeds — these are "configured, but note X" advisories).
|
||||
const hardenWarnings: string[] = [];
|
||||
|
||||
let device;
|
||||
try {
|
||||
device = registry.create(driverId, config); // validates required fields
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: (err as Error).message });
|
||||
}
|
||||
|
||||
// Configure the device on save (before persisting, so we don't store a row
|
||||
// for a device we couldn't configure):
|
||||
// 1. fix preconditions (e.g. disable input_link_relay so a button press
|
||||
// doesn't auto-fire its relay — host must decide first),
|
||||
// 2. harden (relay password + disable unused protocol channels), and
|
||||
// 3. set up input push (Digest creds + push URLs).
|
||||
// Each step is a device config write (the device reboots on apply).
|
||||
try {
|
||||
if (hasPreconditions(device)) {
|
||||
const fixed = await device.fixPreconditions();
|
||||
if (!fixed.ok) {
|
||||
const unfixable = fixed.issues.find((i) => !i.fixable);
|
||||
return reply.code(502).send({
|
||||
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isHardenable(device)) {
|
||||
const { secrets, warnings } = await device.harden();
|
||||
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
||||
// Surface residual-risk warnings (e.g. firmware that won't disable the
|
||||
// password-less string protocol) so the admin can act (web-UI step).
|
||||
for (const w of warnings ?? []) {
|
||||
app.log.warn(`harden(${driverId} ${id}): ${w}`);
|
||||
hardenWarnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPushConfig(device)) {
|
||||
const host = String(config.host ?? "");
|
||||
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
|
||||
const pushHost = backendIp ?? backendIpForDevice(host);
|
||||
if (!pushHost) {
|
||||
return reply.code(400).send({
|
||||
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
|
||||
});
|
||||
}
|
||||
const pushUser = "dingtian";
|
||||
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
||||
// (longer is silently truncated → auth mismatch), so keep it short.
|
||||
const pushPassword = randomBytes(12).toString("hex");
|
||||
await device.configureInputPush({
|
||||
host: pushHost,
|
||||
port: backendPort(),
|
||||
pathBase: `/api/devices/${driverId}/${id}/input`,
|
||||
auth: { user: pushUser, password: pushPassword },
|
||||
});
|
||||
fullConfig.pushUser = pushUser;
|
||||
fullConfig.pushPassword = pushPassword;
|
||||
// Record the backend IP the device was told to push to — lets us detect
|
||||
// a later mismatch if the host's IP changes.
|
||||
fullConfig.backendIp = pushHost;
|
||||
}
|
||||
} catch (err) {
|
||||
return reply
|
||||
.code(502)
|
||||
.send({ error: `device configuration failed: ${(err as Error).message}` });
|
||||
const outcome = await configureDevice(app, { id, driverId, config, backendIp });
|
||||
if ("error" in outcome) {
|
||||
return reply.code(outcome.error.code).send({ error: outcome.error.message });
|
||||
}
|
||||
|
||||
const row = {
|
||||
id,
|
||||
category,
|
||||
driverId,
|
||||
config: fullConfig,
|
||||
config: outcome.config,
|
||||
enabled: true,
|
||||
};
|
||||
await db.insert(devices).values(row);
|
||||
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
||||
return reply.code(201).send({
|
||||
...row,
|
||||
config: redactSecrets(fullConfig),
|
||||
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}),
|
||||
config: redactSecrets(outcome.config),
|
||||
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Edit an assigned device in place. Same configure pipeline as assign, but it
|
||||
// UPDATEs the existing row and KEEPS the id — which matters for controllers,
|
||||
// since the id is baked into the device's input-push URL
|
||||
// (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and
|
||||
// break push until reconfigured; PATCH re-runs harden/push against the same id.
|
||||
// The category and driver are fixed at create time (an edit can't change what
|
||||
// KIND of device a slot is); only config changes. Admin-only.
|
||||
app.patch<{ Params: { id: string }; Body: Omit<AssignBody, "category" | "driverId"> }>(
|
||||
"/api/setup/assign/:id",
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(devices)
|
||||
.where(eq(devices.id, req.params.id))
|
||||
.get();
|
||||
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
||||
|
||||
const { config, backendIp } = req.body;
|
||||
const outcome = await configureDevice(app, {
|
||||
id: existing.id,
|
||||
driverId: existing.driverId,
|
||||
config,
|
||||
backendIp,
|
||||
// Carry forward machine-only secrets the client never received, so an
|
||||
// edit that omits them doesn't blank out push/relay passwords.
|
||||
existingConfig: existing.config,
|
||||
});
|
||||
if ("error" in outcome) {
|
||||
return reply.code(outcome.error.code).send({ error: outcome.error.message });
|
||||
}
|
||||
|
||||
await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id));
|
||||
app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`);
|
||||
return reply.code(200).send({
|
||||
id: existing.id,
|
||||
category: existing.category,
|
||||
driverId: existing.driverId,
|
||||
config: redactSecrets(outcome.config),
|
||||
enabled: existing.enabled,
|
||||
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,9 +7,36 @@ import { getOccupancy } from "../occupancy.js";
|
||||
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
|
||||
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||
|
||||
interface SiteConfigBody {
|
||||
// Optional park-metadata text fields (all nullable). Trimmed; "" → null.
|
||||
const TEXT_FIELDS = [
|
||||
"parkName",
|
||||
"operatorName",
|
||||
"nius",
|
||||
"address",
|
||||
"phone",
|
||||
"email",
|
||||
] as const;
|
||||
type TextField = (typeof TEXT_FIELDS)[number];
|
||||
|
||||
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||
/** Nominal capacity; null = no limit. */
|
||||
capacity: number | null;
|
||||
capacity?: number | null;
|
||||
}
|
||||
|
||||
/** Shape returned by GET/PUT: capacity + every metadata field (null when unset). */
|
||||
type SiteConfig = { capacity: number | null } & Record<TextField, string | null>;
|
||||
|
||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||
const out = { capacity: row?.capacity ?? null } as SiteConfig;
|
||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Trim a text field; empty string becomes null so blank input clears it. */
|
||||
function normText(v: unknown): string | null {
|
||||
if (v == null) return null;
|
||||
const s = String(v).trim();
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
|
||||
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
@@ -19,25 +46,37 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||
|
||||
// Read site config (capacity).
|
||||
// Read site config (capacity + park metadata).
|
||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return { capacity: row?.capacity ?? null };
|
||||
return toSiteConfig(row);
|
||||
});
|
||||
|
||||
// Set capacity (admin). null or 0+ integer.
|
||||
// Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text
|
||||
// (only the fields PRESENT in the body are updated; absent fields are untouched).
|
||||
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
|
||||
const { capacity } = req.body ?? ({} as SiteConfigBody);
|
||||
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
|
||||
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
|
||||
const body = req.body ?? ({} as SiteConfigBody);
|
||||
|
||||
const patch: Partial<typeof siteConfig.$inferInsert> = {};
|
||||
if ("capacity" in body) {
|
||||
const c = body.capacity;
|
||||
if (c != null && (!Number.isInteger(c) || c < 0)) {
|
||||
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
|
||||
}
|
||||
patch.capacity = c ?? null;
|
||||
}
|
||||
for (const f of TEXT_FIELDS) {
|
||||
if (f in body) patch[f] = normText(body[f]);
|
||||
}
|
||||
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const updatedAt = new Date().toISOString();
|
||||
if (existing) {
|
||||
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run();
|
||||
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||
}
|
||||
return { capacity: capacity ?? null };
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return toSiteConfig(row);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { PermitFlow } from "./permit-flow.js";
|
||||
import { ShiftService } from "./shift-service.js";
|
||||
import { ReadDispatcher } from "./read-dispatch.js";
|
||||
import { PrinterMonitor } from "./printer-monitor.js";
|
||||
import { buildSigner } from "./signer.js";
|
||||
import { buildSigner, buildVerifier } from "./signer.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { deviceRoutes } from "./routes/devices.js";
|
||||
import { eventRoutes } from "./routes/events.js";
|
||||
@@ -86,7 +86,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||
// See wiki/decisions/event-streams-split.md.
|
||||
const eventLog = new EventLog(db, buildSigner(app.log));
|
||||
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier);
|
||||
await eventRoutes(app, db, eventLog);
|
||||
|
||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||
|
||||
@@ -58,3 +58,30 @@ export function buildSigner(log?: { warn: (msg: string) => void }): Signer {
|
||||
"event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the signer that can VERIFY an existing event, by its stored `keyId`.
|
||||
* Appends always use the one signer from buildSigner(), but a chain can contain
|
||||
* events signed under different keys across a rotation (e.g. the JWT_SECRET
|
||||
* fallback before a dedicated EVENT_SIGNING_KEY was set, or an ATECC608 swap).
|
||||
* Each event stores its own `keyId`, so verifyChain() must check each row against
|
||||
* the key that produced it — not the current append-signer. Returns undefined for
|
||||
* an unknown keyId (the key is gone / not configured), which verifyChain surfaces
|
||||
* as a distinct failure rather than a false "tampered" alarm.
|
||||
*
|
||||
* TODO(atecc608): add an "atecc608-slotN" case returning a public-key verifier.
|
||||
*/
|
||||
export function buildVerifier(keyId: string): Signer | undefined {
|
||||
switch (keyId) {
|
||||
case "sw-hmac-v2": {
|
||||
const k = process.env.EVENT_SIGNING_KEY;
|
||||
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-v2") : undefined;
|
||||
}
|
||||
case "sw-hmac-jwtfallback": {
|
||||
const k = process.env.JWT_SECRET;
|
||||
return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-jwtfallback") : undefined;
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
assignDevice,
|
||||
editDevice,
|
||||
discoverDevices,
|
||||
fetchBackendIps,
|
||||
fetchCatalog,
|
||||
@@ -127,8 +128,12 @@ function CategorySection({
|
||||
onChanged: () => Promise<void> | void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const showForm = adding || assignments.length === 0;
|
||||
const editing = editingId ? assignments.find((a) => a.id === editingId) : undefined;
|
||||
// Show the add form for an empty category or an explicit "+ Add", but not while
|
||||
// editing an existing row (that row renders its own inline form).
|
||||
const showForm = !editing && (adding || assignments.length === 0);
|
||||
|
||||
// Binding categories need a controller to point at first.
|
||||
const isBound = category !== "access";
|
||||
@@ -162,15 +167,43 @@ function CategorySection({
|
||||
|
||||
{assignments.length > 0 && (
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
||||
{assignments.map((a) => (
|
||||
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} />
|
||||
))}
|
||||
{assignments.map((a) =>
|
||||
editingId === a.id ? (
|
||||
<li key={a.id} style={{ listStyle: "none", padding: 0 }}>
|
||||
<DeviceForm
|
||||
category={category}
|
||||
entries={entries}
|
||||
discoverableIds={discoverableIds}
|
||||
pushCapableIds={pushCapableIds}
|
||||
controllers={controllers}
|
||||
editing={a}
|
||||
onSaved={async (w) => {
|
||||
setWarnings(w);
|
||||
await onChanged();
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
</li>
|
||||
) : (
|
||||
<AssignmentRow
|
||||
key={a.id}
|
||||
assignment={a}
|
||||
controllers={controllers}
|
||||
onChanged={onChanged}
|
||||
onEdit={() => {
|
||||
setAdding(false);
|
||||
setEditingId(a.id);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{blockedNoController ? (
|
||||
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
||||
) : showForm ? (
|
||||
) : editing ? null : showForm ? (
|
||||
<DeviceForm
|
||||
category={category}
|
||||
entries={entries}
|
||||
@@ -197,10 +230,12 @@ function AssignmentRow({
|
||||
assignment,
|
||||
controllers,
|
||||
onChanged,
|
||||
onEdit,
|
||||
}: {
|
||||
assignment: Assignment;
|
||||
controllers: Assignment[];
|
||||
onChanged: () => Promise<void> | void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -237,6 +272,9 @@ function AssignmentRow({
|
||||
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
||||
<button type="button" onClick={onEdit} disabled={removing}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={remove} disabled={removing}>
|
||||
{removing ? "Removing…" : "Remove"}
|
||||
</button>
|
||||
@@ -280,6 +318,7 @@ function DeviceForm({
|
||||
discoverableIds,
|
||||
pushCapableIds,
|
||||
controllers,
|
||||
editing,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
@@ -288,21 +327,42 @@ function DeviceForm({
|
||||
discoverableIds: string[];
|
||||
pushCapableIds: string[];
|
||||
controllers: Assignment[];
|
||||
/** When set, the form edits this assignment in place (driver locked, config
|
||||
* pre-filled) instead of adding a new device. */
|
||||
editing?: Assignment;
|
||||
onSaved: (warnings: string[]) => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
// On edit the driver is fixed (you can't change what KIND of device a slot is —
|
||||
// that's a remove + re-add); pre-select it and lock the picker.
|
||||
const editCfg = editing?.config as Record<string, unknown> | undefined;
|
||||
const [selectedId, setSelectedId] = useState<string>(editing?.driverId ?? "");
|
||||
const selected = entries.find((e) => e.id === selectedId);
|
||||
const canDiscover = selected != null && discoverableIds.includes(selected.id);
|
||||
const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id);
|
||||
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
||||
const isController = category === "access";
|
||||
|
||||
const [config, setConfig] = useState<Record<string, string | number>>({});
|
||||
// Pre-fill scalar config fields from the existing assignment when editing.
|
||||
// (relays/controllerId/relay are model fields handled by their own state below.)
|
||||
const [config, setConfig] = useState<Record<string, string | number>>(() => {
|
||||
if (!editCfg) return {};
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const [k, v] of Object.entries(editCfg)) {
|
||||
if (typeof v === "string" || typeof v === "number") out[k] = v;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
// Controllers: the relay map (which relay = entry/exit/both, + entry button terminal).
|
||||
const [relays, setRelays] = useState<RelaySpec[]>([{ relay: 1, direction: "both" }]);
|
||||
const [relays, setRelays] = useState<RelaySpec[]>(() =>
|
||||
Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }],
|
||||
);
|
||||
// Bound devices: which controller + relay this device sits at.
|
||||
const [controllerId, setControllerId] = useState<string>("");
|
||||
const [boundRelay, setBoundRelay] = useState<number | "">("");
|
||||
const [controllerId, setControllerId] = useState<string>(
|
||||
typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "",
|
||||
);
|
||||
const [boundRelay, setBoundRelay] = useState<number | "">(
|
||||
typeof editCfg?.relay === "number" ? editCfg.relay : "",
|
||||
);
|
||||
|
||||
const [tested, setTested] = useState<TestResult | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
@@ -420,12 +480,17 @@ function DeviceForm({
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = await assignDevice({
|
||||
category,
|
||||
driverId: selected.id,
|
||||
config: mergedConfig(),
|
||||
...(backendIp ? { backendIp } : {}),
|
||||
});
|
||||
const result = editing
|
||||
? await editDevice(editing.id, {
|
||||
config: mergedConfig(),
|
||||
...(backendIp ? { backendIp } : {}),
|
||||
})
|
||||
: await assignDevice({
|
||||
category,
|
||||
driverId: selected.id,
|
||||
config: mergedConfig(),
|
||||
...(backendIp ? { backendIp } : {}),
|
||||
});
|
||||
await onSaved(result.warnings ?? []);
|
||||
} catch (e) {
|
||||
setSaveError((e as Error).message);
|
||||
@@ -439,7 +504,9 @@ function DeviceForm({
|
||||
{entries.length === 0 ? (
|
||||
<em>No drivers registered.</em>
|
||||
) : (
|
||||
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}>
|
||||
// Driver is locked when editing — changing the kind of device is a
|
||||
// remove + re-add, not an in-place edit.
|
||||
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)} disabled={!!editing}>
|
||||
<option value="" disabled>
|
||||
Choose a device…
|
||||
</option>
|
||||
@@ -538,7 +605,7 @@ function DeviceForm({
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
</button>
|
||||
<button type="button" onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save & configure"}
|
||||
{saving ? "Saving…" : editing ? "Save changes" : "Save & configure"}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" onClick={onCancel} disabled={saving}>
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchOccupancy, fetchSiteConfig, setCapacity, type Occupancy } from "./api.js";
|
||||
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
||||
|
||||
// Live occupancy + capacity. Occupancy is shown to everyone (it's a fold over the
|
||||
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse
|
||||
// transient entry at capacity) is enforced server-side in the entry flow.
|
||||
// See wiki/concepts/capacity-occupancy.md.
|
||||
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
||||
// The FULL gate (refuse transient entry at capacity) is enforced server-side in the
|
||||
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
|
||||
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
|
||||
|
||||
// The optional text fields, in display order, with labels + placeholders.
|
||||
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; label: string; placeholder?: string; multiline?: boolean }> = [
|
||||
{ key: "parkName", label: "Park name", placeholder: "e.g. Acme Parking" },
|
||||
{ key: "operatorName", label: "Operator (legal name)", placeholder: "operating company" },
|
||||
{ key: "nius", label: "NIUS", placeholder: "e.g. L01234567A" },
|
||||
{ key: "address", label: "Address", multiline: true },
|
||||
{ key: "phone", label: "Phone" },
|
||||
{ key: "email", label: "Email" },
|
||||
];
|
||||
|
||||
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
const [occ, setOcc] = useState<Occupancy | null>(null);
|
||||
const [capInput, setCapInput] = useState("");
|
||||
const [meta, setMeta] = useState<Record<string, string>>({});
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
function reload() {
|
||||
@@ -17,18 +29,25 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
useEffect(() => {
|
||||
reload();
|
||||
fetchSiteConfig()
|
||||
.then((c) => setCapInput(c.capacity == null ? "" : String(c.capacity)))
|
||||
.then((c) => {
|
||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||
const m: Record<string, string> = {};
|
||||
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
||||
setMeta(m);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
const raw = capInput.trim();
|
||||
const capacity = raw === "" ? null : Math.round(Number(raw));
|
||||
const patch: Partial<SiteConfig> = { capacity: raw === "" ? null : Math.round(Number(raw)) };
|
||||
// Send each metadata field; "" → null is applied server-side.
|
||||
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
||||
try {
|
||||
await setCapacity(capacity);
|
||||
await saveSiteConfig(patch);
|
||||
reload();
|
||||
setMsg("Capacity saved.");
|
||||
setMsg("Saved.");
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
@@ -51,13 +70,37 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div style={{ marginTop: "0.6rem" }}>
|
||||
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
|
||||
<label>
|
||||
Capacity (blank = no limit):{" "}
|
||||
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
|
||||
</label>{" "}
|
||||
<button type="button" onClick={save}>Save</button>
|
||||
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
||||
</label>
|
||||
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
|
||||
Park details (optional — shown on tickets/receipts)
|
||||
</div>
|
||||
{META_FIELDS.map(({ key, label, placeholder, multiline }) => (
|
||||
<label key={key} style={{ display: "flex", flexDirection: "column", fontSize: "0.85rem" }}>
|
||||
{label}
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={meta[key] ?? ""}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||
rows={2}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={meta[key] ?? ""}
|
||||
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
<div>
|
||||
<button type="button" onClick={save}>Save</button>
|
||||
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
+28
-3
@@ -186,6 +186,15 @@ export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
||||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
/** Re-configure an existing device in place, keeping its id (and so its push
|
||||
* URL). Category/driver are fixed at create time, so only config changes. */
|
||||
export function editDevice(
|
||||
id: string,
|
||||
body: Omit<AssignBody, "category" | "driverId">,
|
||||
): Promise<AssignResult> {
|
||||
return apiFetch(`/api/setup/assign/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
|
||||
export interface Assignment {
|
||||
id: string;
|
||||
@@ -333,12 +342,28 @@ export interface Occupancy {
|
||||
full: boolean;
|
||||
}
|
||||
|
||||
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
|
||||
export interface SiteConfig {
|
||||
capacity: number | null;
|
||||
parkName: string | null;
|
||||
operatorName: string | null;
|
||||
/** NIUS — Albanian tax/identification number. */
|
||||
nius: string | null;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export function fetchOccupancy(): Promise<Occupancy> {
|
||||
return apiFetch("/api/occupancy");
|
||||
}
|
||||
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
|
||||
export function fetchSiteConfig(): Promise<SiteConfig> {
|
||||
return apiFetch("/api/site-config");
|
||||
}
|
||||
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> {
|
||||
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) });
|
||||
/** PUT a partial config — only the fields supplied are changed. */
|
||||
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
|
||||
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
|
||||
}
|
||||
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||||
return saveSiteConfig({ capacity });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE `site_config` ADD `park_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `operator_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `nius` text;--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `address` text;--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `phone` text;--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `email` text;
|
||||
@@ -0,0 +1,797 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "2cfc13fa-43fc-4f89-8438-7b9bcaf7ea3b",
|
||||
"prevId": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
|
||||
"tables": {
|
||||
"blocklist": {
|
||||
"name": "blocklist",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active": {
|
||||
"name": "active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"added_by": {
|
||||
"name": "added_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"added_at": {
|
||||
"name": "added_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"device_events": {
|
||||
"name": "device_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"device_id": {
|
||||
"name": "device_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"detail": {
|
||||
"name": "detail",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"occurred_at": {
|
||||
"name": "occurred_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"devices": {
|
||||
"name": "devices",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"driver_id": {
|
||||
"name": "driver_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"ledger_events": {
|
||||
"name": "ledger_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"index": {
|
||||
"name": "index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identity": {
|
||||
"name": "identity",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"payload": {
|
||||
"name": "payload",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"occurred_at": {
|
||||
"name": "occurred_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"prev_hash": {
|
||||
"name": "prev_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"signature": {
|
||||
"name": "signature",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key_id": {
|
||||
"name": "key_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ledger_events_index_unique": {
|
||||
"name": "ledger_events_index_unique",
|
||||
"columns": [
|
||||
"index"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"permit_credentials": {
|
||||
"name": "permit_credentials",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"permit_id": {
|
||||
"name": "permit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"permit_plates": {
|
||||
"name": "permit_plates",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"permit_id": {
|
||||
"name": "permit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plate": {
|
||||
"name": "plate",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"permits": {
|
||||
"name": "permits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"holder_name": {
|
||||
"name": "holder_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"contact": {
|
||||
"name": "contact",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"max_concurrent": {
|
||||
"name": "max_concurrent",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"valid_from": {
|
||||
"name": "valid_from",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"valid_to": {
|
||||
"name": "valid_to",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identity": {
|
||||
"name": "identity",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"permit_id": {
|
||||
"name": "permit_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entered_at": {
|
||||
"name": "entered_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"exited_at": {
|
||||
"name": "exited_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'open'"
|
||||
},
|
||||
"last_event_index": {
|
||||
"name": "last_event_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"setup_state": {
|
||||
"name": "setup_state",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"site_config": {
|
||||
"name": "site_config",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"capacity": {
|
||||
"name": "capacity",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"park_name": {
|
||||
"name": "park_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operator_name": {
|
||||
"name": "operator_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"nius": {
|
||||
"name": "nius",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"address": {
|
||||
"name": "address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"snapshots": {
|
||||
"name": "snapshots",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"direction": {
|
||||
"name": "direction",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"device_id": {
|
||||
"name": "device_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identity": {
|
||||
"name": "identity",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content_type": {
|
||||
"name": "content_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"bytes": {
|
||||
"name": "bytes",
|
||||
"type": "blob",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"captured_at": {
|
||||
"name": "captured_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tariff_versions": {
|
||||
"name": "tariff_versions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tariff_id": {
|
||||
"name": "tariff_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"effective_from": {
|
||||
"name": "effective_from",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"structure": {
|
||||
"name": "structure",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tariffs": {
|
||||
"name": "tariffs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'site'"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1781632874398,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1781682176094,
|
||||
"tag": "0001_neat_slipstream",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -131,9 +131,26 @@ export const setupState = sqliteTable("setup_state", {
|
||||
// Single-row site settings (admin-configurable). The home for site-wide knobs;
|
||||
// `capacity` is the nominal space count the FULL gate refuses transient entry at
|
||||
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
|
||||
// Park identity/metadata (all optional) lives here too — display name, the legal
|
||||
// operator, the NIUS tax id, address and contact. These feed the ticket/receipt
|
||||
// header (park name + NIUS are commonly required on an Albanian parking receipt)
|
||||
// and admin display. All nullable: the lot runs fine with none set.
|
||||
// See wiki/concepts/site-metadata.md.
|
||||
export const siteConfig = sqliteTable("site_config", {
|
||||
id: integer("id").primaryKey(), // always 1
|
||||
capacity: integer("capacity"), // null = no capacity limit
|
||||
/** Park display name shown on the ticket header / UI (e.g. "Acme Parking"). */
|
||||
parkName: text("park_name"),
|
||||
/** Legal entity operating the lot, for receipts (may differ from parkName). */
|
||||
operatorName: text("operator_name"),
|
||||
/** NIUS — Albanian tax/identification number, printed on the receipt when set. */
|
||||
nius: text("nius"),
|
||||
/** Free-text postal address (multi-line allowed). */
|
||||
address: text("address"),
|
||||
/** Contact phone — also used for the ticket "lost ticket? call …" footer. */
|
||||
phone: text("phone"),
|
||||
/** Contact email. */
|
||||
email: text("email"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
|
||||
@@ -40,15 +40,91 @@ const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
|
||||
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
|
||||
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
|
||||
|
||||
/** Encode a printable line as bytes (CP437/ASCII subset) + a line feed. */
|
||||
// Select code page 852 (Latin-2) for the character set: ESC t n, n=18 (0x12).
|
||||
// CP852 carries the Albanian letters we print (ë, ç, …); without it the printer
|
||||
// would interpret our high bytes as CP437 glyphs. Sent in every print's INIT
|
||||
// preamble. See wiki/concepts/site-metadata.md (i18n / codepage).
|
||||
const SELECT_CP852 = Buffer.from([ESC, 0x74, 0x12]);
|
||||
|
||||
// Minimal Unicode → CP852 byte map for the characters Albanian text actually uses
|
||||
// beyond ASCII. Anything not listed is transliterated to an ASCII fallback (below)
|
||||
// so we never emit a byte that renders as the wrong glyph. Extend as needed.
|
||||
const CP852: Record<string, number> = {
|
||||
ë: 0x89, Ë: 0xeb,
|
||||
ç: 0x87, Ç: 0x80,
|
||||
// common Latin-2 extras that may appear in a park name/address:
|
||||
ä: 0x84, ö: 0x94, ü: 0x81, é: 0x82, á: 0xa0, í: 0xa1, ó: 0xa2, ú: 0xa3,
|
||||
};
|
||||
// ASCII transliteration for any char with no CP852 mapping (last-resort, so an
|
||||
// odd glyph degrades to a readable letter rather than garbage).
|
||||
const ASCII_FALLBACK: Record<string, string> = {
|
||||
ë: "e", Ë: "E", ç: "c", Ç: "C", ä: "a", ö: "o", ü: "u",
|
||||
é: "e", á: "a", í: "i", ó: "o", ú: "u",
|
||||
};
|
||||
|
||||
/** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes
|
||||
* through; mapped chars use their CP852 byte; unmapped non-ASCII falls back to an
|
||||
* ASCII letter. Pair with SELECT_CP852 in the print preamble. */
|
||||
function line(text = ""): Buffer {
|
||||
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
|
||||
const out: number[] = [];
|
||||
for (const ch of text) {
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
const mapped = CP852[ch];
|
||||
const fallback = ASCII_FALLBACK[ch];
|
||||
if (code < 0x80) {
|
||||
out.push(code);
|
||||
} else if (mapped !== undefined) {
|
||||
out.push(mapped);
|
||||
} else if (fallback !== undefined) {
|
||||
out.push(...Buffer.from(fallback, "ascii"));
|
||||
} else {
|
||||
out.push(0x3f); // "?" — unknown char, never a wrong glyph
|
||||
}
|
||||
}
|
||||
out.push(LF);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
// --- Scannable symbol (printer-generated, no image rendering) -----------------
|
||||
// The ticket id is the session key (wiki/concepts/ticket-encoding.md). We print it
|
||||
// as a 1D Code128 barcode so ANY legacy laser barcode scanner the booth might have
|
||||
// can read it. The barcode is rendered by the Rongta board from these ESC/POS
|
||||
// commands — we send the data, the firmware draws the bars (no bitmap, no
|
||||
// dependency). The same code is printed as large human-readable digits below, so
|
||||
// the operator can hand-key it if every reader fails. (A QR for phone scanning may
|
||||
// be added later behind an admin toggle.)
|
||||
|
||||
/** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data. */
|
||||
function code128(data: string): Buffer {
|
||||
// Code128 code set B (printable ASCII) — prefix the data with the {B selector.
|
||||
const payload = Buffer.from(`{B${data}`, "ascii");
|
||||
return Buffer.concat([
|
||||
Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle)
|
||||
Buffer.from([GS, 0x77, 0x03]), // GS w 3 — module width = 3 (wider bars for the short-range "Simple" QR/barcode engine; 13-digit Code128 ≈ 495/576 dots, fits 80mm with quiet zones)
|
||||
Buffer.from([GS, 0x48, 0x00]), // GS H 0 — HRI text off (we print the id ourselves)
|
||||
// GS k 73 n <data> — function B form: 73 = Code128, n = data byte length.
|
||||
Buffer.from([GS, 0x6b, 0x49, payload.length]),
|
||||
payload,
|
||||
]);
|
||||
}
|
||||
|
||||
// Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in
|
||||
// one place so a real i18n layer (per-locale tables + a t() helper) can replace this
|
||||
// later without touching the render functions. See wiki/concepts/site-metadata.md.
|
||||
const STR = {
|
||||
/** NIUS label prefix; printed only when the park has a NIUS. */
|
||||
nius: (v: string) => `NIUS: ${v}`,
|
||||
/** "Printed at:" — precedes the issue timestamp. */
|
||||
issuedAt: (v: string) => `Printuar më: ${v}`,
|
||||
/** "Lost your ticket? <phone>" footer; printed only when a phone is set. */
|
||||
lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`,
|
||||
} as const;
|
||||
|
||||
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
||||
function renderReport(report: PrintReport): Buffer {
|
||||
return Buffer.concat([
|
||||
INIT,
|
||||
SELECT_CP852,
|
||||
ALIGN_CENTER,
|
||||
BOLD_ON,
|
||||
line(report.title),
|
||||
@@ -60,23 +136,44 @@ function renderReport(report: PrintReport): Buffer {
|
||||
]);
|
||||
}
|
||||
|
||||
/** Build the full ESC/POS byte stream for an entry ticket. */
|
||||
/** Render the park-identity header from site metadata. Prints the park name large
|
||||
* (or "PARKING" if unset), then operator / NIUS / address lines that are present.
|
||||
* NIUS and the rest only print when set. Non-ASCII renders via CP852 (see line()). */
|
||||
function renderHeader(h: TicketData["header"]): Buffer {
|
||||
const parts: Buffer[] = [ALIGN_CENTER, BOLD_ON, DOUBLE_ON, line(h?.parkName || "PARKING"), DOUBLE_OFF, BOLD_OFF];
|
||||
if (h?.operatorName) parts.push(line(h.operatorName));
|
||||
if (h?.nius) parts.push(line(STR.nius(h.nius)));
|
||||
if (h?.address) {
|
||||
// Address may be multi-line; print each line centered.
|
||||
for (const ln of h.address.split(/\r?\n/)) if (ln.trim()) parts.push(line(ln.trim()));
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** Build the full ESC/POS byte stream for an entry ticket.
|
||||
* Header (park identity) → 1D Code128 barcode of the ticket id → the id in large
|
||||
* digits → issue time → optional lost-ticket footer. Code128 is read by ANY legacy
|
||||
* 1D barcode scanner the booth might have; the printed digits are the fallback if
|
||||
* every reader fails (operator hand-keys the all-numeric code). Text is Albanian.
|
||||
* See wiki/concepts/ticket-encoding.md and site-metadata.md. */
|
||||
function renderTicket(data: TicketData): Buffer {
|
||||
return Buffer.concat([
|
||||
INIT,
|
||||
ALIGN_CENTER,
|
||||
SELECT_CP852,
|
||||
renderHeader(data.header),
|
||||
line(),
|
||||
// The scannable barcode + the same code in large human-readable digits.
|
||||
code128(data.ticketId),
|
||||
line(),
|
||||
BOLD_ON,
|
||||
DOUBLE_ON,
|
||||
line("PARKING"),
|
||||
line(data.ticketId),
|
||||
DOUBLE_OFF,
|
||||
BOLD_OFF,
|
||||
line(),
|
||||
BOLD_ON,
|
||||
line(data.ticketId),
|
||||
BOLD_OFF,
|
||||
ALIGN_LEFT,
|
||||
line(),
|
||||
line(`Issued: ${data.issuedAt}`),
|
||||
line(STR.issuedAt(data.issuedAt)),
|
||||
// Contact footer (lost-ticket help) if a phone is set.
|
||||
...(data.header?.phone ? [line(STR.lostTicket(data.header.phone))] : []),
|
||||
FEED_AND_CUT,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -190,9 +190,23 @@ export interface Snapshot {
|
||||
}
|
||||
|
||||
// --- Printers (ticket dispenser / booth printer) -------------------------
|
||||
/** Optional park identity printed at the top of a ticket/receipt. All fields
|
||||
* optional — the driver prints only what's set. Sourced from site_config; an
|
||||
* Albanian parking receipt commonly must show the park name + NIUS. */
|
||||
export interface TicketHeader {
|
||||
readonly parkName?: string | null;
|
||||
readonly operatorName?: string | null;
|
||||
/** NIUS — Albanian tax/identification number. */
|
||||
readonly nius?: string | null;
|
||||
readonly address?: string | null;
|
||||
readonly phone?: string | null;
|
||||
}
|
||||
|
||||
export interface TicketData {
|
||||
readonly ticketId: string;
|
||||
readonly issuedAt: string; // ISO-8601
|
||||
/** Park identity for the header. Absent → driver prints the generic "PARKING". */
|
||||
readonly header?: TicketHeader;
|
||||
}
|
||||
|
||||
export interface PrinterDevice extends Device {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, config, devices]
|
||||
sources: []
|
||||
updated: 2026-06-17
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Site Metadata (Park Identity)
|
||||
|
||||
Optional, admin-set identity/metadata for the park itself, beyond the operational
|
||||
`capacity` knob. Feeds the ticket/receipt header and admin display. All fields are
|
||||
**optional** — the lot runs fine with none set (the ticket falls back to a generic
|
||||
`PARKING` header).
|
||||
|
||||
## Where it lives
|
||||
|
||||
A single-row extension of the existing **`site_config`** table (`id` always 1) — the
|
||||
established home for site-wide knobs ([[capacity-occupancy]]). **Not** a new table:
|
||||
park identity is one-per-site, same cardinality as capacity, so it shares the row.
|
||||
|
||||
| Column | Purpose |
|
||||
| --- | --- |
|
||||
| `park_name` | Display name on the ticket header / UI (e.g. "Acme Parking"). |
|
||||
| `operator_name` | Legal entity operating the lot — for receipts; may differ from the display name. |
|
||||
| `nius` | **NIUS** — Albanian tax/identification number, printed on the receipt when set (commonly required). |
|
||||
| `address` | Free-text postal address (multi-line allowed; printed line-by-line). |
|
||||
| `phone` | Contact phone — also the ticket "Keni humbur biletën?" footer. |
|
||||
| `email` | Contact email (stored; not yet printed). |
|
||||
|
||||
All are **nullable `text`**. Added in migration `0001` (additive `ADD COLUMN`, no
|
||||
data loss). A **metadata change is not a schema change for the ticket id**, but
|
||||
adding these *fields* IS a schema change — done via a Drizzle migration.
|
||||
|
||||
> **Field history.** The first cut (2026-06-17) had `vat_number` + `registration_number`.
|
||||
> Renamed/trimmed the same day to a single `nius` column (Albanian deployments call the
|
||||
> tax id NIUS; registration number dropped as unused). Migration `0001` was regenerated
|
||||
> in place — it had not shipped beyond the dev DB, so there is no migration debt.
|
||||
|
||||
## Read / write path
|
||||
|
||||
- **API**: `GET /api/site-config` returns capacity + every metadata field (null when
|
||||
unset). `PUT /api/site-config` (**admin only**) accepts a **partial** body — only the
|
||||
fields present are updated; blank string → null (clears). `apps/server/src/routes/site.ts`.
|
||||
- **UI**: `apps/web/src/SiteSettings.tsx` — admin edits capacity + the metadata fields
|
||||
in one form (`saveSiteConfig`).
|
||||
- **API client**: `SiteConfig` type + `fetchSiteConfig` / `saveSiteConfig` in `apps/web/src/api.ts`.
|
||||
|
||||
## On the ticket
|
||||
|
||||
`renderTicket()` ([[rongta-printer]]) prints a header from a `TicketHeader` (the metadata):
|
||||
park name large (or `PARKING` if unset), then operator / `NIUS: <n>` / address lines
|
||||
that are present; a `Keni humbur biletën? <phone>` footer if a phone is set. The entry
|
||||
flow (`#ticketHeader()` in `apps/server/src/entry-flow.ts`) reads `site_config` per print.
|
||||
See [[ticket-encoding]].
|
||||
|
||||
## Localisation (Albanian)
|
||||
|
||||
The ticket prints in **Albanian** for now. Strings are centralised in a `STR` table in
|
||||
[[rongta-printer]] (`Printuar më:`, `Keni humbur biletën?`, `NIUS:`) so a real i18n layer
|
||||
(per-locale tables + a `t()` helper, covering the web UI too) can replace them later
|
||||
without touching the render functions — that broader site translation is the next step.
|
||||
|
||||
**Codepage (resolved 2026-06-17).** Albanian text needs `ë`/`ç`, which ASCII can't carry.
|
||||
The driver now selects **CP852 (Latin-2)** via `ESC t 18` in each print preamble and
|
||||
`line()` encodes text to CP852 (with an ASCII transliteration fallback for anything
|
||||
unmapped, and `?` as a last resort — never a wrong glyph). Verified at byte level: `ë` →
|
||||
`0x89` in "Printuar më" / "biletën" / a sample address.
|
||||
|
||||
## Open
|
||||
|
||||
- **Receipt vs entry ticket** — the same header is used for the entry ticket today;
|
||||
a paid receipt may want more (fee, tariff version, paid-at). Design with [[tariff]].
|
||||
- **Email** is stored but not yet printed (no use decided).
|
||||
- **Full i18n** — only the ticket is Albanian so far; the web UI is still English. A
|
||||
proper locale system (and admin language choice) is the broader task this seeds.
|
||||
- **CP852 coverage** — the map covers the common Albanian/Latin-2 letters; extend if a
|
||||
park name/address uses a glyph outside it (currently transliterated to ASCII).
|
||||
@@ -21,14 +21,35 @@ must have:
|
||||
- **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim
|
||||
someone else's cheaper session). Sequential **physical** stock numbering is a separate
|
||||
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
|
||||
- **All-numeric** (as-built 2026-06-17) — so ANY legacy 1D barcode scanner reads it and an operator
|
||||
can hand-key it. Random (not sequential), so "all-numeric" does not weaken the unguessable
|
||||
property. Format: **13 digits = 12 cryptographically-random digits + 1 Luhn check digit**
|
||||
(10^12 space → negligible collisions at lot scale; the Luhn digit lets manual entry reject a typo
|
||||
rather than fail as "session not found"). `newTicketId()` in `apps/server/src/entry-flow.ts`;
|
||||
validate with `validateTicketCode()` (gate MANUAL entry only — a scanned/looked-up id already in
|
||||
the ledger is authoritative regardless of format).
|
||||
- **Format is a property of minting, not the schema** — `identity` / `sessions.id` are free-form
|
||||
`text`, so changing the id format is a code change with **no migration**. Legacy `T-<uuid>` ids
|
||||
(pre-2026-06-17) remain valid keys and coexist with numeric ones.
|
||||
- **Single logical session** — scanning it at the pay station finds the open session; after payment
|
||||
it's the proof-of-paid the exit checks.
|
||||
|
||||
## Encoding: QR (preferred) — printed by the booth dispenser
|
||||
## Encoding: Code128 numeric barcode — printed by the booth dispenser
|
||||
|
||||
- The [[rongta-printer]] prints the ticket id as a **2D barcode (QR)** plus human-readable text and
|
||||
entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap
|
||||
camera/imager to read.
|
||||
- The [[rongta-printer]] prints the ticket id as a **1D Code128 barcode** (the all-numeric code),
|
||||
with the **same code in large human-readable digits below it**, then the entry time. Code128 over
|
||||
QR for the primary symbology because the booth's reader hardware is unknown and a legacy 1D laser
|
||||
scanner is the lowest common denominator — and the printed digits mean total reader failure still
|
||||
leaves a hand-keyable code. A **QR for phone/imager scanning may be added later behind an admin
|
||||
toggle** (deferred — see Open).
|
||||
|
||||
> **As-built (2026-06-17).** `renderTicket()` in [[rongta-printer]]
|
||||
> (`packages/devices/src/drivers/printer-rongta.ts`) emits the Code128 via ESC/POS `GS k` (code set
|
||||
> B) — **rendered by the printer firmware**, so there is no image-rendering step and no new
|
||||
> dependency (keeps the MIT/Apache/BSD constraint). Resilience rationale: the booth's reader is
|
||||
> uncertain, so the id is carried in two independently-readable forms (1D barcode / printed digits).
|
||||
> The "operator scans with a phone" path reuses the
|
||||
> existing dispatch flow ([[entry-exit-readers]]) and is tracked separately (not yet built).
|
||||
- **Scan points** (both host-side reads — [[entry-exit-readers]]):
|
||||
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
|
||||
payment ([[tariff]], pay-on-foot) → appends `payment`.
|
||||
@@ -51,7 +72,14 @@ isn't captured or is low-confidence (recognition is advisory — [[opencv-anpr-s
|
||||
|
||||
## Open
|
||||
|
||||
- QR symbology/error-correction level + what else prints (site name, tariff summary, help number).
|
||||
- Primary symbology **decided**: Code128 set B over the all-numeric id (as-built above). Still open:
|
||||
what *else* prints (site name, tariff summary, help number).
|
||||
- **Optional QR (deferred)** — an admin toggle to ALSO print a QR for phone/imager users. The
|
||||
`code128()`/`qrCode()` ESC/POS helpers were prototyped 2026-06-16; QR was dropped 2026-06-17 in
|
||||
favor of "1D barcode + hand-keyable numeric code" because the booth's reader hardware is unknown.
|
||||
Revisit when mobile scanning is wanted.
|
||||
- **Phone-scan fallback** (operator scans a ticket with a phone when a reader is down) — designed
|
||||
but not built: an authenticated route feeding the same dispatcher + a minimal mobile scan UI.
|
||||
- Scanner hardware (imager model; same unit at pay station and exit?).
|
||||
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
|
||||
amount).
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ updated: 2026-06-14
|
||||
# Index
|
||||
|
||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
||||
Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
|
||||
Counts: 3 sources · 19 entities · 25 concepts · 5 decision records.
|
||||
|
||||
## Overview & navigation
|
||||
- [[overview]] — the top-level synthesis and entry point.
|
||||
@@ -80,6 +80,7 @@ Counts: 3 sources · 19 entities · 24 concepts · 5 decision records.
|
||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||
- [[shift]] — manned-only accountability period; explicit Start/End (not time-based); End → signed + printed Z-report (cash + POS).
|
||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
||||
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
||||
- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time.
|
||||
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
|
||||
|
||||
+16
@@ -744,3 +744,19 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
||||
builds; no test suite in-repo.
|
||||
- Residual: incidental `lane_devices` / "per-lane" mentions remain in some secondary wiki pages
|
||||
(device-events, device-input-flow, ticket-encoding, etc.) — flagged for a later lint pass.
|
||||
|
||||
## [2026-06-16] build | Scannable ticket — QR + Code128 on the Rongta dispenser
|
||||
|
||||
`renderTicket()` now emits the ticket id as a printer-generated QR (ESC/POS `GS ( k`, model 2, ECC M) AND a Code128 1D barcode (`GS k`, set B), plus the human-readable id. Three independently-readable forms so a dead reader is recoverable (imager / 1D laser / phone camera / hand-keyed). No image rendering, no new dependency. Phone-scan operator fallback deferred (reuses the existing dispatch path). See [[ticket-encoding]].
|
||||
|
||||
## [2026-06-17] build | Ticket id -> all-numeric 13-digit (12 random + Luhn); barcode-only ticket
|
||||
|
||||
Replaced the `T-<uuid>` ticket id with a 13-digit all-numeric code (12 crypto-random digits + Luhn check) in `newTicketId()` so ANY legacy 1D barcode scanner reads it and the operator can hand-key it on total reader failure. Random keeps the unguessable anti-fraud property; Luhn lets manual entry reject typos (`validateTicketCode()`). `renderTicket()` now prints a centered Code128 barcode, the code in large digits below, then the issue time — QR dropped (may return as an admin toggle for mobile users). NOT a schema change: `identity`/`sessions.id` are free-form text; legacy ids coexist. See [[ticket-encoding]].
|
||||
|
||||
## [2026-06-17] build | Park metadata in site_config + ticket header
|
||||
|
||||
Extended `site_config` (single-row) with optional park identity: `park_name`, `operator_name`, `vat_number`, `registration_number`, `address`, `phone`, `email` — all nullable text (Drizzle migration 0001, additive). `GET`/`PUT /api/site-config` now read/write the full config (PUT is a partial patch; admin only); `SiteSettings.tsx` gained the fields. `renderTicket()` prints a header (park name large or "PARKING", then operator/VAT/Reg/address, plus a "Lost ticket? <phone>" footer) sourced from `site_config` via `EntryFlow.#ticketHeader()`. Open: non-ASCII (accent) chars need a printer codepage. See [[site-metadata]], [[ticket-encoding]].
|
||||
|
||||
## [2026-06-17] build | Ticket in Albanian; VAT->NIUS, drop registration; CP852 codepage
|
||||
|
||||
Ticket header now prints in Albanian and uses NIUS instead of VAT. Renamed `site_config.vat_number` -> `nius` and DROPPED `registration_number` (regenerated migration 0001 in place; only the dev DB had it, so no migration debt; dev DB reset + re-migrated). `renderTicket()`: NIUS line (only if set), "Printuar më:" before the timestamp, "Keni humbur biletën? <phone>" footer; strings centralised in a `STR` table for future i18n. Added CP852 (Latin-2) codepage support (`ESC t 18` + a Unicode->CP852 `line()` encoder with ASCII fallback) so `ë`/`ç` render. Touched: schema, migration, routes/site.ts, web api.ts + SiteSettings.tsx, devices interfaces + printer-rongta.ts, entry-flow.ts. Byte-verified ë->0x89. See [[site-metadata]], [[ticket-encoding]].
|
||||
|
||||
Reference in New Issue
Block a user