a9ccf9e20c
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md): - Master data (categories × services price matrix) at /setup/carwash; the desk at /wash (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void; Finished list). Orders freeze names + price; their life is signed (carwash_order, carwash_payment). Migration 0027. - Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed config_change on a flip) — no per-order radio; a stale client is refused (409). - Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash signs the $0 parking payment so the exit reader releases the car. - "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash price off the fee (floored at 0), resolved at done and anchored at the order's intake (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for the wash. Long durations render y/d/h/m. Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills): - TillId booth|carwash; every money event names its till (absent = booth, so the chain re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports, vouchers, carry-forward. A bay payment needs the carwash shift. - Working a till needs that till's module permission (manifest tillPermission; 403 till_forbidden); /api/shift/tills lists only the role's tills. - Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every open shift with till badges + filter; drawer hub switches tills. Modules: landing per module (index route resolves booth → module landing → shifts → profile); guards bounce to "/", /booth needs session:read. Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky backup test under the parallel run). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
723 lines
38 KiB
TypeScript
723 lines
38 KiB
TypeScript
import { sql } from "drizzle-orm";
|
||
import { blob, integer, primaryKey, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||
|
||
// Schema notes:
|
||
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
|
||
// • `ledger_events` — the APPEND-ONLY, hash-chained, ATECC608-SIGNED business ledger.
|
||
// Never UPDATE/DELETE. A correction or void is a new row of type 'void'. Each row
|
||
// chains via `prevHash` and is signed (`signature`). The anti-fraud record; sessions,
|
||
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
|
||
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
|
||
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
|
||
// - Business master data (tariffs/subscriptions/blocklist) IS mutable, but its USE is fixed in a
|
||
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
|
||
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
|
||
// - Authorization is DYNAMIC RBAC, fully local (offline-first): `roles` are data
|
||
// (admin-composable), `role_permissions` is the role→permission grid, and each
|
||
// `users` row points at one role via `role_id`. Permissions are checked per
|
||
// route (see @parking/shared PERMISSIONS). A built-in, locked `admin` role
|
||
// (id='admin') always holds every permission. See wiki/entities/local-jwt-auth.md.
|
||
|
||
/** A composable role: a named bundle of permissions. `builtin` rows (the `admin`
|
||
* role) are protected — not editable or deletable, and always granted all
|
||
* permissions. Everything else is admin-composed at runtime. */
|
||
export const roles = sqliteTable("roles", {
|
||
id: text("id").primaryKey(),
|
||
name: text("name").notNull().unique(),
|
||
// 1 = protected built-in (the `admin` role). 0 = admin-composed.
|
||
builtin: integer("builtin").notNull().default(0),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
// Soft delete (recycle bin): ISO instant the row was deleted, null = live; the admin
|
||
// user id who deleted it. A DELETE stamps these; restore clears them; purge/retention
|
||
// does the real row removal. See wiki/concepts/soft-delete.md.
|
||
deletedAt: text("deleted_at"),
|
||
deletedBy: text("deleted_by"),
|
||
});
|
||
|
||
/** The role→permission grid. One row per granted `resource:action` permission.
|
||
* The `admin` role is granted all permissions implicitly in code, so its rows
|
||
* here are belt-and-suspenders. See @parking/shared PERMISSIONS. */
|
||
export const rolePermissions = sqliteTable(
|
||
"role_permissions",
|
||
{
|
||
roleId: text("role_id")
|
||
.notNull()
|
||
.references(() => roles.id),
|
||
permission: text("permission").notNull(),
|
||
},
|
||
(t) => ({
|
||
// A permission is granted to a role at most once.
|
||
uniq: unique().on(t.roleId, t.permission),
|
||
}),
|
||
);
|
||
|
||
export const users = sqliteTable("users", {
|
||
id: text("id").primaryKey(),
|
||
username: text("username").notNull().unique(),
|
||
passwordHash: text("password_hash").notNull(),
|
||
// One role per user (RBAC). Resolves to a permission set at request time.
|
||
roleId: text("role_id")
|
||
.notNull()
|
||
.references(() => roles.id),
|
||
// Preferred UI language for this user (operator-facing). Loaded on login and
|
||
// restored from any booth. Albanian is the default. Printed tickets are NOT
|
||
// governed by this — they're always Albanian (customer-facing). See i18n.md.
|
||
language: text("language", { enum: ["sq", "en"] })
|
||
.notNull()
|
||
.default("sq"),
|
||
// Preferred UI theme for this user. Persisted like `language` (read on login,
|
||
// restored from any booth, changed without a token refresh). Dark is the default
|
||
// (the booth runs in a dark room). Printed tickets are unaffected. See i18n.md.
|
||
theme: text("theme", { enum: ["dark", "light"] })
|
||
.notNull()
|
||
.default("dark"),
|
||
// Preferred UI font scale (PERCENT of base, e.g. 100 = base, 120 = 20% larger).
|
||
// Persisted like `theme` (read on login, restored from any booth). Integer percent
|
||
// avoids float drift; the client clamps to 80–160 in steps of 10. Printed tickets are
|
||
// unaffected (server-rendered).
|
||
fontScale: integer("font_scale").notNull().default(100),
|
||
// Optional operator profile metadata — display name + contact details. All
|
||
// nullable; only username/password/role are required to create a user. fullName
|
||
// (when set) is the human label for audit/Z-report display.
|
||
fullName: text("full_name"),
|
||
phone: text("phone"),
|
||
email: text("email"),
|
||
address: text("address"),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
// Soft delete (recycle bin) — see roles.deletedAt. NB: `username` stays UNIQUE across
|
||
// live AND deleted rows, so creating a new user reusing a deleted user's name is
|
||
// blocked until that row is restored or purged (the route returns a clear 409).
|
||
deletedAt: text("deleted_at"),
|
||
deletedBy: text("deleted_by"),
|
||
});
|
||
|
||
// --- The signed business ledger (formerly `events`) ----------------------
|
||
// Holds ONLY business/accountability facts: vehicle_entry, vehicle_exit, payment,
|
||
// void, shift_z_report, plus witness-grade barrier_open_command/observed, anomaly.
|
||
// `payload` carries type-specific data (amount, tariffVersionId, sessionRef, tender,
|
||
// plate confidence…) and is part of the SIGNED canonical form, so it is tamper-evident
|
||
// like the rest of the row. See packages/shared ParkingEventType + LedgerPayload.
|
||
export const ledgerEvents = sqliteTable("ledger_events", {
|
||
id: text("id").primaryKey(),
|
||
// Monotonic chain index. Gaps are alarms (see event-log-ingestion).
|
||
index: integer("index").notNull().unique(),
|
||
type: text("type").notNull(),
|
||
direction: text("direction", { enum: ["entry", "exit"] }),
|
||
source: text("source"),
|
||
identity: text("identity"),
|
||
// Type-specific business payload (JSON). Signed as part of the canonical form.
|
||
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>(),
|
||
occurredAt: text("occurred_at").notNull(),
|
||
// Hash of the previous event (hex). Null only for the genesis event.
|
||
prevHash: text("prev_hash"),
|
||
// ATECC608 signature over the canonical event payload (hex).
|
||
signature: text("signature").notNull(),
|
||
// Which signer/key produced `signature` (e.g. "sw-hmac-v1", "atecc608-slot0"),
|
||
// so old events stay verifiable across a signer swap. See packages/shared Signer.
|
||
keyId: text("key_id").notNull(),
|
||
});
|
||
|
||
// --- Device telemetry (unsigned, prunable) -------------------------------
|
||
// Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
|
||
// offline, reader read, raw input edges. Keyed to a `devices` instance. No
|
||
// prevHash/signature — this stream may rotate/prune.
|
||
export const deviceEvents = sqliteTable("device_events", {
|
||
id: text("id").primaryKey(),
|
||
// The `devices` instance that produced it (raw provenance).
|
||
deviceId: text("device_id"),
|
||
category: text("category", {
|
||
enum: ["access", "reader", "camera", "printer"],
|
||
}),
|
||
// e.g. "input", "relay", "status", "read", "snapshot".
|
||
kind: text("kind").notNull(),
|
||
// Free-form telemetry detail (input number + edge, status flags, error…).
|
||
detail: text("detail", { mode: "json" }).$type<Record<string, unknown>>(),
|
||
occurredAt: text("occurred_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
});
|
||
|
||
// --- Camera snapshots (unsigned, prunable, blob-in-DB) -------------------
|
||
// An entry/exit snapshot captured asynchronously AFTER the barrier opens — evidence,
|
||
// not a gate (camera failure never blocks an open; see entry/exit flows). Stored as a
|
||
// BLOB so the appliance keeps a single backed-up file with nothing scattered on disk.
|
||
// Kept in its own table (not inline in device_events) so the hot telemetry scans don't
|
||
// drag image bytes, and so images can be pruned independently. The signed
|
||
// vehicle_entry/exit references a snapshot by `id` in its payload — the image is an
|
||
// independent record (anti-fraud), unsigned and prunable. Retention policy is an open
|
||
// question — see wiki/concepts/entry-exit-points.md. Served via GET /api/snapshots/:id.
|
||
export const snapshots = sqliteTable("snapshots", {
|
||
id: text("id").primaryKey(),
|
||
direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
|
||
// The camera `devices` instance that captured it (raw provenance).
|
||
deviceId: text("device_id"),
|
||
// The session/credential ref (ticket id, plate, subscription) — links to the ledger event.
|
||
identity: text("identity"),
|
||
contentType: text("content_type").notNull(),
|
||
bytes: blob("bytes").notNull().$type<Buffer>(),
|
||
capturedAt: text("captured_at").notNull(),
|
||
});
|
||
|
||
// --- Device assignments (first-run setup) --------------------------------
|
||
// One row per device instance. `driverId` references a driver in the @parking/devices
|
||
// registry; `config` is that driver's JSON config. There is NO lane: a parking lot is
|
||
// one pool of spaces with a flexible set of entry/exit points. Direction lives INSIDE
|
||
// the config, per the hardware:
|
||
// - access controller: config.relays = [{ relay, direction: entry|exit|both, button? }]
|
||
// — one physical board has several relays; each relay opens one barrier in one
|
||
// direction (or both). `button` = the input terminal the entry button is wired to
|
||
// (transient entry trigger; absent = no button at that barrier).
|
||
// - reader / camera: config.controllerId + config.relay BIND it to the barrier it sits
|
||
// at; its direction is INHERITED from that relay. Unbound → falls back to a
|
||
// direction picked in config.
|
||
// See device-registry.md, first-run-setup.md, wiki/concepts/entry-exit-points.md.
|
||
export const devices = sqliteTable("devices", {
|
||
id: text("id").primaryKey(),
|
||
category: text("category", {
|
||
enum: ["access", "reader", "camera", "printer"],
|
||
}).notNull(),
|
||
driverId: text("driver_id").notNull(),
|
||
// Driver-specific connection config as JSON (validated against the driver's
|
||
// declared config fields before persisting). Secrets live here — protect at rest.
|
||
config: text("config", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
|
||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
});
|
||
|
||
// Tracks whether first-run setup has been completed (single-row marker).
|
||
export const setupState = sqliteTable("setup_state", {
|
||
id: integer("id").primaryKey(), // always 1
|
||
completedAt: text("completed_at"),
|
||
});
|
||
|
||
// Single-row site settings (admin-configurable). The home for site-wide knobs;
|
||
// `capacity` is the nominal space count the FULL gate refuses transient entry at
|
||
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
|
||
// Park identity/metadata (all optional) lives here too — display name, the legal
|
||
// operator, the NIUS tax id, address and contact. These feed the ticket/receipt
|
||
// header (park name + NIUS are commonly required on an Albanian parking receipt)
|
||
// and admin display. All nullable: the lot runs fine with none set.
|
||
// See wiki/concepts/site-metadata.md.
|
||
export const siteConfig = sqliteTable("site_config", {
|
||
id: integer("id").primaryKey(), // always 1
|
||
capacity: integer("capacity"), // null = no capacity limit
|
||
/** Park display name shown on the ticket header / UI (e.g. "Acme Parking"). */
|
||
parkName: text("park_name"),
|
||
/** Legal entity operating the lot, for receipts (may differ from parkName). */
|
||
operatorName: text("operator_name"),
|
||
/** NIUS — Albanian tax/identification number, printed on the receipt when set. */
|
||
nius: text("nius"),
|
||
/** Free-text postal address (multi-line allowed). */
|
||
address: text("address"),
|
||
/** Contact phone — also used for the ticket "lost ticket? call …" footer. */
|
||
phone: text("phone"),
|
||
/** Contact email. */
|
||
email: text("email"),
|
||
/** Default for the booth pay modal's "print exit ticket" checkbox. Site-wide
|
||
* because it's booth GEOGRAPHY: when the booth is far from the exit, the
|
||
* customer pays at the booth and self-exits later by scanning a printed exit
|
||
* voucher (= the ticket id reprinted, now paid). When near the exit, the booth
|
||
* opens the barrier directly. The operator may still override per transaction.
|
||
* Stored 0/1 (SQLite has no bool). See wiki/concepts/booth-exit-flow.md. */
|
||
exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" })
|
||
.notNull()
|
||
.default(false),
|
||
/** Default monthly subscription price in minor units (e.g. 1000000 = 10,000.00).
|
||
* A starting value the subscription form pre-fills; each subscription stores its
|
||
* own price and may differ. null = no site default set. See
|
||
* wiki/entities/subscription.md. */
|
||
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
||
/** Venue modules the site admin has ACTIVATED (JSON array of ModuleId, e.g.
|
||
* ["parking","validation"]). null = never set → everything the site is entitled to.
|
||
* The effective set is entitled (MODULES_ENTITLED env) ∩ this, computed server-side
|
||
* (apps/server/src/modules.ts); each change signs a config_change. Disabling a module
|
||
* never deletes anything. See wiki/decisions/venue-modules.md. */
|
||
modulesJson: text("modules_json"),
|
||
/** When ON, the occupancy/full gate RESERVES a spot for each active subscriber's car
|
||
* (by quantity) even when they're not parked — so transients see "full" sooner and
|
||
* the subscriber's spot is held. When OFF (default), only cars physically inside
|
||
* count (the operator handles overflow by valet/key-juggling). Stored 0/1.
|
||
* See wiki/concepts/capacity-occupancy.md. */
|
||
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
|
||
.notNull()
|
||
.default(false),
|
||
/** Site master switch for the ANPR subscriber-entry BRIDGE (anpr-entry.ts): when ON
|
||
* (default), a subscriber's plate read off a lane camera's vehicle detection opens the
|
||
* barrier through the normal gated subscription flow. When OFF, the bridge emits no read
|
||
* (subscribers fall back to their card/QR). This gates ONLY the barrier-driving bridge —
|
||
* advisory snapshot-ANPR recording and lane busy/free are unaffected. Read LIVE per event
|
||
* so toggling takes effect with no restart. Default ON because the feature is already
|
||
* live. Stored 0/1. See wiki/concepts/lane-presence-and-anpr-entry.md. */
|
||
anprEntryEnabled: integer("anpr_entry_enabled", { mode: "boolean" })
|
||
.notNull()
|
||
.default(true),
|
||
/** Entry presence-gate BYPASS (2026-07-02). The entry button — physical press and the
|
||
* operator-issued mint — requires a real vehicle at the barrier: radar/loop presence AND
|
||
* camera detection. When a device is FAULTY, the admin can drop one of those signals as a
|
||
* requirement until support fixes it (the admin is not the adversary). Granular: a dead
|
||
* camera → set bypassPresenceCamera (radar still gates); a dead radar → bypassPresenceRadar.
|
||
* Both false (default) = the normal both-required gate; both true = press-to-print with no
|
||
* presence check. Enabling/disabling is signed as a `config_change` and every ticket issued
|
||
* while bypassed is flagged. Stored 0/1. See wiki/concepts/entry-presence-bypass.md. */
|
||
bypassPresenceRadar: integer("bypass_presence_radar", { mode: "boolean" })
|
||
.notNull()
|
||
.default(false),
|
||
bypassPresenceCamera: integer("bypass_presence_camera", { mode: "boolean" })
|
||
.notNull()
|
||
.default(false),
|
||
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
|
||
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
|
||
* each published tariff version's structure.tz so the windows are frozen/immutable
|
||
* per version — historical sessions reprice deterministically regardless of any
|
||
* later config change. null/absent ⇒ default "Europe/Tirane" at publish time.
|
||
* See wiki/concepts/tariff-time-tiers.md. */
|
||
timezone: text("timezone"),
|
||
/** Default vehicle/customer category assigned to a transient entry when none is
|
||
* captured at the lane (every transient today). Operator policy — a plain car park
|
||
* leaves it "default"; a mixed lot might set "car". Frozen into each vehicle_entry
|
||
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared
|
||
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
|
||
defaultVehicleCategory: text("default_vehicle_category"),
|
||
/** Admin-chosen directory encrypted DB backups are written to — a mounted local/USB/
|
||
* SATA/SMB/NFS path. null = not configured (backups are a no-op). Set from the Backup
|
||
* screen; the encryption key (BACKUP_KEY) stays an env/Komodo secret and is NEVER stored
|
||
* here (a key must not live in the DB it backs up). See wiki/concepts/backup-recovery.md. */
|
||
backupTargetDir: text("backup_target_dir"),
|
||
/** Backup retention (admin-tunable policy, not env). Keep this many newest backups always.
|
||
* null ⇒ code default (7). See wiki/concepts/backup-recovery.md. */
|
||
backupKeepLast: integer("backup_keep_last"),
|
||
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
||
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
||
/** ISO timestamp of the last backup that actually completed successfully. Persisted here
|
||
* (not just in-process memory) so the admin UI's "last successful backup" survives a
|
||
* server restart — before this column existed, a restart silently reset that status to
|
||
* "Never" even with valid backups already on disk. null = no successful run recorded yet.
|
||
* See wiki/concepts/backup-recovery.md. */
|
||
backupLastSuccessAt: text("backup_last_success_at"),
|
||
/** JSON-encoded { path, bytes, prunedFiles } of the last successful run, for the same
|
||
* restart-durability reason as backupLastSuccessAt. null = none recorded yet. */
|
||
backupLastResultJson: text("backup_last_result_json"),
|
||
/** ISO timestamp of the last FAILED scheduled/manual backup attempt, persisted for the same
|
||
* reason. null = no failure recorded (or none since the last success). */
|
||
backupLastErrorAt: text("backup_last_error_at"),
|
||
/** Error message of the last failed attempt. Cleared (set null) on the next success. */
|
||
backupLastError: text("backup_last_error"),
|
||
updatedAt: text("updated_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
});
|
||
|
||
// --- Tariffs (composable, versioned) -------------------------------------
|
||
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
|
||
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
|
||
// A session reprices against the version in force at its entry time; the `payment`
|
||
// ledger event records the tariffVersionId used. "One active tariff per site" today;
|
||
// `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md.
|
||
export const tariffs = sqliteTable("tariffs", {
|
||
id: text("id").primaryKey(),
|
||
// Only "site" used now; "zone" reserved for multi-tariff later.
|
||
scope: text("scope", { enum: ["site", "zone"] }).notNull().default("site"),
|
||
name: text("name").notNull(),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
// Soft delete (recycle bin) — see roles.deletedAt. Stamps the rate-card row; its
|
||
// immutable tariff_versions are kept (referenced for repricing) and ride along.
|
||
deletedAt: text("deleted_at"),
|
||
deletedBy: text("deleted_by"),
|
||
});
|
||
|
||
export const tariffVersions = sqliteTable("tariff_versions", {
|
||
id: text("id").primaryKey(),
|
||
tariffId: text("tariff_id").notNull(),
|
||
// Optional human label ("Winter 2027", carried from the lab draft it was published
|
||
// from). Stamped at publish, immutable like the rest of the row — versions are
|
||
// told apart in the UI by name, not UUID prefix.
|
||
name: text("name"),
|
||
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
|
||
effectiveFrom: text("effective_from").notNull(),
|
||
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
|
||
currency: text("currency").notNull(),
|
||
// The composable rate card (stepped blocks + caps/grace). Shape: TariffStructure
|
||
// in packages/shared. Immutable once published.
|
||
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
|
||
createdBy: text("created_by"),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
});
|
||
|
||
// A LAB DRAFT rate card — the tariff-lab scratchpad. MUTABLE by design (the one
|
||
// exception to "editing publishes a version"): a draft prices nothing and signs
|
||
// nothing — it exists so the admin can experiment in the lab without churning real
|
||
// tariff_versions (each publish is permanent; experimenting through publishes would
|
||
// bury the history in noise and risk a wrong card going live). Publishing a draft
|
||
// goes through the normal POST /api/tariff/versions path (validated, tz-stamped,
|
||
// immutable). See wiki/concepts/tariff.md (Tariff Lab).
|
||
export const tariffDrafts = sqliteTable("tariff_drafts", {
|
||
id: text("id").primaryKey(),
|
||
name: text("name").notNull(),
|
||
currency: text("currency").notNull(),
|
||
// Same TariffStructure shape as tariff_versions.structure; validated on save so
|
||
// the lab can always simulate it.
|
||
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
|
||
createdBy: text("created_by"),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
updatedAt: text("updated_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
});
|
||
|
||
// --- Subscriptions --------------------------------------------------------
|
||
// A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
|
||
// month) instead of paying per stay. Mutable master data; every USE still produces a
|
||
// signed vehicle_entry/exit ledger event. Two optional, independent bindings:
|
||
// car-count (maxConcurrent, default 1, null = unbound) and plate (plates rows,
|
||
// default none = any car). Identity = card/QR OR a matching plate (LPR/ANPR future).
|
||
// Pricing: priceMinor + period + currency record the plan; collecting the fee into
|
||
// the ledger/shift is deferred. See wiki/entities/subscription.md.
|
||
// NB: signed ledger events still carry `permitId` in their payload — immutable
|
||
// history, intentionally NOT renamed. These tables are the mutable master data,
|
||
// renamed permit→subscription in migration 0004.
|
||
// A subscription PLAN — admin-composed, versioned config the operator SELLS from
|
||
// (instead of typing a price). Mirrors tariffVersions: immutable rows, latest with
|
||
// effectiveFrom ≤ saleDate wins, retire via active=0 (never delete = keep history).
|
||
// A plan prices a span as ceil(periods) × pricePerPeriodMinor; period ∈ day/week/month
|
||
// (so a hotel's 1–N day stay is a daily plan over a date span). See
|
||
// wiki/entities/subscription.md.
|
||
export const subscriptionPlans = sqliteTable("subscription_plans", {
|
||
id: text("id").primaryKey(),
|
||
// Stable plan identity across versions (e.g. "hotel-daily"); a new price = a new row.
|
||
planId: text("plan_id").notNull(),
|
||
name: text("name").notNull(),
|
||
period: text("period", { enum: ["day", "week", "month"] }).notNull(),
|
||
pricePerPeriodMinor: integer("price_per_period_minor").notNull(),
|
||
currency: text("currency").notNull(),
|
||
// Latest version with effectiveFrom ≤ the sale instant prices the sale.
|
||
effectiveFrom: text("effective_from").notNull(),
|
||
// Composed allowed-time windows (PlanTimeframes in @parking/shared); null = 24/7, no
|
||
// restriction. When set, a scan OUTSIDE the window is charged the transient tariff for
|
||
// the out-of-window minutes (a "night plan" subscriber arriving early owes that gap).
|
||
// Evaluated in the site timezone. See wiki/entities/subscription.md (tariff bridge).
|
||
timeframes: text("timeframes", { mode: "json" }).$type<Record<string, unknown>>(),
|
||
// Soft-retire (0) without deleting history; active=1 plans are sellable.
|
||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||
createdBy: text("created_by"),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
// Soft delete (recycle bin) — see roles.deletedAt. A plan is VERSIONED (many rows per
|
||
// plan_id); a soft-delete stamps every version row of the plan_id together, and the bin
|
||
// shows/restores the plan as one item. Distinct from `active=0` (retire = unsellable
|
||
// but kept in the catalog); deletedAt removes it from the catalog entirely.
|
||
deletedAt: text("deleted_at"),
|
||
deletedBy: text("deleted_by"),
|
||
});
|
||
|
||
export const subscriptions = sqliteTable("subscriptions", {
|
||
id: text("id").primaryKey(),
|
||
holderName: text("holder_name"),
|
||
contact: text("contact"),
|
||
// Price actually billed for the coverage window, in minor units (e.g. 1000000 =
|
||
// 10,000.00 ALL). Now DERIVED from the chosen plan (periods × per-period price) — the
|
||
// operator never types it. null = no price set (comp/legacy). `period` is display.
|
||
priceMinor: integer("price_minor"),
|
||
// Display period of the sale. Widened day/week/month 2026-06-20 (was "monthly"-only);
|
||
// a legacy "monthly" value reads as "month". Source of truth is the plan version.
|
||
period: text("period", { enum: ["day", "week", "month"] }).notNull().default("month"),
|
||
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
|
||
currency: text("currency"),
|
||
// Which plan + which immutable version priced this sale (null for legacy/comp rows).
|
||
// Persisted so the sale reprices identically later — same reason payments carry
|
||
// tariffVersionId.
|
||
planId: text("plan_id"),
|
||
planVersionId: text("plan_version_id"),
|
||
// How many cars this ONE subscription covers (e.g. a family pays once for 2 cars).
|
||
// Sale amount = plan span price × quantity; maxConcurrent defaults to it. Default 1.
|
||
quantity: integer("quantity").notNull().default(1),
|
||
// Car-count binding: how many of the subscription's cars may be inside at once.
|
||
// null = unbound. Defaults to `quantity` at sale.
|
||
maxConcurrent: integer("max_concurrent").default(1),
|
||
validFrom: text("valid_from"),
|
||
validTo: text("valid_to"),
|
||
status: text("status", { enum: ["active", "suspended", "revoked"] })
|
||
.notNull()
|
||
.default("active"),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
// Soft delete (recycle bin) — see roles.deletedAt. Distinct from `status: "revoked"`
|
||
// (a domain state that BARS the subscriber but keeps it visible); deletedAt removes it
|
||
// from the catalog entirely, recoverable from the bin. Child credential/plate rows are
|
||
// kept and restored with it.
|
||
deletedAt: text("deleted_at"),
|
||
deletedBy: text("deleted_by"),
|
||
});
|
||
|
||
// A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
|
||
export const subscriptionCredentials = sqliteTable("subscription_credentials", {
|
||
id: text("id").primaryKey(),
|
||
subscriptionId: text("subscription_id").notNull(),
|
||
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
|
||
value: text("value").notNull(),
|
||
});
|
||
|
||
// Plate binding (optional). When a subscription has plate rows, a matching plate read
|
||
// is itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
|
||
export const subscriptionPlates = sqliteTable("subscription_plates", {
|
||
id: text("id").primaryKey(),
|
||
subscriptionId: text("subscription_id").notNull(),
|
||
plate: text("plate").notNull(),
|
||
});
|
||
|
||
// --- Merchant validation programs (bar / lavazh) --------------------------
|
||
// Admin-composed master data for in-park merchant discounts: the /setup/site
|
||
// checkboxes toggle the WELL-KNOWN rows ("bar", "lavazh") — a future merchant is a
|
||
// new row, not a migration. Config is plainly MUTABLE (no versioning): the applied
|
||
// validation is a signed ledger event carrying the RESOLVED values, so historical
|
||
// reproducibility never depends on this row. Enabling/saving signs a config_change.
|
||
// See wiki/concepts/validation-discounts.md.
|
||
export const validationPrograms = sqliteTable("validation_programs", {
|
||
// Well-known slug ("bar" | "lavazh"); generic text so future merchants are rows.
|
||
id: text("id").primaryKey(),
|
||
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
|
||
name: text("name").notNull(),
|
||
// How the program discounts — see @parking/shared ValidationMode.
|
||
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent", "doneTolerance", "washPrice"] })
|
||
.notNull()
|
||
.default("comp"),
|
||
// timeCredit: the free minutes.
|
||
minutes: integer("minutes"),
|
||
// percent: 1..100 off the fee.
|
||
percent: integer("percent"),
|
||
// fixed: cap on the amount the merchant may type at scan time (minor units).
|
||
maxAmountMinor: integer("max_amount_minor"),
|
||
// Anti-abuse cap: max applications per local day (null = unlimited).
|
||
maxPerDay: integer("max_per_day"),
|
||
// The /setup/site checkbox. Inactive = merchants can't apply it (row + history kept).
|
||
active: integer("active", { mode: "boolean" }).notNull().default(false),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
// Soft delete (recycle bin) — see roles.deletedAt.
|
||
deletedAt: text("deleted_at"),
|
||
deletedBy: text("deleted_by"),
|
||
});
|
||
|
||
// The program↔user binding: WHICH users may apply a program (the guard is
|
||
// `validation:create` AND a binding row — a bar user can never apply lavazh).
|
||
export const validationProgramUsers = sqliteTable(
|
||
"validation_program_users",
|
||
{
|
||
programId: text("program_id")
|
||
.notNull()
|
||
.references(() => validationPrograms.id),
|
||
userId: text("user_id")
|
||
.notNull()
|
||
.references(() => users.id),
|
||
},
|
||
(t) => ({
|
||
uniq: unique().on(t.programId, t.userId),
|
||
}),
|
||
);
|
||
|
||
// --- Blocklist (banlist) -------------------------------------------------
|
||
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
|
||
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
|
||
export const blocklist = sqliteTable("blocklist", {
|
||
id: text("id").primaryKey(),
|
||
kind: text("kind", { enum: ["plate", "card", "qr"] }).notNull(),
|
||
value: text("value").notNull(),
|
||
reason: text("reason"),
|
||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||
addedBy: text("added_by"),
|
||
addedAt: text("added_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
});
|
||
|
||
// --- Sessions (PROJECTION cache) -----------------------------------------
|
||
// NOT a source of truth — a rebuildable fold over ledger_events for fast queries
|
||
// (occupancy, pay-station lookup, anti-passback, plate search). Always reconstructable
|
||
// from the signed chain; never the authority for "paid". See wiki/concepts/parking-session.md.
|
||
export const sessions = sqliteTable("sessions", {
|
||
// The session key = the entry's identity (ticket id or plate).
|
||
id: text("id").primaryKey(),
|
||
// Identity that opened the session, and how it was read.
|
||
identity: text("identity"),
|
||
source: text("source"),
|
||
// null while transient; set when matched to a subscription.
|
||
subscriptionId: text("subscription_id"),
|
||
enteredAt: text("entered_at").notNull(),
|
||
// null until exit; presence = CLOSED.
|
||
exitedAt: text("exited_at"),
|
||
// Derived state for quick filtering: open | paid | closed | voided.
|
||
state: text("state", { enum: ["open", "paid", "closed", "voided"] })
|
||
.notNull()
|
||
.default("open"),
|
||
// Index of the last ledger event folded into this row (cache freshness / rebuild).
|
||
lastEventIndex: integer("last_event_index"),
|
||
});
|
||
|
||
// --- Application logs (diagnostics, UNSIGNED, prunable) ------------------
|
||
// A THIRD stream, distinct from the signed ledger_events (business facts) and
|
||
// device_events (hardware telemetry): operational/diagnostic logs for debugging the
|
||
// appliance. Backend warn/error/fatal (a Pino sink) AND frontend errors land here —
|
||
// failed requests, uncaught exceptions, rejected promises — so a booth problem is
|
||
// queryable from one place on an offline box. Never signed, never reconciled, pruned
|
||
// by age + row cap. See wiki/concepts/app-logs.md, event-streams-split.md.
|
||
export const appLogs = sqliteTable("app_logs", {
|
||
id: text("id").primaryKey(),
|
||
// pino levels: trace|debug|info|warn|error|fatal. We persist warn+ from the backend.
|
||
level: text("level", {
|
||
enum: ["trace", "debug", "info", "warn", "error", "fatal"],
|
||
}).notNull(),
|
||
// Which side produced it — the booth UI or the host.
|
||
source: text("source", { enum: ["frontend", "backend"] }).notNull(),
|
||
message: text("message").notNull(),
|
||
// Free-form structured detail: the failed request (path/method/status/body), the
|
||
// error name, component, anything the caller attaches. Kept in one JSON column.
|
||
context: text("context", { mode: "json" }).$type<Record<string, unknown>>(),
|
||
// Pulled out of context for cheap filtering of the common "failed request" case.
|
||
httpStatus: integer("http_status"),
|
||
path: text("path"),
|
||
// Captured stack trace, when there is one (uncaught errors / rejections).
|
||
stack: text("stack"),
|
||
// Who was logged in when it happened (frontend) / acted (backend), if known.
|
||
userId: text("user_id"),
|
||
// The browser/user-agent for a frontend log (triage which booth/device).
|
||
userAgent: text("user_agent"),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
});
|
||
|
||
export type UserRow = typeof users.$inferSelect;
|
||
export type RoleRow = typeof roles.$inferSelect;
|
||
export type RolePermissionRow = typeof rolePermissions.$inferSelect;
|
||
export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
|
||
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
|
||
export type SnapshotRow = typeof snapshots.$inferSelect;
|
||
export type DeviceRow = typeof devices.$inferSelect;
|
||
export type SetupStateRow = typeof setupState.$inferSelect;
|
||
export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
||
export type TariffRow = typeof tariffs.$inferSelect;
|
||
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||
export type TariffDraftRow = typeof tariffDrafts.$inferSelect;
|
||
export type SubscriptionRow = typeof subscriptions.$inferSelect;
|
||
export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
|
||
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
||
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||
export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
|
||
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
|
||
export type SessionRow = typeof sessions.$inferSelect;
|
||
export type AppLogRow = typeof appLogs.$inferSelect;
|
||
|
||
// --- Car Wash module (wiki/decisions/venue-modules.md) ----------------------
|
||
// Admin-maintained master data (categories, services, the price matrix) + the order
|
||
// rows that ARE the wash desk's queue. Master data is plainly mutable; every order
|
||
// freezes the category/service NAMES and the price at intake, and the order's life
|
||
// (created / done / void, and a bay payment) is signed onto the ledger — so history
|
||
// never depends on these rows. Soft-delete on the master data (recycle-bin pattern).
|
||
|
||
export const carwashCategories = sqliteTable("carwash_categories", {
|
||
id: text("id").primaryKey(),
|
||
/** Display name, e.g. "Car", "SUV", "Van", "Truck". */
|
||
name: text("name").notNull(),
|
||
sortOrder: integer("sort_order").notNull().default(0),
|
||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
deletedAt: text("deleted_at"),
|
||
deletedBy: text("deleted_by"),
|
||
});
|
||
|
||
export const carwashServices = sqliteTable("carwash_services", {
|
||
id: text("id").primaryKey(),
|
||
/** Display name, e.g. "Standard", "Outside", "Inside", "Details". */
|
||
name: text("name").notNull(),
|
||
sortOrder: integer("sort_order").notNull().default(0),
|
||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||
createdAt: text("created_at")
|
||
.notNull()
|
||
.default(sql`(current_timestamp)`),
|
||
deletedAt: text("deleted_at"),
|
||
deletedBy: text("deleted_by"),
|
||
});
|
||
|
||
/** The price matrix: one row per (category, service) the admin priced. A missing pair
|
||
* is simply not sellable. Minor units. */
|
||
export const carwashPrices = sqliteTable(
|
||
"carwash_prices",
|
||
{
|
||
categoryId: text("category_id")
|
||
.notNull()
|
||
.references(() => carwashCategories.id),
|
||
serviceId: text("service_id")
|
||
.notNull()
|
||
.references(() => carwashServices.id),
|
||
priceMinor: integer("price_minor").notNull(),
|
||
},
|
||
(t) => ({
|
||
pk: primaryKey({ columns: [t.categoryId, t.serviceId] }),
|
||
}),
|
||
);
|
||
|
||
export const carwashOrders = sqliteTable("carwash_orders", {
|
||
id: text("id").primaryKey(),
|
||
/** The parking ticket id = the customer identity (the wash sits inside the park). */
|
||
identity: text("identity").notNull(),
|
||
plate: text("plate"),
|
||
categoryId: text("category_id").notNull(),
|
||
/** Frozen at intake (renames never rewrite an order). */
|
||
categoryName: text("category_name").notNull(),
|
||
serviceId: text("service_id").notNull(),
|
||
serviceName: text("service_name").notNull(),
|
||
priceMinor: integer("price_minor").notNull(),
|
||
currency: text("currency").notNull(),
|
||
/** "booth" | "bay" — see @parking/shared CarWashPayAt. */
|
||
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull(),
|
||
/** "open" | "done" | "void". Paid-ness is the separate paidAt below. */
|
||
status: text("status", { enum: ["open", "done", "void"] }).notNull().default("open"),
|
||
createdAt: text("created_at").notNull(),
|
||
createdBy: text("created_by").notNull(),
|
||
doneAt: text("done_at"),
|
||
doneBy: text("done_by"),
|
||
/** Set when settled — at the bay (carwash_payment) or at the booth (the parking
|
||
* payment that carried this order as a charge line). */
|
||
paidAt: text("paid_at"),
|
||
paidBy: text("paid_by"),
|
||
tender: text("tender"),
|
||
/** Ledger event id of the payment that settled it (carwash_payment or payment). */
|
||
paymentEventId: text("payment_event_id"),
|
||
/** Ledger event id of the sponsorship validation this order applied, if any. */
|
||
validationEventId: text("validation_event_id"),
|
||
voidAt: text("void_at"),
|
||
voidBy: text("void_by"),
|
||
voidReason: text("void_reason"),
|
||
});
|
||
|
||
/** Module-level settings singleton (id = 1). `payAt`: where wash money is taken at this
|
||
* site — "booth" (on the parking ticket) or "bay" (the wash operator's own till). */
|
||
export const carwashConfig = sqliteTable("carwash_config", {
|
||
id: integer("id").primaryKey(),
|
||
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull().default("booth"),
|
||
updatedAt: text("updated_at"),
|
||
updatedBy: text("updated_by"),
|
||
});
|
||
|
||
export type CarwashCategoryRow = typeof carwashCategories.$inferSelect;
|
||
export type CarwashServiceRow = typeof carwashServices.$inferSelect;
|
||
export type CarwashPriceRow = typeof carwashPrices.$inferSelect;
|
||
export type CarwashOrderRow = typeof carwashOrders.$inferSelect;
|