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:
2026-06-17 12:17:21 +02:00
parent 1efa77bf56
commit 727c62da90
20 changed files with 1596 additions and 155 deletions
+50 -11
View File
@@ -7,9 +7,36 @@ import { getOccupancy } from "../occupancy.js";
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
interface SiteConfigBody {
// Optional park-metadata text fields (all nullable). Trimmed; "" → null.
const TEXT_FIELDS = [
"parkName",
"operatorName",
"nius",
"address",
"phone",
"email",
] as const;
type TextField = (typeof TEXT_FIELDS)[number];
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
/** Nominal capacity; null = no limit. */
capacity: number | null;
capacity?: number | null;
}
/** Shape returned by GET/PUT: capacity + every metadata field (null when unset). */
type SiteConfig = { capacity: number | null } & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
const out = { capacity: row?.capacity ?? null } as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
}
/** Trim a text field; empty string becomes null so blank input clears it. */
function normText(v: unknown): string | null {
if (v == null) return null;
const s = String(v).trim();
return s === "" ? null : s;
}
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
@@ -19,25 +46,37 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
// Read site config (capacity).
// Read site config (capacity + park metadata).
app.get("/api/site-config", { preHandler: readGuard }, async () => {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return { capacity: row?.capacity ?? null };
return toSiteConfig(row);
});
// Set capacity (admin). null or 0+ integer.
// Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text
// (only the fields PRESENT in the body are updated; absent fields are untouched).
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
const { capacity } = req.body ?? ({} as SiteConfigBody);
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
const body = req.body ?? ({} as SiteConfigBody);
const patch: Partial<typeof siteConfig.$inferInsert> = {};
if ("capacity" in body) {
const c = body.capacity;
if (c != null && (!Number.isInteger(c) || c < 0)) {
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
}
patch.capacity = c ?? null;
}
for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]);
}
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const updatedAt = new Date().toISOString();
if (existing) {
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run();
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run();
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
}
return { capacity: capacity ?? null };
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return toSiteConfig(row);
});
}