b485e9870bf03a66fd79251f6c66221ef9cfc9a1
29 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e67f0ccef0 |
feat(carwash): review outbox, booth side — plate-blurred vehicle crop + the operator's choice, queued for a trusted remote reviewer
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash order with a vehicle read queues a package for a trusted reviewer over the private overlay (Netbird); the verdict becomes the phase-B training label and the per-operator error rate. wiki/concepts/vision-review-outbox.md. - Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a downscaled copy); vehicleForIdentity() returns them. - carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 % margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped); 400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured. - Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close. - GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash. - Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in .env.example and forwarded by compose. - Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms, queue/drain/backoff/abandon, through the app). Wiki: new concept page, index, venue-modules As built, log. The collector is not built. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
5e1395db18 |
feat(carwash): advisory vehicle category from the entry camera — mapping, pre-select, downgrade flag
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the open half (no bundled recognizer emits body_type yet, so the desk shows nothing until phase A lands in the vision service). - Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT, reason code carwash.categoryDowngrade; settings/order/lookup views carry the read. - Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node client normalises to the vocabulary and drops the rest. - Record: snapshot.ts stores the read in the plate's device_events row (or its own when the plate was unreadable); vehicleForIdentity() resolves it like the plate. - Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"), carwash_config.vision_threshold (signed config_change when it moves), four vision columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId. - Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup offers class chips per category and the threshold. Operator decides. - Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no reason prompt (user, 2026-09-06). Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
50c18405b6 |
feat(roles): roles remember the jobs they follow (re-appliable), every role edit is signed
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix): - `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in @parking/shared surface a followed job whose bundle grew past the role in a later release; the roles list shows a "behind <job>" badge with a one-click "Update to job" (the union, nothing removed) and the editor lints it. Never a runtime union: the grid stays the explicit enforcement layer and an update never widens a role without a click. - Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value = name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes now takes the ledger. - booth-supervisor already carries subscription:*; the stale open note is closed. Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
a9ccf9e20c |
feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
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 |
||
|
|
23d6379be8 |
feat(modules): venue-module registry — entitled ∩ activated, requireModule, Setup panel
Groundwork for the Car Wash pilot (wiki/decisions/venue-modules.md, build-order
steps 1 + 3). No Car Wash code yet; validation is the first module behind the
seam, unchanged in behaviour.
- @parking/shared: MODULE_IDS, ModuleManifest, MODULES (parking required;
validation dependsOn parking), parseEntitledModules / resolveModuleActivation
/ effectiveModules as pure functions.
- DB: site_config.modules_json (migration 0026, hand-written + journal;
additive, nullable = everything entitled).
- Server: modules.ts (entitledModules from MODULES_ENTITLED env, activated
from site_config, effective set, requireModule preHandler → 403
module_disabled); modules/index.ts registers folder-based modules by
iterating the registry (modules/validation); site-config GET exposes
modules/modulesEntitled/modulesActivated, PUT takes the full desired set,
enforces entitlement + dependency rules (400 with reason) and signs one
config_change per module that actually flips; /api/auth/me carries the
effective set; validation routes guarded requireModule → requirePermission.
- Web: lib/modules.ts + modules/{index,validation}; router.tsx spreads
WEB_MODULES into nav + route tree (validate route no longer named there);
Setup → Site "Modules" panel (required shown disabled, dependencies as
hints, server refusal shown verbatim); validation sections + programs fetch
gated on the module; App invalidates the router whenever the session
changes (route-context consumers only re-read on navigation — the nav was
stale after a flip, and after every other setUser too).
- Lavazh validation station retired (STATIONS = ["bar"]; rows untouched).
- Deploy: MODULES_ENTITLED=parking,validation explicit in both booth stacks;
documented in .env.example.
- Tests: modules.test.ts (7); suite 329/329; web build clean; Playwright
round-trip on /setup/site verified live.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
|
||
|
|
2910672b5a |
fix(backup): persist last-success/error status; wall-clock-based schedule
BackupService tracked last-success/last-error as plain in-process fields and scheduled the daily backup via setInterval measured from process start — so any server restart (deploy/crash/OOM/reboot, routine under `restart: always`) silently reset the admin UI to "last successful backup: Never" and drifted the actual cadence, independent of whether backups were writing correctly to disk (they were — a real field incident at park-buzi showed 7 valid rotating backups on disk with the status stuck on "Never"). Persist last-success/error to new site_config columns (migration 0025) and add BackupService.isDue(), computed from the persisted timestamp instead of process uptime; server.ts now polls every 15 min and lets isDue() gate the actual run. No API/UI contract change. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU |
||
|
|
692dff5f89 |
feat(validations): merchant (bar/lavazh) ticket validations end-to-end
In-park merchants discharge customers' parking: a merchant user scans the ticket on their device (/validate; validation:create + program↔user binding) and applies their program — comp / first-N-minutes free / amount-off (capped, typed at scan) / percent. All money stays at the booth: the quote folds live validations in a canonical order (timeCredit → percent → fixed → comp, net floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and CONSUMES the validation ids (an overstay's fresh period never re-applies them), the receipt prints the gross → lines → net story, and the Z/X-report carries discountTotalMinor leakage. Every apply/void is a signed, attributed ledger event (refId = append-only void); program config is /setup/site master data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route integration tests + priceSession fold suite. See wiki/concepts/validation-discounts.md for the full design record. Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm |
||
|
|
fd9885e9ec |
feat(tariff-lab): DB-backed draft tariffs + named published versions
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:
- tariff_drafts table (migration 0021): MUTABLE by design — the one
exception to "editing publishes a version"; a draft prices nothing and
signs nothing. Drafts are validated + tz-stamped on save exactly like a
publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
tariff:update); publishing a draft goes through the normal immutable
POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
(click any to price against it); main pane cut to pure entry/exit
(ticket loader, payment, category inputs dropped); the composer form is
extracted to TariffEditorForm.tsx and reused in a modal (new drafts
prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
publish — carried from the lab draft, or typed in the composer's new
optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
landed in
|
||
|
|
6505a4a73b |
feat(entry): admin bypass of the presence gate for faulty radar/camera
The entry button (physical press AND the operator-issued mint) requires
radar/loop presence + camera detection to confirm a real vehicle. When one
of those devices is faulty, the gate blocks legitimate transient entry. Let
the ADMIN drop a specific signal as a requirement until support fixes the
hardware — the admin is not the adversary, but weakening an anti-fraud gate
stays attributed and auditable:
- Granular: bypass radar and camera independently (Setup → controller
section). A dead camera drops only the camera check; a dead radar only
radar. Both off = normal gate; both on = press-to-print.
- Signed: a DEDICATED endpoint (PUT /api/site-config/presence-bypass,
site:update) appends a signed config_change {setting, value, prev,
operator} per actually-changed signal — new ledger type. No-op toggles
sign nothing; disabling signs too. Kept out of the generic site PUT.
- Flagged: every vehicle_entry issued (and every refusal anomaly) while
bypassed carries presenceBypassed:[...] in its signed payload.
- Persists until turned off; amber warning in Setup while active. The
booth entry light treats a bypassed signal as satisfied (server
re-checks authoritatively). Physical-button path falls through to the
cooldown backstop when radar is bypassed.
- Migration 0020: two boolean site_config columns (default off).
Fixes a latent bug surfaced by the tests: firstRelayByDirection returned no
presenceInput, so issueForOperator's radar gate always read "presence loop
unavailable" — operator-issue never actually gated on radar. The resolver
now attaches the presence input serving the relay (mirrors relayForButton).
10 new tests: 5 gate combinations (each bypass drops only its signal +
records it), 5 route tests (RBAC, signed transitions, no-op, validation).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
84f00db48b |
feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
Retention (keep-last / keep-daily-days) is operational policy the on-site admin should tune, not a server env var requiring a redeploy -- same reasoning that moved the target directory to the UI. - Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable; null = code default 7 / 30 per field). - BackupService reads retention fresh each run; status() exposes keepLast + keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone). - PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null to reset to default; 400 on negative). - UI: two retention fields on the Backup config card; one Save covers target + retention. i18n sq + en. BACKUP_KEY wired into Komodo: - komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret, alongside JWT / signing keys). - komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY. Also carries the small in-progress setup-intro i18n copy trim. Tests: 218 server tests green, incl. retention persist / reset-to-default / reject- negative and the updated status shape. Migration applies cleanly (needed a statement-breakpoint between the two ALTERs). Wiki backup-recovery updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
d5e41500a8 |
feat(backup): admin UI with admin-chosen target directory
The backup destination is now chosen by the on-site admin in the UI (Setup -> Backup), not a server env var. An env-pinned target defeats the purpose: the admin can't point backups at a freshly-plugged USB or a NAS mount without editing .env and restarting. The encryption key stays a server secret. Target storage: - New site_config.backup_target_dir (migration 0016, nullable; null = not configured). BackupService reads it fresh each run, so a UI change takes effect with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it backs up. Routes: - PUT /api/backup/config -- set/clear the target (backup:update; upserts id=1). - POST /api/backup/test -- probe a candidate path server-side (exists / is a directory / writable) so the admin gets feedback before relying on it. - status() now exposes targetDir + keyPresent, so the UI distinguishes 'no target set' from 'BACKUP_KEY missing'. UI (apps/web/src/BackupSettings.tsx): - A Setup -> Backup tab (gated backup:read): an editable target-path field with a Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status panel (config state, last-run size/pruned/error, a distinct amber missing-key warning), a Back up now button, and the restore-is-out-of-band note. Full i18n (sq + en); nav.backup. - API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup. Also includes a small in-progress copy trim to the setup-intro i18n strings. Verified live with Playwright: typed a path -> Test reported writable -> Save persisted it -> status reflected it and showed the key-missing warning. Whole monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
f706726eeb |
feat(prefs): per-user UI font scale (A−/A+), saved to the profile
A header A−/value/A+ control scales the whole UI, persisted per user and restored on login from any booth — cloning the theme-pref pattern end to end. - DB: users.font_scale (migration 0014; percent, 100 = base, NOT NULL default). - Server: PUT /api/auth/font-scale (auth-guarded; clamps to 80–160, snaps to a 10-step); fontScale flows through sessionView → login + /me. - Client: setFontScalePref + applyFontScale; applied in App alongside theme; FontScaleToggle in the header; i18n sq+en. Scaling uses CSS `zoom` on the root, NOT root font-size: the app's type is pinned in px (text-[12px] etc., ~230 spots), which a font-size change would not scale — so the dense Active-sessions / Live-feed logs stayed tiny. `zoom` scales everything uniformly (text, spacing, icons) like the browser's Ctrl+/−, which is the readability win for operators who need larger text. Tests: 4 font-scale auth-route cases (persist + /me, clamp/snap, 400, default-100). Full workspace build/lint/test green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
65328b8c11 |
feat(anpr): subscriber-entry bridge + admin disable toggle
CI / check (push) Failing after 15s
Wire the lane camera's vehicle event into the gated subscription flow: on a vehicle/active push from an opt-in (config.anpr) camera, AnprBridge pulls a fresh snapshot, runs ANPR, applies a stricter entry confidence floor, debounces, and — matching the plate to a subscription BEFORE emitting — emits a kind:"plate" read. The existing ReadDispatcher -> SubscriptionFlow then signs the entry/exit and opens the barrier. A plate is never the sole authority: it routes through the same gate (active/window/blocklist/car-count) as any credential. Fail-soft, fire-and-forget, subscriber-only by construction. Field-verified end to end (plate AA504LX opened the entry barrier and appended a signed vehicle_entry). Add an admin master switch (site_config.anpr_entry_enabled, default ON) in Site Settings that disables ONLY the barrier-driving bridge; advisory snapshot-ANPR and lane busy/free are unaffected. Read live per event, so toggling takes effect with no restart. Migration 0013 (additive ALTER ADD COLUMN, default 1). - New: apps/server/src/anpr-entry.ts (AnprBridge) + tests (9) - hikvision-alarm.ts hands vehicle detections to the bridge (fire-and-forget) + wiring tests (3) - server.ts reorders the read flows above the hik-alarm registration - snapshot.ts exports buildCamera for reuse - env: VISION_ENTRY_MIN_CONFIDENCE (0.85), ANPR_DEBOUNCE_MS (12000) - site route + SiteSettings checkbox + i18n (sq/en parity) - wiki: lane-presence-and-anpr-entry / lpr-camera / index / log -> BUILT Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
7680d9a0ed |
feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were hard and unrecoverable. Now they soft-delete into a recycle bin. Schema (migration 0012): nullable deleted_at + deleted_by on users, roles, subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified against a copy of the live DB. Backend: each resource's DELETE route STAMPS instead of removing; every catalog list filters deleted_at IS NULL. New recycle-bin module + routes (GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a new recyclebin:read/update/delete permission. A 6-hourly + startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever). Invariants: soft-deleted users can't log in (login rejects deleted_at; no-lockout counts live admins only); a soft-deleted subscription doesn't open the barrier; plans are versioned so a delete stamps all versions of the plan_id (bin shows one item); username/role-name UNIQUE spans deleted rows so reuse returns a clear 409 pointing at the bin; restore doesn't auto-cascade a dangling role (guard resolves missing role to empty perms). The signed append-only ledger is OUT of scope (no delete path). Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge + purge confirm; api client + i18n (sq + en parity). Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4 integration: delete -> can't-login -> restore -> login, purge, gating, 409 reuse). server 103/103; build+lint+test 19/19. Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
53e1e7b25c |
feat: subscription v2 — quantity pricing, plan timeframes (tariff bridge), reserved spots
Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).
1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
amount = span price × quantity; maxConcurrent defaults to the quantity so all
N cars can be inside. Quantity rides in the payment payload.
2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
tariff (the subscriber is a transient for that time):
- early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
the vehicle_entry payload), collected at exit;
- late exit: window-close → departure, and exit is GATED
(sub.refused.unpaidWindow) until paid at the booth.
Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
reuses computeFee + the active tariff version
(apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
business gate — the fail-open rule still governs the offline path.
3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
max(0, quantity − itsCarsInside) per active subscription, so transients see
"full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
never gated by full.
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.
Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
fd4608a8f1 |
feat: subscription plan catalog — config-defined pricing, dated spans, no typed amounts
Re-model subscription pricing from per-row, operator-typed prices into an admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now SELLS by picking a plan over a date span; the price is LOOKED UP, never typed — removing the fat-finger risk on a money field — and day/week/month periods make the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span. - Schema/migration 0010: new `subscription_plans` (immutable, effective-dated, keyed by a stable planId; period day/week/month + per-period price + active flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a "Monthly" plan from the existing site default price (no data loss). - Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period), amount = periods × per-period price. Ceil = any started period is full (hotel practice). `resolvePlanVersion` picks the latest active version ≤ sale instant. - Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked sell path derives the amount from the plan; `POST /api/subscriptions/quote` returns a server-computed quote so the operator can't override it. The signed-payment sale fix is unchanged — only the amount SOURCE moved; payload now carries planId/planVersionId/periods. Updates never re-sell (price frozen). - Frontend: SubscriptionManager sell form swaps the price field for a plan picker + start/end dates + a live quote line. New SubscriptionPlansManager (Setup tab) for the admin catalog. i18n (sq+en) for both. Verified on a copy of the live DB: 0010 applies (existing subs intact), a 3-night hotel sale prices to 2,400 ALL, appends one signed payment with planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
bfb6ab0b36 |
feat(logs): app log store — backend pino DB sink + frontend error collection
Add a third data stream (app_logs), distinct from the signed ledger and device telemetry, for operational/diagnostic logs — an offline appliance has no Sentry to ship to, so the host is the log store. Backend: a pino stream tees warn/error/fatal into app_logs (info/debug stay stdout-only) with no call-site change; the DB is built before Fastify so the logger has its sink. Frontend (lib/logger.ts): ships failed API requests (minus 401 churn), window.onerror, unhandledrejection, and a top-level React ErrorBoundary; console warn/error forwarded only at debug/trace. Batched/throttled POST, sendBeacon on pagehide, loop-safe (never logs the /api/logs call), best-effort everywhere. POST /api/logs (any signed-in user, CSRF, tolerant) + GET /api/logs gated by a new log:read permission (new `log` RBAC resource; admin holds it). Retention: pruned by age + row cap, hourly + at startup. UI: a Logs screen under /setup (filter level/source/since, expand to context+stack), sq+en. Migration 0009_app_logs. Verified end-to-end via app.inject: login -> POST 204 -> GET 200 with the record; backend warn/error persisted, info dropped; non-admin GET 403 / POST 204. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
040c0ff4ca |
feat: tabbed setup, user metadata, light theme, scoped shift history
Consolidate the config screens under a single /setup hub with permission- gated tabs (Devices/Tariff/Subscriptions/Site/Users/Roles/Shifts), collapsing the top nav to Booth·Shift·Setup; old top-level paths redirect. Users: add optional profile metadata (full name, phone, email, address) on create/edit. Theme: a light palette saved to the user's profile (users.theme), toggled in the header beside the language switch and applied on load like the language preference. Both ride on a single additive migration (0008). Shift history: a new GET /api/shifts folds the signed shift_z_report chain into completed shifts, SCOPED server-side — operators see only their own; holders of shift:cash see all with an operator + date-range filter. Surfaced as the Shifts tab; an operator cannot read another operator's takings (param spoofing is ignored). These three features share the router, api client and i18n catalogs, so they land together. Verified live: theme persists across reload, metadata round- trips to the DB, and shift scoping holds (operator self-only, admin all+filter). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
d0841c8601 |
feat(auth): dynamic RBAC — composable roles + resource×CRUD permissions
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.
@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).
DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).
auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.
Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).
Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).
Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
cf1ff5676d |
feat(tariff): V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
Bring the legacy ParkSQL2017 pricing BREADTH onto our engine while keeping
integer-minor-unit money + immutable signed versions (rejecting legacy's
float money / mutable rows). TariffStructure becomes a discriminated union:
V1 = the original bare ladder (UNCHANGED, verbatim algorithm, golden-
regression-tested against the live version); V2 = {version:2, tz, shared
knobs, defaultCard, windowedCards[]} where each card is flat OR a block
ladder and may be scoped by wall-clock hour window / day-of-week / date
range / vehicle category.
computeFeeV2 prices by stepping one increment at a time, advancing the
ladder by ELAPSED minutes (continuous) while selecting the active card by
WALL-CLOCK time in the version's FROZEN tz. Decisions: tz is a per-site
setting (site_config.timezone, default Europe/Tirane) stamped server-side
into each version on publish — never the host clock (reproducibility);
default-card cap governs a mixed day; precedence = specificity
(date>dow>hour) -> priority -> name (total, order-independent), validation
rejects ambiguous ties; category = a card FIELD, frozen in the signed
vehicle_entry payload (site_config.default_vehicle_category default), read
at both pricing call-sites.
Composer: default card front-and-centre (flat/ladder toggle), tiers under
an "Advanced" disclosure; emits BARE V1 when no tiers (back-compat). DB:
migrations 0005 (timezone) + 0006 (default_vehicle_category). Stood up
vitest in @parking/shared (was zero tests on the ledger-feeding fee fn);
36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/
cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism,
validation matrix — all green. No event-chain change.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
5697137c52 |
feat(subscription): rename permit→subscription + monthly pricing
The "permit/lejet" feature is really a subscription. Full rename of the mutable master data, plus a recurring monthly price. - DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions, permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id. - Pricing: per-subscription priceMinor + period(monthly) + currency, with a site default (site_config.subscription_monthly_price_minor) pre-filling the form. - Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts (/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en). - The signed ledger `permitId` payload is intentionally kept — immutable hash-chained history; renaming it would break verification of past events. Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber). Also carries the device-footer UI surface (api DeviceStatus, router mount, i18n devices) due to shared-file overlap with the preceding footer commit. Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions preserved). Live DB migrated. Full monorepo builds clean. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
445bca0bf6 |
feat(auth): per-user UI language preference (sq default, en)
Add users.language ('sq'|'en', default 'sq'; migration 0003). Returned from
/api/auth/login and /api/auth/me (read from the DB, not the JWT — so changing it
needs no re-login). New PUT /api/auth/language for self-service. Loaded on login
and restored from any booth. Printed tickets stay Albanian (customer-facing).
|
||
|
|
06dab1e790 |
feat(booth): pay-on-foot at the booth — ticket lookup, pay, exit, voucher, snapshots
Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket id barcode; site_config.exit_voucher_default (migration 0002) drives the default. Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher. Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'), SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle. |
||
|
|
727c62da90 |
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. |
||
|
|
1efa77bf56 |
devices: pool-of-spaces model — drop lane, per-relay direction
A parking lot is one pool of spaces with a flexible set of entry/exit
points — no "lane". Direction is a property of each RELAY inside an access
controller; readers/cameras bind to a controller relay and inherit it.
Schema:
- drop `lane` from ledger_events, device_events, sessions
- rename lane_devices -> devices (no lane/direction columns)
- access config.relays=[{relay,direction,button?}]; reader/camera
config.controllerId+relay binding
- fresh 0000_baseline migration (history reset; dev data was throwaway)
Signed ledger:
- remove `lane` from canonicalize(); bump signer keyId sw-hmac-v1 -> v2
(v1 events won't verify under v2 — intentional, gated per-event by keyId)
Server:
- new device-resolve.ts (replaces lane-map.ts): relayForButton,
relayForDevice, firstRelayByDirection, devicesByDirection
- entry-flow: button terminal -> its relay; exit/permit: reader's bound
relay; dispatcher resolves the bound relay + inherited direction
- camera snapshots fire by direction site-wide, async, never block open
- DeviceConfig widened to nested JSON for relays[]
Web:
- wizard: no lane selector; add controllers (relay map + entry-button
terminal) first, then bind readers/cameras/printers to a controller relay
Wiki: new entry-exit-points.md (replaces lane-direction); reworked
entry-exit-readers, parking-session, first-run-setup, device-registry,
append-only-event-chain, device-events; removed stale lane/LaneMap mentions.
|
||
|
|
e579fe5b6e |
server+web: capacity / FULL gate (occupancy fold + transient refuse)
Occupancy is a fold over the signed ledger (entries minus exits per identity);
getOccupancy returns {count, capacity, free, full}. Capacity is a single-row
site_config table (admin-set; null = uncapped; migration 0001, additive).
FULL gate lives in the transient entry flow: when full, refuse (no ticket, no
vehicle_entry, no open) and sign an anomaly. Permit entry is NOT gated --
subscribers are admitted past transient-full (their own maxConcurrent still
applies), so occupancy can read over capacity by design (reserve-for-permits).
Routes: GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-negative int or null). Web SiteSettings: live
occupancy + FULL badge (everyone), capacity editor (admin).
Verified: fill to cap -> 3rd transient refused; permit admitted past full; exit
frees a slot; RBAC (operator can't set, -5 -> 400); verifyChain ok. Physical
FULL-sign relay output deferred.
|
||
|
|
8c2cf93067 |
db: business-layer schema — ledger/device event split, tariffs, permits, sessions
Implements the wiki design in packages/db + packages/shared. Event split: rename events -> ledger_events (signed business ledger) and add device_events (unsigned telemetry). ledger_events gains a signed JSON payload (amount/tariffVersionId/sessionRef/tender…) + keyId; canonicalize() includes the payload via sorted-key serialization so business data is tamper-evident. Raw Dingtian input now writes device_events, not a signed input_received. New tables: tariffs + immutable tariff_versions (composable/versioned, currency + FX-ready), permits (+ permit_credentials, permit_plates; maxConcurrent default 1), blocklist, sessions (rebuildable projection cache — not a source of truth). shared: split ParkingEvent/Type into LedgerEvent/LedgerEventType + DeviceEventKind; add LedgerPayload, Tender, TariffStructure/TariffBlock. Regenerated a single baseline migration (no production chain data existed). Verified: chain appends + verifyChain ok; tampering a payment payload breaks the signature. Full repo builds (5/5). |
||
|
|
72ba4099ea |
Device-agnostic driver registry + first-run setup
Make the device-adapter pattern selectable so the admin chooses hardware at install — per lane, from a catalog of supported drivers. Adding a device = registering one more driver; no business-logic change. packages/devices: - interfaces.ts: AccessControlDevice / ReaderDevice / CameraDevice / PrinterDevice (adds CameraDevice for entry/exit snapshot-on-event; access relay stays intent-only per "a barrier is not a door"). - registry.ts: driver catalog with per-driver config fields + factory, config validation, and a catalog payload for the setup UI. - drivers/: stub adapters — access (zkteco, esp32-relay), reader (wiegand, tcp-ip), camera (hikvision, dahua). Real vendor protocols TBD. packages/db: - lane_devices + setup_state tables (migration 0001); re-export query helpers. apps/server: - routes/setup.ts: GET /api/setup/catalog (public schema), and admin-only /assign, /state, /complete with registry validation before persisting. - extract auth.ts (requireJwtSecret, requireRole, JWT type aug). apps/web: - SetupWizard scaffold + api client: pick a driver per category for a lane, render its config fields. wiki: device-registry + first-run-setup concept pages; cross-link from device-adapter-pattern; index + log updated. Verified: full turbo build (5/5); catalog lists all drivers; admin assign persists; missing-config and no-token requests are rejected. |
||
|
|
bfe64032d8 |
Initial scaffold: Turborepo monorepo + design wiki
Turborepo (pnpm workspaces) with all dependencies pinned to latest mutually-compatible versions: turbo 2.9, TypeScript 6, Fastify 5, React 19, Vite 8, better-sqlite3 12 + Drizzle ORM 0.45. Layout: - apps/server Fastify backend (local JWT auth + role guard, /health) - apps/web React 19 + Vite 8 operator SPA - packages/db Drizzle schema on SQLite/WAL; append-only events + users - packages/devices reader/printer/relay adapter interfaces (intent-only relay) - packages/shared shared domain types Architecture constraints from the design wiki are encoded in the scaffold: append-only hash-chained + signed event log, device-agnostic adapters, "a barrier is not a door" (relay expresses intent only), fully-local offline-first auth. wiki/ is an LLM-maintained Obsidian knowledge base (28 pages) ingested from the architecture & design notes, with its own maintenance schema. Verified: pnpm install, full turbo build (5/5), server boots and serves /health, drizzle-kit generates the initial migration. |