import { sql } from "drizzle-orm"; import { blob, integer, primaryKey, real, 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), }), ); /** The JOBS a role follows (venue-modules.md §"Permissions matrix", move 2): the * manifest job presets the admin composed it from. Remembered so a later release that * grows a job's bundle can be surfaced ("this role is behind the Wash operator job") * and re-applied with one click — never expanded silently at runtime: what a role may * do is always the explicit `role_permissions` grid. */ export const roleJobs = sqliteTable( "role_jobs", { roleId: text("role_id") .notNull() .references(() => roles.id), jobId: text("job_id").notNull(), }, (t) => ({ uniq: unique().on(t.roleId, t.jobId), }), ); 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>(), 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>(), 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(), 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>(), 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>(), 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>(), 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>(), // 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>(), // 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), /** The vision vocabulary classes this category covers (JSON array of VehicleClass) — * the site's own mapping ("car, sedan → Vetura"). Empty = never suggested by vision. */ visionClasses: text("vision_classes", { mode: "json" }).$type().notNull().default(sql`'[]'`), 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"), // Vision, advisory (venue-modules.md §Vehicle category): what the camera saw at entry, // the category the site mapping suggested, and the `anomaly` signed when the operator // chose a cheaper category above the confidence threshold. Never a tariff input. visionClass: text("vision_class"), visionConfidence: real("vision_confidence"), visionCategoryId: text("vision_category_id"), downgradeEventId: text("downgrade_event_id"), }); /** 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"), /** Confidence floor (0–1) for a vision class to flag a category downgrade. */ visionThreshold: real("vision_threshold").notNull().default(0.8), 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;