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
|
# Generate one with: openssl rand -hex 32
|
||||||
JWT_SECRET=
|
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 ----------------------------------------------------------------
|
# Optional ----------------------------------------------------------------
|
||||||
# PORT=3000
|
# PORT=3000
|
||||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomInt } from "node:crypto";
|
||||||
import { sessions, type Db, type DeviceRow } from "@parking/db";
|
import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
NoPrinterAvailableError,
|
NoPrinterAvailableError,
|
||||||
printWithFailover,
|
printWithFailover,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type PrinterDevice,
|
type PrinterDevice,
|
||||||
type PrinterInstance,
|
type PrinterInstance,
|
||||||
type TicketData,
|
type TicketData,
|
||||||
|
type TicketHeader,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceInputEvent } from "./device-events.js";
|
import type { DeviceInputEvent } from "./device-events.js";
|
||||||
@@ -91,7 +92,7 @@ export class EntryFlow {
|
|||||||
const printers = this.#loadPrinters();
|
const printers = this.#loadPrinters();
|
||||||
|
|
||||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
// 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 {
|
try {
|
||||||
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
||||||
d.printTicket(ticket),
|
d.printTicket(ticket),
|
||||||
@@ -182,9 +183,67 @@ export class EntryFlow {
|
|||||||
}
|
}
|
||||||
return out;
|
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 {
|
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");
|
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 {
|
export class EventLog {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #signer: Signer;
|
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. */
|
/** Serialize appends: each waits for the previous to finish. */
|
||||||
#tail: Promise<unknown> = Promise.resolve();
|
#tail: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
constructor(db: Db, signer: Signer) {
|
constructor(db: Db, signer: Signer, resolveVerifier?: SignerResolver) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#signer = signer;
|
this.#signer = signer;
|
||||||
|
this.#resolveVerifier = resolveVerifier ?? (() => signer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
/** 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
|
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
|
||||||
* first detected break, or { ok: true }. This is what reconciliation and an
|
* first detected break, or { ok: true }. This is what reconciliation and an
|
||||||
* integrity self-check call. Catches: tampered content, reordering, a deleted
|
* 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 } {
|
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
||||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
@@ -159,8 +174,16 @@ export class EventLog {
|
|||||||
if ((row.prevHash ?? null) !== prevHash) {
|
if ((row.prevHash ?? null) !== prevHash) {
|
||||||
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
|
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);
|
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)" };
|
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
|
||||||
}
|
}
|
||||||
prevHash = hashEvent(canonical);
|
prevHash = hashEvent(canonical);
|
||||||
|
|||||||
+172
-82
@@ -51,6 +51,127 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
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> {
|
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
@@ -166,100 +287,69 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const fullConfig: Record<string, unknown> = { ...config };
|
const outcome = await configureDevice(app, { id, driverId, config, backendIp });
|
||||||
// The web password the admin typed is a DESIRED value, not a stored fact:
|
if ("error" in outcome) {
|
||||||
// it's passed to the driver (via create(config) below) as the rotation
|
return reply.code(outcome.error.code).send({ error: outcome.error.message });
|
||||||
// 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 row = {
|
const row = {
|
||||||
id,
|
id,
|
||||||
category,
|
category,
|
||||||
driverId,
|
driverId,
|
||||||
config: fullConfig,
|
config: outcome.config,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
await db.insert(devices).values(row);
|
await db.insert(devices).values(row);
|
||||||
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
...row,
|
...row,
|
||||||
config: redactSecrets(fullConfig),
|
config: redactSecrets(outcome.config),
|
||||||
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}),
|
...(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
|
// 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.
|
// 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. */
|
/** 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> {
|
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.
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
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 () => {
|
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
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) => {
|
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
|
||||||
const { capacity } = req.body ?? ({} as SiteConfigBody);
|
const body = 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 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 existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
const updatedAt = new Date().toISOString();
|
const updatedAt = new Date().toISOString();
|
||||||
if (existing) {
|
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 {
|
} 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 { ShiftService } from "./shift-service.js";
|
||||||
import { ReadDispatcher } from "./read-dispatch.js";
|
import { ReadDispatcher } from "./read-dispatch.js";
|
||||||
import { PrinterMonitor } from "./printer-monitor.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 { authRoutes } from "./routes/auth.js";
|
||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
@@ -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
|
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||||
// See wiki/decisions/event-streams-split.md.
|
// 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);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
// 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.",
|
"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 { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
assignDevice,
|
assignDevice,
|
||||||
|
editDevice,
|
||||||
discoverDevices,
|
discoverDevices,
|
||||||
fetchBackendIps,
|
fetchBackendIps,
|
||||||
fetchCatalog,
|
fetchCatalog,
|
||||||
@@ -127,8 +128,12 @@ function CategorySection({
|
|||||||
onChanged: () => Promise<void> | void;
|
onChanged: () => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
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.
|
// Binding categories need a controller to point at first.
|
||||||
const isBound = category !== "access";
|
const isBound = category !== "access";
|
||||||
@@ -162,15 +167,43 @@ function CategorySection({
|
|||||||
|
|
||||||
{assignments.length > 0 && (
|
{assignments.length > 0 && (
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
||||||
{assignments.map((a) => (
|
{assignments.map((a) =>
|
||||||
<AssignmentRow key={a.id} assignment={a} controllers={controllers} onChanged={onChanged} />
|
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>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{blockedNoController ? (
|
{blockedNoController ? (
|
||||||
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
<p style={{ color: "#b45309", margin: 0 }}>Add a controller first — a {noun} points at one of its relays.</p>
|
||||||
) : showForm ? (
|
) : editing ? null : showForm ? (
|
||||||
<DeviceForm
|
<DeviceForm
|
||||||
category={category}
|
category={category}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
@@ -197,10 +230,12 @@ function AssignmentRow({
|
|||||||
assignment,
|
assignment,
|
||||||
controllers,
|
controllers,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
assignment: Assignment;
|
assignment: Assignment;
|
||||||
controllers: Assignment[];
|
controllers: Assignment[];
|
||||||
onChanged: () => Promise<void> | void;
|
onChanged: () => Promise<void> | void;
|
||||||
|
onEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [removing, setRemoving] = useState(false);
|
const [removing, setRemoving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -237,6 +272,9 @@ function AssignmentRow({
|
|||||||
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
||||||
|
<button type="button" onClick={onEdit} disabled={removing}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<button type="button" onClick={remove} disabled={removing}>
|
<button type="button" onClick={remove} disabled={removing}>
|
||||||
{removing ? "Removing…" : "Remove"}
|
{removing ? "Removing…" : "Remove"}
|
||||||
</button>
|
</button>
|
||||||
@@ -280,6 +318,7 @@ function DeviceForm({
|
|||||||
discoverableIds,
|
discoverableIds,
|
||||||
pushCapableIds,
|
pushCapableIds,
|
||||||
controllers,
|
controllers,
|
||||||
|
editing,
|
||||||
onSaved,
|
onSaved,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
@@ -288,21 +327,42 @@ function DeviceForm({
|
|||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
pushCapableIds: string[];
|
pushCapableIds: string[];
|
||||||
controllers: Assignment[];
|
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;
|
onSaved: (warnings: string[]) => Promise<void> | void;
|
||||||
onCancel?: () => 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 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 pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
|
||||||
const isController = category === "access";
|
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).
|
// 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.
|
// Bound devices: which controller + relay this device sits at.
|
||||||
const [controllerId, setControllerId] = useState<string>("");
|
const [controllerId, setControllerId] = useState<string>(
|
||||||
const [boundRelay, setBoundRelay] = useState<number | "">("");
|
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 [tested, setTested] = useState<TestResult | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
@@ -420,12 +480,17 @@ function DeviceForm({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
const result = await assignDevice({
|
const result = editing
|
||||||
category,
|
? await editDevice(editing.id, {
|
||||||
driverId: selected.id,
|
config: mergedConfig(),
|
||||||
config: mergedConfig(),
|
...(backendIp ? { backendIp } : {}),
|
||||||
...(backendIp ? { backendIp } : {}),
|
})
|
||||||
});
|
: await assignDevice({
|
||||||
|
category,
|
||||||
|
driverId: selected.id,
|
||||||
|
config: mergedConfig(),
|
||||||
|
...(backendIp ? { backendIp } : {}),
|
||||||
|
});
|
||||||
await onSaved(result.warnings ?? []);
|
await onSaved(result.warnings ?? []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSaveError((e as Error).message);
|
setSaveError((e as Error).message);
|
||||||
@@ -439,7 +504,9 @@ function DeviceForm({
|
|||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<em>No drivers registered.</em>
|
<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>
|
<option value="" disabled>
|
||||||
Choose a device…
|
Choose a device…
|
||||||
</option>
|
</option>
|
||||||
@@ -538,7 +605,7 @@ function DeviceForm({
|
|||||||
{testing ? "Testing…" : "Test connection"}
|
{testing ? "Testing…" : "Test connection"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={save} disabled={saving}>
|
<button type="button" onClick={save} disabled={saving}>
|
||||||
{saving ? "Saving…" : "Save & configure"}
|
{saving ? "Saving…" : editing ? "Save changes" : "Save & configure"}
|
||||||
</button>
|
</button>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button type="button" onClick={onCancel} disabled={saving}>
|
<button type="button" onClick={onCancel} disabled={saving}>
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import { useEffect, useState } from "react";
|
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
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||||
// signed ledger); the capacity field is admin-editable. The FULL gate (refuse
|
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
||||||
// transient entry at capacity) is enforced server-side in the entry flow.
|
// The FULL gate (refuse transient entry at capacity) is enforced server-side in the
|
||||||
// See wiki/concepts/capacity-occupancy.md.
|
// 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 }) {
|
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||||
const [occ, setOcc] = useState<Occupancy | null>(null);
|
const [occ, setOcc] = useState<Occupancy | null>(null);
|
||||||
const [capInput, setCapInput] = useState("");
|
const [capInput, setCapInput] = useState("");
|
||||||
|
const [meta, setMeta] = useState<Record<string, string>>({});
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
@@ -17,18 +29,25 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
fetchSiteConfig()
|
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(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
const raw = capInput.trim();
|
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 {
|
try {
|
||||||
await setCapacity(capacity);
|
await saveSiteConfig(patch);
|
||||||
reload();
|
reload();
|
||||||
setMsg("Capacity saved.");
|
setMsg("Saved.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg((e as Error).message);
|
setMsg((e as Error).message);
|
||||||
}
|
}
|
||||||
@@ -51,13 +70,37 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div style={{ marginTop: "0.6rem" }}>
|
<div style={{ marginTop: "0.6rem", display: "grid", gap: "0.5rem" }}>
|
||||||
<label>
|
<label>
|
||||||
Capacity (blank = no limit):{" "}
|
Capacity (blank = no limit):{" "}
|
||||||
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
|
<input value={capInput} onChange={(e) => setCapInput(e.target.value)} style={{ width: 80 }} placeholder="e.g. 120" />
|
||||||
</label>{" "}
|
</label>
|
||||||
<button type="button" onClick={save}>Save</button>
|
<div style={{ borderTop: "1px solid #eee", paddingTop: "0.5rem", color: "#666", fontSize: "0.85rem" }}>
|
||||||
{msg && <span style={{ marginLeft: "0.5rem", color: "#555" }}>{msg}</span>}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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) });
|
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). */
|
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
|
||||||
export interface Assignment {
|
export interface Assignment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -333,12 +342,28 @@ export interface Occupancy {
|
|||||||
full: boolean;
|
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> {
|
export function fetchOccupancy(): Promise<Occupancy> {
|
||||||
return apiFetch("/api/occupancy");
|
return apiFetch("/api/occupancy");
|
||||||
}
|
}
|
||||||
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
|
export function fetchSiteConfig(): Promise<SiteConfig> {
|
||||||
return apiFetch("/api/site-config");
|
return apiFetch("/api/site-config");
|
||||||
}
|
}
|
||||||
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> {
|
/** PUT a partial config — only the fields supplied are changed. */
|
||||||
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) });
|
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,
|
"when": 1781632874398,
|
||||||
"tag": "0000_baseline",
|
"tag": "0000_baseline",
|
||||||
"breakpoints": true
|
"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;
|
// 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
|
// `capacity` is the nominal space count the FULL gate refuses transient entry at
|
||||||
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
|
// (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", {
|
export const siteConfig = sqliteTable("site_config", {
|
||||||
id: integer("id").primaryKey(), // always 1
|
id: integer("id").primaryKey(), // always 1
|
||||||
capacity: integer("capacity"), // null = no capacity limit
|
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")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.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 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
|
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 {
|
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). */
|
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
||||||
function renderReport(report: PrintReport): Buffer {
|
function renderReport(report: PrintReport): Buffer {
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
INIT,
|
INIT,
|
||||||
|
SELECT_CP852,
|
||||||
ALIGN_CENTER,
|
ALIGN_CENTER,
|
||||||
BOLD_ON,
|
BOLD_ON,
|
||||||
line(report.title),
|
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 {
|
function renderTicket(data: TicketData): Buffer {
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
INIT,
|
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,
|
BOLD_ON,
|
||||||
DOUBLE_ON,
|
DOUBLE_ON,
|
||||||
line("PARKING"),
|
line(data.ticketId),
|
||||||
DOUBLE_OFF,
|
DOUBLE_OFF,
|
||||||
BOLD_OFF,
|
BOLD_OFF,
|
||||||
line(),
|
line(),
|
||||||
BOLD_ON,
|
line(STR.issuedAt(data.issuedAt)),
|
||||||
line(data.ticketId),
|
// Contact footer (lost-ticket help) if a phone is set.
|
||||||
BOLD_OFF,
|
...(data.header?.phone ? [line(STR.lostTicket(data.header.phone))] : []),
|
||||||
ALIGN_LEFT,
|
|
||||||
line(),
|
|
||||||
line(`Issued: ${data.issuedAt}`),
|
|
||||||
FEED_AND_CUT,
|
FEED_AND_CUT,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,9 +190,23 @@ export interface Snapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Printers (ticket dispenser / booth printer) -------------------------
|
// --- 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 {
|
export interface TicketData {
|
||||||
readonly ticketId: string;
|
readonly ticketId: string;
|
||||||
readonly issuedAt: string; // ISO-8601
|
readonly issuedAt: string; // ISO-8601
|
||||||
|
/** Park identity for the header. Absent → driver prints the generic "PARKING". */
|
||||||
|
readonly header?: TicketHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrinterDevice extends Device {
|
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
|
- **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
|
someone else's cheaper session). Sequential **physical** stock numbering is a separate
|
||||||
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
|
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
|
- **Single logical session** — scanning it at the pay station finds the open session; after payment
|
||||||
it's the proof-of-paid the exit checks.
|
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
|
- The [[rongta-printer]] prints the ticket id as a **1D Code128 barcode** (the all-numeric code),
|
||||||
entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap
|
with the **same code in large human-readable digits below it**, then the entry time. Code128 over
|
||||||
camera/imager to read.
|
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]]):
|
- **Scan points** (both host-side reads — [[entry-exit-readers]]):
|
||||||
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
|
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
|
||||||
payment ([[tariff]], pay-on-foot) → appends `payment`.
|
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
|
## 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?).
|
- Scanner hardware (imager model; same unit at pay station and exit?).
|
||||||
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
|
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
|
||||||
amount).
|
amount).
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,7 @@ updated: 2026-06-14
|
|||||||
# Index
|
# Index
|
||||||
|
|
||||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
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 & navigation
|
||||||
- [[overview]] — the top-level synthesis and entry point.
|
- [[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.
|
- [[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).
|
- [[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.
|
- [[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.
|
- [[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.
|
- [[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.
|
- [[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.
|
builds; no test suite in-repo.
|
||||||
- Residual: incidental `lane_devices` / "per-lane" mentions remain in some secondary wiki pages
|
- 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.
|
(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