Compare commits
318 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7a262ac9a | |||
| f9cb973fe9 | |||
| 1a0fe59488 | |||
| 8bfc29db2a | |||
| dbbb051ebd | |||
| 3e57af5abc | |||
| ef55d1c6a9 | |||
| 0411b71c2d | |||
| b485e9870b | |||
| ec44547122 | |||
| e67f0ccef0 | |||
| 78ca58d264 | |||
| 20a3cb3e80 | |||
| 5e1395db18 | |||
| 50c18405b6 | |||
| e14e31a840 | |||
| ea304bbfd1 | |||
| 2aa1045ddc | |||
| acde3bba5b | |||
| 9a13528611 | |||
| 6f88026d3e | |||
| c481c1e788 | |||
| 3a7c3fae11 | |||
| 55d6242c7d | |||
| a9ccf9e20c | |||
| 23d6379be8 | |||
| db9c3e0e31 | |||
| d86bffa500 | |||
| 9c05f86c86 | |||
| 54e691a4c9 | |||
| 52862db8ad | |||
| 8fa66c9911 | |||
| 70e1e9939f | |||
| 5c6a21e2c3 | |||
| 969bf2b191 | |||
| 7d67934a10 | |||
| 56904422af | |||
| 8bcdea9e4a | |||
| 7804285dec | |||
| 4a7029cea6 | |||
| 7317042e8d | |||
| 439b11d16d | |||
| 276b048fa9 | |||
| faa3265e49 | |||
| 21bfdce27a | |||
| d3288e29eb | |||
| baf7a4a99d | |||
| 885b410e48 | |||
| a1f3103a76 | |||
| 0fd66b261a | |||
| dfc5a07c10 | |||
| 5aabd7a791 | |||
| 0e9b9f5d82 | |||
| 642c5f4f70 | |||
| cb9f4d4979 | |||
| ea8fe22969 | |||
| 2910672b5a | |||
| 3a176c5cc8 | |||
| 19dff97c74 | |||
| 0ed43239c3 | |||
| 28bd838696 | |||
| 692dff5f89 | |||
| ba7538aeb5 | |||
| bb365b5d6e | |||
| c52a42dad2 | |||
| 22544ecf63 | |||
| ba5b4b1f4e | |||
| 51b160bfc9 | |||
| e2d5105da2 | |||
| 5287be5278 | |||
| 3a85483e6c | |||
| 6ceaadfbf2 | |||
| 7f42805e8d | |||
| cd3b534e51 | |||
| 011fe5a4c4 | |||
| 6f3f6ca596 | |||
| 5443b910c6 | |||
| a02957034d | |||
| ee61c24bb9 | |||
| 3a186d29df | |||
| 827445d514 | |||
| f9887c2a76 | |||
| 7649b897c4 | |||
| ab968eb25e | |||
| 5e1a885dcb | |||
| 6cf3492bff | |||
| ffe8c13a1c | |||
| fcea992e1e | |||
| 81bc2e357c | |||
| 7ef332999e | |||
| a2e102f3dd | |||
| 14638c2e13 | |||
| 4f902d869e | |||
| a5e54a8b93 | |||
| 0180394c45 | |||
| d905dd19b4 | |||
| c5ed3f1308 | |||
| 0b7eb28dfa | |||
| d5ff2097bd | |||
| 1de209be48 | |||
| dc2cdc0a91 | |||
| fd9885e9ec | |||
| 52a89bfa56 | |||
| d9e6c13831 | |||
| 493210bbb0 | |||
| a9f18be700 | |||
| 72ad504b8d | |||
| 5cdf8f227b | |||
| 365b648282 | |||
| c03ef2a34b | |||
| d9829eb61f | |||
| bafa3282c7 | |||
| 93f9ebea05 | |||
| c21babf293 | |||
| 44f34d68c4 | |||
| 43c1f45e29 | |||
| 35e593ab63 | |||
| b4f1418858 | |||
| 9d73561855 | |||
| 094e963e5e | |||
| 6505a4a73b | |||
| 8b65e199a3 | |||
| f486dcbbfc | |||
| c142166972 | |||
| 306d136a08 | |||
| 61b9955160 | |||
| b7e4037fbe | |||
| d2ab2e022e | |||
| 33c4ea1e91 | |||
| 114a32e6f2 | |||
| 018328a877 | |||
| 266e9b0027 | |||
| d92b8d1e6a | |||
| 61de1fe772 | |||
| cfac14e09e | |||
| 1b86750b0d | |||
| 9c6741a485 | |||
| d0b609e375 | |||
| 8f32d90d28 | |||
| 07295f8063 | |||
| 652d6599d3 | |||
| 39c778fbac | |||
| e16bccc2f5 | |||
| 2ab001054d | |||
| 381046190b | |||
| 84f00db48b | |||
| d5e41500a8 | |||
| 0c218179c4 | |||
| 9e442586af | |||
| 11567a417f | |||
| f6e35bbebf | |||
| 96acd6b662 | |||
| cce99aadfd | |||
| f706726eeb | |||
| 6734e9815e | |||
| 38481f105f | |||
| 4418594af0 | |||
| 25a72ff20a | |||
| c2a861208f | |||
| a888125eca | |||
| 96fd97efa9 | |||
| 2a13b95da6 | |||
| 513566c89e | |||
| f77ed11782 | |||
| e4a17efd97 | |||
| 6d32e0fc0f | |||
| 3a60367232 | |||
| 045892bc94 | |||
| 916c147b4d | |||
| 1ea1aa4189 | |||
| 9c20faf8de | |||
| a68dc23393 | |||
| c87dcb2253 | |||
| 7eadf71a0b | |||
| 9918f278b2 | |||
| 83298bc0c5 | |||
| 898cf1953a | |||
| dd0f6e483a | |||
| 40de8a7467 | |||
| f0fd15bb88 | |||
| 40ffa90dac | |||
| b3cb67188e | |||
| b1c4109045 | |||
| 50dd554b43 | |||
| 6d7682ab4a | |||
| 793b8d83ee | |||
| 7366ad19cb | |||
| 5a5fedf4f4 | |||
| 830993bcb8 | |||
| fd15988a73 | |||
| 420542ce10 | |||
| 2915d141aa | |||
| 215a3ac405 | |||
| e0cfeb5e71 | |||
| 8129b63a8c | |||
| f9bd586265 | |||
| aa546235fb | |||
| c637b2783c | |||
| 77b2acb1ca | |||
| 10923164ad | |||
| 0a22eab4a8 | |||
| 9d65099d9b | |||
| 8155ff456b | |||
| 492a08a079 | |||
| 8a437d0c4b | |||
| 65328b8c11 | |||
| 411572511d | |||
| a2bdf99db2 | |||
| 89542d4ab6 | |||
| e0b9442acc | |||
| 6f4e390c05 | |||
| df6a1ca63a | |||
| 547061edf9 | |||
| b7300ec080 | |||
| 461275521d | |||
| 3db8f517d3 | |||
| 6133923094 | |||
| 7680d9a0ed | |||
| 3527f48d76 | |||
| 5a5f5c554b | |||
| 742653aefb | |||
| 66c1291578 | |||
| 7629d5d7b1 | |||
| 2fb947e908 | |||
| cae900afd2 | |||
| 7e912e193b | |||
| 352c643009 | |||
| 5e9be16f65 | |||
| 0985b86fa7 | |||
| 3ed785c33e | |||
| 35c10a7310 | |||
| 2a9e6846a1 | |||
| 051b440627 | |||
| eb47016ae3 | |||
| 78d1f6808a | |||
| 31f116a068 | |||
| 663bf0e925 | |||
| 8acef0464c | |||
| df5caf8d87 | |||
| 0cbae94842 | |||
| ae5c122980 | |||
| d0536da3d7 | |||
| ae736a9e3e | |||
| 1b54775b4d | |||
| f2734641b2 | |||
| de858e91f4 | |||
| 294ca85ded | |||
| eafbc3ddbb | |||
| 36f30d39ff | |||
| 488dcb5e4e | |||
| c64457020f | |||
| ff04ec10be | |||
| e0e218fa61 | |||
| 21bd0f6227 | |||
| 53e1e7b25c | |||
| fd4608a8f1 | |||
| 052da8c3a7 | |||
| cb68cbafdb | |||
| 2835f78635 | |||
| a20400c2c5 | |||
| cdb55a8652 | |||
| b0c9ba0f8c | |||
| 9a1feeeb20 | |||
| cc507f490f | |||
| 3d02134711 | |||
| a4712774ab | |||
| 918f76fbef | |||
| 9ec644811a | |||
| ecaaefd899 | |||
| 4af8b56dda | |||
| 540b333b06 | |||
| 7e086ff0d7 | |||
| 236cbfecab | |||
| 17fdf3d482 | |||
| 4833b4373d | |||
| 5cedcaefe1 | |||
| 6933406ae3 | |||
| ee28b7302f | |||
| e4827c9651 | |||
| c0a775818b | |||
| 30e7fe85de | |||
| bfb6ab0b36 | |||
| 0074e82a2a | |||
| bbf61c48df | |||
| 00f3d141b6 | |||
| f31e57b4ae | |||
| 040c0ff4ca | |||
| 8444bf34c3 | |||
| 808fb26ab6 | |||
| ef0ecadff9 | |||
| d0841c8601 | |||
| d71ba82999 | |||
| 9c9f777784 | |||
| 486f8deae6 | |||
| 3e6773a6d5 | |||
| cf1ff5676d | |||
| 91cc79b14e | |||
| dfa76346d6 | |||
| c9a2ef81a9 | |||
| b8ddda86e7 | |||
| bba988c4e8 | |||
| 5697137c52 | |||
| ca8c7f2fa2 | |||
| f87e4c0d6b | |||
| 4e2e4feedb | |||
| 48660d3ec8 | |||
| 14c83e182a | |||
| 445bca0bf6 | |||
| 062feeae2f | |||
| 50a3095ef3 | |||
| eb3dc18e67 | |||
| 06dab1e790 | |||
| 9956488fd5 | |||
| 49df2015c8 | |||
| c2f06a5d2a | |||
| 58d8f06ba0 | |||
| 71aaad03b9 | |||
| 727c62da90 |
@@ -1,24 +1,5 @@
|
|||||||
{
|
{
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PreToolUse": [
|
"PreToolUse": []
|
||||||
{
|
|
||||||
"matcher": "Bash",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": "Read|Glob",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);s=(str(t.get('file_path') or '')+' '+str(t.get('pattern') or '')+' '+str(t.get('path') or '')).lower().replace(chr(92),'/');exts=('.py','.js','.ts','.tsx','.jsx','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');sys.stdout.write('1' if 'graphify-out/' not in s and any(e in s for e in exts) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Build context hygiene for the server + vision images (context = repo root).
|
||||||
|
# Keep the context small and NEVER bake build artifacts, secrets, or the live DB.
|
||||||
|
|
||||||
|
# Node / build outputs (rebuilt inside the image)
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
**/.turbo/
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# Python (vision) — rebuilt by uv inside the image
|
||||||
|
**/.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
**/.mypy_cache/
|
||||||
|
**/.pytest_cache/
|
||||||
|
**/.ruff_cache/
|
||||||
|
|
||||||
|
# Secrets + local env (the image gets config via runtime env, never baked)
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
|
||||||
|
# NEVER bake the live signed-ledger DB (or any of its WAL/SHM/backup variants) into an
|
||||||
|
# image — it lives on a mounted volume. Match the base file AND every -wal/-shm/.bak-*
|
||||||
|
# sibling (deploy copies the package dir's files, ignoring .gitignore).
|
||||||
|
**/*.sqlite
|
||||||
|
**/*.sqlite-*
|
||||||
|
**/parking.sqlite*
|
||||||
|
|
||||||
|
# Desktop app is built by its own tag-only release.yml, not these images
|
||||||
|
apps/desktop/
|
||||||
|
|
||||||
|
# VCS, logs, caches, editor cruft
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
*.log
|
||||||
|
**/.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Wiki raw sources / large docs (not needed to build)
|
||||||
|
wiki/raw/
|
||||||
|
|
||||||
|
# Plans / scratch
|
||||||
|
.planning/
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Booth deploy env — copy to `.env` and fill in, then run ./scripts/booth.sh up
|
||||||
|
# (prod). Consumed by docker-compose.yml + the prod override via --env-file.
|
||||||
|
# See wiki/decisions/container-deployment.md. Do NOT commit the filled-in .env.
|
||||||
|
|
||||||
|
# --- image source (prod pulls from the house Gitea registry) ------------------
|
||||||
|
# The registry namespace; combined with the image name + TAG below.
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# Image tag to deploy. CI publishes TWO tags per build: a MOVING branch tag
|
||||||
|
# (`dev`, and `main` once that branch is built) republished on every push, and an
|
||||||
|
# IMMUTABLE per-commit `dev-<sha>` (e.g. dev-830993b). Use the moving tag for a
|
||||||
|
# self-updating booth (`booth.sh update` pulls the latest); pin the `<branch>-<sha>`
|
||||||
|
# form for a reproducible, deterministic deploy. NOTE: `main` images only exist once
|
||||||
|
# something is built on main — until then deploy from `dev`.
|
||||||
|
TAG=dev
|
||||||
|
|
||||||
|
# --- secrets (NO safe defaults — the server refuses to boot without a real one) -
|
||||||
|
# JWT signing secret. Generate yourself, never share it: openssl rand -hex 32
|
||||||
|
# Must be 32+ chars and must NOT contain change-me / insecure / dev-only.
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Ledger-signing key for the append-only signed event chain. Set a DISTINCT value
|
||||||
|
# in prod (don't reuse JWT_SECRET). openssl rand -hex 32
|
||||||
|
EVENT_SIGNING_KEY=
|
||||||
|
|
||||||
|
# --- booth LAN specifics ------------------------------------------------------
|
||||||
|
# Auth cookie is HTTPS-only by default; the booth is plain HTTP behind Caddy on
|
||||||
|
# :80, so this MUST stay 0 or operators cannot log in. Set to 1 only behind TLS.
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
|
||||||
|
# Remote origins the live WS feed must accept (same-origin always passes). Add any
|
||||||
|
# address admins hit the UI from beyond the booth itself, comma-separated, e.g.
|
||||||
|
# http://parksystems.msai.al (leave blank if only the local booth URL is used).
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# Vision/ANPR. Prod override already forces the fast_alpr engine; leave VISION_ENABLED=1
|
||||||
|
# unless you are running without the camera. (Set 0 to disable the vision call entirely.)
|
||||||
|
VISION_ENABLED=1
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
name: Build desktop
|
||||||
|
|
||||||
|
# Build the Tauri desktop installers (.deb + .AppImage) on every push to dev/main and
|
||||||
|
# upload them as workflow ARTIFACTS — a downloadable, per-commit build for testing the
|
||||||
|
# native shell. This is NOT a release: it's unsigned (no updater key) and creates no Gitea
|
||||||
|
# Release. Signed, versioned releases stay on release.yml (tag v* → .deb/.rpm/.AppImage +
|
||||||
|
# latest.json for the auto-updater). See wiki/decisions/desktop-shell-tauri.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
paths:
|
||||||
|
- 'apps/desktop/**'
|
||||||
|
- 'apps/web/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.gitea/workflows/build-desktop.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
desktop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
|
||||||
|
- name: Install Tauri system deps
|
||||||
|
# Same set release.yml uses (verified): WebKitGTK 4.1 + libsoup-3 + the GTK/
|
||||||
|
# appindicator/rsvg stack + AppImage tooling (patchelf, file).
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
file \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache cargo + target
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
apps/desktop/src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build desktop bundle (.deb + .AppImage)
|
||||||
|
# Unsigned — no TAURI_SIGNING_* here (this is a test artifact, not an updater
|
||||||
|
# release). The config sets createUpdaterArtifacts:true (release.yml signs them),
|
||||||
|
# which makes tauri DEMAND the signing key and fail without it — so override it to
|
||||||
|
# false for this build via --config (a JSON patch merged over tauri.conf.json).
|
||||||
|
# --bundles restricts to the two installers we ship; tauri builds the web SPA
|
||||||
|
# first (beforeBuildCommand), so the desktop UI matches.
|
||||||
|
run: >
|
||||||
|
pnpm --filter @parking/desktop bundle
|
||||||
|
--bundles deb,appimage
|
||||||
|
--config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||||
|
|
||||||
|
- name: Collect installers
|
||||||
|
id: collect
|
||||||
|
# Copy out the two installers under SPACE-FREE names (tauri names them
|
||||||
|
# "Parking System_0.0.0_amd64.deb" — spaces break asset URLs). Short SHA in the
|
||||||
|
# name so a downloaded file is traceable to its commit.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||||
|
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
|
||||||
|
mkdir -p dist
|
||||||
|
deb=$(find "$BUNDLE/deb" -name '*.deb' | head -1)
|
||||||
|
app=$(find "$BUNDLE/appimage" -name '*.AppImage' | head -1)
|
||||||
|
cp "$deb" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.deb"
|
||||||
|
cp "$app" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.AppImage"
|
||||||
|
echo "Artifacts:"; ls -la dist/
|
||||||
|
|
||||||
|
- name: Publish to a rolling per-branch pre-release
|
||||||
|
# actions/upload-artifact's backend isn't reliable on this Gitea runner, so we
|
||||||
|
# publish to a Gitea RELEASE via the API instead (the proven pattern from
|
||||||
|
# release.yml — built-in token, plain curl). One ROLLING pre-release per branch
|
||||||
|
# (tag desktop-<branch>): delete + recreate each push so it always holds the
|
||||||
|
# latest dev/main installer. This is NOT the signed updater release (release.yml,
|
||||||
|
# tag v*) — it's a prerelease, unsigned, with no latest.json.
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.api_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TAG: desktop-${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
auth="Authorization: token ${TOKEN}"
|
||||||
|
# Drop any existing rolling release for this branch (ignore if absent) so its
|
||||||
|
# tag + stale assets don't pile up; recreate it fresh below.
|
||||||
|
OLD=$(curl -sS -H "$auth" "${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
|
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
if [ -n "$OLD" ]; then
|
||||||
|
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/releases/${OLD}" || true
|
||||||
|
# Also delete the tag itself so the recreate points at this commit.
|
||||||
|
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/git/refs/tags/${TAG}" || true
|
||||||
|
fi
|
||||||
|
REL=$(curl -sS -X POST -H "$auth" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"Desktop build (${GITHUB_REF_NAME})\",\"body\":\"Unsigned per-commit desktop installers from ${GITHUB_REF_NAME} @ ${GITHUB_SHA}. Rolling — overwritten each push. Not an updater release.\",\"draft\":false,\"prerelease\":true}" \
|
||||||
|
"${API}/repos/${REPO}/releases")
|
||||||
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
echo "release id: ${REL_ID}"
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "uploading ${name}"
|
||||||
|
curl -sS -X POST -H "$auth" -H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${f}" \
|
||||||
|
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||||
|
done
|
||||||
|
echo "done"
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
name: Build & push images
|
||||||
|
|
||||||
|
# Build the SERVER (API + SPA), COLLECTOR (wash review), VISION (ANPR) and TRAINER (phase-B job) container images and push them to the
|
||||||
|
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage,
|
||||||
|
# main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle).
|
||||||
|
# Mirrors the house pattern (cf. trm/processor build.yml). See
|
||||||
|
# wiki/decisions/container-deployment.md and fleet-deployment-komodo.md (dev→stage→main tiers).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, stage, main]
|
||||||
|
paths:
|
||||||
|
- 'apps/server/**'
|
||||||
|
- 'apps/web/**'
|
||||||
|
- 'apps/vision/**'
|
||||||
|
- 'apps/collector/**'
|
||||||
|
- 'apps/trainer/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'docker-compose*.yml'
|
||||||
|
- '.dockerignore'
|
||||||
|
- '.gitea/workflows/build-images.yml'
|
||||||
|
# Deploy/IaC changes (compose above, plus the Komodo Stack defs) also rebuild — so a
|
||||||
|
# promotion or a Stack tweak gets the same build+checks sanity pass before it reaches a
|
||||||
|
# booth, and a komodo-only push to `stage` still produces a :stage image.
|
||||||
|
- 'komodo/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.infra.msai.al/mca/parking_solution
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
images:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Set up uv (for @parking/vision checks)
|
||||||
|
# Install uv via its official standalone script rather than a third-party action —
|
||||||
|
# the Gitea runner can't reliably resolve astral-sh/setup-uv. uv provisions the
|
||||||
|
# pinned Python (apps/vision/.python-version) itself. Add it to PATH for later steps.
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Sync vision deps
|
||||||
|
working-directory: apps/vision
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Sync trainer deps
|
||||||
|
# Light core only — NOT the `train` extra (CPU torch, ~200 MB); the torch tests skip.
|
||||||
|
working-directory: apps/trainer
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
# Don't publish a broken image — run the same checks as ci.yml first.
|
||||||
|
- name: Build + lint + test (Turbo)
|
||||||
|
run: pnpm turbo run build lint test
|
||||||
|
|
||||||
|
- name: Compute tags
|
||||||
|
id: meta
|
||||||
|
# BRANCH = the pushed branch (dev|main); SHA = short commit. Two tags per image:
|
||||||
|
# the moving branch tag + an immutable branch-SHA tag.
|
||||||
|
run: |
|
||||||
|
BRANCH="${GITHUB_REF_NAME}"
|
||||||
|
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
|
||||||
|
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: docker-container
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.infra.msai.al
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build & push SERVER (API + SPA)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/server/Dockerfile
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
||||||
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Build & push COLLECTOR (wash review)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/collector/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}
|
||||||
|
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Build & push VISION (ANPR)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: apps/vision
|
||||||
|
file: apps/vision/Dockerfile
|
||||||
|
push: true
|
||||||
|
# The phase-B body-type classifier is fetched from the Gitea generic package registry
|
||||||
|
# at build when apps/vision/models/bodytype.version pins a version (empty = none). The
|
||||||
|
# registry user's credentials double as the fetch auth (BuildKit secret, never a layer).
|
||||||
|
secrets: |
|
||||||
|
bodytype_auth=${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}
|
||||||
|
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Build & push TRAINER (phase-B job)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: apps/trainer
|
||||||
|
file: apps/trainer/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/parking-trainer:${{ steps.meta.outputs.branch }}
|
||||||
|
${{ env.REGISTRY }}/parking-trainer:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-trainer:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-trainer:buildcache,mode=max
|
||||||
|
|
||||||
|
# Optional: trigger a Komodo stack redeploy (cf. trm/processor). Enable by setting the
|
||||||
|
# KOMODO_* secrets; left guarded so it no-ops until the parking stack is wired.
|
||||||
|
- name: Trigger Komodo redeploy
|
||||||
|
if: success() && vars.KOMODO_ENABLED == 'true'
|
||||||
|
env:
|
||||||
|
URL: ${{ secrets.KOMODO_STACK_WEBHOOK_URL }}
|
||||||
|
SECRET: ${{ secrets.KOMODO_WEBHOOK_SECRET }}
|
||||||
|
run: |
|
||||||
|
body="{\"ref\":\"refs/heads/${GITHUB_REF_NAME}\"}"
|
||||||
|
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "X-Hub-Signature-256: sha256=$sig" \
|
||||||
|
-d "$body" \
|
||||||
|
"$URL"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Lint/typecheck/test the whole Turborepo on every push/PR to dev. Mirrors the
|
||||||
|
# house pattern (cf. trm/processor): setup-node + corepack pnpm + frozen install.
|
||||||
|
# No Docker, no signing — pure checks. The desktop bundle is a separate, tag-only
|
||||||
|
# pipeline (see release.yml).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
pull_request:
|
||||||
|
branches: [dev, main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
# Pin to the repo's packageManager version (pnpm 10), not latest.
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Set up uv (Python toolchain for @parking/vision)
|
||||||
|
# The vision service is a Python package wired into the Turbo graph via a
|
||||||
|
# package.json shim; its lint/typecheck/test scripts shell to `uv run …`. CI
|
||||||
|
# has no Python by default, so `uv run` would fail with "uv: not found" and
|
||||||
|
# break the whole Turbo run. Install uv via its official standalone script
|
||||||
|
# (the Gitea runner can't reliably resolve astral-sh/setup-uv); uv provisions the
|
||||||
|
# pinned Python (.python-version) itself. See wiki/decisions/vision-service-packaging.md.
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Sync vision deps
|
||||||
|
# Light deps + the dev group (ruff/mypy/pytest) only — NOT the optional `alpr`
|
||||||
|
# extra (heavy onnx/model stack), which isn't needed to lint/typecheck/test.
|
||||||
|
working-directory: apps/vision
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Sync trainer deps
|
||||||
|
# Same rule: light core only, not the `train` extra (CPU torch); torch tests skip.
|
||||||
|
working-directory: apps/trainer
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Build + lint (Turbo)
|
||||||
|
# Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en
|
||||||
|
# key fails the build), AND the vision service's ruff lint via uv.
|
||||||
|
run: pnpm turbo run build lint
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm turbo run test
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
name: Release desktop
|
||||||
|
|
||||||
|
# Build the signed Tauri desktop installers on a version tag and publish them as
|
||||||
|
# a Gitea Release — TWICE: once on this (private, source) repo for our own
|
||||||
|
# records/history, and once mirrored to mca/public_releases, which is what the
|
||||||
|
# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at.
|
||||||
|
#
|
||||||
|
# WHY a separate public repo: the updater runs on offline-first field appliances
|
||||||
|
# with no Gitea credentials, so its endpoint + installer downloads must be
|
||||||
|
# reachable unauthenticated. Mirroring compiled installers to a public
|
||||||
|
# releases-only repo avoids embedding any read token in the shipped app (which
|
||||||
|
# would leak the moment a booth PC is compromised — this box's threat model
|
||||||
|
# names the operator/booth as the primary adversary, see CLAUDE.md). Source
|
||||||
|
# stays private; only signed installers become public, same as most desktop
|
||||||
|
# software. mca/public_releases is shared across apps in the org, not
|
||||||
|
# parking-specific — namespace release tags/asset names accordingly if another
|
||||||
|
# app starts publishing there too.
|
||||||
|
#
|
||||||
|
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
|
||||||
|
# with the updater key (Gitea secrets), assembles latest.json pointing at the
|
||||||
|
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bundle:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
|
||||||
|
- name: Install Tauri system deps
|
||||||
|
# ubuntu-latest runner has no GUI/webkit libs by default. These are the
|
||||||
|
# exact deps a Tauri v2 Linux build needs (verified locally): WebKitGTK
|
||||||
|
# 4.1 + libsoup-3 + the GTK/appindicator/rsvg stack + AppImage tooling.
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
file \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache cargo + target
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
apps/desktop/src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Sync tauri.conf.json version to the git tag
|
||||||
|
# tauri.conf.json's own "version" field is what Tauri bakes into the
|
||||||
|
# bundle filename, the app's internal version, AND the updater's
|
||||||
|
# "current vs. new" comparison — it is NOT derived from the git tag
|
||||||
|
# automatically. Hit in v0.1.1: the tag was bumped but this file
|
||||||
|
# wasn't, so the signed binary + its .sig were still built (and
|
||||||
|
# named) as 0.1.0 while latest.json (built from TAG below) claimed
|
||||||
|
# 0.1.1 — the updater found the "update", downloaded a file whose
|
||||||
|
# signature didn't match what the manifest claimed to sign, and
|
||||||
|
# silently failed (a separate bug in desktop-updater.ts's error
|
||||||
|
# handling made this invisible — also fixed). Patch it here so the
|
||||||
|
# checked-in value is only ever a placeholder for local dev builds;
|
||||||
|
# a real release's version is always driven by the tag.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" apps/desktop/src-tauri/tauri.conf.json
|
||||||
|
grep '"version"' apps/desktop/src-tauri/tauri.conf.json
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
|
||||||
|
- name: Build + sign desktop bundle
|
||||||
|
env:
|
||||||
|
# Updater signing key (Gitea repo/org secrets). Without these the
|
||||||
|
# bundle is unsigned and the updater would reject it.
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
run: pnpm --filter @parking/desktop bundle
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
id: collect
|
||||||
|
# Gather the installers + their .sig into a flat dist/ for upload, spaces
|
||||||
|
# stripped from filenames. productName is "Parking System" (a space), so
|
||||||
|
# Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an
|
||||||
|
# unescaped space in a filename breaks the later curl asset-upload URL
|
||||||
|
# ("URL rejected: Malformed input to a URL function", hit on the very
|
||||||
|
# first v0.1.0 release) AND would land in latest.json's asset url, which
|
||||||
|
# the updater's plain HTTP GET can't handle either. Rename on copy.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||||
|
mkdir -p dist
|
||||||
|
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
||||||
|
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
||||||
|
-print0 | while IFS= read -r -d '' f; do
|
||||||
|
name=$(basename "$f" | tr ' ' '-')
|
||||||
|
cp "$f" "dist/${name}"
|
||||||
|
done
|
||||||
|
echo "Artifacts:"; ls -la dist/
|
||||||
|
|
||||||
|
- name: Assemble latest.json
|
||||||
|
# The Tauri updater fetches a manifest describing the newest version, its
|
||||||
|
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||||
|
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
||||||
|
# appliances actually reach; see the workflow header for why.
|
||||||
|
#
|
||||||
|
# ONE ENTRY PER INSTALLER TYPE — this is what made every in-app update
|
||||||
|
# v0.1.0→v0.1.6 fail. tauri-plugin-updater looks up
|
||||||
|
# `{os}-{arch}-{installer}` FIRST (linux-x86_64-deb / -rpm / -appimage,
|
||||||
|
# from the running app's detected bundle type) and only then the bare
|
||||||
|
# `linux-x86_64`. The booths run the .deb, and the manifest used to
|
||||||
|
# carry ONLY `linux-x86_64` → the AppImage. So a .deb install found the
|
||||||
|
# "update", downloaded the AppImage, verified its signature fine, then
|
||||||
|
# handed the bytes to install_deb(), which checks they're a .deb
|
||||||
|
# (infer::archive::is_deb) and bails with InvalidUpdaterFormat — after
|
||||||
|
# the download, before any relaunch, with the error swallowed client-
|
||||||
|
# side until v0.1.6. Now each installer gets its own signed asset; the
|
||||||
|
# bare key stays for an AppImage install. .deb/.rpm updates run
|
||||||
|
# `pkexec dpkg -i` / `rpm -U`, so the operator sees a polkit password
|
||||||
|
# prompt — intended: updating a root-installed package IS an admin
|
||||||
|
# action on this box (see wiki/decisions/desktop-shell-tauri.md).
|
||||||
|
env:
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
MIRROR_REPO: mca/public_releases
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
ASSET_BASE="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest"
|
||||||
|
cat > /tmp/latest.js <<'JS'
|
||||||
|
const fs = require("fs");
|
||||||
|
const [version, tag, base] = process.argv.slice(2);
|
||||||
|
const files = fs.readdirSync("dist");
|
||||||
|
const pick = (ext) => files.find((f) => f.endsWith(ext));
|
||||||
|
const entry = (f) => ({
|
||||||
|
signature: fs.readFileSync(`dist/${f}.sig`, "utf8").trim(),
|
||||||
|
url: `${base}/${f}`,
|
||||||
|
});
|
||||||
|
const deb = pick(".deb"), rpm = pick(".rpm"), appimage = pick(".AppImage");
|
||||||
|
if (!deb || !appimage) {
|
||||||
|
console.error(`missing bundle in dist/: deb=${deb} appimage=${appimage}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const platforms = {
|
||||||
|
"linux-x86_64-deb": entry(deb),
|
||||||
|
...(rpm ? { "linux-x86_64-rpm": entry(rpm) } : {}),
|
||||||
|
"linux-x86_64": entry(appimage),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
"dist/latest.json",
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version,
|
||||||
|
notes: `Parking System ${tag}`,
|
||||||
|
pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
||||||
|
platforms,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + "\n",
|
||||||
|
);
|
||||||
|
JS
|
||||||
|
node /tmp/latest.js "${VERSION}" "${TAG}" "${ASSET_BASE}"
|
||||||
|
echo "latest.json:"; cat dist/latest.json
|
||||||
|
|
||||||
|
- name: Create release + upload assets (Gitea API)
|
||||||
|
# Uses the built-in token; no marketplace release action required. Creates
|
||||||
|
# the release for this tag (idempotent-ish: ignores "already exists") and
|
||||||
|
# uploads every file in dist/ as an asset.
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.api_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# Create the release (capture id; tolerate an existing one).
|
||||||
|
REL=$(curl -sS -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
|
"${API}/repos/${REPO}/releases" || true)
|
||||||
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
# Release may already exist for this tag — look it up by tag.
|
||||||
|
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
|
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
fi
|
||||||
|
echo "release id: ${REL_ID}"
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "uploading ${name}"
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${f}" \
|
||||||
|
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||||
|
done
|
||||||
|
echo "done"
|
||||||
|
|
||||||
|
- name: Mirror release to mca/public_releases (Gitea API)
|
||||||
|
# This is the release the updater and any human downloader actually use —
|
||||||
|
# public_releases has no source, only installers, so it can be public
|
||||||
|
# without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository
|
||||||
|
# token scoped for pushing releases into that repo (Gitea's org secrets,
|
||||||
|
# not exposed to any deployed client).
|
||||||
|
#
|
||||||
|
# Publishes to TWO tags there, since public_releases is shared across
|
||||||
|
# apps in the org and Gitea's "latest release" redirect resolves by
|
||||||
|
# newest tag on the WHOLE repo (would break the moment another app
|
||||||
|
# publishes something newer):
|
||||||
|
# - desktop-<TAG> versioned, permanent — audit trail / rollback.
|
||||||
|
# - desktop-latest moving — assets deleted + re-uploaded each release.
|
||||||
|
# This is the fixed URL tauri.conf.json's updater endpoint points at
|
||||||
|
# (a stable name every appliance can always resolve, regardless of
|
||||||
|
# what else gets released in this repo meanwhile).
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }}
|
||||||
|
API: ${{ github.api_url }}
|
||||||
|
MIRROR_REPO: mca/public_releases
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
create_or_get_release() {
|
||||||
|
local mirror_tag="$1" prerelease="$2"
|
||||||
|
REL=$(curl -sS -w '\n%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases" || true)
|
||||||
|
echo "create response (${mirror_tag}): ${REL}"
|
||||||
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}")
|
||||||
|
echo "tag lookup response (${mirror_tag}): ${LOOKUP}"
|
||||||
|
REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
fi
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
upload_assets() {
|
||||||
|
local rel_id="$1"
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "mirroring ${name} -> release ${rel_id}"
|
||||||
|
HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${f}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}")
|
||||||
|
if [ "$HTTP_CODE" -ge 300 ]; then
|
||||||
|
echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Versioned, permanent.
|
||||||
|
create_or_get_release "desktop-${TAG}" false
|
||||||
|
echo "versioned mirror release id: ${REL_ID}"
|
||||||
|
upload_assets "${REL_ID}"
|
||||||
|
|
||||||
|
# 2. Moving desktop-latest — delete existing assets first (re-upload
|
||||||
|
# with the same name 409s otherwise), then re-upload.
|
||||||
|
create_or_get_release "desktop-latest" false
|
||||||
|
LATEST_REL_ID="${REL_ID}"
|
||||||
|
echo "latest mirror release id: ${LATEST_REL_ID}"
|
||||||
|
EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets")
|
||||||
|
printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null
|
||||||
|
done || true
|
||||||
|
upload_assets "${LATEST_REL_ID}"
|
||||||
|
echo "done"
|
||||||
@@ -11,6 +11,8 @@ dist/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
# Committed (non-secret): the desktop/prod build's backend origin — see apps/web/.env.production
|
||||||
|
!.env.production
|
||||||
|
|
||||||
# Editor/OS
|
# Editor/OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -24,3 +26,10 @@ dist/
|
|||||||
|
|
||||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||||
graphify-out/
|
graphify-out/
|
||||||
|
parking.sqlite*.bak-*
|
||||||
|
questions.txt
|
||||||
|
|
||||||
|
# session planning files (planning-with-files skill)
|
||||||
|
task_plan.md
|
||||||
|
findings.md
|
||||||
|
progress.md
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ parking-system/
|
|||||||
├── turbo.json
|
├── turbo.json
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── server/ # Fastify backend (device drivers, API, auth); serves the SPA
|
│ ├── server/ # Fastify backend (device drivers, API, auth); serves the SPA
|
||||||
│ └── web/ # React + Vite SPA (operator UI)
|
│ ├── web/ # React + Vite SPA (operator UI)
|
||||||
|
│ └── vision/ # Python/FastAPI ANPR service (planned; separate process, Turbo shim — see wiki/decisions/vision-service-packaging.md)
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── db/ # Drizzle ORM schema + migrations (SQLite local; PostgreSQL sync target)
|
│ ├── db/ # Drizzle ORM schema + migrations (SQLite local; PostgreSQL sync target)
|
||||||
│ ├── devices/ # device adapters behind shared interfaces (reader/printer/relay)
|
│ ├── devices/ # device adapters behind shared interfaces (reader/printer/relay)
|
||||||
@@ -86,13 +87,3 @@ For the full reasoning behind each, follow the links from `wiki/overview.md`.
|
|||||||
|
|
||||||
- TypeScript throughout. Match the style of surrounding code.
|
- TypeScript throughout. Match the style of surrounding code.
|
||||||
- Confirm before destructive or outward-facing actions. Commit/push only when asked.
|
- Confirm before destructive or outward-facing actions. Commit/push only when asked.
|
||||||
|
|
||||||
## graphify
|
|
||||||
|
|
||||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
|
||||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
|
||||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
|
||||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Booth reverse proxy. `:80` matches ANY hostname/IP, so the booth is reachable as
|
||||||
|
# http://<booth-ip>/, http://localhost/, or http://parksystems.msai.al/ (the name pointed
|
||||||
|
# at the booth's IP via hosts/DNS on-site) — with no domain baked into any image. The SPA
|
||||||
|
# uses a relative /api base, so everything (HTTP + the /api/ws WebSocket, which Caddy
|
||||||
|
# upgrades automatically) just flows through to the server container.
|
||||||
|
#
|
||||||
|
# TLS later: replace `:80` with the real hostname (e.g. `parksystems.msai.al`), uncomment
|
||||||
|
# Caddy's :443 in docker-compose.prod.yml, and Caddy auto-provisions HTTPS. For a private
|
||||||
|
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
||||||
|
:80 {
|
||||||
|
encode gzip
|
||||||
|
# Host network (prod): the server runs on the host's net namespace (to reach the booth LAN /
|
||||||
|
# device VLAN), so reach it over loopback, not the compose service name `server`.
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Car Wash review collector (wiki/concepts/vision-review-outbox.md). Runs on the
|
||||||
|
# reviewer's host (art-docker-station), reachable by the booths ONLY over the Netbird
|
||||||
|
# overlay. Deployed by its own Komodo stack (komodo/resources.toml, "wash-collector").
|
||||||
|
|
||||||
|
# COLLECTOR_HOST=0.0.0.0 # in Docker the compose file binds the published port to the overlay IP
|
||||||
|
# COLLECTOR_PORT=8090
|
||||||
|
# COLLECTOR_DATA_DIR=/data # collector.sqlite + crops/<booth>/<item>.jpg
|
||||||
|
|
||||||
|
# One bearer token per booth: "<boothId>:<token>" pairs, comma- or newline-separated. The
|
||||||
|
# booth id is the pseudonymous CARWASH_REVIEW_BOOTH_ID that booth was deployed with — never
|
||||||
|
# a site name. Generate tokens with: openssl rand -hex 32
|
||||||
|
COLLECTOR_BOOTH_TOKENS=booth-7:REPLACE,booth-9:REPLACE
|
||||||
|
|
||||||
|
# The reviewer's login for the review screen and the export (HTTP Basic over the overlay).
|
||||||
|
COLLECTOR_REVIEWER_USER=reviewer
|
||||||
|
COLLECTOR_REVIEWER_PASS=REPLACE
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# parking-collector — the Car Wash review collector (wiki/concepts/vision-review-outbox.md).
|
||||||
|
# Built from the monorepo root (context: .) like the server image, so it shares the
|
||||||
|
# lockfile and @parking/shared. Runs on the REVIEWER's host (not a booth), delivered by
|
||||||
|
# its own Komodo stack (docker-compose.collector.yml). Data on /data: collector.sqlite +
|
||||||
|
# crops/<booth>/<item>.jpg — the trainer on the same host reads the crops off that volume.
|
||||||
|
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache python3 make g++ # node-gyp for better-sqlite3
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||||
|
COPY apps/server/package.json apps/server/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY apps/vision/package.json apps/vision/
|
||||||
|
COPY apps/collector/package.json apps/collector/
|
||||||
|
COPY packages/db/package.json packages/db/
|
||||||
|
COPY packages/devices/package.json packages/devices/
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm fetch
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
ENV CI=true
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --offline
|
||||||
|
RUN pnpm turbo run build --filter=@parking/collector
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm --filter=@parking/collector --legacy deploy --prod /deploy
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ARG BUILD_VERSION=""
|
||||||
|
ENV BUILD_VERSION=$BUILD_VERSION
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN apk add --no-cache libstdc++ wget # better-sqlite3 native runtime; wget for the healthcheck
|
||||||
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
COPY --from=build --chown=app:app /deploy ./
|
||||||
|
ENV COLLECTOR_DATA_DIR=/data
|
||||||
|
ENV COLLECTOR_HOST=0.0.0.0
|
||||||
|
ENV COLLECTOR_PORT=8090
|
||||||
|
RUN mkdir -p /data && chown app:app /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
USER app
|
||||||
|
EXPOSE 8090
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -qO- "http://localhost:${COLLECTOR_PORT:-8090}/health" >/dev/null 2>&1 || exit 1
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@parking/collector",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Car Wash review collector: receives plate-blurred vehicle crops + the operator's category choice from booths over the private overlay, serves the reviewer's screen, exports labels for training. See wiki/concepts/vision-review-outbox.md.",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b",
|
||||||
|
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
|
||||||
|
"start": "node --env-file-if-exists=.env dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/multipart": "^9.2.1",
|
||||||
|
"@parking/shared": "workspace:*",
|
||||||
|
"better-sqlite3": "12.10.1",
|
||||||
|
"fastify": "5.8.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "7.6.13",
|
||||||
|
"@types/node": "25.9.3",
|
||||||
|
"tsx": "4.22.4",
|
||||||
|
"typescript": "6.0.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { buildCollector, type CollectorApp } from "./app.js";
|
||||||
|
import { parseBoothTokens } from "./config.js";
|
||||||
|
|
||||||
|
// The collector: one ingest surface (bearer per booth, idempotent), one review surface
|
||||||
|
// (Basic), one export. Exercised over app.inject with a hand-built multipart body.
|
||||||
|
|
||||||
|
let app: CollectorApp;
|
||||||
|
let dir: string;
|
||||||
|
const TOKENS = new Map([["booth-7", "0123456789abcdef0123456789abcdef"], ["booth-9", "fedcba9876543210fedcba9876543210"]]);
|
||||||
|
const REVIEWER = { user: "julian", pass: "review-pass-123" };
|
||||||
|
const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64");
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(path.join(tmpdir(), "collector-"));
|
||||||
|
app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: TOKENS, reviewer: REVIEWER }, { dbFile: ":memory:" });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A minimal JPEG-looking blob (SOI marker + padding) — the collector checks the magic only. */
|
||||||
|
const JPEG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(200, 1)]);
|
||||||
|
|
||||||
|
function meta(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
v: 1, booth: "booth-7", item: "item-1", order: "o-1", at: "2026-09-06T10:00:00.000Z", operator: "ab12cd34ef56ab12",
|
||||||
|
operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan", "hatchback"] }, service: "Standard",
|
||||||
|
vision: { class: "suv", confidence: 0.91, categoryId: "suv" }, downgraded: true,
|
||||||
|
image: { width: 320, height: 200, plateBlurred: true },
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function multipart(fields: Record<string, string>, file: Buffer | null): { body: Buffer; type: string } {
|
||||||
|
const b = "----collector-test";
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
||||||
|
if (file) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="image"; filename="x.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`), file, Buffer.from("\r\n"));
|
||||||
|
parts.push(Buffer.from(`--${b}--\r\n`));
|
||||||
|
return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ingest(m: Record<string, unknown>, token = TOKENS.get("booth-7")!, file: Buffer | null = JPEG, extra: Record<string, string> = {}) {
|
||||||
|
const { body, type } = multipart({ meta: JSON.stringify(m) }, file);
|
||||||
|
return app.inject({ method: "POST", url: "/ingest", headers: { authorization: `Bearer ${token}`, "content-type": type, ...extra }, payload: body });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ingest", () => {
|
||||||
|
it("stores the crop and the decision under the token's booth; retries are idempotent", async () => {
|
||||||
|
const r = await ingest(meta());
|
||||||
|
expect(r.statusCode).toBe(201);
|
||||||
|
const row = app.collectorDb.get("item-1")!;
|
||||||
|
expect(row).toMatchObject({ booth: "booth-7", operatorCategoryName: "Vetura", visionClass: "suv", downgraded: 1, plateBlurred: 1, imagePath: "crops/booth-7/item-1.jpg" });
|
||||||
|
expect(JSON.parse(row.operatorClasses)).toEqual(["car", "sedan", "hatchback"]);
|
||||||
|
const again = await ingest(meta());
|
||||||
|
expect(again.statusCode).toBe(200);
|
||||||
|
expect(again.json()).toEqual({ ok: true, duplicate: true });
|
||||||
|
expect((await app.inject({ method: "GET", url: "/health" })).json()).toMatchObject({ ok: true, booths: 1, pending: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a bad token, a booth mismatch, a non-JPEG, and malformed meta", async () => {
|
||||||
|
expect((await ingest(meta(), "nope-nope-nope-nope-nope")).statusCode).toBe(401);
|
||||||
|
expect((await ingest(meta({ booth: "booth-9" }))).statusCode).toBe(422); // token is booth-7's
|
||||||
|
expect((await ingest(meta(), TOKENS.get("booth-7")!, JPEG, { "x-booth-id": "booth-9" })).statusCode).toBe(403);
|
||||||
|
expect((await ingest(meta(), TOKENS.get("booth-7")!, Buffer.alloc(300, 7))).statusCode).toBe(415);
|
||||||
|
expect((await ingest(meta(), TOKENS.get("booth-7")!, null)).statusCode).toBe(400);
|
||||||
|
expect((await ingest(meta({ vision: { class: "spaceship", confidence: 0.5, categoryId: null } }))).statusCode).toBe(422);
|
||||||
|
expect((await ingest(meta({ item: "../../etc/passwd" }))).statusCode).toBe(422);
|
||||||
|
expect((await ingest(meta({ v: 2 }))).statusCode).toBe(422);
|
||||||
|
expect(app.collectorDb.stats().booths).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("review + export", () => {
|
||||||
|
it("the reviewer lists pending items, sees the crop, labels it; stats compare the label with the operator's category; the export lists usable labels only", async () => {
|
||||||
|
await ingest(meta());
|
||||||
|
await ingest(meta({ item: "item-2", operator: "ab12cd34ef56ab12", vision: { class: "car", confidence: 0.8, categoryId: "car" }, downgraded: false }));
|
||||||
|
await ingest(meta({ item: "item-3", booth: "booth-9", operator: "9999999999999999" }), TOKENS.get("booth-9")!);
|
||||||
|
|
||||||
|
// No login → 401 with a challenge; nothing without a configured reviewer is tested in config.
|
||||||
|
const anon = await app.inject({ method: "GET", url: "/api/items" });
|
||||||
|
expect(anon.statusCode).toBe(401);
|
||||||
|
expect(anon.headers["www-authenticate"]).toContain("Basic");
|
||||||
|
expect((await app.inject({ method: "GET", url: "/review", headers: { authorization: basic } })).headers["content-type"]).toContain("text/html");
|
||||||
|
|
||||||
|
const list = (await app.inject({ method: "GET", url: "/api/items?status=pending", headers: { authorization: basic } })).json();
|
||||||
|
expect(list.items.map((i: { id: string }) => i.id)).toEqual(["item-1", "item-2", "item-3"]);
|
||||||
|
expect(list.items[0].imagePath).toBeUndefined();
|
||||||
|
const img = await app.inject({ method: "GET", url: "/api/items/item-1/image", headers: { authorization: basic } });
|
||||||
|
expect(img.statusCode).toBe(200);
|
||||||
|
expect(img.headers["content-type"]).toBe("image/jpeg");
|
||||||
|
expect(img.rawPayload.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
|
||||||
|
|
||||||
|
// item-1: operator said Vetura (car/sedan/hatchback), reviewer says suv → disagree.
|
||||||
|
// item-2: reviewer says sedan → inside Vetura → agree. item-3: unusable.
|
||||||
|
const post = (id: string, label: string) =>
|
||||||
|
app.inject({ method: "POST", url: `/api/items/${id}/review`, headers: { authorization: basic, "content-type": "application/json" }, payload: { label } });
|
||||||
|
expect((await post("item-1", "suv")).json()).toMatchObject({ reviewLabel: "suv", reviewer: "julian" });
|
||||||
|
expect((await post("item-2", "sedan")).statusCode).toBe(200);
|
||||||
|
expect((await post("item-3", "unusable")).statusCode).toBe(200);
|
||||||
|
expect((await post("item-3", "spaceship")).statusCode).toBe(400);
|
||||||
|
expect((await post("nope", "suv")).statusCode).toBe(404);
|
||||||
|
|
||||||
|
const stats = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
|
||||||
|
expect(stats.booths).toEqual([
|
||||||
|
{ booth: "booth-7", received: 2, pending: 0, reviewed: 2, entries: 0 },
|
||||||
|
{ booth: "booth-9", received: 1, pending: 0, reviewed: 1, entries: 0 },
|
||||||
|
]);
|
||||||
|
expect(stats.operators).toEqual([
|
||||||
|
{ booth: "booth-7", operatorRef: "ab12cd34ef56ab12", reviewed: 2, agree: 1, disagree: 1, unusable: 0 },
|
||||||
|
{ booth: "booth-9", operatorRef: "9999999999999999", reviewed: 1, agree: 0, disagree: 0, unusable: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } });
|
||||||
|
expect(csv.statusCode).toBe(200);
|
||||||
|
const lines = csv.body.trim().split("\n");
|
||||||
|
expect(lines[0]).toBe("item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at");
|
||||||
|
expect(lines).toHaveLength(3); // header + 2 usable labels; the unusable one is left out
|
||||||
|
expect(lines[1]).toContain('"item-1","booth-7","wash","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"');
|
||||||
|
|
||||||
|
// An ENTRY sample: no order, no operator — accepted, reviewable, in the export, and
|
||||||
|
// never counted in any operator's agreement.
|
||||||
|
const entry = await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-1", at: "2026-09-06T11:00:00.000Z", vision: { class: "car", confidence: 0.7 }, image: { width: 300, height: 180, plateBlurred: true } });
|
||||||
|
expect(entry.statusCode).toBe(201);
|
||||||
|
expect((await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-2", at: "x", vision: { class: "car", confidence: 0.7 }, image: { width: 1, height: 1, plateBlurred: true } })).statusCode).toBe(422);
|
||||||
|
expect((await post("entry-1", "suv")).statusCode).toBe(200);
|
||||||
|
const stats2 = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
|
||||||
|
expect(stats2.booths[0]).toEqual({ booth: "booth-7", received: 3, pending: 0, reviewed: 3, entries: 1 });
|
||||||
|
expect(stats2.operators.find((o: { booth: string }) => o.booth === "booth-7")).toMatchObject({ reviewed: 2, agree: 1, disagree: 1 });
|
||||||
|
const csv3 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
|
||||||
|
expect(csv3).toContain('"entry-1","booth-7","entry","crops/booth-7/entry-1.jpg","suv","","","car"');
|
||||||
|
|
||||||
|
// A booth-supplied name that looks like a spreadsheet formula is neutralised in the export.
|
||||||
|
await ingest(meta({ item: "item-4", operatorCategory: { id: "x", name: "=HYPERLINK(\"http://evil\")", classes: ["car"] } }));
|
||||||
|
await post("item-4", "car");
|
||||||
|
const csv2 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
|
||||||
|
expect(csv2).toContain(`"'=HYPERLINK(""http://evil"")"`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("config", () => {
|
||||||
|
it("parses booth:token pairs and refuses short tokens", () => {
|
||||||
|
expect([...parseBoothTokens("a:0123456789abcdef, b:fedcba9876543210\nc:0000000000000000").keys()]).toEqual(["a", "b", "c"]);
|
||||||
|
expect(() => parseBoothTokens("a:short")).toThrow(/too short/);
|
||||||
|
expect(() => parseBoothTokens("nocolon")).toThrow(/bad pair/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||||
|
import multipart from "@fastify/multipart";
|
||||||
|
import { isVehicleClass } from "@parking/shared";
|
||||||
|
import type { CollectorConfig } from "./config.js";
|
||||||
|
import { CollectorDb, type ItemRow, type ReviewVerdict } from "./db.js";
|
||||||
|
import { reviewPage } from "./review-page.js";
|
||||||
|
|
||||||
|
// The collector — the far end of the booth's review outbox
|
||||||
|
// (wiki/concepts/vision-review-outbox.md). Three surfaces and nothing else:
|
||||||
|
// POST /ingest one package from one booth (bearer token per booth; idempotent)
|
||||||
|
// /review + /api/* the reviewer's screen (HTTP Basic, one login)
|
||||||
|
// GET /export/labels.csv the training set: reviewed, usable rows (crops sit beside it on
|
||||||
|
// the volume, so the trainer on this host reads them directly)
|
||||||
|
// It deliberately has no fleet features and no path back into a booth.
|
||||||
|
|
||||||
|
/** The package's `meta` part, as the booth sends it (review-outbox.ts). */
|
||||||
|
interface IngestMeta {
|
||||||
|
v: number;
|
||||||
|
/** "wash" (default when absent) = a desk decision; "entry" = a sampled entry read with
|
||||||
|
* no order and no operator — crop + the camera's class only. */
|
||||||
|
kind?: "wash" | "entry";
|
||||||
|
booth: string;
|
||||||
|
item: string;
|
||||||
|
order?: string;
|
||||||
|
at: string;
|
||||||
|
operator?: string;
|
||||||
|
operatorCategory?: { id: string; name: string; classes?: string[] };
|
||||||
|
service?: string;
|
||||||
|
vision: { class: string; confidence: number; categoryId?: string | null };
|
||||||
|
downgraded?: boolean;
|
||||||
|
image: { width: number; height: number; plateBlurred: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
||||||
|
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
function str(v: unknown, max = 200): string | null {
|
||||||
|
return typeof v === "string" && v.length > 0 && v.length <= max ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate the meta part; returns a message on the first problem. */
|
||||||
|
function checkMeta(m: unknown, booth: string): { ok: true; meta: IngestMeta } | { ok: false; why: string } {
|
||||||
|
if (!m || typeof m !== "object") return { ok: false, why: "meta must be an object" };
|
||||||
|
const x = m as Record<string, unknown>;
|
||||||
|
if (x.v !== 1) return { ok: false, why: "unsupported meta version" };
|
||||||
|
if (x.booth !== booth) return { ok: false, why: "meta.booth does not match the token's booth" };
|
||||||
|
if (!str(x.item, 64) || !ID_RE.test(x.item as string)) return { ok: false, why: "bad item id" };
|
||||||
|
if (!str(x.at, 40) || Number.isNaN(Date.parse(x.at as string))) return { ok: false, why: "bad timestamp" };
|
||||||
|
const kind = x.kind === undefined ? "wash" : x.kind;
|
||||||
|
if (kind !== "wash" && kind !== "entry") return { ok: false, why: "bad kind" };
|
||||||
|
const v = x.vision as Record<string, unknown> | undefined;
|
||||||
|
if (!v || !isVehicleClass(v.class) || typeof v.confidence !== "number" || v.confidence < 0 || v.confidence > 1) return { ok: false, why: "bad vision read" };
|
||||||
|
if (v.categoryId != null && !str(v.categoryId, 64)) return { ok: false, why: "bad vision.categoryId" };
|
||||||
|
if (kind === "wash") {
|
||||||
|
if (!str(x.order, 64)) return { ok: false, why: "bad order ref" };
|
||||||
|
if (!str(x.operator, 64)) return { ok: false, why: "bad operator ref" };
|
||||||
|
const oc = x.operatorCategory as Record<string, unknown> | undefined;
|
||||||
|
if (!oc || !str(oc.id, 64) || !str(oc.name, 120)) return { ok: false, why: "bad operatorCategory" };
|
||||||
|
if (oc.classes !== undefined && (!Array.isArray(oc.classes) || !oc.classes.every(isVehicleClass))) return { ok: false, why: "bad operatorCategory.classes" };
|
||||||
|
if (!str(x.service, 120)) return { ok: false, why: "bad service" };
|
||||||
|
if (typeof x.downgraded !== "boolean") return { ok: false, why: "bad downgraded" };
|
||||||
|
}
|
||||||
|
const im = x.image as Record<string, unknown> | undefined;
|
||||||
|
if (!im || typeof im.width !== "number" || typeof im.height !== "number" || typeof im.plateBlurred !== "boolean") return { ok: false, why: "bad image meta" };
|
||||||
|
return { ok: true, meta: x as unknown as IngestMeta };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ba = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectorApp extends FastifyInstance {
|
||||||
|
collectorDb: CollectorDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: string } = {}): Promise<CollectorApp> {
|
||||||
|
await mkdir(path.join(cfg.dataDir, "crops"), { recursive: true });
|
||||||
|
const db = new CollectorDb(opts.dbFile ?? path.join(cfg.dataDir, "collector.sqlite"));
|
||||||
|
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" }, bodyLimit: 64 * 1024 }) as unknown as CollectorApp;
|
||||||
|
app.collectorDb = db;
|
||||||
|
await app.register(multipart, { limits: { fileSize: MAX_IMAGE_BYTES, files: 1, fields: 4, parts: 6 } });
|
||||||
|
app.addHook("onClose", async () => db.close());
|
||||||
|
|
||||||
|
/** Which booth this bearer token belongs to, or null. Constant-time per candidate. */
|
||||||
|
function boothForToken(req: FastifyRequest): string | null {
|
||||||
|
const h = req.headers.authorization ?? "";
|
||||||
|
if (!h.startsWith("Bearer ")) return null;
|
||||||
|
const token = h.slice(7).trim();
|
||||||
|
let found: string | null = null;
|
||||||
|
for (const [booth, t] of cfg.boothTokens) if (safeEqual(token, t)) found = booth;
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP Basic for the reviewer. */
|
||||||
|
async function requireReviewer(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
|
if (!cfg.reviewer) return reply.code(503).send({ error: "reviewer login not configured" });
|
||||||
|
const h = req.headers.authorization ?? "";
|
||||||
|
if (h.startsWith("Basic ")) {
|
||||||
|
const [user, ...rest] = Buffer.from(h.slice(6), "base64").toString("utf8").split(":");
|
||||||
|
const pass = rest.join(":");
|
||||||
|
if (user && safeEqual(user, cfg.reviewer.user) && safeEqual(pass, cfg.reviewer.pass)) return;
|
||||||
|
}
|
||||||
|
return reply.code(401).header("www-authenticate", 'Basic realm="wash review", charset="UTF-8"').send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/health", async () => {
|
||||||
|
const s = db.stats();
|
||||||
|
return { ok: true, booths: s.booths.length, pending: s.booths.reduce((n, b) => n + b.pending, 0) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Ingest (booths) -----------------------------------------------------------------
|
||||||
|
app.post("/ingest", async (req, reply) => {
|
||||||
|
const booth = boothForToken(req);
|
||||||
|
if (!booth) return reply.code(401).send({ error: "unauthorized" });
|
||||||
|
const claimed = req.headers["x-booth-id"];
|
||||||
|
if (typeof claimed === "string" && claimed !== booth) return reply.code(403).send({ error: "booth id does not match the token" });
|
||||||
|
if (!req.isMultipart()) return reply.code(415).send({ error: "multipart/form-data expected" });
|
||||||
|
|
||||||
|
let metaRaw: string | null = null;
|
||||||
|
let image: Buffer | null = null;
|
||||||
|
try {
|
||||||
|
for await (const part of req.parts()) {
|
||||||
|
if (part.type === "file" && part.fieldname === "image") {
|
||||||
|
image = await part.toBuffer();
|
||||||
|
} else if (part.type === "field" && part.fieldname === "meta") {
|
||||||
|
metaRaw = String(part.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string }).code;
|
||||||
|
return reply.code(code === "FST_REQ_FILE_TOO_LARGE" ? 413 : 400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!metaRaw) return reply.code(400).send({ error: "meta part missing" });
|
||||||
|
if (!image || image.length < 100) return reply.code(400).send({ error: "image part missing" });
|
||||||
|
if (!(image[0] === 0xff && image[1] === 0xd8 && image[2] === 0xff)) return reply.code(415).send({ error: "image must be a JPEG" });
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(metaRaw);
|
||||||
|
} catch {
|
||||||
|
return reply.code(400).send({ error: "meta is not JSON" });
|
||||||
|
}
|
||||||
|
const checked = checkMeta(parsed, booth);
|
||||||
|
if (!checked.ok) return reply.code(422).send({ error: checked.why });
|
||||||
|
const meta = checked.meta;
|
||||||
|
|
||||||
|
// Idempotent on the item id: a booth retrying after a lost 2xx must not duplicate.
|
||||||
|
if (db.get(meta.item)) return reply.code(200).send({ ok: true, duplicate: true });
|
||||||
|
|
||||||
|
const rel = path.posix.join("crops", booth, `${meta.item}.jpg`);
|
||||||
|
await mkdir(path.join(cfg.dataDir, "crops", booth), { recursive: true });
|
||||||
|
await writeFile(path.join(cfg.dataDir, rel), image);
|
||||||
|
const kind = meta.kind ?? "wash";
|
||||||
|
db.insert({
|
||||||
|
id: meta.item,
|
||||||
|
booth,
|
||||||
|
kind,
|
||||||
|
orderRef: meta.order ?? "",
|
||||||
|
at: meta.at,
|
||||||
|
operatorRef: meta.operator ?? "",
|
||||||
|
operatorCategoryId: meta.operatorCategory?.id ?? "",
|
||||||
|
operatorCategoryName: meta.operatorCategory?.name ?? "",
|
||||||
|
operatorClasses: JSON.stringify(meta.operatorCategory?.classes ?? []),
|
||||||
|
service: meta.service ?? "",
|
||||||
|
visionClass: meta.vision.class,
|
||||||
|
visionConfidence: meta.vision.confidence,
|
||||||
|
visionCategoryId: meta.vision.categoryId ?? null,
|
||||||
|
downgraded: meta.downgraded ? 1 : 0,
|
||||||
|
imageWidth: meta.image.width,
|
||||||
|
imageHeight: meta.image.height,
|
||||||
|
plateBlurred: meta.image.plateBlurred ? 1 : 0,
|
||||||
|
imagePath: rel,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
req.log.info(`ingest: ${booth} ${kind} ${meta.item} (${meta.vision.class}${kind === "wash" ? ` → ${meta.operatorCategory!.name}` : ""})`);
|
||||||
|
return reply.code(201).send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Review (the trusted person) -----------------------------------------------------
|
||||||
|
const page = reviewPage();
|
||||||
|
app.get("/", { preHandler: requireReviewer }, async (_req, reply) => reply.redirect("/review"));
|
||||||
|
app.get("/review", { preHandler: requireReviewer }, async (_req, reply) => reply.type("text/html; charset=utf-8").send(page));
|
||||||
|
|
||||||
|
app.get<{ Querystring: { status?: string; limit?: string; booth?: string } }>(
|
||||||
|
"/api/items",
|
||||||
|
{ preHandler: requireReviewer },
|
||||||
|
async (req) => {
|
||||||
|
const status = req.query.status === "reviewed" ? "reviewed" : "pending";
|
||||||
|
const limit = Math.min(Math.max(Number(req.query.limit) || 25, 1), 200);
|
||||||
|
return { items: db.list(status, limit, req.query.booth || undefined).map(publicItem) };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>("/api/items/:id/image", { preHandler: requireReviewer }, async (req, reply) => {
|
||||||
|
const row = db.get(req.params.id);
|
||||||
|
if (!row) return reply.code(404).send({ error: "not found" });
|
||||||
|
return reply.type("image/jpeg").header("cache-control", "private, max-age=3600").send(createReadStream(path.join(cfg.dataDir, row.imagePath)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { label?: unknown } }>("/api/items/:id/review", { preHandler: requireReviewer }, async (req, reply) => {
|
||||||
|
const label = req.body?.label;
|
||||||
|
if (label !== "unusable" && !isVehicleClass(label)) return reply.code(400).send({ error: "label must be a vehicle class or 'unusable'" });
|
||||||
|
if (!db.get(req.params.id)) return reply.code(404).send({ error: "not found" });
|
||||||
|
const row = db.review(req.params.id, label as ReviewVerdict, cfg.reviewer!.user);
|
||||||
|
return publicItem(row!);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/stats", { preHandler: requireReviewer }, async () => db.stats());
|
||||||
|
|
||||||
|
// --- Export (the training set) --------------------------------------------------------
|
||||||
|
app.get("/export/labels.csv", { preHandler: requireReviewer }, async (_req, reply) => {
|
||||||
|
const rows = db.labelled();
|
||||||
|
// Quote every cell; a cell starting like a spreadsheet formula (=, +, -, @, tab, CR)
|
||||||
|
// gets a leading apostrophe — the category/service names are booth-supplied text and
|
||||||
|
// the reviewer will open this in a spreadsheet (CSV formula injection).
|
||||||
|
const q = (s: string | number | null) => {
|
||||||
|
let v = String(s ?? "");
|
||||||
|
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
|
||||||
|
return `"${v.replace(/"/g, '""')}"`;
|
||||||
|
};
|
||||||
|
const head = "item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at";
|
||||||
|
const lines = rows.map((r) =>
|
||||||
|
[r.id, r.booth, r.kind, r.imagePath, r.reviewLabel, r.operatorCategoryName, JSON.parse(r.operatorClasses).join("|"), r.visionClass, r.visionConfidence, r.downgraded, r.at, r.reviewedAt].map(q).join(","),
|
||||||
|
);
|
||||||
|
return reply.type("text/csv; charset=utf-8").header("content-disposition", 'attachment; filename="labels.csv"').send([head, ...lines].join("\n") + "\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row as the review screen sees it (no server paths). */
|
||||||
|
function publicItem(r: ItemRow): Omit<ItemRow, "imagePath"> {
|
||||||
|
const { imagePath: _p, ...rest } = r;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export interface CollectorConfig {
|
||||||
|
readonly host: string;
|
||||||
|
readonly port: number;
|
||||||
|
readonly dataDir: string;
|
||||||
|
/** boothId → bearer token. */
|
||||||
|
readonly boothTokens: ReadonlyMap<string, string>;
|
||||||
|
/** The single reviewer login; null = review screen and export refuse (503). */
|
||||||
|
readonly reviewer: { readonly user: string; readonly pass: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "booth-7:abc,booth-9:def" (commas, whitespace or newlines between pairs). */
|
||||||
|
export function parseBoothTokens(raw: string): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
for (const pair of raw.split(/[,\s]+/)) {
|
||||||
|
if (!pair) continue;
|
||||||
|
const i = pair.indexOf(":");
|
||||||
|
if (i <= 0) throw new Error(`COLLECTOR_BOOTH_TOKENS: bad pair "${pair}" (want boothId:token)`);
|
||||||
|
const booth = pair.slice(0, i).trim();
|
||||||
|
const token = pair.slice(i + 1).trim();
|
||||||
|
if (!booth || token.length < 16) throw new Error(`COLLECTOR_BOOTH_TOKENS: token for "${booth}" too short (>=16 chars)`);
|
||||||
|
out.set(booth, token);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configFromEnv(env: NodeJS.ProcessEnv = process.env): CollectorConfig {
|
||||||
|
const user = (env.COLLECTOR_REVIEWER_USER ?? "").trim();
|
||||||
|
const pass = env.COLLECTOR_REVIEWER_PASS ?? "";
|
||||||
|
return {
|
||||||
|
host: env.COLLECTOR_HOST ?? "0.0.0.0",
|
||||||
|
port: Number(env.COLLECTOR_PORT ?? 8090),
|
||||||
|
dataDir: env.COLLECTOR_DATA_DIR ?? "/data",
|
||||||
|
boothTokens: parseBoothTokens(env.COLLECTOR_BOOTH_TOKENS ?? ""),
|
||||||
|
reviewer: user && pass.length >= 8 ? { user, pass } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import type { VehicleClass } from "@parking/shared";
|
||||||
|
|
||||||
|
// One table. Each row is one booth decision: what the camera saw, what the operator
|
||||||
|
// chose, and (once reviewed) what a trusted person says the vehicle is. The crop itself
|
||||||
|
// lives on disk beside the DB (crops/<booth>/<item>.jpg) so the trainer on the same host
|
||||||
|
// reads it straight off the volume.
|
||||||
|
|
||||||
|
export interface ItemRow {
|
||||||
|
id: string;
|
||||||
|
booth: string;
|
||||||
|
/** "wash" = a desk decision (operator fields set); "entry" = a sampled entry read (pure
|
||||||
|
* training material: crop + the camera's class, operator fields empty). */
|
||||||
|
kind: "wash" | "entry";
|
||||||
|
orderRef: string;
|
||||||
|
at: string;
|
||||||
|
operatorRef: string;
|
||||||
|
operatorCategoryId: string;
|
||||||
|
operatorCategoryName: string;
|
||||||
|
/** The vision classes the operator's category covers at that site (its mapping) — what
|
||||||
|
* lets a reviewer's CLASS be compared with an operator's CATEGORY. JSON array. */
|
||||||
|
operatorClasses: string;
|
||||||
|
service: string;
|
||||||
|
visionClass: string;
|
||||||
|
visionConfidence: number;
|
||||||
|
visionCategoryId: string | null;
|
||||||
|
downgraded: number;
|
||||||
|
imageWidth: number;
|
||||||
|
imageHeight: number;
|
||||||
|
plateBlurred: number;
|
||||||
|
imagePath: string;
|
||||||
|
receivedAt: string;
|
||||||
|
reviewLabel: string | null; // a VehicleClass, or "unusable"
|
||||||
|
reviewedAt: string | null;
|
||||||
|
reviewer: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReviewVerdict = VehicleClass | "unusable";
|
||||||
|
|
||||||
|
export class CollectorDb {
|
||||||
|
readonly #db: Database.Database;
|
||||||
|
|
||||||
|
constructor(file: string) {
|
||||||
|
this.#db = new Database(file);
|
||||||
|
this.#db.pragma("journal_mode = WAL");
|
||||||
|
this.#db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
booth TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'wash',
|
||||||
|
order_ref TEXT NOT NULL,
|
||||||
|
at TEXT NOT NULL,
|
||||||
|
operator_ref TEXT NOT NULL DEFAULT '',
|
||||||
|
operator_category_id TEXT NOT NULL DEFAULT '',
|
||||||
|
operator_category_name TEXT NOT NULL DEFAULT '',
|
||||||
|
operator_classes TEXT NOT NULL DEFAULT '[]',
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
vision_class TEXT NOT NULL,
|
||||||
|
vision_confidence REAL NOT NULL,
|
||||||
|
vision_category_id TEXT,
|
||||||
|
downgraded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
image_width INTEGER NOT NULL,
|
||||||
|
image_height INTEGER NOT NULL,
|
||||||
|
plate_blurred INTEGER NOT NULL,
|
||||||
|
image_path TEXT NOT NULL,
|
||||||
|
received_at TEXT NOT NULL,
|
||||||
|
review_label TEXT,
|
||||||
|
reviewed_at TEXT,
|
||||||
|
reviewer TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS items_pending ON items (reviewed_at, received_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS items_booth ON items (booth, received_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.#db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
static #map(r: Record<string, unknown>): ItemRow {
|
||||||
|
return {
|
||||||
|
id: r.id as string,
|
||||||
|
booth: r.booth as string,
|
||||||
|
kind: r.kind === "entry" ? "entry" : "wash",
|
||||||
|
orderRef: r.order_ref as string,
|
||||||
|
at: r.at as string,
|
||||||
|
operatorRef: r.operator_ref as string,
|
||||||
|
operatorCategoryId: r.operator_category_id as string,
|
||||||
|
operatorCategoryName: r.operator_category_name as string,
|
||||||
|
operatorClasses: r.operator_classes as string,
|
||||||
|
service: r.service as string,
|
||||||
|
visionClass: r.vision_class as string,
|
||||||
|
visionConfidence: r.vision_confidence as number,
|
||||||
|
visionCategoryId: (r.vision_category_id as string | null) ?? null,
|
||||||
|
downgraded: r.downgraded as number,
|
||||||
|
imageWidth: r.image_width as number,
|
||||||
|
imageHeight: r.image_height as number,
|
||||||
|
plateBlurred: r.plate_blurred as number,
|
||||||
|
imagePath: r.image_path as string,
|
||||||
|
receivedAt: r.received_at as string,
|
||||||
|
reviewLabel: (r.review_label as string | null) ?? null,
|
||||||
|
reviewedAt: (r.reviewed_at as string | null) ?? null,
|
||||||
|
reviewer: (r.reviewer as string | null) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): ItemRow | null {
|
||||||
|
const r = this.#db.prepare("SELECT * FROM items WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
||||||
|
return r ? CollectorDb.#map(r) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void {
|
||||||
|
this.#db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO items (id, booth, kind, order_ref, at, operator_ref, operator_category_id, operator_category_name,
|
||||||
|
operator_classes, service, vision_class, vision_confidence, vision_category_id, downgraded,
|
||||||
|
image_width, image_height, plate_blurred, image_path, received_at)
|
||||||
|
VALUES (@id, @booth, @kind, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
|
||||||
|
@operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded,
|
||||||
|
@imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`,
|
||||||
|
)
|
||||||
|
.run(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(status: "pending" | "reviewed", limit: number, booth?: string): ItemRow[] {
|
||||||
|
const where = [status === "pending" ? "reviewed_at IS NULL" : "reviewed_at IS NOT NULL"];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
if (booth) {
|
||||||
|
where.push("booth = ?");
|
||||||
|
params.push(booth);
|
||||||
|
}
|
||||||
|
const order = status === "pending" ? "received_at ASC" : "reviewed_at DESC";
|
||||||
|
const rows = this.#db
|
||||||
|
.prepare(`SELECT * FROM items WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`)
|
||||||
|
.all(...params, limit) as Record<string, unknown>[];
|
||||||
|
return rows.map((r) => CollectorDb.#map(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
review(id: string, label: ReviewVerdict, reviewer: string): ItemRow | null {
|
||||||
|
this.#db
|
||||||
|
.prepare("UPDATE items SET review_label = ?, reviewed_at = ?, reviewer = ? WHERE id = ?")
|
||||||
|
.run(label, new Date().toISOString(), reviewer, id);
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per booth: received / pending / reviewed. Per operator (booth + hash): how often the
|
||||||
|
* reviewer's class fell inside the operator's chosen category (agree) or outside
|
||||||
|
* (disagree) — the honest-mistake / fraud rate the outbox exists for. */
|
||||||
|
stats(): {
|
||||||
|
booths: { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
|
||||||
|
operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[];
|
||||||
|
} {
|
||||||
|
const booths = this.#db
|
||||||
|
.prepare(
|
||||||
|
`SELECT booth, COUNT(*) AS received,
|
||||||
|
SUM(CASE WHEN reviewed_at IS NULL THEN 1 ELSE 0 END) AS pending,
|
||||||
|
SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed,
|
||||||
|
SUM(CASE WHEN kind = 'entry' THEN 1 ELSE 0 END) AS entries
|
||||||
|
FROM items GROUP BY booth ORDER BY booth`,
|
||||||
|
)
|
||||||
|
.all() as { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
|
||||||
|
// Operator agreement is a WASH thing — an entry sample has no operator decision.
|
||||||
|
const reviewed = this.#db
|
||||||
|
.prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL AND kind = 'wash'")
|
||||||
|
.all() as { booth: string; operator_ref: string; operator_classes: string; review_label: string }[];
|
||||||
|
const ops = new Map<string, { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }>();
|
||||||
|
for (const r of reviewed) {
|
||||||
|
const key = `${r.booth} ${r.operator_ref}`;
|
||||||
|
let o = ops.get(key);
|
||||||
|
if (!o) ops.set(key, (o = { booth: r.booth, operatorRef: r.operator_ref, reviewed: 0, agree: 0, disagree: 0, unusable: 0 }));
|
||||||
|
o.reviewed += 1;
|
||||||
|
if (r.review_label === "unusable") o.unusable += 1;
|
||||||
|
else if ((JSON.parse(r.operator_classes) as string[]).includes(r.review_label)) o.agree += 1;
|
||||||
|
else o.disagree += 1;
|
||||||
|
}
|
||||||
|
return { booths, operators: [...ops.values()].sort((a, b) => b.disagree - a.disagree) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reviewed, usable rows — the training set. */
|
||||||
|
labelled(): ItemRow[] {
|
||||||
|
const rows = this.#db
|
||||||
|
.prepare("SELECT * FROM items WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY reviewed_at")
|
||||||
|
.all() as Record<string, unknown>[];
|
||||||
|
return rows.map((r) => CollectorDb.#map(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { buildCollector } from "./app.js";
|
||||||
|
import { configFromEnv } from "./config.js";
|
||||||
|
|
||||||
|
const cfg = configFromEnv();
|
||||||
|
const app = await buildCollector(cfg);
|
||||||
|
if (cfg.boothTokens.size === 0) app.log.warn("COLLECTOR_BOOTH_TOKENS is empty — no booth can ingest");
|
||||||
|
if (!cfg.reviewer) app.log.warn("COLLECTOR_REVIEWER_USER/PASS not set — the review screen and export refuse");
|
||||||
|
app.log.info(`collector: ${cfg.boothTokens.size} booth token(s), data in ${cfg.dataDir}`);
|
||||||
|
await app.listen({ host: cfg.host, port: cfg.port });
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
await app.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGTERM", () => void stop());
|
||||||
|
process.on("SIGINT", () => void stop());
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { VEHICLE_CLASSES } from "@parking/shared";
|
||||||
|
|
||||||
|
// The reviewer's screen: one pending crop at a time, the operator's pick and the camera's
|
||||||
|
// pick beside it, one button per vocabulary class + "unusable". Served by the collector
|
||||||
|
// itself (no build step, no framework) — this is deliberately the whole UI.
|
||||||
|
|
||||||
|
export function reviewPage(): string {
|
||||||
|
const classes = JSON.stringify(VEHICLE_CLASSES);
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Wash review</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#111; --panel:#1b1b1b; --text:#e8e8e8; --muted:#9a9a9a; --amber:#e0a030; --green:#4caf50; --red:#e05050; }
|
||||||
|
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, sans-serif; }
|
||||||
|
header { display:flex; justify-content:space-between; align-items:center; padding:.6rem 1rem; border-bottom:1px solid #333; }
|
||||||
|
header b { letter-spacing:.08em; text-transform:uppercase; color:var(--amber); font-size:.75rem; }
|
||||||
|
main { max-width:960px; margin:0 auto; padding:1rem; display:grid; gap:1rem; }
|
||||||
|
.card { background:var(--panel); border:1px solid #333; border-radius:6px; padding:1rem; }
|
||||||
|
img { max-width:100%; max-height:60vh; display:block; margin:0 auto; background:#000; border-radius:4px; }
|
||||||
|
dl { display:grid; grid-template-columns:max-content 1fr; gap:.2rem .8rem; margin:0; font-variant-numeric:tabular-nums; }
|
||||||
|
dt { color:var(--muted); }
|
||||||
|
.buttons { display:flex; flex-wrap:wrap; gap:.4rem; }
|
||||||
|
button { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.5rem .8rem; font:inherit; cursor:pointer; }
|
||||||
|
button:hover { border-color:var(--amber); }
|
||||||
|
button.mono { font-family:ui-monospace, monospace; }
|
||||||
|
button.hint { border-color:var(--amber); }
|
||||||
|
button.unusable { color:var(--red); }
|
||||||
|
button.skip { color:var(--muted); }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
.warn { color:var(--amber); }
|
||||||
|
table { border-collapse:collapse; width:100%; font-variant-numeric:tabular-nums; }
|
||||||
|
td, th { text-align:left; padding:.2rem .5rem; border-bottom:1px solid #2a2a2a; }
|
||||||
|
th { color:var(--muted); font-weight:normal; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; }
|
||||||
|
kbd { background:#2a2a2a; border:1px solid #444; border-radius:3px; padding:0 .3rem; font-size:.75rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><b>Wash review</b><span id="counts" class="muted"></span></header>
|
||||||
|
<main>
|
||||||
|
<section class="card" id="item">
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<table id="stats"><thead><tr><th>booth</th><th>operator</th><th>reviewed</th><th>agree</th><th>disagree</th><th>unusable</th></tr></thead><tbody></tbody></table>
|
||||||
|
</section>
|
||||||
|
<p class="muted">Keys: <kbd>1</kbd>–<kbd>9</kbd>, <kbd>0</kbd> pick a class in order · <kbd>u</kbd> unusable · <kbd>s</kbd> skip. Skipped items come back after a reload. Your verdict is the training label; the operator's pick is only compared against it.</p>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const CLASSES = ${classes};
|
||||||
|
const skipped = new Set();
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
async function api(path, init) {
|
||||||
|
const r = await fetch(path, init);
|
||||||
|
if (!r.ok) throw new Error(path + ' → HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const s = await api('/api/stats');
|
||||||
|
const pending = s.booths.reduce((n, b) => n + b.pending, 0);
|
||||||
|
const reviewed = s.booths.reduce((n, b) => n + b.reviewed, 0);
|
||||||
|
document.getElementById('counts').textContent = pending + ' waiting · ' + reviewed + ' reviewed';
|
||||||
|
const tb = document.querySelector('#stats tbody');
|
||||||
|
tb.innerHTML = s.operators.map(o => '<tr><td>' + esc(o.booth) + '</td><td class="mono">' + esc(o.operatorRef) + '</td><td>' + o.reviewed + '</td><td>' + o.agree + '</td><td' + (o.disagree ? ' class="warn"' : '') + '>' + o.disagree + '</td><td>' + o.unusable + '</td></tr>').join('') || '<tr><td colspan="6" class="muted">nothing reviewed yet</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function next() {
|
||||||
|
const { items } = await api('/api/items?status=pending&limit=25');
|
||||||
|
current = items.find(i => !skipped.has(i.id)) || null;
|
||||||
|
const el = document.getElementById('item');
|
||||||
|
if (!current) { el.innerHTML = '<p class="muted">Nothing waiting for review.</p>'; return; }
|
||||||
|
const it = current;
|
||||||
|
const opClasses = JSON.parse(it.operatorClasses || '[]');
|
||||||
|
el.innerHTML =
|
||||||
|
'<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' +
|
||||||
|
'<dl style="margin-top:.8rem">' +
|
||||||
|
(it.kind === 'entry'
|
||||||
|
? '<dt>sample</dt><dd><span class="muted">entry stream — no wash, no operator decision; label the vehicle</span></dd>'
|
||||||
|
: '<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>') +
|
||||||
|
'<dt>camera saw</dt><dd class="mono">' + esc(it.visionClass) + ' <span class="muted">' + Math.round(it.visionConfidence * 100) + '%</span>' + (it.downgraded ? ' <span class="warn">flagged downgrade at the booth</span>' : '') + '</dd>' +
|
||||||
|
(it.kind === 'entry' ? '<dt>booth</dt><dd class="mono">' + esc(it.booth) + '</dd>' :
|
||||||
|
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
|
||||||
|
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>') +
|
||||||
|
'<dt>at</dt><dd>' + esc(it.at) + '</dd>' +
|
||||||
|
'</dl>' +
|
||||||
|
'<div class="buttons" style="margin-top:.8rem">' +
|
||||||
|
CLASSES.map((c, i) => '<button class="mono' + (c === it.visionClass ? ' hint' : '') + '" data-label="' + c + '" title="key ' + ((i + 1) % 10) + '">' + c + '</button>').join('') +
|
||||||
|
'<button class="unusable" data-label="unusable">unusable</button>' +
|
||||||
|
'<button class="skip" data-skip="1">skip</button>' +
|
||||||
|
'</div>';
|
||||||
|
el.querySelectorAll('button[data-label]').forEach(b => b.addEventListener('click', () => verdict(b.dataset.label)));
|
||||||
|
el.querySelector('button[data-skip]').addEventListener('click', skip);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verdict(label) {
|
||||||
|
if (!current) return;
|
||||||
|
await api('/api/items/' + encodeURIComponent(current.id) + '/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) });
|
||||||
|
await Promise.all([next(), loadStats()]);
|
||||||
|
}
|
||||||
|
function skip() { if (current) { skipped.add(current.id); next(); } }
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.target.tagName === 'INPUT') return;
|
||||||
|
if (e.key === 'u') verdict('unusable');
|
||||||
|
else if (e.key === 's') skip();
|
||||||
|
else if (/^[0-9]$/.test(e.key)) { const i = e.key === '0' ? 9 : Number(e.key) - 1; if (CLASSES[i]) verdict(CLASSES[i]); }
|
||||||
|
});
|
||||||
|
|
||||||
|
next().catch(e => { document.getElementById('item').innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; });
|
||||||
|
loadStats().catch(() => {});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"references": [{ "path": "../../packages/shared" }],
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: { include: ["src/**/*.test.ts"], env: { LOG_LEVEL: "silent" } },
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Desktop (Tauri) build — the @parking/web SPA needs to know where Fastify is.
|
||||||
|
#
|
||||||
|
# In a BROWSER (dev via the Vite proxy, or prod where Fastify serves the SPA),
|
||||||
|
# leave VITE_API_BASE UNSET — requests stay relative/same-origin.
|
||||||
|
#
|
||||||
|
# For the DESKTOP build, the bundled SPA loads from tauri://localhost and has no
|
||||||
|
# proxy, so point it at the appliance's Fastify origin. This is read at WEB build
|
||||||
|
# time, so export it before `pnpm --filter @parking/desktop build` (or put it in
|
||||||
|
# apps/web/.env.production).
|
||||||
|
VITE_API_BASE=http://127.0.0.1:3000
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Rust / Tauri build artifacts
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# @parking/desktop — Tauri v2 kiosk shell
|
||||||
|
|
||||||
|
A **thin native desktop window** over the `@parking/web` SPA. It contains **no UI and no business
|
||||||
|
logic** of its own: the window renders the *same* web app the browser does, so the desktop and the
|
||||||
|
browser stay identical and never drift. Device/auth/ledger logic stays in `@parking/server`. See
|
||||||
|
`wiki/decisions/desktop-shell-tauri.md`.
|
||||||
|
|
||||||
|
## How the "same look & functionality" guarantee works
|
||||||
|
|
||||||
|
| | Source of the UI |
|
||||||
|
| --- | --- |
|
||||||
|
| **Dev** (`tauri dev`) | the window loads `http://localhost:5173` — the **`@parking/web` Vite dev server**. Edit a component in `apps/web` → HMR updates the desktop window live. |
|
||||||
|
| **Prod** (`tauri build`) | the window bundles `apps/web`'s built `dist/`. `beforeBuildCommand` rebuilds the SPA first. |
|
||||||
|
|
||||||
|
There is only one UI codebase (`apps/web`); this package just wraps it.
|
||||||
|
|
||||||
|
## Backend connection
|
||||||
|
|
||||||
|
The SPA talks to Fastify over HTTP/WS. In a browser that's same-origin (relative `/api`). In the
|
||||||
|
desktop build the bundled assets load from `tauri://localhost`, so set **`VITE_API_BASE`** (read at
|
||||||
|
web build time — see `.env.example`) to the appliance's Fastify origin, e.g.
|
||||||
|
`http://127.0.0.1:3000`. The CSP `connect-src` in `tauri.conf.json` is already allowed for that
|
||||||
|
origin, and the backend must include the Tauri origin in `WS_ALLOWED_ORIGINS` for the live feed.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @parking/desktop dev # native window over the web dev server (HMR)
|
||||||
|
pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app (.deb/.rpm/.AppImage)
|
||||||
|
```
|
||||||
|
|
||||||
|
> `build` is a **no-op** in this package so `turbo run build` stays fast — the real desktop bundle
|
||||||
|
> (compiles Rust, minutes long) is the explicit `bundle` script above.
|
||||||
|
|
||||||
|
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
|
||||||
|
window needs a display (WSLg or an X server).
|
||||||
|
|
||||||
|
## Auto-update
|
||||||
|
|
||||||
|
Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored
|
||||||
|
to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first
|
||||||
|
field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated —
|
||||||
|
see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and
|
||||||
|
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
|
||||||
|
committed.
|
||||||
|
|
||||||
|
**The manifest carries one entry per installer type** (`linux-x86_64-deb`, `linux-x86_64-rpm`,
|
||||||
|
and bare `linux-x86_64` for AppImage). The updater picks the entry matching how the running app
|
||||||
|
was installed — a `.deb` install will only ever accept a signed `.deb`. Booths run the `.deb`,
|
||||||
|
so an in-app update ends in a **polkit password prompt** (`pkexec dpkg -i`): that is expected,
|
||||||
|
and it is the right gate — the package lives in `/usr/bin`, root-owned, and the operator is not
|
||||||
|
supposed to be able to replace it silently. Cancel the prompt and the app keeps running the old
|
||||||
|
version; the failure is logged to the server's Logs viewer.
|
||||||
|
|
||||||
|
## Release gate — run the REAL bundle locally before tagging
|
||||||
|
|
||||||
|
`tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle
|
||||||
|
loads it from `tauri://localhost`, a *secure* custom-scheme origin — and every desktop-only bug
|
||||||
|
found in the field on 2026-09-03/04 (relative-URL DOMException, mixed content, missing WS
|
||||||
|
`Origin`, the reqwest-vs-webview cookie split, the WS handshake that can't carry the cookie)
|
||||||
|
depends on that difference. **Dev mode cannot reproduce any of them**, so "works in `tauri dev`"
|
||||||
|
carries no information about a release. Before pushing a `vX.Y.Z` tag:
|
||||||
|
|
||||||
|
1. `pnpm --filter @parking/server dev` (local backend; `.env` must have `COOKIE_SECURE=0` and
|
||||||
|
`tauri://localhost` in `WS_ALLOWED_ORIGINS`).
|
||||||
|
2. `pnpm --filter @parking/desktop bundle` and run the produced AppImage from
|
||||||
|
`src-tauri/target/release/bundle/appimage/` (WSLg is enough).
|
||||||
|
3. On the ConnectScreen enter `127.0.0.1:3000`, **Test** must say reachable, then **Save**.
|
||||||
|
4. Log in. The booth header must show **LIVE** (not "JASHTË LINJË") within a few seconds.
|
||||||
|
5. Perform one mutation (e.g. change your UI language) — it must succeed (proves CSRF).
|
||||||
|
6. Open Setup → Logs and confirm a `frontend`-sourced row from this desktop session exists
|
||||||
|
(proves the desktop log channel; historically it was silently 403'd).
|
||||||
|
|
||||||
|
Only then tag. If a release still fails in the field, the gap is in this list — fix the list.
|
||||||
|
|
||||||
|
## Not here (deliberately)
|
||||||
|
|
||||||
|
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
|
||||||
|
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@parking/desktop",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"//": "Tauri v2 desktop shell — a THIN native window over the @parking/web SPA. No business logic lives here (device/auth/ledger stay in @parking/server); see wiki/decisions/desktop-shell-tauri.md. Dev loads the web dev server (HMR); build bundles the web app's dist/, so the desktop UI and the browser UI are the SAME codebase and never drift.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tauri dev",
|
||||||
|
"build": "echo 'no-op in the Turbo graph — the real desktop bundle is a deliberate `pnpm --filter @parking/desktop bundle` (compiles Rust + packages installers, minutes long)'",
|
||||||
|
"bundle": "tauri build",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"lint": "echo 'no JS lint (Tauri shell; Rust checked via cargo)'"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.9.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.10.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
[package]
|
||||||
|
name = "parking-desktop"
|
||||||
|
version = "0.0.0"
|
||||||
|
description = "Parking System — desktop kiosk shell"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77"
|
||||||
|
|
||||||
|
# Thin Tauri v2 shell. Deliberately holds NO business logic — it loads the
|
||||||
|
# @parking/web SPA and lets it talk to the local Fastify server. Device/auth/
|
||||||
|
# ledger stay server-side. See wiki/decisions/desktop-shell-tauri.md.
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "parking_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde_json = "1"
|
||||||
|
# Auto-update: prompt the operator, download a signed update, relaunch.
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
|
# HTTP client for the SPA's API/WS calls to the local Fastify server. The window
|
||||||
|
# runs at tauri://localhost, which WebKitGTK treats as a secure origin — a plain
|
||||||
|
# http://127.0.0.1:3000 fetch() from inside it is blocked as mixed content (a
|
||||||
|
# long-standing WebKit limitation, not fixable via CSP). Routing through this
|
||||||
|
# plugin sends the request via Tauri's Rust side instead of the webview's own
|
||||||
|
# fetch, sidestepping the browser mixed-content check entirely.
|
||||||
|
tauri-plugin-http = "2"
|
||||||
|
# Same mixed-content problem as above, but for the live-feed WebSocket
|
||||||
|
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS
|
||||||
|
# are separate browser checks, so this needs its own plugin.
|
||||||
|
tauri-plugin-websocket = "2"
|
||||||
|
# Persists the operator-configured backend URL (host:port of the Fastify
|
||||||
|
# server this install talks to) across restarts. Read before any API call —
|
||||||
|
# see apps/web/src/lib/backend-config.ts.
|
||||||
|
tauri-plugin-store = "2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Minimal capability set for the kiosk shell. The window only needs to render the SPA; it is granted NOTHING that touches the filesystem, shell, or devices — those stay server-side. Add a named permission here only when a concrete need arises (deny-by-default). See wiki/decisions/desktop-shell-tauri.md.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"updater:default",
|
||||||
|
"process:default",
|
||||||
|
"websocket:default",
|
||||||
|
"store:default",
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"//": "Backend address is operator-configured at runtime (backend-config.ts) so the exact host:port can't be allow-listed at build time. Wildcarded to any host — the CSP forces ALL backend traffic through this plugin (see tauri.conf.json), so this scope is the real boundary; a compromised/malicious page still can't reach anything the operator hasn't pointed the app at, since the app only ever calls the one configured origin. All 4 forms needed: a known Tauri scope-matching quirk drops http://*:PORT unless both bare and :* variants are listed.",
|
||||||
|
"allow": [
|
||||||
|
{ "url": "http://*" },
|
||||||
|
{ "url": "https://*" },
|
||||||
|
{ "url": "http://*:*" },
|
||||||
|
{ "url": "https://*:*" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 953 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 552 B |
|
After Width: | Height: | Size: 745 B |
|
After Width: | Height: | Size: 891 B |
|
After Width: | Height: | Size: 1016 B |
|
After Width: | Height: | Size: 997 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 562 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 643 B |
|
After Width: | Height: | Size: 748 B |
|
After Width: | Height: | Size: 838 B |
|
After Width: | Height: | Size: 706 B |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
// Parking System desktop shell — entry point.
|
||||||
|
//
|
||||||
|
// Intentionally minimal: build the default Tauri app and run it. The window
|
||||||
|
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
|
||||||
|
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
|
||||||
|
// the backend over HTTP to a Fastify server (address operator-configured at
|
||||||
|
// runtime, not baked in — see apps/web/src/lib/backend-config.ts), NOT through
|
||||||
|
// Tauri IPC. This keeps the shell a thin presentation wrapper with a
|
||||||
|
// deny-by-default native surface (see wiki/decisions/desktop-shell-tauri.md).
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
// Auto-update: the JS side (apps/web) checks on launch, prompts the
|
||||||
|
// operator, and installs + relaunches on confirm. These plugins expose
|
||||||
|
// the update check/install and the relaunch to that flow. The updater
|
||||||
|
// endpoint + signing pubkey live in tauri.conf.json.
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
|
// Routes the SPA's fetch()/WS calls to the operator-configured Fastify
|
||||||
|
// server through Tauri's native HTTP client — see the Cargo.toml
|
||||||
|
// comment on why the webview's own fetch() can't reach it directly.
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
|
||||||
|
// above, but WS needs its own plugin (separate browser check).
|
||||||
|
.plugin(tauri_plugin_websocket::init())
|
||||||
|
// Persists the operator-configured backend URL across restarts (JSON
|
||||||
|
// file in the app's config dir) — see backend-config.ts.
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running the Parking System desktop shell");
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents an extra console window on Windows in release.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
parking_desktop_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Parking System",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.parking.desktop",
|
||||||
|
"build": {
|
||||||
|
"devUrl": "http://localhost:5173",
|
||||||
|
"frontendDist": "../../web/dist",
|
||||||
|
"beforeDevCommand": "pnpm --filter @parking/web dev",
|
||||||
|
"beforeBuildCommand": "pnpm --filter @parking/web build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "Parking System",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800,
|
||||||
|
"minWidth": 1024,
|
||||||
|
"minHeight": 640,
|
||||||
|
"resizable": true,
|
||||||
|
"maximized": true,
|
||||||
|
"fullscreen": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
||||||
|
"endpoints": [
|
||||||
|
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
|
||||||
|
],
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"extends": ["//"],
|
||||||
|
"//": "Tauri shell as a first-class Turbo node. build outputs [] so `turbo run build` doesn't try to cache/compile the Rust bundle on every pass (a real desktop bundle is a deliberate `pnpm --filter @parking/desktop build`).",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,99 @@
|
|||||||
# Generate one with: openssl rand -hex 32
|
# Generate one with: openssl rand -hex 32
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Dedicated HMAC key for signing the append-only event ledger (>=16 chars).
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for
|
||||||
|
# dev, but set a dedicated key before production. Events store the key that signed
|
||||||
|
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
||||||
|
EVENT_SIGNING_KEY=
|
||||||
|
|
||||||
|
# On-site encrypted DB backup (durability for the signed ledger). A daily timer + an admin
|
||||||
|
# "back up now" button write a consistent, AES-256-GCM-encrypted copy to the target. The
|
||||||
|
# TARGET DIRECTORY is chosen by the admin in the UI (Setup → Backup) and stored in the DB —
|
||||||
|
# NOT here. Only the encryption KEY is an env secret. RESTORE is an out-of-band runbook action,
|
||||||
|
# not a console call. See wiki/concepts/backup-recovery.md.
|
||||||
|
#
|
||||||
|
# Dedicated backup-encryption key (>=16 chars), SEPARATE from EVENT_SIGNING_KEY so it can
|
||||||
|
# rotate without fracturing the signed chain. Generate with: openssl rand -hex 32
|
||||||
|
# Escrow it offsite (alongside EVENT_SIGNING_KEY) — recovery needs both, and neither is ever
|
||||||
|
# stored inside the backup it unlocks. Backups stay a no-op until BOTH this key and an in-UI
|
||||||
|
# target directory are set. The target directory AND retention (keep-last / keep-daily) are
|
||||||
|
# admin-chosen in the UI (Setup → Backup), NOT env — only this key is an env secret.
|
||||||
|
# BACKUP_KEY=
|
||||||
|
|
||||||
# Optional ----------------------------------------------------------------
|
# Optional ----------------------------------------------------------------
|
||||||
# PORT=3000
|
# PORT=3000
|
||||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||||
# LOG_LEVEL=info
|
# LOG_LEVEL=info
|
||||||
# DATABASE_URL=./parking.sqlite
|
# DATABASE_URL=./parking.sqlite
|
||||||
# NODE_ENV=production # set in prod: makes auth cookies Secure (HTTPS-only)
|
#
|
||||||
|
# Auth-cookie Secure flag. FAIL-SAFE: cookies are Secure (HTTPS-only) BY DEFAULT —
|
||||||
|
# you only ever opt OUT, never in. Set COOKIE_SECURE=0 for a plain-HTTP deployment
|
||||||
|
# (e.g. the LAN appliance serving the SPA same-origin over http, where a Secure
|
||||||
|
# cookie would never be sent and would lock operators out). Local dev over
|
||||||
|
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
|
||||||
|
# COOKIE_SECURE=0
|
||||||
|
|
||||||
|
# Recycle bin retention: a soft-deleted user/role/subscription/plan/tariff is auto-purged
|
||||||
|
# this many days after deletion (a 6-hourly sweep). Default 30. Set 0 to keep deleted
|
||||||
|
# items forever (manual purge only). See wiki/concepts/soft-delete.md.
|
||||||
|
# RECYCLE_BIN_RETENTION_DAYS=30
|
||||||
|
|
||||||
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
||||||
# ADMIN_USER=admin
|
# ADMIN_USER=admin
|
||||||
# ADMIN_PASS=
|
# ADMIN_PASS=
|
||||||
|
|
||||||
|
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
|
||||||
|
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
|
||||||
|
# The Tauri DESKTOP shell loads from tauri://localhost (Linux may also send
|
||||||
|
# http://tauri.localhost), which is NOT same-origin with the backend — add both
|
||||||
|
# so the desktop app's live feed connects. See apps/desktop.
|
||||||
|
# To open the dev SPA from another LAN device (phone over wifi), Vite must bind
|
||||||
|
# 0.0.0.0 (vite.config.ts) AND the host's LAN origin must be listed here, e.g.
|
||||||
|
# http://10.0.10.203:5173 — the WS handshake's Origin is that LAN address.
|
||||||
|
WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhost
|
||||||
|
|
||||||
|
# Vision / ANPR (optional) -------------------------------------------------
|
||||||
|
# OFF by default. The Node SERVER's view of the vision microservice (apps/vision),
|
||||||
|
# which runs as a separate process with its OWN apps/vision/.env. Both sides share the
|
||||||
|
# VISION_ prefix but are different processes — keep the two .env files separate.
|
||||||
|
# See wiki/entities/opencv-anpr-service.md "Configuration".
|
||||||
|
# ANPR rides the entry/exit snapshot (button / QR / RFID triggers it) — no polling.
|
||||||
|
# VISION_ENABLED=1 # master switch — nothing runs without it
|
||||||
|
# VISION_URL=http://127.0.0.1:8089 # must match apps/vision VISION_HOST:VISION_PORT
|
||||||
|
# VISION_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane
|
||||||
|
# VISION_MIN_CONFIDENCE=0.5 # advisory confidence floor; keep in sync with the service
|
||||||
|
#
|
||||||
|
# ANPR subscriber-entry bridge (anpr-entry.ts): a subscriber's plate, read off a lane
|
||||||
|
# camera's vehicle detection, admits them through the gated SubscriptionFlow. Opt-in per
|
||||||
|
# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay.
|
||||||
|
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR)
|
||||||
|
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
|
||||||
|
|
||||||
|
# Venue modules --------------------------------------------------------------
|
||||||
|
# Comma-separated ids of the modules this site is ENTITLED to (a vendor/deployment
|
||||||
|
# decision — set in the Komodo stack env, never by a site role). The site admin then
|
||||||
|
# ACTIVATES within this set in Setup → Site; effective = entitled ∩ activated. Unset or
|
||||||
|
# blank = every registered module (parking,validation,carwash) — a DEV convenience. In
|
||||||
|
# Docker, docker-compose.yml forwards it with a default of parking,validation, so a booth
|
||||||
|
# is never entitled to a module its Komodo stack env does not name. Required modules
|
||||||
|
# (parking) are always on. See wiki/decisions/venue-modules.md.
|
||||||
|
#MODULES_ENTITLED=parking,validation
|
||||||
|
|
||||||
|
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md) -------------------------
|
||||||
|
# The operator's category choice is a hypothesis: each wash order with a vehicle read queues
|
||||||
|
# the vehicle CROP (plate blurred) + the choice for a trusted remote reviewer, drained one-way
|
||||||
|
# over the private overlay (Netbird). All three or off. URL = the collector's ingest endpoint
|
||||||
|
# (reachable only over the overlay); TOKEN = this booth's own bearer token; BOOTH_ID = a
|
||||||
|
# pseudonymous label the reviewer maps to a site (NEVER the site name — it travels with every
|
||||||
|
# item). Set in the Komodo stack env, per booth. Nothing is queued while off.
|
||||||
|
# CARWASH_REVIEW_URL=
|
||||||
|
# CARWASH_REVIEW_TOKEN=
|
||||||
|
# CARWASH_REVIEW_BOOTH_ID=
|
||||||
|
# CARWASH_REVIEW_INTERVAL_SEC=60
|
||||||
|
# Entry-stream sampling: also queue one in N ENTRY vehicle reads (no wash, no operator) as
|
||||||
|
# pure training material in the gate view — many times the wash stream, zero domain shift.
|
||||||
|
# 1 = every entry (the reviewer labels what they have time for; the rest waits and stays
|
||||||
|
# useful), N = one in N, 0/unset = off. Needs the three settings above.
|
||||||
|
# CARWASH_REVIEW_ENTRY_SAMPLE=1
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# Parking SERVER image: Fastify API + the bundled React SPA (one container serves both —
|
||||||
|
# offline-first single appliance). Build CONTEXT is the REPO ROOT (it's a pnpm/turbo
|
||||||
|
# monorepo). better-sqlite3 is a native module → build stage needs node-gyp toolchain,
|
||||||
|
# runtime needs libstdc++. Mirrors the house multi-stage pattern (cf. trm/processor).
|
||||||
|
# See wiki/decisions/container-deployment.md.
|
||||||
|
|
||||||
|
# ---- deps: cache-friendly pnpm fetch (only manifests change the layer) ----
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache python3 make g++ # node-gyp for better-sqlite3
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
# Workspace manifests + lock first, so the fetch layer caches across source edits.
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||||
|
COPY apps/server/package.json apps/server/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY apps/vision/package.json apps/vision/
|
||||||
|
COPY apps/collector/package.json apps/collector/
|
||||||
|
COPY packages/db/package.json packages/db/
|
||||||
|
COPY packages/devices/package.json packages/devices/
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm fetch
|
||||||
|
|
||||||
|
# ---- build: install (offline from the fetched store) + turbo build everything ----
|
||||||
|
FROM deps AS build
|
||||||
|
ENV CI=true
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --offline
|
||||||
|
# Force the SPA to use a SAME-ORIGIN (relative) API base for THIS image. Vite auto-loads
|
||||||
|
# apps/web/.env.production, which sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
|
||||||
|
# DESKTOP build — but here Fastify serves the SPA same-origin, so an absolute base would
|
||||||
|
# make the browser hit 127.0.0.1:3000 cross-origin and fail CORS. `.env.production.local`
|
||||||
|
# has higher precedence than `.env.production`, so this empties it for the server image only.
|
||||||
|
RUN echo 'VITE_API_BASE=' > apps/web/.env.production.local
|
||||||
|
# Builds shared/db/devices, the server dist, AND the web SPA dist (apps/web/dist).
|
||||||
|
RUN pnpm turbo run build --filter=@parking/server --filter=@parking/web
|
||||||
|
# `pnpm deploy` produces a SELF-CONTAINED prod bundle for the server in /deploy: a hoisted
|
||||||
|
# node_modules with only @parking/server's prod deps (incl. the workspace packages' built
|
||||||
|
# dist + their native deps like better-sqlite3 — properly linked, unlike `prune` at root).
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm --filter=@parking/server --legacy deploy --prod /deploy
|
||||||
|
# The server's own dist + scripts (deploy copies the package's package.json + files, but we
|
||||||
|
# copy dist explicitly so the layout under /deploy is predictable). The web SPA + db
|
||||||
|
# migrations are copied in the runtime stage from their build locations.
|
||||||
|
|
||||||
|
# ---- runtime: slim, non-root ----
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
# Set by CI to "<branch>-<short-sha>" (e.g. "stage-28bd838"), matching the same string used
|
||||||
|
# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the
|
||||||
|
# same string an admin would look up there. Empty/absent on a local `docker build` (dev only).
|
||||||
|
ARG BUILD_VERSION=""
|
||||||
|
ENV BUILD_VERSION=$BUILD_VERSION
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
||||||
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
|
||||||
|
# The self-contained deploy bundle: dist/ + a hoisted node_modules carrying the server's
|
||||||
|
# prod deps AND the workspace packages (@parking/db|devices|shared) with their built dist,
|
||||||
|
# the drizzle migrations, and the native better-sqlite3 binding. Single COPY — no scattered
|
||||||
|
# package dirs, no root node_modules.
|
||||||
|
COPY --from=build --chown=app:app /deploy ./
|
||||||
|
|
||||||
|
# The built SPA — served by Fastify static at WEB_DIST_DIR. (Not part of the server's deploy
|
||||||
|
# bundle, so copied from the web build output.)
|
||||||
|
COPY --from=build --chown=app:app /app/apps/web/dist ./web/dist
|
||||||
|
|
||||||
|
# DB lives on a mounted volume (never in the image). Default points at /data.
|
||||||
|
ENV DATABASE_URL=/data/parking.sqlite
|
||||||
|
ENV WEB_DIST_DIR=/app/web/dist
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
RUN mkdir -p /data && chown app:app /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
USER app
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- "http://localhost:${PORT:-3000}/health" >/dev/null 2>&1 || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Container entrypoint for the parking server. Applies DB migrations against the mounted
|
||||||
|
# volume (DATABASE_URL), optionally seeds the first admin, then execs the server. Idempotent:
|
||||||
|
# the runtime migrator (drizzle-orm migrator, no drizzle-kit) only applies pending migrations,
|
||||||
|
# so a restart is a no-op. See packages/db/scripts/migrate-runtime.mjs.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[entrypoint] DATABASE_URL=${DATABASE_URL}"
|
||||||
|
|
||||||
|
# Apply migrations against the mounted DB file (creates it + the schema on first boot).
|
||||||
|
# The migrator ships inside the @parking/db package in the deploy bundle's node_modules.
|
||||||
|
node node_modules/@parking/db/scripts/migrate-runtime.mjs
|
||||||
|
|
||||||
|
# Optional first-boot admin seed: set SEED_ADMIN=1 plus ADMIN_USER + ADMIN_PASS (the seed
|
||||||
|
# script PROMPTS when these are unset, which would hang a container — so require ADMIN_PASS).
|
||||||
|
# The seed is idempotent: it won't overwrite an existing user unless FORCE=1.
|
||||||
|
if [ "${SEED_ADMIN}" = "1" ]; then
|
||||||
|
if [ -z "${ADMIN_PASS}" ]; then
|
||||||
|
echo "[entrypoint] SEED_ADMIN=1 but ADMIN_PASS is unset — skipping seed (would hang on prompt)"
|
||||||
|
else
|
||||||
|
echo "[entrypoint] seeding admin (${ADMIN_USER:-admin})"
|
||||||
|
node scripts/seed-admin.mjs || echo "[entrypoint] seed-admin skipped/failed (non-fatal)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] starting server"
|
||||||
|
exec "$@"
|
||||||
@@ -9,24 +9,28 @@
|
|||||||
"start": "node --env-file-if-exists=.env dist/index.js",
|
"start": "node --env-file-if-exists=.env dist/index.js",
|
||||||
"seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs",
|
"seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "11.2.0",
|
"@fastify/cors": "11.2.0",
|
||||||
"@fastify/jwt": "10.1.0",
|
"@fastify/jwt": "10.1.0",
|
||||||
"@fastify/static": "9.1.3",
|
"@fastify/static": "9.1.3",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@parking/db": "workspace:*",
|
"@parking/db": "workspace:*",
|
||||||
"@parking/devices": "workspace:*",
|
"@parking/devices": "workspace:*",
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"fastify": "5.8.5",
|
"fastify": "5.8.5",
|
||||||
"fastify-plugin": "6.0.0"
|
"fastify-plugin": "6.0.0",
|
||||||
|
"sharp": "^0.35.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "6.0.0",
|
"@types/bcrypt": "6.0.0",
|
||||||
"@types/node": "25.9.3",
|
"@types/node": "25.9.3",
|
||||||
"tsx": "4.22.4",
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
|
|||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const bcrypt = require("bcrypt");
|
const bcrypt = require("bcrypt");
|
||||||
const { createDb, users, eq } = require("@parking/db");
|
const { createDb, users, roles, eq } = require("@parking/db");
|
||||||
|
|
||||||
const DEFAULT_USERNAME = "admin";
|
const DEFAULT_USERNAME = "admin";
|
||||||
|
|
||||||
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
|
||||||
|
// Self-heal the built-in `admin` ROLE row. Migration 0007 seeds it once, but the
|
||||||
|
// training reset (reset-db.mjs --users/--all) wipes the roles table and points here
|
||||||
|
// to re-seed — without this, the user insert dies on the role_id FOREIGN KEY (field
|
||||||
|
// failure 2026-07-06). The admin permission SET is resolved in code (auth.ts), so
|
||||||
|
// the row alone is all the FK needs.
|
||||||
|
await db.insert(roles).values({ id: "admin", name: "Admin", builtin: 1 }).onConflictDoNothing();
|
||||||
|
|
||||||
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
||||||
if (existing && process.env.FORCE !== "1") {
|
if (existing && process.env.FORCE !== "1") {
|
||||||
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
||||||
@@ -62,15 +70,41 @@ if (existing && process.env.FORCE !== "1") {
|
|||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await db.update(users).set({ passwordHash, role: "admin" }).where(eq(users.id, existing.id));
|
await db.update(users).set({ passwordHash, roleId: "admin" }).where(eq(users.id, existing.id));
|
||||||
console.log(`reset password for admin "${username}"`);
|
console.log(`reset password for admin "${username}"`);
|
||||||
} else {
|
} else {
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
username,
|
username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
role: "admin",
|
roleId: "admin",
|
||||||
});
|
});
|
||||||
console.log(`created admin "${username}"`);
|
console.log(`created admin "${username}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record the action into the SIGNED ledger (config_change). A console seed/reset is
|
||||||
|
// a Linux-admin action the app can't gate — but it must stay ATTRIBUTABLE after the
|
||||||
|
// fact (the chain is the audit record; whoever holds root can reset a password, they
|
||||||
|
// can't do it silently). Uses the server's own compiled EventLog + signer from dist/
|
||||||
|
// (present in the container; in a dev checkout run `pnpm build` first). Best-effort:
|
||||||
|
// a missing build or signing key WARNS loudly but never blocks the seed — locking an
|
||||||
|
// admin out to protect an audit line would invert the priority.
|
||||||
|
try {
|
||||||
|
const { EventLog } = await import("../dist/event-log.js");
|
||||||
|
const { buildSigner } = await import("../dist/signer.js");
|
||||||
|
const log = new EventLog(db, buildSigner());
|
||||||
|
await log.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: `user:${username}`,
|
||||||
|
payload: {
|
||||||
|
setting: existing ? "admin.passwordReset" : "admin.seeded",
|
||||||
|
username,
|
||||||
|
operator: "console:seed-admin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("recorded to the signed ledger (config_change)");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`WARNING: NOT recorded to the signed ledger: ${err.message}`);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
import type { VisionClient, VisionResult } from "./vision-client.js";
|
||||||
|
import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js";
|
||||||
|
|
||||||
|
// The ANPR bridge: a camera vehicle detection → (opt-in) snapshot → plate → MATCH a
|
||||||
|
// subscriber → emit a plate read. We mock the camera build (buildCamera) so no real
|
||||||
|
// snapshot HTTP is made, and pass fake Vision/Subscription so the test is the bridge's
|
||||||
|
// own logic only. See anpr-entry.ts.
|
||||||
|
|
||||||
|
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
|
||||||
|
// (no registry, no network). The factory returns a fresh shot each call.
|
||||||
|
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
||||||
|
// The bridge now goes through captureSnapshotShared (the dedup wrapper, exercised in
|
||||||
|
// snapshot.test.ts); here it just delegates to the fake camera's captureSnapshot so this
|
||||||
|
// suite stays focused on the bridge's own match/debounce/emit logic.
|
||||||
|
vi.mock("./snapshot.js", () => ({
|
||||||
|
buildCamera: () => ({ captureSnapshot }),
|
||||||
|
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
|
||||||
|
camera.captureSnapshot(ctx as never),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import AFTER the mock is registered.
|
||||||
|
const { AnprBridge } = await import("./anpr-entry.js");
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
captureSnapshot.mockClear();
|
||||||
|
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
||||||
|
delete process.env.ANPR_DEBOUNCE_MS;
|
||||||
|
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
|
||||||
|
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
|
||||||
|
// bridge reads these in its constructor, so set them before `new AnprBridge`.
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "5";
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete process.env.ANPR_POLL_MS;
|
||||||
|
delete process.env.ANPR_POLL_WINDOW_MS;
|
||||||
|
delete process.env.ANPR_POLL_MAX_MS;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
|
||||||
|
* per-camera auto-open gate (absent ⇒ defaults on). */
|
||||||
|
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
|
||||||
|
const controllerId = randomUUID();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: controllerId,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: "10.0.0.5", relays: [{ relay: 1, direction: "entry" }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const camId = randomUUID();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: camId,
|
||||||
|
category: "camera",
|
||||||
|
driverId: "hikvision",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.9",
|
||||||
|
controllerId,
|
||||||
|
relay: 1,
|
||||||
|
...(opts.anpr ? { anpr: true } : {}),
|
||||||
|
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
return camId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fake VisionClient: enabled, returning a chosen plate/confidence (or null). */
|
||||||
|
function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: number } = {}): VisionClient {
|
||||||
|
const enabled = opts.enabled ?? true;
|
||||||
|
const result: VisionResult | null =
|
||||||
|
opts.plate == null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
vehicle: null,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
analyze: vi.fn(async () => (enabled ? result : null)),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
||||||
|
function fakeSubFlow(
|
||||||
|
match: SubscriptionMatch | null,
|
||||||
|
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
|
||||||
|
// credential closing an occurrence mid-poll → count changes).
|
||||||
|
openCounts: number | number[] = 1,
|
||||||
|
): SubscriptionFlow {
|
||||||
|
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
|
||||||
|
return {
|
||||||
|
match: vi.fn(() => match),
|
||||||
|
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
|
||||||
|
} as unknown as SubscriptionFlow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
||||||
|
|
||||||
|
/** Capture read events emitted during `fn` (async). */
|
||||||
|
async function captureReads(fn: () => Promise<void>): Promise<DeviceReadEvent[]> {
|
||||||
|
const got: DeviceReadEvent[] = [];
|
||||||
|
const off = deviceEvents.onRead((e) => got.push(e));
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
return got;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AnprBridge", () => {
|
||||||
|
it("does nothing for an opt-OUT camera (no anpr flag) — no analyze, no read", async () => {
|
||||||
|
const cam = seedCamera({ anpr: false });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB" });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
expect(vision.analyze).not.toHaveBeenCalled();
|
||||||
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
|
||||||
|
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
|
||||||
|
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
|
||||||
|
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ deviceId: cam, value: "AA111BB", kind: "plate", driverId: "hikvision" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a plate below the entry confidence floor", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.6 }); // < default 0.85
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
|
||||||
|
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
|
||||||
|
// must re-pull until one frame clears the floor, not give up on the first bad frame.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
|
||||||
|
const confs = [0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
vehicle: null,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
// Generous window so all 3 escalation attempts run deterministically under suite load
|
||||||
|
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "2000";
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
|
||||||
|
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
|
||||||
|
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
|
||||||
|
// extends the deadline, so the loop keeps polling and reads the car that settles at the
|
||||||
|
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
|
||||||
|
// the start bumps the deadline so attempt 5's confident read still lands.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
vehicle: null,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
process.env.ANPR_POLL_MS = "5";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
|
||||||
|
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
const loop = bridge.onVehicleDetected(cam); // starts the loop
|
||||||
|
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
|
||||||
|
for (let k = 0; k < 5; k++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
|
||||||
|
}
|
||||||
|
await loop;
|
||||||
|
});
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
|
||||||
|
// The car's plate is read (identity known) but stays below the floor; meanwhile the
|
||||||
|
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
|
||||||
|
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
|
||||||
|
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
|
||||||
|
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
|
||||||
|
const bridge = new AnprBridge(db, vision, sub, silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]); // aborted — the credential already handled it
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
|
||||||
|
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
|
||||||
|
expect(skips).toHaveLength(1);
|
||||||
|
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("analyzes AT LEAST ONE frame even if the poll window already elapsed (loaded host)", async () => {
|
||||||
|
// Regression for a CI flake (2026-07-04): with a plain `while`, a window that lapsed
|
||||||
|
// between deadline-set and loop-entry (slow runner; here forced with a 0ms window)
|
||||||
|
// meant ZERO analyze attempts — the detection was silently dropped ("gave up") and no
|
||||||
|
// skip was recorded. The do-while guarantees one frame per detection regardless of load.
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "0";
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
|
||||||
|
|
||||||
|
await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(captureSnapshot).toHaveBeenCalledTimes(1); // the guaranteed first attempt
|
||||||
|
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
|
||||||
|
expect(skips).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
await bridge.onVehicleDetected(cam);
|
||||||
|
await bridge.onVehicleDetected(cam); // within the 12s window → suppressed
|
||||||
|
});
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(captureSnapshot).toHaveBeenCalledTimes(1); // 2nd was gated before the snapshot
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op (no throw) when vision is disabled or reads nothing", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const disabled = new AnprBridge(db, fakeVision({ enabled: false, plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
const noPlate = new AnprBridge(db, fakeVision({ plate: undefined }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
await disabled.onVehicleDetected(cam);
|
||||||
|
await noPlate.onVehicleDetected(cam);
|
||||||
|
});
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on an unknown device id", async () => {
|
||||||
|
const bridge = new AnprBridge(db, fakeVision({ plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
await expect(bridge.onVehicleDetected("nope")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOTHING when the admin has disabled the bridge (site_config.anprEntryEnabled = false)", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: false }).run();
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
// The flag is checked FIRST — no snapshot, no analyze, no match attempt.
|
||||||
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(vision.analyze).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still emits when the bridge is explicitly enabled (anprEntryEnabled = true)", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: true }).run();
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
||||||
|
import { directionOf, type FlowDirection } from "./device-resolve.js";
|
||||||
|
import { buildCamera } from "./snapshot.js";
|
||||||
|
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
|
// The ANPR "bridge": a subscriber's plate, read from the lane camera, admits them through
|
||||||
|
// the SAME gated SubscriptionFlow a QR/card scan uses. It is the one missing wire between
|
||||||
|
// the camera's vehicle PUSH (hikvision-alarm.ts) and the read bus — NOT a new service.
|
||||||
|
//
|
||||||
|
// On a `vehicle`/`active` event from an OPT-IN camera (config.anpr === true), the bridge:
|
||||||
|
// pull a fresh snapshot → vision.analyze → entry confidence floor → debounce → MATCH the
|
||||||
|
// plate to a subscription → emit a DeviceReadEvent{kind:"plate"} ONLY if it matched.
|
||||||
|
// The existing onRead → ReadDispatcher then re-matches and runs the gated SubscriptionFlow
|
||||||
|
// (active / window / blocklist / car-count), which signs the entry/exit and opens the relay.
|
||||||
|
//
|
||||||
|
// INVARIANTS (see wiki/concepts/lane-presence-and-anpr-entry.md §2, append-only-event-chain.md):
|
||||||
|
// - Advisory, never sole authority: the bridge only emitRead()s — the signed decision +
|
||||||
|
// barrier open stay inside the existing flow. A spoofed printed plate is just another
|
||||||
|
// credential through the same gate.
|
||||||
|
// - Subscriber-ONLY: it MATCHES before emitting, so a random plate never reaches the
|
||||||
|
// transient plate-as-ticket exit flow.
|
||||||
|
// - Fail-soft + fire-and-forget: any snapshot/vision error degrades to the card/QR path;
|
||||||
|
// never throws into the push handler, never awaited on the camera's 200 response.
|
||||||
|
// - Opt-in per camera, and debounced (the camera re-fires ~1Hz while a car sits).
|
||||||
|
|
||||||
|
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
||||||
|
interface CameraConfig {
|
||||||
|
readonly anpr?: boolean;
|
||||||
|
/** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is
|
||||||
|
* on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a
|
||||||
|
* shared entry/exit lane. */
|
||||||
|
readonly anprAutoTrigger?: boolean;
|
||||||
|
readonly [k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stricter-than-advisory confidence floor for a BARRIER-driving plate read. A near-miss
|
||||||
|
* read falls back to the subscriber's card/QR, so we'd rather skip than wrongly admit.
|
||||||
|
* Distinct from vision-client's advisory VISION_MIN_CONFIDENCE. */
|
||||||
|
function entryMinConfidence(): number {
|
||||||
|
const raw = Number(process.env.VISION_ENTRY_MIN_CONFIDENCE ?? 0.85);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same plate/camera within this window = ONE credential presentation. The camera re-fires
|
||||||
|
* ~1Hz while a car is present; emitting every second would drive repeat entries (a fleet
|
||||||
|
* sub opens a 2nd occurrence) or exit spam. Required for correctness, not CPU. */
|
||||||
|
function debounceMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_DEBOUNCE_MS ?? 12_000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the
|
||||||
|
* first frame often has a small/blurry/absent plate (a low-confidence misread). But the car
|
||||||
|
* then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed
|
||||||
|
* moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and
|
||||||
|
* re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */
|
||||||
|
function pollMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MS ?? 1000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
|
||||||
|
* push for the camera extends the deadline by this much from now — so a loop started by a
|
||||||
|
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
|
||||||
|
* barrier (the loop tracks "whoever is here now", not the car that started it). */
|
||||||
|
function pollWindowMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
|
||||||
|
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
|
||||||
|
function pollMaxMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
|
||||||
|
* subscriber by plate during the poll loop without re-building the whole event. */
|
||||||
|
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
|
||||||
|
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnprBridge {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #vision: VisionClient | null;
|
||||||
|
readonly #subscription: SubscriptionFlow;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #entryMinConfidence: number;
|
||||||
|
readonly #debounceMs: number;
|
||||||
|
readonly #pollMs: number;
|
||||||
|
readonly #pollWindowMs: number;
|
||||||
|
readonly #pollMaxMs: number;
|
||||||
|
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
||||||
|
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
||||||
|
readonly #lastFire = new Map<string, number>();
|
||||||
|
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
|
||||||
|
* while the car sits) must NOT start a second concurrent loop on the same camera. */
|
||||||
|
readonly #polling = new Set<string>();
|
||||||
|
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
|
||||||
|
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
|
||||||
|
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
|
||||||
|
readonly #pollDeadline = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#vision = vision;
|
||||||
|
this.#subscription = subscription;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#entryMinConfidence = entryMinConfidence();
|
||||||
|
this.#debounceMs = debounceMs();
|
||||||
|
this.#pollMs = pollMs();
|
||||||
|
this.#pollWindowMs = pollWindowMs();
|
||||||
|
this.#pollMaxMs = pollMaxMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A camera reported a vehicle. If the camera opts into ANPR, pull a snapshot, read the
|
||||||
|
* plate, and — only if it matches a subscription — emit a plate read onto the bus.
|
||||||
|
* Fire-and-forget; fail-soft. Never throws (the push handler must always 200).
|
||||||
|
*/
|
||||||
|
async onVehicleDetected(deviceId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!this.#vision?.enabled) return; // no recognizer configured
|
||||||
|
// Admin master switch (read LIVE so toggling in Site Settings takes effect with no
|
||||||
|
// restart). Gates ONLY this barrier-driving bridge — advisory snapshot-ANPR and lane
|
||||||
|
// busy/free are unaffected. Absent/unreadable config ⇒ enabled (the default).
|
||||||
|
const site = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (site && site.anprEntryEnabled === false) return;
|
||||||
|
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
|
if (!row || !row.enabled || row.category !== "camera") return;
|
||||||
|
const cfg = row.config as CameraConfig;
|
||||||
|
if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path)
|
||||||
|
// Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on;
|
||||||
|
// this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane
|
||||||
|
// sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the
|
||||||
|
// car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger).
|
||||||
|
if (cfg.anprAutoTrigger === false) return;
|
||||||
|
|
||||||
|
// Post-success debounce: once we've emitted a read for this camera, ignore the
|
||||||
|
// ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
|
||||||
|
// window is a new presentation and may start a new poll loop.
|
||||||
|
if (this.#debounced(deviceId)) return;
|
||||||
|
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
|
||||||
|
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
|
||||||
|
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
|
||||||
|
const now = Date.now();
|
||||||
|
if (this.#polling.has(deviceId)) {
|
||||||
|
const cur = this.#pollDeadline.get(deviceId) ?? now;
|
||||||
|
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
|
||||||
|
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#polling.add(deviceId);
|
||||||
|
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
|
||||||
|
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
|
||||||
|
|
||||||
|
const camera = buildCamera(row);
|
||||||
|
if (!camera) {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
|
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
||||||
|
// the gated flow infers the verb from the camera's bound relay direction).
|
||||||
|
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
||||||
|
|
||||||
|
// POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
|
||||||
|
// plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
|
||||||
|
// barrier — the stationary, well-framed moment the manual test reads at ~100%. So we
|
||||||
|
// pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the
|
||||||
|
// #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each
|
||||||
|
// tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame.
|
||||||
|
// While polling, watch whether THIS subscriber transacts by another credential
|
||||||
|
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
|
||||||
|
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
|
||||||
|
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
|
||||||
|
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
|
||||||
|
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
|
||||||
|
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
|
||||||
|
let watchedSubId: string | null = null;
|
||||||
|
let baselineOpen = 0;
|
||||||
|
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
|
||||||
|
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
|
||||||
|
const hardCap = Date.now() + this.#pollMaxMs;
|
||||||
|
let attempts = 0;
|
||||||
|
try {
|
||||||
|
// DO-while: a detection always analyzes AT LEAST ONE frame, however loaded the
|
||||||
|
// host — a plain while could zero-iterate if the window elapsed between setting
|
||||||
|
// the deadline and reaching the loop (seen as a CI flake with the tests' 5ms
|
||||||
|
// window; on a busy booth it would silently drop a real car's detection). Exit
|
||||||
|
// is via the breaks below (confident read, or next tick would pass the deadline).
|
||||||
|
do {
|
||||||
|
attempts++;
|
||||||
|
const shot = await camera.captureSnapshot({ direction });
|
||||||
|
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||||
|
|
||||||
|
// Identify the subscriber from ANY readable plate (even below the barrier floor),
|
||||||
|
// and baseline their open count once — so we can detect a credential beating us.
|
||||||
|
if (r?.plate?.text) {
|
||||||
|
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
|
||||||
|
if (m0 && watchedSubId == null) {
|
||||||
|
watchedSubId = m0.subscriptionId;
|
||||||
|
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
|
||||||
|
// they already transacted; stop polling and do NOT emit.
|
||||||
|
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
|
||||||
|
result = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (r?.plate) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` +
|
||||||
|
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
|
||||||
|
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
|
||||||
|
if (Date.now() + this.#pollMs >= effDeadline) break;
|
||||||
|
await sleep(this.#pollMs);
|
||||||
|
} while (true);
|
||||||
|
} finally {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
|
this.#pollDeadline.delete(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result || !result.plate) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
|
||||||
|
`in ${this.#pollWindowMs}ms — gave up`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plate = result.plate.text.trim().toUpperCase();
|
||||||
|
if (!plate) return;
|
||||||
|
|
||||||
|
const e: DeviceReadEvent = {
|
||||||
|
driverId: row.driverId,
|
||||||
|
deviceId,
|
||||||
|
value: plate,
|
||||||
|
kind: "plate",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// MATCH BEFORE EMIT — subscriber-only. A non-subscriber plate records advisory
|
||||||
|
// telemetry and stops; it must NEVER reach the transient plate-as-ticket exit flow.
|
||||||
|
const match = this.#subscription.match(e);
|
||||||
|
if (!match) {
|
||||||
|
this.#recordSkip(deviceId, plate, result.plate.confidence);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final guard against the credential-mid-poll race: if the subscriber transacted between
|
||||||
|
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
|
||||||
|
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
|
||||||
|
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
||||||
|
// camera-level gate re-emitting the SAME plate.
|
||||||
|
const plateKey = `${deviceId}:${plate}`;
|
||||||
|
if (this.#debounced(plateKey)) return;
|
||||||
|
this.#stamp(plateKey);
|
||||||
|
// Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz
|
||||||
|
// re-fires (and any new poll loop) for #debounceMs.
|
||||||
|
this.#stamp(deviceId);
|
||||||
|
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
||||||
|
);
|
||||||
|
deviceEvents.emitRead(e); // → onRead → ReadDispatcher → gated SubscriptionFlow
|
||||||
|
} catch (err) {
|
||||||
|
// Fail-soft: an ANPR failure degrades to the subscriber's card/QR, never strands the lane.
|
||||||
|
this.#logger.warn(`anpr-bridge failed (${deviceId}): ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#debounced(key: string): boolean {
|
||||||
|
const last = this.#lastFire.get(key);
|
||||||
|
return last != null && Date.now() - last < this.#debounceMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stamp(key: string): void {
|
||||||
|
this.#lastFire.set(key, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advisory telemetry: a plate was read at the lane but matched no subscription. Not a
|
||||||
|
* read on the bus — just a breadcrumb so the operator can see ANPR is working. */
|
||||||
|
#recordSkip(deviceId: string, plate: string, confidence: number): void {
|
||||||
|
this.#logger.info(`anpr-bridge: plate '${plate}' matched no subscription — skipped`);
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.insert(deviceEventsTable)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId,
|
||||||
|
category: "camera",
|
||||||
|
kind: "anpr-skip",
|
||||||
|
detail: { plate, confidence, source: "anpr-bridge", reason: "no subscription match" },
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`anpr-bridge skip-record insert failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceRow is re-exported for the test's seed typing convenience.
|
||||||
|
export type { DeviceRow };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { secureCookies } from "./auth.js";
|
||||||
|
|
||||||
|
// The auth/CSRF cookies' Secure flag must be FAIL-SAFE: Secure by default, dropped only
|
||||||
|
// on a deliberate opt-out. The old behaviour (Secure iff NODE_ENV==="production") leaked
|
||||||
|
// cookies over plain HTTP on an appliance that forgot to set NODE_ENV — this pins the
|
||||||
|
// corrected matrix.
|
||||||
|
|
||||||
|
let savedCookieSecure: string | undefined;
|
||||||
|
let savedNodeEnv: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
savedCookieSecure = process.env.COOKIE_SECURE;
|
||||||
|
savedNodeEnv = process.env.NODE_ENV;
|
||||||
|
delete process.env.COOKIE_SECURE;
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
restore("COOKIE_SECURE", savedCookieSecure);
|
||||||
|
restore("NODE_ENV", savedNodeEnv);
|
||||||
|
});
|
||||||
|
function restore(key: string, val: string | undefined) {
|
||||||
|
if (val === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("secureCookies — fail-safe Secure flag", () => {
|
||||||
|
it("defaults to Secure when nothing is set (the appliance-forgot-NODE_ENV case)", () => {
|
||||||
|
expect(secureCookies()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays Secure in production", () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
expect(secureCookies()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops Secure only for an explicit local-dev NODE_ENV", () => {
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
expect(secureCookies()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("COOKIE_SECURE override wins: falsey values opt OUT", () => {
|
||||||
|
for (const v of ["0", "false", "no", "off", "FALSE", " Off "]) {
|
||||||
|
process.env.COOKIE_SECURE = v;
|
||||||
|
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("COOKIE_SECURE override wins: any other value opts IN (even in dev)", () => {
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
for (const v of ["1", "true", "yes", "on", ""]) {
|
||||||
|
process.env.COOKIE_SECURE = v;
|
||||||
|
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
import type { Role } from "@parking/shared";
|
import { eq, rolePermissions, users, type Db } from "@parking/db";
|
||||||
|
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||||
|
|
||||||
// Local JWT auth helpers — fully local, no external identity provider
|
// Local JWT auth helpers — fully local, no external identity provider
|
||||||
// (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it);
|
// (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it);
|
||||||
// a separate readable CSRF cookie + matching header defends mutations
|
// a separate readable CSRF cookie + matching header defends mutations
|
||||||
// (double-submit). See wiki/entities/local-jwt-auth.md.
|
// (double-submit). See wiki/entities/local-jwt-auth.md.
|
||||||
|
//
|
||||||
|
// Authorization is DYNAMIC RBAC: the token carries the user's `roleId`, and each
|
||||||
|
// guarded route resolves that role's PERMISSION SET (cached in memory) and checks
|
||||||
|
// the permission it requires. Editing a role takes effect on the next request —
|
||||||
|
// no re-login, no token bloat, no stale perms. See @parking/shared PERMISSIONS.
|
||||||
|
|
||||||
declare module "@fastify/jwt" {
|
declare module "@fastify/jwt" {
|
||||||
interface FastifyJWT {
|
interface FastifyJWT {
|
||||||
payload: { sub: string; username: string; role: Role; csrf: string };
|
payload: { sub: string; username: string; roleId: string; csrf: string };
|
||||||
user: { sub: string; username: string; role: Role; csrf: string };
|
user: { sub: string; username: string; roleId: string; csrf: string };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,9 +50,28 @@ export function requireJwtSecret(): string {
|
|||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cookies are secure in production; relaxed for local http dev. */
|
/**
|
||||||
function secureCookies(): boolean {
|
* Whether to set the `Secure` flag on the auth/CSRF cookies. FAIL-SAFE: default is
|
||||||
return process.env.NODE_ENV === "production";
|
* `true` (Secure) — a misconfigured/forgotten env can only ever make cookies MORE
|
||||||
|
* restrictive, never silently drop the flag.
|
||||||
|
*
|
||||||
|
* The previous gate keyed off `NODE_ENV === "production"`, which meant an appliance
|
||||||
|
* deployed without that var leaked cookies over plain HTTP. Now `Secure` is the
|
||||||
|
* default and is dropped ONLY for an explicit, deliberate opt-out — `COOKIE_SECURE`
|
||||||
|
* set to a falsey value (`0/false/no/off`), or the legacy `NODE_ENV !== production`
|
||||||
|
* signal kept as a fallback so existing dev setups still work over http://localhost.
|
||||||
|
*
|
||||||
|
* The parking appliance often serves the SPA same-origin over the LAN with no TLS;
|
||||||
|
* THAT box sets `COOKIE_SECURE=0` on purpose (a Secure cookie would never be sent
|
||||||
|
* over its http origin and would lock operators out). Everything else stays secure.
|
||||||
|
*/
|
||||||
|
export function secureCookies(): boolean {
|
||||||
|
const override = process.env.COOKIE_SECURE;
|
||||||
|
if (override !== undefined) {
|
||||||
|
return !/^(0|false|no|off)$/i.test(override.trim());
|
||||||
|
}
|
||||||
|
// No explicit override: secure unless this is an obvious local-dev run.
|
||||||
|
return process.env.NODE_ENV !== "development";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newCsrfToken(): string {
|
export function newCsrfToken(): string {
|
||||||
@@ -96,17 +121,133 @@ function assertCsrf(req: FastifyRequest): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Permission resolution + cache -------------------------------------------
|
||||||
|
// A role's permission set is read from `role_permissions` and cached in memory.
|
||||||
|
// SQLite is single-writer/single-process here, so a module-level Map is a correct
|
||||||
|
// cache: every role / role-permission mutation calls bumpPermsCache() to clear it,
|
||||||
|
// and the next request re-reads. The built-in `admin` role always resolves to the
|
||||||
|
// FULL permission set in code (never trusts the DB rows for it), so administration
|
||||||
|
// can't be accidentally narrowed.
|
||||||
|
|
||||||
|
const ADMIN_PERMS: ReadonlySet<Permission> = new Set(PERMISSIONS);
|
||||||
|
const permsCache = new Map<string, ReadonlySet<Permission>>();
|
||||||
|
|
||||||
|
// The DB handle the permission resolver reads from. Set ONCE at startup via
|
||||||
|
// initAuth() so route guards don't each have to thread `db` (several route
|
||||||
|
// modules only receive a monitor/service, not the db). Single-process server.
|
||||||
|
let authDb: Db | null = null;
|
||||||
|
|
||||||
|
/** Wire the permission resolver to the app's DB. Call once in buildServer(). */
|
||||||
|
export function initAuth(db: Db): void {
|
||||||
|
authDb = db;
|
||||||
|
permsCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the permission + role caches. Call after ANY write to roles / role_permissions
|
||||||
|
* or to a user's roleId / deletion, so the change takes effect on the next request. */
|
||||||
|
export function bumpPermsCache(): void {
|
||||||
|
permsCache.clear();
|
||||||
|
roleCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** userId → CURRENT roleId, cached until bumpPermsCache(). */
|
||||||
|
const roleCache = new Map<string, string | null>();
|
||||||
|
|
||||||
|
/** The user's CURRENT role. The token pins the roleId that was current at LOGIN; an
|
||||||
|
* admin reassigning a user's role (or deleting the user) must take effect on the next
|
||||||
|
* request exactly like editing a role does — otherwise the reassigned user keeps the
|
||||||
|
* old role's rights until they log out (found 2026-09-05: a user moved to a new
|
||||||
|
* wash role kept 403ing on the new role's permissions). null = the user is gone. */
|
||||||
|
export function currentRoleId(sub: string): string | null {
|
||||||
|
if (!authDb) throw new Error("auth not initialised (call initAuth)");
|
||||||
|
const hit = roleCache.get(sub);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const row = authDb
|
||||||
|
.select({ roleId: users.roleId, deletedAt: users.deletedAt })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, sub))
|
||||||
|
.get();
|
||||||
|
const roleId = row && row.deletedAt == null ? row.roleId : null;
|
||||||
|
roleCache.set(sub, roleId);
|
||||||
|
return roleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After jwtVerify: replace the token's pinned roleId with the user's current one, or
|
||||||
|
* end the session if the user no longer exists. */
|
||||||
|
function refreshRole(req: FastifyRequest): void {
|
||||||
|
const roleId = currentRoleId(req.user.sub);
|
||||||
|
if (roleId === null) throw Object.assign(new Error("session no longer valid"), { statusCode: 401 });
|
||||||
|
if (roleId !== req.user.roleId) req.user.roleId = roleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The permission set for a role id, cached. `admin` is always the full set. */
|
||||||
|
export function permissionsFor(roleId: string): ReadonlySet<Permission> {
|
||||||
|
if (roleId === ADMIN_ROLE_ID) return ADMIN_PERMS;
|
||||||
|
const hit = permsCache.get(roleId);
|
||||||
|
if (hit) return hit;
|
||||||
|
if (!authDb) throw new Error("auth not initialised (call initAuth)");
|
||||||
|
const rows = authDb
|
||||||
|
.select({ permission: rolePermissions.permission })
|
||||||
|
.from(rolePermissions)
|
||||||
|
.where(eq(rolePermissions.roleId, roleId))
|
||||||
|
.all();
|
||||||
|
const set = new Set(rows.map((r) => r.permission as Permission));
|
||||||
|
permsCache.set(roleId, set);
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the role grants every listed permission. */
|
||||||
|
export function roleHasPermissions(
|
||||||
|
roleId: string,
|
||||||
|
required: readonly Permission[],
|
||||||
|
): boolean {
|
||||||
|
const granted = permissionsFor(roleId);
|
||||||
|
return required.every((p) => granted.has(p));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* preHandler role guard. Verifies the JWT (from the HttpOnly cookie), enforces
|
* preHandler permission guard. Verifies the JWT (from the HttpOnly cookie),
|
||||||
* CSRF on mutations, then checks the role. Authorization is a simple per-route
|
* enforces CSRF on mutations, then requires the user's role to grant ALL of the
|
||||||
* role check — no Casbin/RBAC engine needed at this scale.
|
* listed permissions. Authorization is a per-route permission check against the
|
||||||
|
* dynamic, admin-composed role grid — no Casbin/RBAC engine needed at this scale.
|
||||||
*/
|
*/
|
||||||
export function requireRole(...allowed: Role[]) {
|
export function requirePermission(...required: Permission[]) {
|
||||||
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||||
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
||||||
assertCsrf(req);
|
assertCsrf(req);
|
||||||
if (!req.user || !allowed.includes(req.user.role)) {
|
refreshRole(req);
|
||||||
|
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* preHandler guard satisfied by ANY ONE of the listed permissions — for a read that
|
||||||
|
* two jobs legitimately share (a module's master data: the desk that works with it
|
||||||
|
* reads it under the module's own permission, Setup reads it under site:read).
|
||||||
|
*/
|
||||||
|
export function requireAnyPermission(...anyOf: Permission[]) {
|
||||||
|
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||||
|
await req.jwtVerify();
|
||||||
|
assertCsrf(req);
|
||||||
|
refreshRole(req);
|
||||||
|
if (!req.user || !anyOf.some((p) => roleHasPermissions(req.user!.roleId, [p]))) {
|
||||||
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* preHandler that requires a valid signed-in session but NO specific permission —
|
||||||
|
* for "about me" routes (/me, change own language) every authenticated user may
|
||||||
|
* call regardless of role. Still enforces CSRF on mutations.
|
||||||
|
*/
|
||||||
|
export async function requireAuth(
|
||||||
|
req: FastifyRequest,
|
||||||
|
_reply: FastifyReply,
|
||||||
|
): Promise<void> {
|
||||||
|
await req.jwtVerify();
|
||||||
|
assertCsrf(req);
|
||||||
|
refreshRole(req);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { eq, siteConfig } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { BackupService } from "./backup-service.js";
|
||||||
|
|
||||||
|
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
|
||||||
|
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
|
||||||
|
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
|
||||||
|
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
|
||||||
|
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
|
||||||
|
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
|
||||||
|
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
|
||||||
|
// See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||||
|
|
||||||
|
let workDir: string;
|
||||||
|
let target: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
|
||||||
|
target = join(workDir, "target");
|
||||||
|
process.env.BACKUP_KEY = KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(workDir, { recursive: true, force: true });
|
||||||
|
delete process.env.BACKUP_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
|
||||||
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (existing) {
|
||||||
|
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BackupService — persisted status survives a restart", () => {
|
||||||
|
it("a fresh instance sees the previous instance's last success", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
expect(first.status().lastSuccessAt).toBeNull();
|
||||||
|
const result = await first.run("manual");
|
||||||
|
|
||||||
|
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
|
||||||
|
// production this would be a fresh process re-opening the same sqlite file).
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
const status = second.status();
|
||||||
|
expect(status.lastSuccessAt).not.toBeNull();
|
||||||
|
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
|
||||||
|
expect(status.lastError).toBeNull();
|
||||||
|
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
|
||||||
|
// throw, giving us a real, deterministic failure without needing to mock anything.
|
||||||
|
const badTarget = join(workDir, "not-a-dir");
|
||||||
|
writeFileSync(badTarget, "x");
|
||||||
|
setTargetDir(t.db, badTarget);
|
||||||
|
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
await expect(first.run("manual")).rejects.toThrow();
|
||||||
|
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
const status = second.status();
|
||||||
|
expect(status.lastError).not.toBeNull();
|
||||||
|
expect(status.lastErrorAt).not.toBeNull();
|
||||||
|
expect(status.lastSuccessAt).toBeNull();
|
||||||
|
|
||||||
|
// Now point at a real directory and succeed — the persisted error must clear.
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
await second.run("manual");
|
||||||
|
const third = new BackupService(t.db);
|
||||||
|
const finalStatus = third.status();
|
||||||
|
expect(finalStatus.lastSuccessAt).not.toBeNull();
|
||||||
|
expect(finalStatus.lastError).toBeNull();
|
||||||
|
expect(finalStatus.lastErrorAt).toBeNull();
|
||||||
|
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
|
||||||
|
it("is due immediately when no success has ever been recorded", () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
expect(svc.isDue()).toBe(true);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
await first.run("manual");
|
||||||
|
|
||||||
|
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
|
||||||
|
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
expect(second.isDue()).toBe(false);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is due once the persisted last-success timestamp is old enough", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
await svc.run("manual");
|
||||||
|
|
||||||
|
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
|
||||||
|
expect(svc.isDue(almostADayLater)).toBe(false);
|
||||||
|
|
||||||
|
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
|
||||||
|
expect(svc.isDue(overADayLater)).toBe(true);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
await svc.run("manual");
|
||||||
|
const afterFirst = svc.status().lastSuccessAt;
|
||||||
|
|
||||||
|
await svc.runScheduled(); // not due yet — must not run again
|
||||||
|
expect(svc.status().lastSuccessAt).toBe(afterFirst);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { constants } from "node:fs";
|
||||||
|
import { access, stat } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRetention } from "./backup.js";
|
||||||
|
|
||||||
|
// Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen
|
||||||
|
// and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI
|
||||||
|
// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) —
|
||||||
|
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
||||||
|
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||||
|
// wiki/concepts/backup-recovery.md.
|
||||||
|
//
|
||||||
|
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
|
||||||
|
// memory — an earlier version tracked these as plain in-process fields only, so every server
|
||||||
|
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
|
||||||
|
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
|
||||||
|
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||||
|
export function backupKeyFromEnv(): string {
|
||||||
|
return process.env.BACKUP_KEY ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TargetCheck {
|
||||||
|
readonly ok: boolean;
|
||||||
|
/** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */
|
||||||
|
readonly reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupStatus {
|
||||||
|
/** True once a target dir is set AND a usable key is present (else backups are a no-op). */
|
||||||
|
readonly configured: boolean;
|
||||||
|
/** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */
|
||||||
|
readonly targetDir: string | null;
|
||||||
|
/** Admin-tuned retention (resolved: DB value or code default) — surfaced for the UI form. */
|
||||||
|
readonly keepLast: number;
|
||||||
|
readonly keepDailyDays: number;
|
||||||
|
/** Whether the env key is present + long enough (the UI flags a missing key distinctly). */
|
||||||
|
readonly keyPresent: boolean;
|
||||||
|
readonly running: boolean;
|
||||||
|
readonly lastSuccessAt: string | null;
|
||||||
|
readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null;
|
||||||
|
readonly lastErrorAt: string | null;
|
||||||
|
readonly lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
|
||||||
|
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
|
||||||
|
const trimmed = dir.trim();
|
||||||
|
if (!trimmed) return { ok: false, reason: "empty" };
|
||||||
|
const path = resolve(trimmed);
|
||||||
|
let st: Awaited<ReturnType<typeof stat>>;
|
||||||
|
try {
|
||||||
|
st = await stat(path);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "missing" };
|
||||||
|
}
|
||||||
|
if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" };
|
||||||
|
try {
|
||||||
|
await access(path, constants.W_OK);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "not_writable" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BackupService {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger?: FastifyBaseLogger;
|
||||||
|
|
||||||
|
#running = false;
|
||||||
|
|
||||||
|
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
|
||||||
|
* or reset on restart). */
|
||||||
|
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
|
||||||
|
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#persist(patch: {
|
||||||
|
backupLastSuccessAt?: string | null;
|
||||||
|
backupLastResultJson?: string | null;
|
||||||
|
backupLastErrorAt?: string | null;
|
||||||
|
backupLastError?: string | null;
|
||||||
|
}): void {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const existing = this.#row();
|
||||||
|
if (existing) {
|
||||||
|
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||||
|
targetDir(): string | null {
|
||||||
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const dir = row?.backupTargetDir?.trim();
|
||||||
|
return dir ? dir : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved retention from site_config, falling back to the code default per field. Read fresh. */
|
||||||
|
retention(): BackupRetention {
|
||||||
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const keepLast = row?.backupKeepLast;
|
||||||
|
const keepDailyDays = row?.backupKeepDailyDays;
|
||||||
|
return {
|
||||||
|
keepLast: keepLast != null && keepLast >= 0 ? keepLast : DEFAULT_BACKUP_RETENTION.keepLast,
|
||||||
|
keepDailyDays:
|
||||||
|
keepDailyDays != null && keepDailyDays >= 0 ? keepDailyDays : DEFAULT_BACKUP_RETENTION.keepDailyDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get keyPresent(): boolean {
|
||||||
|
return backupKeyFromEnv().length >= 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
get configured(): boolean {
|
||||||
|
return this.targetDir() !== null && this.keyPresent;
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): BackupStatus {
|
||||||
|
const r = this.retention();
|
||||||
|
const row = this.#row();
|
||||||
|
let lastResult: BackupStatus["lastResult"] = null;
|
||||||
|
if (row?.backupLastResultJson) {
|
||||||
|
try {
|
||||||
|
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
|
||||||
|
} catch {
|
||||||
|
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configured: this.configured,
|
||||||
|
targetDir: this.targetDir(),
|
||||||
|
keepLast: r.keepLast,
|
||||||
|
keepDailyDays: r.keepDailyDays,
|
||||||
|
keyPresent: this.keyPresent,
|
||||||
|
running: this.#running,
|
||||||
|
lastSuccessAt: row?.backupLastSuccessAt ?? null,
|
||||||
|
lastResult,
|
||||||
|
lastErrorAt: row?.backupLastErrorAt ?? null,
|
||||||
|
lastError: row?.backupLastError ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one backup. `trigger` is just for the log line. Serialized: if one is already in
|
||||||
|
* flight, resolves to that same promise. Reads the target dir + key at run time. Records
|
||||||
|
* last-success/last-error. Re-throws on failure so a manual caller (the route) can surface
|
||||||
|
* it; the scheduled timer wraps + swallows.
|
||||||
|
*/
|
||||||
|
#inflight: Promise<BackupResult> | null = null;
|
||||||
|
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
|
||||||
|
if (this.#inflight) return this.#inflight;
|
||||||
|
const targetDir = this.targetDir();
|
||||||
|
const key = backupKeyFromEnv();
|
||||||
|
if (!targetDir) throw new Error("backup: no target directory configured");
|
||||||
|
if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
|
||||||
|
|
||||||
|
this.#running = true;
|
||||||
|
this.#inflight = (async () => {
|
||||||
|
try {
|
||||||
|
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||||
|
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||||
|
this.#persist({
|
||||||
|
backupLastSuccessAt: new Date().toISOString(),
|
||||||
|
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
|
||||||
|
backupLastErrorAt: null,
|
||||||
|
backupLastError: null,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
const message = (err as Error).message;
|
||||||
|
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
|
||||||
|
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.#running = false;
|
||||||
|
this.#inflight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return this.#inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
|
||||||
|
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
|
||||||
|
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
|
||||||
|
* frequent backups.
|
||||||
|
*/
|
||||||
|
async runScheduled(): Promise<void> {
|
||||||
|
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||||
|
if (!this.isDue()) return;
|
||||||
|
try {
|
||||||
|
await this.run("scheduled");
|
||||||
|
} catch {
|
||||||
|
/* recorded in last-error; already logged */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
|
||||||
|
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
|
||||||
|
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
|
||||||
|
* (or skips a whole day) across every restart, since the countdown restarts from zero each
|
||||||
|
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
|
||||||
|
*/
|
||||||
|
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
|
||||||
|
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
|
||||||
|
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
|
||||||
|
const last = new Date(lastSuccessAt).getTime();
|
||||||
|
if (Number.isNaN(last)) return true;
|
||||||
|
return now.getTime() - last >= intervalMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||||
|
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createTestDb, openRawDb } from "@parking/db/testing";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_BACKUP_RETENTION,
|
||||||
|
parseBackupStamp,
|
||||||
|
pruneOldBackups,
|
||||||
|
runBackup,
|
||||||
|
} from "./backup.js";
|
||||||
|
|
||||||
|
// Mirror of the engine's header layout, so the test decrypts independently (a real restore
|
||||||
|
// tool would do exactly this) rather than trusting the engine to also decrypt.
|
||||||
|
const MAGIC = Buffer.from("PKBK", "ascii");
|
||||||
|
const SALT_LEN = 16;
|
||||||
|
const IV_LEN = 12;
|
||||||
|
const TAG_LEN = 16;
|
||||||
|
|
||||||
|
function decryptBackup(enc: Buffer, key: string): Buffer {
|
||||||
|
expect(enc.subarray(0, 4)).toEqual(MAGIC);
|
||||||
|
expect(enc[4]).toBe(1); // format version
|
||||||
|
let off = 5;
|
||||||
|
const salt = enc.subarray(off, (off += SALT_LEN));
|
||||||
|
const iv = enc.subarray(off, (off += IV_LEN));
|
||||||
|
const tag = enc.subarray(enc.length - TAG_LEN);
|
||||||
|
const ciphertext = enc.subarray(off, enc.length - TAG_LEN);
|
||||||
|
const derived = scryptSync(key, salt, 32);
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", derived, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let workDir: string;
|
||||||
|
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workDir = mkdtempSync(join(tmpdir(), "pk-backup-test-"));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(workDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runBackup — round-trip", () => {
|
||||||
|
it("produces an encrypted backup that decrypts to a byte-identical, queryable DB", async () => {
|
||||||
|
// A real on-disk DB so the engine's better-sqlite3 .backup() runs for real.
|
||||||
|
const dbPath = join(workDir, "source.sqlite");
|
||||||
|
const t = createTestDb(dbPath);
|
||||||
|
// Put some recognizable data in.
|
||||||
|
t.sqlite.exec("CREATE TABLE marker (k TEXT PRIMARY KEY, v TEXT)");
|
||||||
|
t.sqlite.prepare("INSERT INTO marker (k, v) VALUES (?, ?)").run("hello", "world");
|
||||||
|
|
||||||
|
const targetDir = join(workDir, "target");
|
||||||
|
const res = await runBackup(t.db, { targetDir, key: KEY });
|
||||||
|
t.close();
|
||||||
|
|
||||||
|
expect(res.bytes).toBeGreaterThan(0);
|
||||||
|
expect(res.path).toMatch(/parking-backup-\d{8}T\d{6}Z\.sqlite\.enc$/);
|
||||||
|
|
||||||
|
// Decrypt independently and open the recovered DB raw (no migrations — verify as-written).
|
||||||
|
const plain = decryptBackup(readFileSync(res.path), KEY);
|
||||||
|
const restoredPath = join(workDir, "restored.sqlite");
|
||||||
|
writeFileSync(restoredPath, plain);
|
||||||
|
const restored = openRawDb(restoredPath);
|
||||||
|
const row = restored.prepare("SELECT v FROM marker WHERE k = ?").get("hello") as { v: string };
|
||||||
|
expect(row.v).toBe("world");
|
||||||
|
restored.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing/short key before touching the filesystem", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
await expect(runBackup(t.db, { targetDir: join(workDir, "t"), key: "short" })).rejects.toThrow(
|
||||||
|
/BACKUP_KEY/,
|
||||||
|
);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the plaintext scratch copy after a successful run", async () => {
|
||||||
|
const scratchDir = join(workDir, "scratch");
|
||||||
|
const t = createTestDb();
|
||||||
|
await runBackup(t.db, {
|
||||||
|
targetDir: join(workDir, "target"),
|
||||||
|
key: KEY,
|
||||||
|
scratchDir,
|
||||||
|
// Stub the copy so we don't need a file-backed handle here.
|
||||||
|
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "PRAGMA;"),
|
||||||
|
});
|
||||||
|
t.close();
|
||||||
|
// The only thing left in scratch must NOT be a .sqlite plaintext.
|
||||||
|
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
|
||||||
|
expect(left).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wipes the plaintext scratch copy even when the copy step fails", async () => {
|
||||||
|
const scratchDir = join(workDir, "scratch");
|
||||||
|
mkdirSync(scratchDir, { recursive: true });
|
||||||
|
const t = createTestDb();
|
||||||
|
// Force a failure: the copy step writes the plaintext, then throws (mid-pipeline). The
|
||||||
|
// finally{} must still remove the plaintext it left behind.
|
||||||
|
await expect(
|
||||||
|
runBackup(t.db, {
|
||||||
|
targetDir: join(workDir, "target"),
|
||||||
|
key: KEY,
|
||||||
|
scratchDir,
|
||||||
|
makeConsistentCopy: async (_db, dest) => {
|
||||||
|
writeFileSync(dest, "PRAGMA;"); // leave a plaintext intermediate…
|
||||||
|
throw new Error("simulated copy failure"); // …then fail
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/simulated copy failure/);
|
||||||
|
t.close();
|
||||||
|
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
|
||||||
|
expect(left).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("backup encryption — tamper evidence (AES-256-GCM)", () => {
|
||||||
|
it("a flipped ciphertext byte fails authentication on decrypt", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
const targetDir = join(workDir, "target");
|
||||||
|
const res = await runBackup(t.db, {
|
||||||
|
targetDir,
|
||||||
|
key: KEY,
|
||||||
|
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "the quick brown fox".repeat(100)),
|
||||||
|
});
|
||||||
|
t.close();
|
||||||
|
|
||||||
|
const enc = readFileSync(res.path);
|
||||||
|
// Flip a byte in the ciphertext region (after the header, before the tag).
|
||||||
|
enc[5 + SALT_LEN + IV_LEN + 3] ^= 0xff;
|
||||||
|
expect(() => decryptBackup(enc, KEY)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the wrong key fails authentication", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
const res = await runBackup(t.db, {
|
||||||
|
targetDir: join(workDir, "target"),
|
||||||
|
key: KEY,
|
||||||
|
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "payload".repeat(50)),
|
||||||
|
});
|
||||||
|
t.close();
|
||||||
|
expect(() => decryptBackup(readFileSync(res.path), "a-different-but-also-long-key-xx")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseBackupStamp", () => {
|
||||||
|
it("round-trips a stamped name and rejects non-backups", () => {
|
||||||
|
const d = parseBackupStamp("parking-backup-20260629T141503Z.sqlite.enc");
|
||||||
|
expect(d?.toISOString()).toBe("2026-06-29T14:15:03.000Z");
|
||||||
|
expect(parseBackupStamp("random.txt")).toBeNull();
|
||||||
|
expect(parseBackupStamp("parking-backup-not-a-date.sqlite.enc")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pruneOldBackups — keep-last-N + dailies", () => {
|
||||||
|
const day = 24 * 60 * 60 * 1000;
|
||||||
|
const now = new Date("2026-06-29T12:00:00Z");
|
||||||
|
|
||||||
|
function seed(stamps: string[]) {
|
||||||
|
const dir = join(workDir, "retain");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
for (const s of stamps) writeFileSync(join(dir, `parking-backup-${s}.sqlite.enc`), "x");
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
const stamp = (ms: number) =>
|
||||||
|
new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||||
|
|
||||||
|
it("keeps the keepLast newest regardless of age", async () => {
|
||||||
|
// 5 backups within the last hour; keepLast=3 → 2 pruned, even though all are recent.
|
||||||
|
const t = now.getTime();
|
||||||
|
const dir = seed([0, 1, 2, 3, 4].map((i) => stamp(t - i * 60 * 1000)));
|
||||||
|
const pruned = await pruneOldBackups(dir, { keepLast: 3, keepDailyDays: 0 }, now);
|
||||||
|
expect(pruned).toBe(2);
|
||||||
|
expect(readdirSync(dir).length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one-per-day within the daily window and drops older", async () => {
|
||||||
|
const t = now.getTime();
|
||||||
|
// Two backups today, one 5 days ago, one 40 days ago. keepLast=1, keepDailyDays=30.
|
||||||
|
const dir = seed([
|
||||||
|
stamp(t), // today A (newest → kept by keepLast)
|
||||||
|
stamp(t - 60 * 1000), // today B (same day as the kept one → pruned)
|
||||||
|
stamp(t - 5 * day), // 5 days ago (kept: within window, unique day)
|
||||||
|
stamp(t - 40 * day), // 40 days ago (pruned: outside the window)
|
||||||
|
]);
|
||||||
|
const pruned = await pruneOldBackups(dir, { keepLast: 1, keepDailyDays: 30 }, now);
|
||||||
|
expect(pruned).toBe(2);
|
||||||
|
const left = readdirSync(dir);
|
||||||
|
expect(left.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op on a missing target dir", async () => {
|
||||||
|
const pruned = await pruneOldBackups(join(workDir, "does-not-exist"), DEFAULT_BACKUP_RETENTION, now);
|
||||||
|
expect(pruned).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { createCipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||||
|
import { createReadStream, createWriteStream } from "node:fs";
|
||||||
|
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { basename, join, resolve } from "node:path";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
|
||||||
|
// On-site encrypted DB backup — the durability half of the anti-fraud design. The SQLite
|
||||||
|
// DB *is* the signed append-only ledger, so a disk failure / stolen-or-destroyed PC means
|
||||||
|
// total revenue-history loss. This produces a consistent, encrypted, restore-to-a-fresh-
|
||||||
|
// appliance copy. See wiki/concepts/backup-recovery.md.
|
||||||
|
//
|
||||||
|
// Two load-bearing properties:
|
||||||
|
// 1. CONSISTENT copy of a LIVE WAL-mode DB — via better-sqlite3's online .backup() (NOT a
|
||||||
|
// raw file copy, which can capture a torn WAL). The result must still verifyChain.
|
||||||
|
// 2. Encrypted with a DEDICATED key (BACKUP_KEY / park_buzi_backup_key), SEPARATE from
|
||||||
|
// EVENT_SIGNING_KEY — so the backup key can rotate without fracturing the signed chain,
|
||||||
|
// and a backup target never exposes the signing key. The key is NEVER written into the
|
||||||
|
// backup it unlocks.
|
||||||
|
//
|
||||||
|
// This module is the engine (consistent copy → encrypt → retention). Targets beyond a local/
|
||||||
|
// mounted path (SMB/NFS are just mount paths; SFTP) and the manual button/route are layered on
|
||||||
|
// top. RESTORE is intentionally NOT here — it's an out-of-band runbook action on a fresh box.
|
||||||
|
|
||||||
|
/** AES-256-GCM with a scrypt-derived key. Self-describing header so a restore tool needs only
|
||||||
|
* the key + the file. Layout: magic | version | salt(16) | iv(12) | ciphertext… | authTag(16). */
|
||||||
|
const MAGIC = Buffer.from("PKBK", "ascii"); // ParKing BacKup
|
||||||
|
const FORMAT_VERSION = 1;
|
||||||
|
const SALT_LEN = 16;
|
||||||
|
const IV_LEN = 12;
|
||||||
|
const TAG_LEN = 16;
|
||||||
|
const SCRYPT_KEYLEN = 32; // AES-256
|
||||||
|
|
||||||
|
export interface BackupRetention {
|
||||||
|
/** Keep at least this many most-recent backups regardless of age. */
|
||||||
|
readonly keepLast: number;
|
||||||
|
/** Beyond keepLast, keep one backup per day for this many days; older ones are pruned. */
|
||||||
|
readonly keepDailyDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code defaults — the fallback when the admin hasn't set a value in site_config (the source of
|
||||||
|
// truth). NOT env-driven: retention is operational policy tuned from the Backup screen.
|
||||||
|
export const DEFAULT_BACKUP_RETENTION: BackupRetention = {
|
||||||
|
keepLast: 7,
|
||||||
|
keepDailyDays: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BackupOptions {
|
||||||
|
/** Directory the encrypted backup is written to (a mounted local/USB/SATA/SMB/NFS path). */
|
||||||
|
readonly targetDir: string;
|
||||||
|
/** Encryption key (BACKUP_KEY / park_buzi_backup_key). ≥16 chars enforced. */
|
||||||
|
readonly key: string;
|
||||||
|
readonly retention?: BackupRetention;
|
||||||
|
/** Override the consistent-copy step (tests inject a fake to avoid a real sqlite handle). */
|
||||||
|
readonly makeConsistentCopy?: (db: Db, destPath: string) => Promise<void>;
|
||||||
|
/** Override "now" for deterministic filenames/retention in tests. */
|
||||||
|
readonly now?: () => Date;
|
||||||
|
/** Scratch dir for the intermediate plaintext copy (default os.tmpdir()). */
|
||||||
|
readonly scratchDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupResult {
|
||||||
|
/** Absolute path of the encrypted backup written. */
|
||||||
|
readonly path: string;
|
||||||
|
/** Size of the encrypted file in bytes. */
|
||||||
|
readonly bytes: number;
|
||||||
|
/** Backups pruned by the retention policy this run. */
|
||||||
|
readonly prunedFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filename convention: parking-backup-YYYYMMDDTHHMMSSZ.sqlite.enc — sortable, UTC, parseable. */
|
||||||
|
const FILE_PREFIX = "parking-backup-";
|
||||||
|
const FILE_SUFFIX = ".sqlite.enc";
|
||||||
|
|
||||||
|
function stampFor(d: Date): string {
|
||||||
|
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the UTC instant back out of a backup filename, or null if it doesn't match. */
|
||||||
|
export function parseBackupStamp(name: string): Date | null {
|
||||||
|
const base = basename(name);
|
||||||
|
if (!base.startsWith(FILE_PREFIX) || !base.endsWith(FILE_SUFFIX)) return null;
|
||||||
|
const stamp = base.slice(FILE_PREFIX.length, -FILE_SUFFIX.length);
|
||||||
|
// 20260629T141503Z → 2026-06-29T14:15:03Z
|
||||||
|
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
|
||||||
|
if (!m) return null;
|
||||||
|
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
||||||
|
const dt = new Date(iso);
|
||||||
|
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistent online copy of the live WAL-mode DB via better-sqlite3's native backup(). */
|
||||||
|
async function defaultConsistentCopy(db: Db, destPath: string): Promise<void> {
|
||||||
|
// db.$client is the raw better-sqlite3 Database; .backup() returns a promise and copies a
|
||||||
|
// transactionally-consistent snapshot even while the source is being written.
|
||||||
|
const client = db.$client as { backup: (dest: string) => Promise<unknown> };
|
||||||
|
await client.backup(destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt `srcPath` → `destPath` streaming, with the self-describing header. */
|
||||||
|
async function encryptFile(srcPath: string, destPath: string, key: string): Promise<void> {
|
||||||
|
const salt = randomBytes(SALT_LEN);
|
||||||
|
const iv = randomBytes(IV_LEN);
|
||||||
|
const derived = scryptSync(key, salt, SCRYPT_KEYLEN);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", derived, iv);
|
||||||
|
|
||||||
|
const out = createWriteStream(destPath);
|
||||||
|
const header = Buffer.concat([MAGIC, Buffer.from([FORMAT_VERSION]), salt, iv]);
|
||||||
|
out.write(header);
|
||||||
|
|
||||||
|
await pipeline(createReadStream(srcPath), cipher, out, { end: false });
|
||||||
|
// GCM auth tag is available only after the cipher has flushed; append it, then close.
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
await new Promise<void>((res, rej) => {
|
||||||
|
out.end(tag, () => res());
|
||||||
|
out.on("error", rej);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one backup: consistent copy → encrypt → prune old backups by retention.
|
||||||
|
* Best-effort caller-facing: throws on real failure (so a manual run surfaces the error),
|
||||||
|
* but the scheduled timer wraps it and logs.
|
||||||
|
*/
|
||||||
|
export async function runBackup(
|
||||||
|
db: Db,
|
||||||
|
opts: BackupOptions,
|
||||||
|
logger?: FastifyBaseLogger,
|
||||||
|
): Promise<BackupResult> {
|
||||||
|
if (!opts.key || opts.key.length < 16) {
|
||||||
|
throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
|
||||||
|
}
|
||||||
|
const now = opts.now ?? (() => new Date());
|
||||||
|
const retention = opts.retention ?? DEFAULT_BACKUP_RETENTION;
|
||||||
|
const targetDir = resolve(opts.targetDir);
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const stamp = stampFor(now());
|
||||||
|
const finalPath = join(targetDir, `${FILE_PREFIX}${stamp}${FILE_SUFFIX}`);
|
||||||
|
|
||||||
|
// Intermediate plaintext copy in scratch (NOT the target dir — the target may be a network
|
||||||
|
// share / removable disk; keep the plaintext local and short-lived, then wipe it).
|
||||||
|
const scratch = opts.scratchDir ?? tmpdir();
|
||||||
|
await mkdir(scratch, { recursive: true });
|
||||||
|
const plainPath = join(scratch, `${FILE_PREFIX}${stamp}.sqlite`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copy = opts.makeConsistentCopy ?? defaultConsistentCopy;
|
||||||
|
await copy(db, plainPath);
|
||||||
|
await encryptFile(plainPath, finalPath, opts.key);
|
||||||
|
} finally {
|
||||||
|
// Always wipe the plaintext intermediate, success or fail — it's the unencrypted ledger.
|
||||||
|
await rm(plainPath, { force: true }).catch((err) =>
|
||||||
|
logger?.warn(`backup: failed to remove plaintext scratch copy: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { size } = await stat(finalPath);
|
||||||
|
const prunedFiles = await pruneOldBackups(targetDir, retention, now());
|
||||||
|
logger?.info(
|
||||||
|
`backup: wrote ${basename(finalPath)} (${(size / 1048576).toFixed(1)} MB)` +
|
||||||
|
(prunedFiles > 0 ? `, pruned ${prunedFiles} old` : ""),
|
||||||
|
);
|
||||||
|
return { path: finalPath, bytes: size, prunedFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retention: keep the `keepLast` most-recent backups always; beyond those, keep at most one
|
||||||
|
* backup per UTC day for `keepDailyDays` days; delete anything older or any extra same-day
|
||||||
|
* duplicates outside the keepLast window. Returns the count deleted.
|
||||||
|
*/
|
||||||
|
export async function pruneOldBackups(
|
||||||
|
targetDir: string,
|
||||||
|
retention: BackupRetention,
|
||||||
|
now: Date,
|
||||||
|
): Promise<number> {
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = await readdir(targetDir);
|
||||||
|
} catch {
|
||||||
|
return 0; // target gone/unmounted — nothing to prune (the write would have failed first)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = names
|
||||||
|
.map((n) => ({ name: n, at: parseBackupStamp(n) }))
|
||||||
|
.filter((b): b is { name: string; at: Date } => b.at !== null)
|
||||||
|
.sort((a, b) => b.at.getTime() - a.at.getTime()); // newest first
|
||||||
|
|
||||||
|
const keep = new Set<string>();
|
||||||
|
// 1. Always keep the keepLast newest.
|
||||||
|
for (const b of backups.slice(0, Math.max(0, retention.keepLast))) keep.add(b.name);
|
||||||
|
|
||||||
|
// 2. Beyond that, keep the newest per UTC day within the keepDailyDays window.
|
||||||
|
const cutoff = now.getTime() - retention.keepDailyDays * 24 * 60 * 60 * 1000;
|
||||||
|
const seenDays = new Set<string>();
|
||||||
|
for (const b of backups) {
|
||||||
|
if (keep.has(b.name)) {
|
||||||
|
seenDays.add(b.at.toISOString().slice(0, 10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (b.at.getTime() < cutoff) continue; // too old → not kept
|
||||||
|
const day = b.at.toISOString().slice(0, 10);
|
||||||
|
if (seenDays.has(day)) continue; // already have a backup for this day → prune the extra
|
||||||
|
seenDays.add(day);
|
||||||
|
keep.add(b.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pruned = 0;
|
||||||
|
for (const b of backups) {
|
||||||
|
if (keep.has(b.name)) continue;
|
||||||
|
await rm(join(targetDir, b.name), { force: true });
|
||||||
|
pruned += 1;
|
||||||
|
}
|
||||||
|
return pruned;
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
printWithFailover,
|
||||||
|
registry,
|
||||||
|
type PrinterDevice,
|
||||||
|
type PrinterInstance,
|
||||||
|
type ReceiptData,
|
||||||
|
type TicketHeader,
|
||||||
|
printerRoleOf,
|
||||||
|
} from "@parking/devices";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { devicesByDirection } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
|
||||||
|
// from the exit, the customer pays at the booth and walks a printed voucher to the
|
||||||
|
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
|
||||||
|
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
|
||||||
|
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
|
||||||
|
//
|
||||||
|
// This mirrors the entry flow's printer selection + header build, but prints on the
|
||||||
|
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
|
||||||
|
|
||||||
|
/** Park identity for the voucher header, from site_config (all fields optional). */
|
||||||
|
function ticketHeader(db: Db): TicketHeader | undefined {
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (!row) return undefined;
|
||||||
|
return {
|
||||||
|
parkName: row.parkName,
|
||||||
|
operatorName: row.operatorName,
|
||||||
|
nius: row.nius,
|
||||||
|
address: row.address,
|
||||||
|
phone: row.phone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build live printer instances for failover selection (entry direction covers the
|
||||||
|
* booth-receipt role too — the booth printer is configured on the entry side). */
|
||||||
|
function loadPrinters(db: Db): PrinterInstance[] {
|
||||||
|
const rows = devicesByDirection(db, "printer", "entry");
|
||||||
|
const out: PrinterInstance[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) continue;
|
||||||
|
const cfg = row.config as Record<string, unknown>;
|
||||||
|
const role = printerRoleOf(cfg);
|
||||||
|
try {
|
||||||
|
out.push({
|
||||||
|
id: row.id,
|
||||||
|
role,
|
||||||
|
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
||||||
|
device: driver.create(cfg as never) as PrinterDevice,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// skip a printer whose config won't build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The receipt figures for a paid session, folded from the SIGNED ledger
|
||||||
|
* (authoritative). Null if there's no entry or no payment for this id — the
|
||||||
|
* caller should have validated paid + open before printing. */
|
||||||
|
function receiptFigures(
|
||||||
|
db: Db,
|
||||||
|
ticketId: string,
|
||||||
|
): Omit<ReceiptData, "voucher" | "header"> | null {
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, ticketId))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return null;
|
||||||
|
// The LATEST payment is the one we receipt (an overstay top-up re-pays).
|
||||||
|
let payment: (typeof rows)[number] | undefined;
|
||||||
|
for (const r of rows) if (r.type === "payment") payment = r;
|
||||||
|
if (!payment) return null;
|
||||||
|
const p = (payment.payload ?? {}) as {
|
||||||
|
amountMinor?: number;
|
||||||
|
currency?: string;
|
||||||
|
tender?: "cash" | "card";
|
||||||
|
graceExitMin?: number;
|
||||||
|
grossMinor?: number;
|
||||||
|
validationLines?: { label: string; discountMinor: number }[];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ticketId,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
paidAt: payment.occurredAt,
|
||||||
|
amountMinor: typeof p.amountMinor === "number" ? p.amountMinor : 0,
|
||||||
|
currency: p.currency ?? "ALL",
|
||||||
|
tender: p.tender === "card" ? "card" : "cash",
|
||||||
|
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
||||||
|
// Merchant validations, as settled on the signed payment (gross → lines → net).
|
||||||
|
grossMinor: typeof p.grossMinor === "number" ? p.grossMinor : null,
|
||||||
|
validationLines: Array.isArray(p.validationLines) ? p.validationLines : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print a PAYMENT RECEIPT for a paid session on the booth printer (failing over
|
||||||
|
* to the entry dispenser). The receipt is the customer's transparency record:
|
||||||
|
* entry time, payment time, duration, amount + tender — folded from the signed
|
||||||
|
* ledger. In VOUCHER mode it also carries the scannable ticket-id barcode + the
|
||||||
|
* walk-back grace, so the one slip both proves payment AND self-exits at a
|
||||||
|
* distant exit reader (this replaces the old barcode-only voucher). In standalone
|
||||||
|
* mode (`voucher:false`) it is detail-only, printed at payment when the booth is
|
||||||
|
* at the exit. Returns the id of the printer that printed it.
|
||||||
|
* Throws NoPrinterAvailableError if none can; throws if the session isn't payable.
|
||||||
|
*/
|
||||||
|
export async function printPaymentReceipt(
|
||||||
|
db: Db,
|
||||||
|
ticketId: string,
|
||||||
|
opts: { voucher: boolean },
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): Promise<string> {
|
||||||
|
const figures = receiptFigures(db, ticketId);
|
||||||
|
if (!figures) {
|
||||||
|
throw new Error(`no paid session to receipt for ${ticketId}`);
|
||||||
|
}
|
||||||
|
const printers = loadPrinters(db);
|
||||||
|
const data: ReceiptData = {
|
||||||
|
...figures,
|
||||||
|
voucher: opts.voucher,
|
||||||
|
header: ticketHeader(db),
|
||||||
|
};
|
||||||
|
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
|
||||||
|
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
||||||
|
d.printReceipt(data),
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`${opts.voucher ? "exit voucher" : "payment receipt"} for ${ticketId} printed on ${printedBy}`,
|
||||||
|
);
|
||||||
|
return printedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
|
||||||
|
* a scannable QR of the credential code + holder/validity, so the operator can hand
|
||||||
|
* it to the customer. Used on subscription creation and on a "reprint" action.
|
||||||
|
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
|
||||||
|
*/
|
||||||
|
export async function printSubscriptionCard(
|
||||||
|
db: Db,
|
||||||
|
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): Promise<string> {
|
||||||
|
const printers = loadPrinters(db);
|
||||||
|
const data = {
|
||||||
|
code: card.code,
|
||||||
|
holderName: card.holderName ?? null,
|
||||||
|
validFrom: card.validFrom ?? null,
|
||||||
|
validTo: card.validTo ?? null,
|
||||||
|
header: ticketHeader(db),
|
||||||
|
};
|
||||||
|
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
||||||
|
d.printSubscriptionCard(data),
|
||||||
|
);
|
||||||
|
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
|
||||||
|
return printedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print an ADVISORY "out-of-window" slip when a subscriber enters (or exits) outside
|
||||||
|
* their plan's allowed hours. It is NOT a payable ticket and carries NO final amount —
|
||||||
|
* the total is computed at the booth on settlement (early-entry AND any late-exit time
|
||||||
|
* combined). It just gives the subscriber paper proof that a fee is pending against this
|
||||||
|
* occurrence. Albanian (like every customer-facing slip — see i18n.md). Best-effort:
|
||||||
|
* the caller swallows failures so a missing printer never blocks the barrier.
|
||||||
|
*/
|
||||||
|
export async function printWindowChargeNotice(
|
||||||
|
db: Db,
|
||||||
|
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number | null; edge: "entry" | "exit" },
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): Promise<string> {
|
||||||
|
const printers = loadPrinters(db);
|
||||||
|
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
||||||
|
d.printWindowChargeNotice({
|
||||||
|
occurrenceId: notice.occurrenceId,
|
||||||
|
holderName: notice.holderName ?? null,
|
||||||
|
at: notice.at,
|
||||||
|
edge: notice.edge,
|
||||||
|
windowOpensMin: notice.windowOpensMin ?? null,
|
||||||
|
header: ticketHeader(db),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
|
||||||
|
return printedBy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
|
||||||
|
// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
|
||||||
|
// trigger active + lane busy -> SOLID on
|
||||||
|
// trigger active + lane free -> BLINK (~1 Hz)
|
||||||
|
// otherwise -> OFF
|
||||||
|
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may
|
||||||
|
// carry several alert relays (each its own row + trigger input), keyed independently.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CONTROLLER = "ctl-1";
|
||||||
|
const RADAR_INPUT = 2; // I2
|
||||||
|
const LAMP_RELAY = 3; // spare relay R3
|
||||||
|
|
||||||
|
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
|
||||||
|
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
|
||||||
|
return {
|
||||||
|
async setAux(channel: number, on: boolean): Promise<void> {
|
||||||
|
if (throwOnce.v) {
|
||||||
|
throwOnce.v = false;
|
||||||
|
throw new Error("UDP down");
|
||||||
|
}
|
||||||
|
record.push({ ch: channel, on });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CONTROLLER,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emit a radar (presence input) edge for the controller. */
|
||||||
|
function radar(present: boolean): void {
|
||||||
|
deviceEvents.emitInput({
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CONTROLLER,
|
||||||
|
input: RADAR_INPUT,
|
||||||
|
edge: present ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit a lane status (entry busy/free). */
|
||||||
|
function lane(entryBusy: boolean): void {
|
||||||
|
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
|
||||||
|
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
|
||||||
|
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ButtonLightController truth table", () => {
|
||||||
|
it("OFF at start (no radar, no car)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
|
||||||
|
// device is confirmed OFF and at most one call was made.
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane busy -> SOLID on", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
|
||||||
|
// Solid = no blinking: advancing time produces no further sends.
|
||||||
|
const n = calls.length;
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // lane still free
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // blink
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
lane(true); // camera confirms
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
// No more toggles (blink torn down) — the device stays ON over time.
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar clears -> OFF", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid
|
||||||
|
await flush();
|
||||||
|
radar(false); // car gone
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes redundant writes (no spam on repeat events)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid, on
|
||||||
|
await flush();
|
||||||
|
const n = calls.length;
|
||||||
|
radar(true); // same state — no new edge (present unchanged)
|
||||||
|
lane(true); // same lane — no change
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails OFF: a setAux error does not throw or escalate", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const throwOnce = { v: true };
|
||||||
|
const aux = fakeAux(calls, throwOnce);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// First write (initial off) throws — must be swallowed.
|
||||||
|
expect(() => ctl.start()).not.toThrow();
|
||||||
|
await flush();
|
||||||
|
// The failure arms a backoff (1s) rather than retrying inline; desired-state
|
||||||
|
// changes during the window just update the target the retry will assert.
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBeNull(); // still backing off
|
||||||
|
await vi.advanceTimersByTimeAsync(1000); // retry fires; aux is healthy again
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // converged to solid ON
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unreachable controller backs off (1s→30s), not a hot retry loop", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const aux: AuxOutputDevice = {
|
||||||
|
async setAux() {
|
||||||
|
attempts += 1;
|
||||||
|
throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const errors: string[] = [];
|
||||||
|
const logger = silentLogger();
|
||||||
|
(logger as { error: (msg: string) => void }).error = (msg) => errors.push(msg);
|
||||||
|
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||||
|
ctl.start(); // initial OFF write → attempt 1 fails at t=0
|
||||||
|
await flush();
|
||||||
|
expect(attempts).toBe(1); // the old code hot-looped here
|
||||||
|
|
||||||
|
// Failures at t≈0,1,3,7,15,31 (doubling, capped 30s) → 6 attempts in the first
|
||||||
|
// minute instead of thousands.
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(attempts).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(attempts).toBeLessThanOrEqual(7);
|
||||||
|
|
||||||
|
// Only the FIRST failure was logged so far; the next log is a ≥60s summary.
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(35_000); // t≈95s → the t=61s attempt logged a summary
|
||||||
|
expect(errors.length).toBe(2);
|
||||||
|
expect(errors[1]).toContain("still failing");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a single recovery line and resets the backoff after success", async () => {
|
||||||
|
let failing = true;
|
||||||
|
let attempts = 0;
|
||||||
|
const aux: AuxOutputDevice = {
|
||||||
|
async setAux() {
|
||||||
|
attempts += 1;
|
||||||
|
if (failing) throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const infos: string[] = [];
|
||||||
|
const logger = silentLogger();
|
||||||
|
(logger as { info: (msg: string) => void }).info = (msg) => infos.push(msg);
|
||||||
|
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
await vi.advanceTimersByTimeAsync(3_000); // attempts at t=0,1,3 all fail
|
||||||
|
const failed = attempts;
|
||||||
|
expect(failed).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
failing = false; // controller reachable again
|
||||||
|
await vi.advanceTimersByTimeAsync(8_000); // next armed retry succeeds
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // OFF asserted on the device
|
||||||
|
expect(infos.filter((m) => m.includes("recovered"))).toHaveLength(1);
|
||||||
|
|
||||||
|
// Backoff reset: a fresh state change sends immediately (no lingering retryAt).
|
||||||
|
const before = attempts;
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
expect(attempts).toBe(before + 1);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores controllers without an alert relay", () => {
|
||||||
|
// A second controller, no alert relay.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "ctl-2",
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
expect(ctl.stateOf("ctl-2")).toBeNull();
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
|
||||||
|
// Fresh controller with a radar input but NO alert relay yet.
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// Replace the seeded controller with one that has the radar but no lamp.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
// No lamp configured → an input does nothing.
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBeNull();
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
radar(false);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Admin saves an alert relay (relay 3, trigger I2) — without restarting the server.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// The very next radar edge reconciles + blinks (lane still free).
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drives two alert relays on one controller independently", async () => {
|
||||||
|
const R3 = 3;
|
||||||
|
const R4 = 4;
|
||||||
|
const I2 = 2;
|
||||||
|
const I3 = 3;
|
||||||
|
// Controller with two alert lamps, each on its own trigger input.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" },
|
||||||
|
{ relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
{ relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||||
|
|
||||||
|
// I2 active → only R3 blinks; R4 stays off (different trigger).
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||||
|
|
||||||
|
// I3 active → R4 blinks too, independently.
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// Camera confirms a car → BOTH lock solid (lane-busy is site-wide).
|
||||||
|
lane(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
|
||||||
|
// I2 clears → R3 off, R4 still solid (its trigger still active).
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => {
|
||||||
|
const R4 = 4;
|
||||||
|
const I5 = 5; // exit radar
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
// Exit alert lamp: triggers on the exit radar, locks on the EXIT camera.
|
||||||
|
{ relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
const aux = fakeAux([]);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Exit radar active → blink.
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// ENTRY camera busy must NOT lock this exit lamp — it still blinks.
|
||||||
|
deviceEvents.emitLaneStatus({ entry: true, exit: false });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// EXIT camera busy → SOLID.
|
||||||
|
deviceEvents.emitLaneStatus({ entry: true, exit: true });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
|
||||||
|
import { alertRelaysOf, relayForPresence, type RelaySpec } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V
|
||||||
|
// light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger
|
||||||
|
// input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement
|
||||||
|
// indicator:
|
||||||
|
// trigger active + lane busy (camera confirms a car) → SOLID on
|
||||||
|
// trigger active + lane free (radar sees something, no car) → BLINK (~1 Hz)
|
||||||
|
// otherwise → OFF
|
||||||
|
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
|
||||||
|
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
|
||||||
|
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
|
||||||
|
// misleading solid "go". A controller may have several alert relays (each its own row +
|
||||||
|
// trigger input), keyed independently. See wiki/concepts/button-light-indicator.md.
|
||||||
|
|
||||||
|
type LightState = "off" | "solid" | "blink";
|
||||||
|
|
||||||
|
const DEFAULT_BLINK_MS = 500;
|
||||||
|
|
||||||
|
// Failed-send retry backoff: 1s doubling to 30s, reset on success. Without this an
|
||||||
|
// unreachable controller (ENETUNREACH) became a hot loop — the failure re-pump retried
|
||||||
|
// instantly, thousands of sends + error lines per minute (field incident 2026-07-07).
|
||||||
|
const RETRY_BASE_MS = 1_000;
|
||||||
|
const RETRY_MAX_MS = 30_000;
|
||||||
|
/** After the first failure of a streak, log at most one summary line per this window. */
|
||||||
|
const FAIL_LOG_EVERY_MS = 60_000;
|
||||||
|
|
||||||
|
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
||||||
|
interface LampState {
|
||||||
|
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
||||||
|
readonly controllerId: string;
|
||||||
|
/** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in
|
||||||
|
* place when the admin changes the alert config without a restart. */
|
||||||
|
spec: RelaySpec;
|
||||||
|
/** Is the lamp's trigger input (the radar) currently active? */
|
||||||
|
present: boolean;
|
||||||
|
/** The high-level state we're rendering (to avoid restarting a running blink). */
|
||||||
|
rendered: LightState | null;
|
||||||
|
/** Active blink timer, if blinking. */
|
||||||
|
blink: ReturnType<typeof setInterval> | null;
|
||||||
|
/** Blink phase (true = currently on). */
|
||||||
|
blinkOn: boolean;
|
||||||
|
/** The output we WANT the relay to be in. The serialized worker drives the device
|
||||||
|
* toward this. The blink timer only flips this flag — it never sends directly. */
|
||||||
|
desiredOn: boolean;
|
||||||
|
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
|
||||||
|
confirmedOn: boolean | null;
|
||||||
|
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||||
|
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||||
|
sending: boolean;
|
||||||
|
/** Consecutive failed sends (0 = healthy). Drives the backoff delay + log summaries. */
|
||||||
|
failCount: number;
|
||||||
|
/** Epoch ms before which #pump must not send (0 = no backoff). The armed retry
|
||||||
|
* timer re-pumps when it elapses; desired-state changes in between just update
|
||||||
|
* `desiredOn` and are picked up by that same retry. */
|
||||||
|
retryAt: number;
|
||||||
|
/** The armed backoff retry, if any. */
|
||||||
|
retryTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
/** Epoch ms of the last failure line we actually logged (rate-limits the flood). */
|
||||||
|
lastFailLogAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||||
|
* driver registry; tests inject a spy. Returns null when the controller has no
|
||||||
|
* aux-output capability (or won't build). */
|
||||||
|
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
|
||||||
|
|
||||||
|
export class ButtonLightController {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #resolveAux: AuxResolver;
|
||||||
|
/** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */
|
||||||
|
readonly #lamps = new Map<string, LampState>();
|
||||||
|
/** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks
|
||||||
|
* SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the
|
||||||
|
* exit camera, not the entry one. */
|
||||||
|
#entryBusy = false;
|
||||||
|
#exitBusy = false;
|
||||||
|
/** Controllers we've already warned lack the aux-output capability (warn once). */
|
||||||
|
readonly #warned = new Set<string>();
|
||||||
|
#unsubInput: (() => void) | null = null;
|
||||||
|
#unsubLane: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
|
||||||
|
start(): void {
|
||||||
|
this.#reconcile();
|
||||||
|
// All lamps start OFF (known-safe baseline) regardless of prior device state.
|
||||||
|
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||||
|
|
||||||
|
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
|
||||||
|
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
|
||||||
|
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
|
||||||
|
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
|
||||||
|
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
|
||||||
|
* before handling each event, so a just-saved lamp takes effect immediately. */
|
||||||
|
#reconcile(): void {
|
||||||
|
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.enabled) continue;
|
||||||
|
for (const spec of alertRelaysOf(row)) {
|
||||||
|
const key = lampKey(row.id, spec.relay);
|
||||||
|
seen.add(key);
|
||||||
|
const existing = this.#lamps.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.spec = spec; // pick up a changed trigger input / blink cadence
|
||||||
|
} else {
|
||||||
|
this.#lamps.set(key, {
|
||||||
|
controllerId: row.id,
|
||||||
|
spec,
|
||||||
|
present: false,
|
||||||
|
rendered: null,
|
||||||
|
blink: null,
|
||||||
|
blinkOn: false,
|
||||||
|
desiredOn: false,
|
||||||
|
confirmedOn: null,
|
||||||
|
sending: false,
|
||||||
|
failCount: 0,
|
||||||
|
retryAt: 0,
|
||||||
|
retryTimer: null,
|
||||||
|
lastFailLogAt: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||||
|
for (const [key, lamp] of this.#lamps) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
this.#disarm(lamp);
|
||||||
|
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
||||||
|
this.#lamps.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
|
||||||
|
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
|
||||||
|
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
|
||||||
|
this.#reconcile();
|
||||||
|
const present = e.edge === "on";
|
||||||
|
for (const lamp of this.#lamps.values()) {
|
||||||
|
if (lamp.controllerId !== e.deviceId) continue;
|
||||||
|
// A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's
|
||||||
|
// entry-relay presence terminal (resolved the SAME way the entry flow does) so the
|
||||||
|
// lamp and the one-car-one-ticket gate always agree on "a car is here".
|
||||||
|
const trigger =
|
||||||
|
lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput;
|
||||||
|
if (trigger !== e.input) continue; // not this lamp's trigger terminal
|
||||||
|
if (present === lamp.present) continue;
|
||||||
|
lamp.present = present;
|
||||||
|
this.#apply(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */
|
||||||
|
#onLane(s: LaneStatusEvent): void {
|
||||||
|
if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return;
|
||||||
|
this.#entryBusy = s.entry;
|
||||||
|
this.#exitBusy = s.exit;
|
||||||
|
// Re-render every lamp (each picks its own lane's camera in #apply).
|
||||||
|
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
|
||||||
|
* timer/state machine is synchronous; the UDP write resolves on its own). */
|
||||||
|
#apply(lamp: LampState): void {
|
||||||
|
// SOLID only once THIS lamp's lane camera confirms a car (default entry).
|
||||||
|
const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy;
|
||||||
|
const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink";
|
||||||
|
if (target === lamp.rendered) return; // already rendering this state
|
||||||
|
|
||||||
|
// Tear down any running blink before switching states.
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
lamp.rendered = target;
|
||||||
|
|
||||||
|
if (target === "off") {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(lamp);
|
||||||
|
} else if (target === "solid") {
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
this.#pump(lamp);
|
||||||
|
} else {
|
||||||
|
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
|
||||||
|
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
|
||||||
|
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
|
||||||
|
// relay can't get stuck on a stale packet.
|
||||||
|
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
|
||||||
|
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
|
||||||
|
lamp.blinkOn = true;
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
const tick = () => {
|
||||||
|
lamp.blinkOn = !lamp.blinkOn;
|
||||||
|
lamp.desiredOn = lamp.blinkOn;
|
||||||
|
this.#pump(lamp);
|
||||||
|
if (onMs !== offMs && lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lamp.blink = setInterval(tick, onMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
this.#pump(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
|
||||||
|
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||||
|
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||||
|
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
||||||
|
* so the LAST desired state is always the one finally asserted on the device.
|
||||||
|
*
|
||||||
|
* Failures back off (1s → 30s, reset on success) instead of retrying inline: an
|
||||||
|
* unreachable controller rejects instantly, and an immediate re-pump was a hot loop.
|
||||||
|
* During backoff `desiredOn` keeps tracking the truth table; the armed retry timer
|
||||||
|
* converges to whatever it says when it fires. Only the FIRST failure of a streak is
|
||||||
|
* logged, then one summary per minute, and an info line on recovery. */
|
||||||
|
#pump(lamp: LampState): void {
|
||||||
|
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
||||||
|
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
||||||
|
if (Date.now() < lamp.retryAt) return; // backing off — the retry timer will re-pump
|
||||||
|
const aux = this.#resolveAux(lamp.controllerId);
|
||||||
|
if (!aux) return;
|
||||||
|
const target = lamp.desiredOn;
|
||||||
|
lamp.sending = true;
|
||||||
|
void aux
|
||||||
|
.setAux(lamp.spec.relay, target)
|
||||||
|
.then(() => {
|
||||||
|
lamp.confirmedOn = target;
|
||||||
|
if (lamp.failCount > 0) {
|
||||||
|
this.#logger.info(
|
||||||
|
`button-light setAux recovered (${lamp.controllerId} R${lamp.spec.relay}) after ${lamp.failCount} failed attempts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lamp.failCount = 0;
|
||||||
|
lamp.retryAt = 0;
|
||||||
|
lamp.lastFailLogAt = 0;
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// Leave confirmedOn unchanged so the armed retry re-asserts the (then-current)
|
||||||
|
// desired state. Never escalates — a dead lamp is "no hint", never a fault.
|
||||||
|
lamp.failCount += 1;
|
||||||
|
const delay = Math.min(RETRY_BASE_MS * 2 ** (lamp.failCount - 1), RETRY_MAX_MS);
|
||||||
|
lamp.retryAt = Date.now() + delay;
|
||||||
|
const now = Date.now();
|
||||||
|
if (lamp.failCount === 1 || now - lamp.lastFailLogAt >= FAIL_LOG_EVERY_MS) {
|
||||||
|
lamp.lastFailLogAt = now;
|
||||||
|
const streak =
|
||||||
|
lamp.failCount > 1 ? ` — still failing (attempt ${lamp.failCount}, retrying ≤${RETRY_MAX_MS / 1000}s)` : "";
|
||||||
|
this.#logger.error(
|
||||||
|
`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}${streak}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lamp.retryTimer) clearTimeout(lamp.retryTimer);
|
||||||
|
lamp.retryTimer = setTimeout(() => {
|
||||||
|
lamp.retryTimer = null;
|
||||||
|
this.#pump(lamp);
|
||||||
|
}, delay);
|
||||||
|
lamp.retryTimer.unref?.();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
lamp.sending = false;
|
||||||
|
// Desired state may have changed while we were busy — re-pump to converge (the
|
||||||
|
// backoff gate above makes this a no-op right after a failure). This is what
|
||||||
|
// makes the final state authoritative.
|
||||||
|
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the live aux-output adapter for a controller, or null (logged once). */
|
||||||
|
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
|
||||||
|
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
|
||||||
|
if (!row) return null;
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
let device: unknown;
|
||||||
|
try {
|
||||||
|
device = driver.create(row.config as never);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!hasAuxOutput(device)) {
|
||||||
|
if (!this.#warned.has(controllerId)) {
|
||||||
|
this.#warned.add(controllerId);
|
||||||
|
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsubInput?.();
|
||||||
|
this.#unsubLane?.();
|
||||||
|
this.#unsubInput = null;
|
||||||
|
this.#unsubLane = null;
|
||||||
|
for (const lamp of this.#lamps.values()) {
|
||||||
|
this.#disarm(lamp);
|
||||||
|
// Best-effort fail-OFF on shutdown.
|
||||||
|
this.#finalOff(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop a lamp's timers (blink + backoff retry) without touching the device. */
|
||||||
|
#disarm(lamp: LampState): void {
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
if (lamp.retryTimer) {
|
||||||
|
clearTimeout(lamp.retryTimer);
|
||||||
|
lamp.retryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
||||||
|
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
||||||
|
* in-flight send — it converges to OFF. Any backoff is waived so the last-gasp OFF
|
||||||
|
* gets one immediate try (a lamp mid-backoff may just have recovered). */
|
||||||
|
#finalOff(lamp: LampState): void {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
lamp.retryAt = 0;
|
||||||
|
this.#pump(lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: current high-level state being rendered for a lamp (controller + relay).
|
||||||
|
* `relay` defaults to the controller's only/first alert relay for single-lamp tests. */
|
||||||
|
stateOf(controllerId: string, relay?: number): LightState | null {
|
||||||
|
return this.#lamp(controllerId, relay)?.rendered ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: the state last CONFIRMED on the device for a lamp (after a successful
|
||||||
|
* send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */
|
||||||
|
confirmedOf(controllerId: string, relay?: number): boolean | null {
|
||||||
|
return this.#lamp(controllerId, relay)?.confirmedOn ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a lamp by controller + relay. When `relay` is omitted, returns the
|
||||||
|
* controller's single lamp (the common single-alert case); ambiguous if several. */
|
||||||
|
#lamp(controllerId: string, relay?: number): LampState | undefined {
|
||||||
|
if (relay != null) return this.#lamps.get(lampKey(controllerId, relay));
|
||||||
|
for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composite key for the lamp map (a controller may carry several alert relays). */
|
||||||
|
function lampKey(controllerId: string, relay: number): string {
|
||||||
|
return `${controllerId}:${relay}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a controller row's live aux device (exported for reuse/tests). */
|
||||||
|
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
const device = driver.create(row.config as never);
|
||||||
|
return hasAuxOutput(device) ? device : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Credential capture ("enroll a card"): lets an operator present a physical RFID
|
||||||
|
// card/chip (or a QR) to ONE chosen reader and have its value captured for a
|
||||||
|
// subscription credential, instead of typing it. SINGLE-SHOT + short TTL so the
|
||||||
|
// chosen reader is only "borrowed" for one read / a few seconds; the OTHER reader is
|
||||||
|
// never affected and keeps serving the live entry/exit flow.
|
||||||
|
//
|
||||||
|
// Flow: arm(deviceId) → the reader route checks tryConsume() on each read; the next
|
||||||
|
// read from that armed reader is captured (NOT dispatched to the access flow — the
|
||||||
|
// barrier must not open for a card being enrolled) and capture auto-disarms. The
|
||||||
|
// booth form polls result() until the value appears (or it times out / is cancelled).
|
||||||
|
//
|
||||||
|
// In-memory + single-site single-writer (one booth) → no DB, no cross-process
|
||||||
|
// concerns. See wiki/entities/subscription.md.
|
||||||
|
|
||||||
|
const CAPTURE_TTL_MS = Number(process.env.CAPTURE_TTL_MS ?? 30_000);
|
||||||
|
|
||||||
|
export type CaptureState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
|
||||||
|
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
|
||||||
|
| { status: "expired"; deviceId: string };
|
||||||
|
|
||||||
|
export class CredentialCapture {
|
||||||
|
#armedDeviceId: string | null = null;
|
||||||
|
#expiresAt = 0;
|
||||||
|
#captured: { deviceId: string; value: string; capturedAt: number } | null = null;
|
||||||
|
#lastExpiredDeviceId: string | null = null;
|
||||||
|
|
||||||
|
/** Arm a single-shot capture on one reader (by its `devices.id`). Replaces any
|
||||||
|
* prior arming (only one capture at a time). Clears a stale captured/expired
|
||||||
|
* result so the form starts fresh. */
|
||||||
|
arm(deviceId: string): { expiresAt: number } {
|
||||||
|
this.#armedDeviceId = deviceId;
|
||||||
|
this.#expiresAt = Date.now() + CAPTURE_TTL_MS;
|
||||||
|
this.#captured = null;
|
||||||
|
this.#lastExpiredDeviceId = null;
|
||||||
|
return { expiresAt: this.#expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel any pending arming (operator closed the form / clicked cancel). */
|
||||||
|
cancel(): void {
|
||||||
|
this.#armedDeviceId = null;
|
||||||
|
this.#expiresAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the reader route on EVERY read. If this reader is the armed one (and
|
||||||
|
* not expired), capture the value, disarm, and return true → the caller must NOT
|
||||||
|
* dispatch this read to the access flow. Otherwise false → dispatch normally.
|
||||||
|
*/
|
||||||
|
tryConsume(deviceId: string, value: string): boolean {
|
||||||
|
if (this.#armedDeviceId == null) return false;
|
||||||
|
if (Date.now() > this.#expiresAt) {
|
||||||
|
// Window lapsed before a card was presented — disarm, mark expired.
|
||||||
|
this.#lastExpiredDeviceId = this.#armedDeviceId;
|
||||||
|
this.#armedDeviceId = null;
|
||||||
|
this.#expiresAt = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (deviceId !== this.#armedDeviceId) return false; // a read from the OTHER reader
|
||||||
|
if (!value) return false;
|
||||||
|
this.#captured = { deviceId, value, capturedAt: Date.now() };
|
||||||
|
this.#armedDeviceId = null; // single-shot
|
||||||
|
this.#expiresAt = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current state for the booth form's poll. Lazily transitions armed→expired. */
|
||||||
|
state(): CaptureState {
|
||||||
|
if (this.#captured) return { status: "captured", ...this.#captured };
|
||||||
|
if (this.#armedDeviceId != null) {
|
||||||
|
if (Date.now() > this.#expiresAt) {
|
||||||
|
this.#lastExpiredDeviceId = this.#armedDeviceId;
|
||||||
|
this.#armedDeviceId = null;
|
||||||
|
this.#expiresAt = 0;
|
||||||
|
return { status: "expired", deviceId: this.#lastExpiredDeviceId };
|
||||||
|
}
|
||||||
|
return { status: "armed", deviceId: this.#armedDeviceId, armedAt: this.#expiresAt - CAPTURE_TTL_MS, expiresAt: this.#expiresAt };
|
||||||
|
}
|
||||||
|
if (this.#lastExpiredDeviceId) return { status: "expired", deviceId: this.#lastExpiredDeviceId };
|
||||||
|
return { status: "idle" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear a consumed/expired result once the form has read it. */
|
||||||
|
clear(): void {
|
||||||
|
this.#captured = null;
|
||||||
|
this.#lastExpiredDeviceId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { PrinterStatus } from "@parking/devices";
|
import type { PrinterStatus } from "@parking/devices";
|
||||||
|
import type { LedgerEventRow } from "@parking/db";
|
||||||
|
import type { VehicleRead } from "@parking/shared";
|
||||||
|
|
||||||
// Internal event bus for device-originated events (button presses, etc.).
|
// Internal event bus for device-originated events (button presses, etc.).
|
||||||
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
||||||
@@ -16,25 +18,32 @@ export interface DeviceInputEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
|
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
|
||||||
// Drives identity-based flows (exit validation, permits, pay-station lookup). `kind`
|
// Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
|
||||||
// mirrors IdentitySource. See parking-session.md.
|
// mirrors IdentitySource. See parking-session.md.
|
||||||
export interface DeviceReadEvent {
|
export interface DeviceReadEvent {
|
||||||
readonly driverId: string;
|
readonly driverId: string;
|
||||||
readonly deviceId: string; // devices id of the reader/scanner/camera
|
readonly deviceId: string; // devices id of the reader/scanner/camera
|
||||||
readonly value: string; // the ticket id / plate / card number
|
readonly value: string; // the ticket id / plate / card number
|
||||||
readonly kind: "ticket" | "plate" | "qr" | "card";
|
readonly kind: "ticket" | "plate" | "qr" | "card";
|
||||||
|
/** The CONFIRMED physical channel the value arrived on, when the reader tags it
|
||||||
|
* (the DT-008 output prefixes — see routes/qr-reader.ts). `optical` = decoded by
|
||||||
|
* the barcode/QR engine; `rf` = read from a card/chip. Undefined = legacy reader
|
||||||
|
* with no prefixes configured (channel unknown — flows must not assume). Lets the
|
||||||
|
* subscription match refuse an OPTICAL decode claiming an RF credential (a printed
|
||||||
|
* copy of a card's UID must not clone the card). */
|
||||||
|
readonly channel?: "optical" | "rf";
|
||||||
readonly at: string; // ISO-8601
|
readonly at: string; // ISO-8601
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
||||||
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
|
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
|
||||||
* device. A fire-and-forget reader simply ignores it. See wiki/entities/gee-qr-er80.md.
|
* device. A fire-and-forget reader simply ignores it. See wiki/entities/dingtian-dt008-reader.md.
|
||||||
*/
|
*/
|
||||||
export interface ReadOutcome {
|
export interface ReadOutcome {
|
||||||
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||||
readonly accepted: boolean;
|
readonly accepted: boolean;
|
||||||
/** Which way it went, when known (permit/exit infer this). */
|
/** Which way it went, when known (subscription/exit infer this). */
|
||||||
readonly direction?: "entry" | "exit";
|
readonly direction?: "entry" | "exit";
|
||||||
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
|
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
|
||||||
readonly reason?: string;
|
readonly reason?: string;
|
||||||
@@ -44,10 +53,80 @@ export interface ReadOutcome {
|
|||||||
export interface PrinterStatusEvent {
|
export interface PrinterStatusEvent {
|
||||||
readonly deviceId: string; // devices id
|
readonly deviceId: string; // devices id
|
||||||
readonly driverId: string;
|
readonly driverId: string;
|
||||||
readonly role?: string; // entry-dispenser | booth-receipt
|
readonly role?: string; // entry-dispenser | booth-receipt | wash-desk
|
||||||
readonly status: PrinterStatus;
|
readonly status: PrinterStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unified live status of ANY configured device — what the booth footer shows.
|
||||||
|
* Every enabled device is polled: printers via their rich `readStatus()`
|
||||||
|
* (paper/cover/cutter), all other categories via the generic `healthCheck()`
|
||||||
|
* reachability probe. `state` is the common traffic-light; `detail` carries the
|
||||||
|
* human summary (e.g. "paper out", or an unreachable error). See device-monitor.ts
|
||||||
|
* and wiki/concepts/device-status-monitoring.md.
|
||||||
|
*/
|
||||||
|
export interface DeviceStatusEvent {
|
||||||
|
readonly deviceId: string; // devices id
|
||||||
|
readonly driverId: string;
|
||||||
|
readonly category: "access" | "reader" | "camera" | "printer" | "vision";
|
||||||
|
/**
|
||||||
|
* The device's ROLE descriptor for the footer label — NOT the vendor. A
|
||||||
|
* direction-style token the client localises and pairs with the category, so the
|
||||||
|
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
|
||||||
|
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
|
||||||
|
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
|
||||||
|
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
|
||||||
|
* - undetermined: null (chip shows the category alone)
|
||||||
|
*/
|
||||||
|
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null;
|
||||||
|
readonly state: "ready" | "degraded" | "offline";
|
||||||
|
readonly detail?: string;
|
||||||
|
readonly checkedAt: string; // ISO-8601
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lane occupancy from a camera's vehicle detection — a per-direction "busy/free"
|
||||||
|
* the booth shows as barrier lights. ADVISORY ONLY: a detection is a hint, never a
|
||||||
|
* gate (it never blocks a ticket or opens a barrier). "busy" is set by a vehicle
|
||||||
|
* `active` event; it auto-clears to "free" after a timeout (this camera class sends
|
||||||
|
* no leave/`inactive` signal — see wiki/entities/lpr-camera.md). */
|
||||||
|
export interface LaneStatusEvent {
|
||||||
|
readonly entry: boolean; // true = busy (a vehicle is at the entry vicinity)
|
||||||
|
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plate was RECOGNIZED for a session AFTER its entry/exit event already shipped. Plate
|
||||||
|
* recognition is async/advisory (a vision round-trip off the snapshot), so it lands a
|
||||||
|
* moment after the signed event — too late for the event's own WS push to carry it. This
|
||||||
|
* notifies the booth so it can fill in the plate badge on the already-rendered feed row /
|
||||||
|
* active session in place, no refresh. Advisory; never touches the signed ledger. See
|
||||||
|
* snapshot.ts (recognizePlate) + event-enrich.ts. */
|
||||||
|
export interface PlateRecognizedEvent {
|
||||||
|
readonly identity: string; // the session identity the plate is tied to
|
||||||
|
readonly plate: string; // normalized plate text (trimmed, upper)
|
||||||
|
readonly direction: "entry" | "exit";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emitted when vision classified the vehicle in an entry/exit frame (advisory; stored on
|
||||||
|
* the read row like the plate). A module may sample these — the Car Wash review outbox
|
||||||
|
* queues one in N ENTRY reads for the remote reviewer, in the gate view the classifier
|
||||||
|
* will be trained on (wiki/concepts/vision-review-outbox.md). The core emits; it never
|
||||||
|
* knows who listens. */
|
||||||
|
export interface VehicleReadEvent {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly direction: "entry" | "exit";
|
||||||
|
readonly read: VehicleRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
||||||
|
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
||||||
|
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
||||||
|
* radar-present + camera-not-busy. Drives the booth's barrier light blink. Advisory only —
|
||||||
|
* it gates nothing. See wiki/concepts/button-light-indicator.md. */
|
||||||
|
export interface LanePresenceEvent {
|
||||||
|
readonly entry: boolean; // true = a presence input on an entry barrier is active
|
||||||
|
readonly exit: boolean; // true = a presence input on an exit barrier is active
|
||||||
|
}
|
||||||
|
|
||||||
class DeviceEventBus extends EventEmitter {
|
class DeviceEventBus extends EventEmitter {
|
||||||
emitInput(event: DeviceInputEvent): void {
|
emitInput(event: DeviceInputEvent): void {
|
||||||
this.emit("input", event);
|
this.emit("input", event);
|
||||||
@@ -74,6 +153,69 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("printer-status", cb);
|
this.on("printer-status", cb);
|
||||||
return () => this.off("printer-status", cb);
|
return () => this.off("printer-status", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emitted by the device monitor whenever ANY device's unified status CHANGES
|
||||||
|
* (all categories — relays, readers, cameras, printers). Drives the booth
|
||||||
|
* device-status footer over the WS. */
|
||||||
|
emitDeviceStatus(event: DeviceStatusEvent): void {
|
||||||
|
this.emit("device-status", event);
|
||||||
|
}
|
||||||
|
onDeviceStatus(cb: (event: DeviceStatusEvent) => void): () => void {
|
||||||
|
this.on("device-status", cb);
|
||||||
|
return () => this.off("device-status", cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted AFTER a signed business event is appended to the ledger (entry, exit,
|
||||||
|
* payment, void, …). The payload is the persisted row — business facts only, no
|
||||||
|
* secrets — so it is safe to fan out to authenticated booth clients over the WS.
|
||||||
|
* This is a read-side notification ONLY: it never feeds back into append/sign/
|
||||||
|
* chain logic. See event-log.ts (emitted from EventLog.append) and routes/ws.ts.
|
||||||
|
*/
|
||||||
|
emitLedger(event: LedgerEventRow): void {
|
||||||
|
this.emit("ledger", event);
|
||||||
|
}
|
||||||
|
onLedger(cb: (event: LedgerEventRow) => void): () => void {
|
||||||
|
this.on("ledger", cb);
|
||||||
|
return () => this.off("ledger", cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emitted whenever a lane's busy/free state CHANGES (from camera vehicle
|
||||||
|
* detection). Drives the booth's barrier lights. Advisory only. */
|
||||||
|
emitLaneStatus(event: LaneStatusEvent): void {
|
||||||
|
this.emit("lane-status", event);
|
||||||
|
}
|
||||||
|
onLaneStatus(cb: (event: LaneStatusEvent) => void): () => void {
|
||||||
|
this.on("lane-status", cb);
|
||||||
|
return () => this.off("lane-status", cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared
|
||||||
|
* at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */
|
||||||
|
emitLanePresence(event: LanePresenceEvent): void {
|
||||||
|
this.emit("lane-presence", event);
|
||||||
|
}
|
||||||
|
onLanePresence(cb: (event: LanePresenceEvent) => void): () => void {
|
||||||
|
this.on("lane-presence", cb);
|
||||||
|
return () => this.off("lane-presence", cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emitted when an async plate recognition completes for a session (after its event
|
||||||
|
* already shipped). Lets the booth backfill the plate badge in place. Advisory only. */
|
||||||
|
emitPlateRecognized(event: PlateRecognizedEvent): void {
|
||||||
|
this.emit("plate-recognized", event);
|
||||||
|
}
|
||||||
|
onPlateRecognized(cb: (event: PlateRecognizedEvent) => void): () => void {
|
||||||
|
this.on("plate-recognized", cb);
|
||||||
|
return () => this.off("plate-recognized", cb);
|
||||||
|
}
|
||||||
|
emitVehicleRead(event: VehicleReadEvent): void {
|
||||||
|
this.emit("vehicle-read", event);
|
||||||
|
}
|
||||||
|
onVehicleRead(cb: (event: VehicleReadEvent) => void): () => void {
|
||||||
|
this.on("vehicle-read", cb);
|
||||||
|
return () => this.off("vehicle-read", cb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process-wide device event bus. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { localIsoWithOffset } from "./device-monitor.js";
|
||||||
|
|
||||||
|
// The camera clock-sync sends the SITE's wall-clock now with an explicit UTC offset
|
||||||
|
// (ISAPI localTime) — the offset is what makes the instant unambiguous regardless of
|
||||||
|
// the camera's own tz/DST config. Pin the DST both-sides behaviour for the site tz.
|
||||||
|
|
||||||
|
describe("localIsoWithOffset (camera clock sync payload)", () => {
|
||||||
|
it("Tirane summer = +02:00 (CEST)", () => {
|
||||||
|
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||||
|
"2026-07-07T12:00:00+02:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("Tirane winter = +01:00 (CET)", () => {
|
||||||
|
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-01-15T10:00:00Z"))).toBe(
|
||||||
|
"2026-01-15T11:00:00+01:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("UTC = +00:00", () => {
|
||||||
|
expect(localIsoWithOffset("UTC", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||||
|
"2026-07-07T10:00:00+00:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import { isClockSyncable, isMonitorable, registry, type Device } from "@parking/devices";
|
||||||
|
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
||||||
|
import { directionOf, relaysOf } from "./device-resolve.js";
|
||||||
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
import { siteTz } from "./subscription-window.js";
|
||||||
|
|
||||||
|
/** Synthetic device id for the vision service in the status footer (it's a service,
|
||||||
|
* not a device row, but shares the footer's traffic-light + WS plumbing). */
|
||||||
|
const VISION_STATUS_ID = "vision-service";
|
||||||
|
|
||||||
|
// Unified live DEVICE monitor — the source for the booth's device-status footer.
|
||||||
|
// Every enabled, configured device is probed on an interval, regardless of
|
||||||
|
// category: a printer via its rich readStatus() (paper/cover/cutter — reusing the
|
||||||
|
// same capability the PrinterMonitor uses), and a relay/reader/camera via the
|
||||||
|
// generic healthCheck() reachability probe every Device implements. The result is
|
||||||
|
// flattened to a common traffic-light (ready | degraded | offline) + a detail
|
||||||
|
// string, cached per device id, and emitted on the bus ONLY when it changes.
|
||||||
|
//
|
||||||
|
// This is device-agnostic (talks to the adapter interfaces, never a driver SDK)
|
||||||
|
// and read-only — polling a device never drives a relay or mutates the ledger.
|
||||||
|
// See wiki/concepts/device-status-monitoring.md, printer-status-monitoring.md.
|
||||||
|
|
||||||
|
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
||||||
|
|
||||||
|
// Camera clock sync (Hikvision loses its clock on power cuts — reboots at the 1970
|
||||||
|
// epoch until a human logs into its web UI). The monitor re-syncs from the HOST
|
||||||
|
// clock (the site's offline time authority) at the offline→ready edge — exactly the
|
||||||
|
// power-restored moment — plus a daily backstop; drift under the threshold is left
|
||||||
|
// alone. See wiki/entities/lpr-camera.md (clock sync).
|
||||||
|
const CLOCK_SYNC_BACKSTOP_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const CLOCK_MAX_DRIFT_SEC = 60;
|
||||||
|
|
||||||
|
/** The site's wall-clock now as ISO WITH utc offset (e.g. 2026-07-07T15:30:22+02:00)
|
||||||
|
* — what ISAPI's localTime wants. Derived via Intl for the site tz (no dep). */
|
||||||
|
export function localIsoWithOffset(tz: string, at = new Date()): string {
|
||||||
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
|
||||||
|
const wallAsUtcMs = Date.UTC(
|
||||||
|
Number(p.year), Number(p.month) - 1, Number(p.day),
|
||||||
|
Number(p.hour), Number(p.minute), Number(p.second),
|
||||||
|
);
|
||||||
|
const offMin = Math.round((wallAsUtcMs - at.getTime()) / 60_000);
|
||||||
|
const sign = offMin < 0 ? "-" : "+";
|
||||||
|
const abs = Math.abs(offMin);
|
||||||
|
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||||
|
const mm = String(abs % 60).padStart(2, "0");
|
||||||
|
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}${sign}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
||||||
|
* tokens the client localises next to the category:
|
||||||
|
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
|
||||||
|
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
|
||||||
|
* than one direction; null if it declares none yet
|
||||||
|
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
|
||||||
|
*/
|
||||||
|
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
||||||
|
switch (row.category) {
|
||||||
|
case "reader":
|
||||||
|
case "camera": {
|
||||||
|
const d = directionOf(db, row); // entry | exit | both
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
case "access": {
|
||||||
|
// Only barrier relays carry a role direction; alert (radarAlert) relays don't.
|
||||||
|
const dirs = new Set(
|
||||||
|
relaysOf(row)
|
||||||
|
.map((r) => r.direction)
|
||||||
|
.filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"),
|
||||||
|
);
|
||||||
|
if (dirs.size === 0) return null;
|
||||||
|
if (dirs.size > 1) return "mixed";
|
||||||
|
const only = [...dirs][0]; // entry | exit | both
|
||||||
|
return only ?? null;
|
||||||
|
}
|
||||||
|
case "printer": {
|
||||||
|
const role = (row.config as { role?: string }).role;
|
||||||
|
if (role === "booth-receipt") return "booth";
|
||||||
|
if (role === "entry-dispenser") return "lane";
|
||||||
|
if (role === "wash-desk") return "wash";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceMonitor {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: FastifyBaseLogger;
|
||||||
|
readonly #pollMs: number;
|
||||||
|
/** Latest unified status per device id. */
|
||||||
|
readonly #latest = new Map<string, DeviceStatusEvent>();
|
||||||
|
#timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
#ticking = false;
|
||||||
|
|
||||||
|
/** Optional: the vision service client. When present + enabled, the monitor probes
|
||||||
|
* its /health each tick and shows it as a "vision" chip in the footer. */
|
||||||
|
readonly #vision: VisionClient | null;
|
||||||
|
|
||||||
|
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS, vision: VisionClient | null = null) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#pollMs = pollMs;
|
||||||
|
this.#vision = vision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin polling. Idempotent. */
|
||||||
|
start(): void {
|
||||||
|
if (this.#timer) return;
|
||||||
|
void this.#tick(); // immediate first pass so the footer fills without a wait
|
||||||
|
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
|
||||||
|
this.#timer.unref?.();
|
||||||
|
this.#log.info(`device-monitor: polling every ${this.#pollMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.#timer) {
|
||||||
|
clearInterval(this.#timer);
|
||||||
|
this.#timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current snapshot for the API / a freshly-connected WS client. */
|
||||||
|
snapshot(): DeviceStatusEvent[] {
|
||||||
|
return [...this.#latest.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async #tick(): Promise<void> {
|
||||||
|
if (this.#ticking) return; // never overlap polls
|
||||||
|
this.#ticking = true;
|
||||||
|
try {
|
||||||
|
// Re-read the device set each tick so a newly-assigned/removed device is
|
||||||
|
// picked up without a restart.
|
||||||
|
const rows = await this.#db.select().from(devices).all();
|
||||||
|
const enabled = rows.filter((r) => r.enabled);
|
||||||
|
const present = new Set(enabled.map((r) => r.id));
|
||||||
|
|
||||||
|
// The vision service is a pseudo-device — keep it in the present set when enabled
|
||||||
|
// so the cleanup below doesn't evict it.
|
||||||
|
if (this.#vision?.enabled) present.add(VISION_STATUS_ID);
|
||||||
|
|
||||||
|
// Drop devices that are gone/disabled (so the footer doesn't show stale ones).
|
||||||
|
for (const id of [...this.#latest.keys()]) {
|
||||||
|
if (!present.has(id)) this.#latest.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([...enabled.map((r) => this.#poll(r)), this.#pollVision()]);
|
||||||
|
} catch (err) {
|
||||||
|
this.#log.warn(`device-monitor tick failed: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.#ticking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #poll(row: DeviceRow): Promise<void> {
|
||||||
|
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
||||||
|
const base = {
|
||||||
|
deviceId: row.id,
|
||||||
|
driverId: row.driverId,
|
||||||
|
category: row.category,
|
||||||
|
roleKind: roleKindOf(this.#db, row),
|
||||||
|
};
|
||||||
|
|
||||||
|
let next: DeviceStatusEvent;
|
||||||
|
let device: Device | null = null;
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) {
|
||||||
|
// Configured against a driver that's no longer registered — surface it,
|
||||||
|
// don't silently hide it.
|
||||||
|
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
device = driver.create(cfg as never);
|
||||||
|
// Printers expose richer paper/cover/cutter status; everything else uses
|
||||||
|
// the generic reachability probe. Both flatten to the same traffic-light.
|
||||||
|
if (isMonitorable(device)) {
|
||||||
|
const s = await device.readStatus();
|
||||||
|
next = { ...base, state: s.status, detail: s.detail, checkedAt: s.checkedAt };
|
||||||
|
} else {
|
||||||
|
const h = await device.healthCheck();
|
||||||
|
next = { ...base, state: h.status, detail: h.detail, checkedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// A probe that throws (build error, timeout) reads as offline — never crash
|
||||||
|
// the tick, and fail toward "there's a problem" rather than false-healthy.
|
||||||
|
next = { ...base, state: "offline", detail: (err as Error).message, checkedAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera clock re-sync at the power-restored edge (prev offline/unknown →
|
||||||
|
// ready) + a daily backstop. Stamped BEFORE the async attempt so a failing
|
||||||
|
// camera is retried at backstop cadence, never every poll.
|
||||||
|
if (row.category === "camera" && next.state === "ready" && device && isClockSyncable(device)) {
|
||||||
|
const prev = this.#latest.get(row.id);
|
||||||
|
const cameBack = !prev || prev.state === "offline";
|
||||||
|
const last = this.#clockSyncedAt.get(row.id) ?? 0;
|
||||||
|
if (cameBack || Date.now() - last > CLOCK_SYNC_BACKSTOP_MS) {
|
||||||
|
this.#clockSyncedAt.set(row.id, Date.now());
|
||||||
|
const cam = device;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const r = await cam.syncClock(localIsoWithOffset(siteTz(this.#db)), CLOCK_MAX_DRIFT_SEC);
|
||||||
|
if (r.synced) {
|
||||||
|
// A large jump is the 1970 power-cut signature — warn (persisted) so
|
||||||
|
// the reboot stays visible; a small correction is routine info.
|
||||||
|
const msg = `device-monitor: camera ${row.id} clock synced (was ${r.driftSeconds ?? "unparseable"}s off)`;
|
||||||
|
if (r.driftSeconds == null || r.driftSeconds > 3600) this.#log.warn(msg);
|
||||||
|
else this.#log.info(msg);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.#log.warn(`device-monitor: camera ${row.id} clock sync failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#publish(row.id, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe the vision service /health and publish it as a "vision" footer chip. Skipped
|
||||||
|
* entirely when no client is wired or it's disabled (no chip then). */
|
||||||
|
async #pollVision(): Promise<void> {
|
||||||
|
if (!this.#vision?.enabled) return;
|
||||||
|
const h = await this.#vision.health();
|
||||||
|
const state: DeviceStatusEvent["state"] = h.ok && h.ready ? "ready" : h.ready ? "degraded" : "offline";
|
||||||
|
this.#publish(VISION_STATUS_ID, {
|
||||||
|
deviceId: VISION_STATUS_ID,
|
||||||
|
driverId: "vision",
|
||||||
|
category: "vision",
|
||||||
|
roleKind: null,
|
||||||
|
state,
|
||||||
|
detail: h.ready ? h.recognizer : (h.detail ?? "not ready"),
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-camera timestamp of the last clock-sync ATTEMPT (backstop pacing). */
|
||||||
|
readonly #clockSyncedAt = new Map<string, number>();
|
||||||
|
|
||||||
|
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
||||||
|
#publish(id: string, next: DeviceStatusEvent): void {
|
||||||
|
const prev = this.#latest.get(id);
|
||||||
|
this.#latest.set(id, next);
|
||||||
|
if (!prev || prev.state !== next.state || prev.detail !== next.detail) {
|
||||||
|
this.#log.info(
|
||||||
|
`device-monitor: ${next.category}/${next.roleKind ?? "—"} ${id} -> ${next.state}${next.detail ? ` (${next.detail})` : ""}`,
|
||||||
|
);
|
||||||
|
deviceEvents.emitDeviceStatus(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { inputsOf, relayForButton, relayForPresence } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// device-resolve: the input resolution layer. Inputs live in config.inputs[] (the first-class
|
||||||
|
// model); a pre-inputs[] controller is back-compat-synthesized from the legacy per-relay
|
||||||
|
// button/presenceInput fields. relayForButton/relayForPresence must resolve IDENTICALLY from
|
||||||
|
// either shape, so an exit radar = just another presence row.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CTL = "ctl-1";
|
||||||
|
|
||||||
|
function seed(config: Record<string, unknown>): void {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({ id: CTL, category: "access", driverId: "dingtian", config, enabled: true }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("inputsOf back-compat synth", () => {
|
||||||
|
it("synthesizes inputs[] from legacy relay button/presence fields", () => {
|
||||||
|
seed({
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "radar", presenceActiveLow: true },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const row = db.select().from(devices).get()!;
|
||||||
|
const inputs = inputsOf(row);
|
||||||
|
expect(inputs).toEqual([
|
||||||
|
{ input: 1, role: "button", relay: 1, cooldownSec: undefined },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an explicit inputs[] over the legacy fields", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 1, direction: "entry", button: 9 /* legacy ignored */ }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||||
|
});
|
||||||
|
const row = db.select().from(devices).get()!;
|
||||||
|
expect(inputsOf(row)).toEqual([{ input: 1, role: "button", relay: 1 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("relayForButton / relayForPresence", () => {
|
||||||
|
it("resolves a button + presence from inputs[]", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 1, role: "button", relay: 1 },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const byBtn = relayForButton(db, CTL, 1);
|
||||||
|
expect(byBtn).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2, presenceKind: "radar" });
|
||||||
|
const byPres = relayForPresence(db, CTL, 2);
|
||||||
|
expect(byPres).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves IDENTICALLY from the legacy shape (no inputs[])", () => {
|
||||||
|
seed({ relays: [{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "loop" }] });
|
||||||
|
expect(relayForButton(db, CTL, 1)).toMatchObject({ relay: 1, presenceInput: 2, presenceKind: "loop" });
|
||||||
|
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1, presenceInput: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an EXIT presence row to the exit relay (the exit radar)", () => {
|
||||||
|
seed({
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" }, // entry radar
|
||||||
|
{ input: 5, role: "presence", relay: 2, kind: "radar" }, // exit radar
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// NOTE: relayForPresence only gates entry/both relays (transient entry). The exit radar
|
||||||
|
// resolves to null HERE (the exit barrier has no entry gate) — but it's still a valid
|
||||||
|
// inputs[] row the lamp can trigger on. The entry radar resolves to relay 1.
|
||||||
|
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1 });
|
||||||
|
expect(relayForPresence(db, CTL, 5)).toBeNull(); // exit relay isn't a transient-entry gate
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a button on an exit-only relay is not a transient-entry trigger", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 2, direction: "exit" }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 2 }],
|
||||||
|
});
|
||||||
|
expect(relayForButton(db, CTL, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,20 +10,74 @@ export type Direction = "entry" | "exit" | "both";
|
|||||||
/** A concrete flow a credential/button drives (never "both"). */
|
/** A concrete flow a credential/button drives (never "both"). */
|
||||||
export type FlowDirection = "entry" | "exit";
|
export type FlowDirection = "entry" | "exit";
|
||||||
|
|
||||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
/** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
|
||||||
* and (optionally) the input terminal its entry button is wired to. */
|
* `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is
|
||||||
|
* active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" —
|
||||||
|
* the action is implied by the event. See wiki/concepts/button-light-indicator.md. */
|
||||||
|
export type RelayEvent = Direction | "radarAlert";
|
||||||
|
|
||||||
|
/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence`
|
||||||
|
* = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that
|
||||||
|
* starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */
|
||||||
|
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||||
|
|
||||||
|
/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec).
|
||||||
|
* An exit radar is just another `presence` row serving the exit relay. */
|
||||||
|
export interface InputSpec {
|
||||||
|
/** 1-based input terminal the host reads. */
|
||||||
|
readonly input: number;
|
||||||
|
readonly role: InputRole;
|
||||||
|
/** The barrier relay this input serves. Required for `button`/`presence` (the gate is
|
||||||
|
* keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */
|
||||||
|
readonly relay?: number;
|
||||||
|
/** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */
|
||||||
|
readonly kind?: "loop" | "radar";
|
||||||
|
/** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button.
|
||||||
|
* Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */
|
||||||
|
readonly activeLow?: boolean;
|
||||||
|
/** `button` only — presence-less fallback: suppress repeat presses for N seconds after a
|
||||||
|
* ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */
|
||||||
|
readonly cooldownSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One relay on an access controller: the event it reacts to. Input wiring (button,
|
||||||
|
* presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read
|
||||||
|
* (back-compat) but no longer written by the UI. */
|
||||||
export interface RelaySpec {
|
export interface RelaySpec {
|
||||||
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
/** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
|
||||||
/** 1-based input terminal of the entry button that fires this relay (transient
|
* drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
|
||||||
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
readonly direction: RelayEvent;
|
||||||
|
|
||||||
|
// ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||||
|
// Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()`
|
||||||
|
// synthesizes InputSpec rows from these when a controller has no `inputs[]` yet.
|
||||||
readonly button?: number;
|
readonly button?: number;
|
||||||
|
readonly presenceInput?: number;
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
readonly presenceActiveLow?: boolean;
|
||||||
|
readonly entryCooldownSec?: number;
|
||||||
|
|
||||||
|
// ── radarAlert-only (direction === "radarAlert") ──
|
||||||
|
// A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
|
||||||
|
// 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
|
||||||
|
// camera lane status: blink while the trigger is active + lane free, SOLID once the
|
||||||
|
// camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
|
||||||
|
/** 1-based input terminal whose active edge starts the blink (the radar). */
|
||||||
|
readonly triggerInput?: number;
|
||||||
|
/** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default
|
||||||
|
* "entry". An exit radar's lamp must lock on the EXIT camera. */
|
||||||
|
readonly lockLane?: FlowDirection;
|
||||||
|
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||||
|
readonly blinkOnMs?: number;
|
||||||
|
readonly blinkOffMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Access controller config (the `relays[]` map + connection fields). */
|
/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
|
||||||
interface AccessConfig {
|
interface AccessConfig {
|
||||||
readonly relays?: RelaySpec[];
|
readonly relays?: RelaySpec[];
|
||||||
|
readonly inputs?: InputSpec[];
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +92,19 @@ interface BoundConfig {
|
|||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A resolved barrier: the controller row + the specific relay to pulse. */
|
/** A resolved barrier: the controller row + the specific relay to pulse. Carries the
|
||||||
|
* transient-entry anti-double-press config (presence loop / cooldown) when resolved
|
||||||
|
* from a button press, so the entry flow can enforce one-car-one-ticket. */
|
||||||
export interface ResolvedRelay {
|
export interface ResolvedRelay {
|
||||||
readonly controller: DeviceRow;
|
readonly controller: DeviceRow;
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
readonly direction: Direction;
|
||||||
|
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
|
||||||
|
readonly presenceInput?: number;
|
||||||
|
/** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
|
||||||
|
readonly entryCooldownSec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All enabled access controller rows. */
|
/** All enabled access controller rows. */
|
||||||
@@ -62,9 +124,49 @@ export function relaysOf(row: DeviceRow): RelaySpec[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a button press to the relay it fires: the access controller with this
|
* The INPUT terminals declared on an access controller — the back-compat keystone. Returns
|
||||||
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
* `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
|
||||||
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
* per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a
|
||||||
|
* `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads
|
||||||
|
* inputs goes through here, so the legacy fold lives in exactly one place.
|
||||||
|
*/
|
||||||
|
export function inputsOf(row: DeviceRow): InputSpec[] {
|
||||||
|
const cfg = row.config as AccessConfig;
|
||||||
|
if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs;
|
||||||
|
const synth: InputSpec[] = [];
|
||||||
|
for (const r of relaysOf(row)) {
|
||||||
|
if (typeof r.button === "number") {
|
||||||
|
synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||||
|
}
|
||||||
|
if (typeof r.presenceInput === "number") {
|
||||||
|
synth.push({
|
||||||
|
input: r.presenceInput,
|
||||||
|
role: "presence",
|
||||||
|
relay: r.relay,
|
||||||
|
kind: r.presenceKind ?? "loop",
|
||||||
|
activeLow: r.presenceActiveLow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return synth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null —
|
||||||
|
* only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */
|
||||||
|
function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null {
|
||||||
|
if (typeof spec.relay !== "number") return null;
|
||||||
|
const relay = relaysOf(row).find((r) => r.relay === spec.relay);
|
||||||
|
if (!relay) return null;
|
||||||
|
if (relay.direction !== "entry" && relay.direction !== "both") return null;
|
||||||
|
return { ...relay, direction: relay.direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a button press to the relay it fires: the access controller with this deviceId,
|
||||||
|
* and the relay served by the `button` input on this terminal (via inputsOf). Only an
|
||||||
|
* ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket
|
||||||
|
* config (presence input + cooldown) for that relay so the entry flow can enforce it.
|
||||||
|
* Returns null otherwise.
|
||||||
*/
|
*/
|
||||||
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||||
const row = db
|
const row = db
|
||||||
@@ -73,10 +175,73 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
|||||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
.get();
|
.get();
|
||||||
if (!row || !row.enabled) return null;
|
if (!row || !row.enabled) return null;
|
||||||
const spec = relaysOf(row).find((r) => r.button === terminal);
|
const inputs = inputsOf(row);
|
||||||
if (!spec) return null;
|
const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
|
||||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
if (!btn) return null;
|
||||||
return { controller: row, relay: spec.relay, direction: spec.direction };
|
const relay = barrierForInput(row, btn);
|
||||||
|
if (!relay) return null;
|
||||||
|
// The presence sensor (if any) serving the SAME relay supplies the gate.
|
||||||
|
const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay);
|
||||||
|
return {
|
||||||
|
controller: row,
|
||||||
|
relay: relay.relay,
|
||||||
|
direction: relay.direction,
|
||||||
|
presenceInput: presence?.input,
|
||||||
|
presenceKind: presence?.kind ?? "loop",
|
||||||
|
entryCooldownSec: btn.cooldownSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
|
||||||
|
* deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
|
||||||
|
* flow track "a car is physically at this entry barrier" so it issues exactly one ticket
|
||||||
|
* per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||||
|
*/
|
||||||
|
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
|
.get();
|
||||||
|
if (!row || !row.enabled) return null;
|
||||||
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||||
|
if (!presence) return null;
|
||||||
|
const relay = barrierForInput(row, presence);
|
||||||
|
if (!relay) return null;
|
||||||
|
return {
|
||||||
|
controller: row,
|
||||||
|
relay: relay.relay,
|
||||||
|
direction: relay.direction,
|
||||||
|
presenceInput: presence.input,
|
||||||
|
presenceKind: presence.kind ?? "loop",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The alert (radarAlert) relay rows declared on an access controller — the lamps the
|
||||||
|
* ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
|
||||||
|
export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
|
||||||
|
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
|
||||||
|
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
|
||||||
|
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
|
||||||
|
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
|
||||||
|
*/
|
||||||
|
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
|
.get();
|
||||||
|
if (!row || !row.enabled) return null;
|
||||||
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||||
|
if (!presence || typeof presence.relay !== "number") return null;
|
||||||
|
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
|
||||||
|
if (!relay) return null;
|
||||||
|
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,7 +263,10 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
|||||||
.get();
|
.get();
|
||||||
if (controller && controller.enabled) {
|
if (controller && controller.enabled) {
|
||||||
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
// Only a barrier relay opens; an alert (radarAlert) relay is never a barrier.
|
||||||
|
if (spec && spec.direction !== "radarAlert") {
|
||||||
|
return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -118,9 +286,22 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
|||||||
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||||
for (const controller of accessRows(db)) {
|
for (const controller of accessRows(db)) {
|
||||||
const spec = relaysOf(controller).find(
|
const spec = relaysOf(controller).find(
|
||||||
(r) => r.direction === direction || r.direction === "both",
|
(r): r is RelaySpec & { direction: Direction } =>
|
||||||
|
r.direction === direction || r.direction === "both",
|
||||||
);
|
);
|
||||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
if (spec) {
|
||||||
|
// Attach the presence sensor (if any) serving the SAME relay, so callers that gate on
|
||||||
|
// presence (the operator-issued entry) see it. Without this the ResolvedRelay carried
|
||||||
|
// no presenceInput and the presence gate read as "unavailable". Mirrors relayForButton.
|
||||||
|
const presence = inputsOf(controller).find((i) => i.role === "presence" && i.relay === spec.relay);
|
||||||
|
return {
|
||||||
|
controller,
|
||||||
|
relay: spec.relay,
|
||||||
|
direction: spec.direction,
|
||||||
|
presenceInput: presence?.input,
|
||||||
|
presenceKind: presence?.kind ?? "loop",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { deviceEvents as deviceEventsTable, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { flagDuplicateEntryPlate } from "./snapshot.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Entry-side duplicate-plate reconciliation (2026-07-04): when ANPR recognizes a plate on
|
||||||
|
// a fresh transient entry and that plate is already OPEN under another RECENT session,
|
||||||
|
// the same car most likely minted a second ticket (a motion radar dropped the stationary
|
||||||
|
// car → the button re-armed). We sign ONE entry.duplicatePlate anomaly for the operator
|
||||||
|
// to void. Post-hoc + advisory: recognition never gates the (already-open) barrier —
|
||||||
|
// exactly the non-blocking role the plate can play here.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let log: EventLog;
|
||||||
|
|
||||||
|
const PLATE = "AA111BB";
|
||||||
|
const OLD = "11111111111";
|
||||||
|
const NEW = "22222222222";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
log = makeLog(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Seed the prior entry's unsigned plate-read telemetry (what recognizePlate records). */
|
||||||
|
function seedPriorRead(opts: { identity?: string; plate?: string; direction?: string; agoMs?: number } = {}) {
|
||||||
|
db.insert(deviceEventsTable).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId: "cam-entry",
|
||||||
|
category: "camera",
|
||||||
|
kind: "read",
|
||||||
|
detail: {
|
||||||
|
identity: opts.identity ?? OLD,
|
||||||
|
direction: opts.direction ?? "entry",
|
||||||
|
plate: opts.plate ?? PLATE,
|
||||||
|
snapshotId: "snap-old",
|
||||||
|
source: "entry-exit-snapshot",
|
||||||
|
},
|
||||||
|
occurredAt: new Date(Date.now() - (opts.agoMs ?? 60_000)).toISOString(),
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSession(id: string, state: "open" | "closed") {
|
||||||
|
db.insert(sessions).values({
|
||||||
|
id,
|
||||||
|
identity: id,
|
||||||
|
source: "ticket",
|
||||||
|
enteredAt: new Date(Date.now() - 60_000).toISOString(),
|
||||||
|
state,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const flag = () =>
|
||||||
|
flagDuplicateEntryPlate({ db, log, identity: NEW, plate: PLATE, snapshotId: "snap-new", logger: silentLogger() });
|
||||||
|
|
||||||
|
const anomalies = () =>
|
||||||
|
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||||
|
|
||||||
|
describe("flagDuplicateEntryPlate", () => {
|
||||||
|
it("same plate OPEN under another recent session → signs ONE entry.duplicatePlate anomaly", async () => {
|
||||||
|
seedPriorRead();
|
||||||
|
seedSession(OLD, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(1);
|
||||||
|
const a = anomalies()[0];
|
||||||
|
expect(a.identity).toBe(NEW); // keyed to the NEW (suspect) ticket
|
||||||
|
expect(a.payload).toMatchObject({
|
||||||
|
reasonCode: "entry.duplicatePlate",
|
||||||
|
duplicateEntrySuspected: true,
|
||||||
|
plate: PLATE,
|
||||||
|
otherIdentity: OLD,
|
||||||
|
snapshotId: "snap-new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prior session already CLOSED → no anomaly (that car drove off; a re-visit is legit)", async () => {
|
||||||
|
seedPriorRead();
|
||||||
|
seedSession(OLD, "closed");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prior read outside the window → no anomaly (stale coincidence, not a double press)", async () => {
|
||||||
|
seedPriorRead({ agoMs: 30 * 60_000 }); // beyond the 15-min default window
|
||||||
|
seedSession(OLD, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("own read (same identity) never flags itself", async () => {
|
||||||
|
seedPriorRead({ identity: NEW });
|
||||||
|
seedSession(NEW, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("different plate / exit-side reads are ignored", async () => {
|
||||||
|
seedPriorRead({ plate: "ZZ999ZZ" });
|
||||||
|
seedPriorRead({ direction: "exit" });
|
||||||
|
seedSession(OLD, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateTicketCode } from "./entry-flow.js";
|
||||||
|
|
||||||
|
// validateTicketCode is the manual-entry typo guard: an all-digit code whose last digit
|
||||||
|
// is the Luhn check of the rest. The booth uses it to reject a mistyped ticket up front
|
||||||
|
// (instead of a confusing "session not found"). The capacity-gate / print-hold / sign-
|
||||||
|
// before-open paths of EntryFlow need device fakes and are exercised in the device +
|
||||||
|
// route phases; here we pin the pure, exported checksum contract.
|
||||||
|
|
||||||
|
describe("validateTicketCode (Luhn)", () => {
|
||||||
|
it("accepts a well-formed 11-digit id", () => {
|
||||||
|
// 10-digit body + its Luhn check digit. 0000000000 → check digit 0.
|
||||||
|
expect(validateTicketCode("00000000000")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a single-digit typo", () => {
|
||||||
|
expect(validateTicketCode("00000000000")).toBe(true);
|
||||||
|
expect(validateTicketCode("00000000010")).toBe(false); // flipped a digit, checksum now wrong
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-digit and out-of-length strings", () => {
|
||||||
|
expect(validateTicketCode("abc")).toBe(false);
|
||||||
|
expect(validateTicketCode("123")).toBe(false); // too short
|
||||||
|
expect(validateTicketCode("123456789012345")).toBe(false); // too long
|
||||||
|
expect(validateTicketCode("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a generated body+check (Luhn is self-consistent)", () => {
|
||||||
|
// Construct a valid code: pick a body, compute its check the same way the issuer does.
|
||||||
|
const body = "4992739871";
|
||||||
|
// brute the check digit 0..9 — exactly one makes a valid code.
|
||||||
|
const valid = Array.from({ length: 10 }, (_, d) => body + d).filter(validateTicketCode);
|
||||||
|
expect(valid).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a legacy 13-digit id shape", () => {
|
||||||
|
// 12-digit body 000000000000 → check 0; the validator is length-agnostic in 10..14.
|
||||||
|
expect(validateTicketCode("0000000000000")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomInt, randomUUID } from "node:crypto";
|
||||||
import { sessions, type Db, type DeviceRow } from "@parking/db";
|
import { deviceEvents as deviceEventsTable, eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
NoPrinterAvailableError,
|
NoPrinterAvailableError,
|
||||||
printWithFailover,
|
printWithFailover,
|
||||||
@@ -8,13 +8,17 @@ import {
|
|||||||
type PrinterDevice,
|
type PrinterDevice,
|
||||||
type PrinterInstance,
|
type PrinterInstance,
|
||||||
type TicketData,
|
type TicketData,
|
||||||
|
type TicketHeader,
|
||||||
|
printerRoleOf,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
|
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceInputEvent } from "./device-events.js";
|
import type { DeviceInputEvent, LaneStatusEvent } from "./device-events.js";
|
||||||
import { getOccupancy } from "./occupancy.js";
|
import { getOccupancy } from "./occupancy.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { devicesByDirection, relayForButton, type ResolvedRelay } from "./device-resolve.js";
|
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||||
import { snapshotAsync } from "./snapshot.js";
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
|
// The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry
|
||||||
// → open the barrier. The button is wired into an access controller's input; the
|
// → open the barrier. The button is wired into an access controller's input; the
|
||||||
@@ -33,6 +37,43 @@ import { snapshotAsync } from "./snapshot.js";
|
|||||||
//
|
//
|
||||||
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
|
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
|
||||||
// (fail) sign anomaly, stop.
|
// (fail) sign anomaly, stop.
|
||||||
|
//
|
||||||
|
// ONE CAR = ONE TICKET (anti-double-press). The entry button can be physically held
|
||||||
|
// or mashed; without a guard each press mints a fresh ticket + signed vehicle_entry
|
||||||
|
// (corrupting occupancy and letting a transient shop the cheapest ticket at exit). The
|
||||||
|
// guard is per-relay and CONFIGURED on the relay spec (config.relays[]), chosen by what
|
||||||
|
// barrier feedback exists at the lane:
|
||||||
|
// - PRESENCE loop (preferred): `presenceInput` ties ticketing to a real vehicle. A
|
||||||
|
// press prints only while a car is present, and NO second ticket issues until the
|
||||||
|
// loop CLEARS (car drove in) and a new car re-occupies it. We observe the loop's
|
||||||
|
// input edges to track presence + "armed" per relay.
|
||||||
|
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
|
||||||
|
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
|
||||||
|
// When a loop IS wired the cooldown still runs as a BACKSTOP behind it: a motion
|
||||||
|
// radar can drop a STATIONARY car (no doppler return) and spuriously re-arm, and the
|
||||||
|
// cooldown bounds how fast that re-armed press can mint a second ticket.
|
||||||
|
// - CAMERA (when an entry camera is configured): a press is live only while the entry
|
||||||
|
// lane camera confirms a vehicle — the button lamp's SOLID state (button-light.ts).
|
||||||
|
// A radar false-positive (rain, a pedestrian) blinks the lamp but prints nothing.
|
||||||
|
// Camera-less sites keep the radar-only gate; a faulty camera is dropped via the
|
||||||
|
// admin bypass (wiki/concepts/entry-presence-bypass.md).
|
||||||
|
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
|
||||||
|
// See wiki/concepts/entry-double-press.md.
|
||||||
|
|
||||||
|
/** A presence signal the entry gate can require (or, when a device is faulty, the admin
|
||||||
|
* can bypass): the radar/loop presence input, or the camera vehicle-detection. */
|
||||||
|
export type PresenceSignal = "radar" | "camera";
|
||||||
|
|
||||||
|
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
|
||||||
|
interface RelayGuardState {
|
||||||
|
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
|
||||||
|
lastTicketAt: number;
|
||||||
|
/** PRESENCE mode: is a vehicle currently on the loop? (from loop input edges) */
|
||||||
|
present: boolean;
|
||||||
|
/** PRESENCE mode: ready to issue a ticket for a NEW car. Set false after a ticket
|
||||||
|
* prints; re-armed when the loop CLEARS (the car drove through). */
|
||||||
|
armed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class EntryFlow {
|
export class EntryFlow {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
@@ -40,17 +81,36 @@ export class EntryFlow {
|
|||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
/** Guard against double-fire from the same physical press (on edge only). */
|
/** Guard against double-fire from the same physical press (on edge only). */
|
||||||
readonly #inFlight = new Set<string>();
|
readonly #inFlight = new Set<string>();
|
||||||
|
/** Per-relay one-car-one-ticket state (presence + cooldown), keyed controllerId:relay. */
|
||||||
|
readonly #guard = new Map<string, RelayGuardState>();
|
||||||
|
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
|
||||||
|
readonly #vision: VisionClient | null;
|
||||||
|
/** Live entry-lane camera state (LaneStatus mirror, fed by onLaneStatus). Gates the
|
||||||
|
* physical press when an entry camera is configured — advisory sensor, but here it
|
||||||
|
* only ever SUPPRESSES a reprint; it never opens a barrier or traps a car. */
|
||||||
|
#entryBusy = false;
|
||||||
|
|
||||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#log = log;
|
this.#log = log;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
|
this.#vision = vision;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle a device input edge. Acts only on the rising ("on") edge of an entry
|
/** Handle a device input edge. Two kinds of edge matter to this flow:
|
||||||
* button — an input terminal mapped to an entry relay on its controller. */
|
* (1) an ENTRY BUTTON press (rising edge) → run entry, subject to the per-relay
|
||||||
|
* anti-double-press guard; (2) a PRESENCE LOOP edge (either direction) → update
|
||||||
|
* presence state so the guard knows when a car arrives/leaves. The same physical
|
||||||
|
* input is never both, so we resolve each independently. */
|
||||||
async onInput(e: DeviceInputEvent): Promise<void> {
|
async onInput(e: DeviceInputEvent): Promise<void> {
|
||||||
if (e.edge !== "on") return; // release edge is just telemetry
|
// Presence-loop edge (both directions matter): keep the per-relay state current.
|
||||||
|
const presence = relayForPresence(this.#db, e.deviceId, e.input);
|
||||||
|
if (presence) {
|
||||||
|
this.#onPresenceEdge(presence, e.edge);
|
||||||
|
return; // a loop input is not a button — nothing else to do
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.edge !== "on") return; // for buttons, the release edge is just telemetry
|
||||||
|
|
||||||
// The firing device must be an access controller, and the pressed input terminal
|
// The firing device must be an access controller, and the pressed input terminal
|
||||||
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
|
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
|
||||||
@@ -58,6 +118,14 @@ export class EntryFlow {
|
|||||||
const resolved = relayForButton(this.#db, e.deviceId, e.input);
|
const resolved = relayForButton(this.#db, e.deviceId, e.input);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
|
|
||||||
|
// ANTI-DOUBLE-PRESS: is this press allowed to issue a ticket? (presence/cooldown)
|
||||||
|
const suppressed = this.#suppressReason(resolved);
|
||||||
|
if (suppressed) {
|
||||||
|
this.#recordSuppressedPress(e, resolved, suppressed);
|
||||||
|
this.#logger.info(`entry press suppressed (${this.#relayKey(resolved)}): ${suppressed}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const key = `${e.deviceId}:${e.input}`;
|
const key = `${e.deviceId}:${e.input}`;
|
||||||
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
|
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
|
||||||
this.#inFlight.add(key);
|
this.#inFlight.add(key);
|
||||||
@@ -70,33 +138,190 @@ export class EntryFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Track the entry lane's camera state (wired to deviceEvents.onLaneStatus in
|
||||||
|
* server.ts). LaneStatus emits on every flip, so this mirror stays current. */
|
||||||
|
onLaneStatus(s: LaneStatusEvent): void {
|
||||||
|
this.#entryBusy = s.entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable per-relay key for the guard map. */
|
||||||
|
#relayKey(r: ResolvedRelay): string {
|
||||||
|
return `${r.controller.id}:${r.relay}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazily get (or create) the guard state for a relay. New relays start ARMED and
|
||||||
|
* with no car present, so the first press on a fresh lane works immediately. */
|
||||||
|
#guardState(r: ResolvedRelay): RelayGuardState {
|
||||||
|
const key = this.#relayKey(r);
|
||||||
|
let s = this.#guard.get(key);
|
||||||
|
if (!s) {
|
||||||
|
s = { lastTicketAt: 0, present: false, armed: true };
|
||||||
|
this.#guard.set(key, s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a presence-loop edge to a relay's state. The car ARRIVING re-arms ticketing;
|
||||||
|
* the car LEAVING the loop (after its entry) re-arms for the NEXT car. */
|
||||||
|
#onPresenceEdge(r: ResolvedRelay, edge: "on" | "off"): void {
|
||||||
|
const s = this.#guardState(r);
|
||||||
|
if (edge === "on") {
|
||||||
|
s.present = true; // a vehicle is at the barrier
|
||||||
|
} else {
|
||||||
|
// Loop cleared: the car drove through (or backed off). Re-arm for the next car —
|
||||||
|
// this is the gate that makes a *new* car necessary before another ticket.
|
||||||
|
s.present = false;
|
||||||
|
s.armed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
|
||||||
|
* Three layered gates: CAMERA (when an entry camera is configured), PRESENCE
|
||||||
|
* (when a loop is wired), and COOLDOWN — no longer alternatives: the cooldown
|
||||||
|
* runs as a backstop BEHIND presence, because a motion radar can drop a
|
||||||
|
* stationary car and spuriously re-arm one-car-one-ticket. */
|
||||||
|
#suppressReason(r: ResolvedRelay): string | null {
|
||||||
|
const s = this.#guardState(r);
|
||||||
|
const bypass = this.#presenceBypass();
|
||||||
|
|
||||||
|
// CAMERA GATE — the lamp's blink-vs-solid rule, enforced at the press: with an entry
|
||||||
|
// camera configured, a press is live only once the camera confirms a vehicle in the
|
||||||
|
// entry zone (SOLID). Blink (radar-only — rain, a pedestrian, a reflection) prints
|
||||||
|
// nothing. Only ever suppresses a ticket; never opens or traps (advisory rule kept).
|
||||||
|
// A camera-less site skips this; a faulty camera is dropped via the admin bypass.
|
||||||
|
if (!bypass.camera && !this.#entryBusy && this.#entryCameraConfigured()) {
|
||||||
|
return "no camera-confirmed vehicle in the entry zone";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin bypass for a FAULTY radar/loop: skip the presence-loop check so a press prints.
|
||||||
|
// A dead loop can't re-arm one-car-one-ticket, so the cooldown below is what stops a
|
||||||
|
// held button minting a burst. If no cooldown is configured there's no anti-double-press
|
||||||
|
// left — that's the admin's accepted tradeoff while bypassed. See
|
||||||
|
// wiki/concepts/entry-presence-bypass.md.
|
||||||
|
if (typeof r.presenceInput === "number" && !bypass.radar) {
|
||||||
|
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
|
||||||
|
// ticket already issued for this still-present car).
|
||||||
|
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
|
||||||
|
if (!s.armed) return "ticket already issued for the car at the barrier";
|
||||||
|
// Fall THROUGH to the cooldown backstop: a presence-approved press can still be the
|
||||||
|
// SAME stationary car after a radar dropout re-armed the guard.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
|
||||||
|
const elapsed = Date.now() - s.lastTicketAt;
|
||||||
|
if (elapsed < r.entryCooldownSec * 1000) {
|
||||||
|
const remain = Math.ceil((r.entryCooldownSec * 1000 - elapsed) / 1000);
|
||||||
|
return `within ${r.entryCooldownSec}s entry cooldown (${remain}s left)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is at least one enabled camera bound to the entry lane? The camera gate applies only
|
||||||
|
* then — a site with no entry camera keeps the radar-only press gate. Read live (like
|
||||||
|
* the bypass flags) so adding/removing a camera needs no restart. */
|
||||||
|
#entryCameraConfigured(): boolean {
|
||||||
|
return devicesByDirection(this.#db, "camera", "entry").length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
|
||||||
|
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
|
||||||
|
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.insert(deviceEventsTable)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId: e.deviceId,
|
||||||
|
category: "access",
|
||||||
|
kind: "input",
|
||||||
|
detail: {
|
||||||
|
driverId: e.driverId,
|
||||||
|
input: e.input,
|
||||||
|
edge: e.edge,
|
||||||
|
entrySuppressed: true,
|
||||||
|
relay: r.relay,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
occurredAt: e.at,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`suppressed-press telemetry insert failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async #runEntry(resolved: ResolvedRelay): Promise<void> {
|
async #runEntry(resolved: ResolvedRelay): Promise<void> {
|
||||||
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
||||||
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT
|
// no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
|
||||||
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
|
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
|
||||||
// subscribers aren't locked out. "Full" is a soft policy seam for valet over-
|
// they aren't locked out. "Full" is a soft policy seam for valet over-
|
||||||
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
||||||
const occ = getOccupancy(this.#db);
|
const occ = getOccupancy(this.#db);
|
||||||
if (occ.full) {
|
if (occ.full) {
|
||||||
|
// No ticket id exists for a refused entry, so mint a synthetic ref to key the
|
||||||
|
// anomaly + its evidence snapshot together. The operator wants the photo of WHO
|
||||||
|
// was turned away (a fraud/dispute signal), so we still fire the entry camera.
|
||||||
|
const refusedRef = `REFUSED-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true },
|
identity: refusedRef,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("entry.refused.full", { count: occ.count, capacity: occ.capacity ?? 0 }),
|
||||||
|
entryRefused: true,
|
||||||
|
full: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
this.#fireSnapshot("entry", refusedRef);
|
||||||
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
|
this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.#issueTicket(resolved, { source: "ticket" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared "issue a transient ticket" sequence used by BOTH the physical button
|
||||||
|
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
|
||||||
|
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
|
||||||
|
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
|
||||||
|
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
|
||||||
|
* signed entry AND append a companion `anomaly` (the operator-adversary path always
|
||||||
|
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
|
||||||
|
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
|
||||||
|
*/
|
||||||
|
async #issueTicket(
|
||||||
|
resolved: ResolvedRelay,
|
||||||
|
opts: {
|
||||||
|
source: "ticket" | "manual";
|
||||||
|
operator?: string;
|
||||||
|
overCapacity?: { count: number; capacity: number | null };
|
||||||
|
/** Presence signals that were BYPASSED (admin dropped them due to faulty hardware).
|
||||||
|
* Recorded on the signed entry so a ticket issued under a weakened gate is auditable. */
|
||||||
|
presenceBypassed?: PresenceSignal[];
|
||||||
|
},
|
||||||
|
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
|
||||||
const ticketId = newTicketId();
|
const ticketId = newTicketId();
|
||||||
const issuedAt = new Date().toISOString();
|
const issuedAt = new Date().toISOString();
|
||||||
const printers = this.#loadPrinters();
|
const printers = this.#loadPrinters();
|
||||||
|
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
|
||||||
|
// + operatorInitiated:true in the payload. The button path is source "ticket".
|
||||||
|
const operatorInitiated = opts.source === "manual";
|
||||||
|
|
||||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||||
const ticket: TicketData = { ticketId, issuedAt };
|
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||||
try {
|
try {
|
||||||
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
||||||
d.printTicket(ticket),
|
d.printTicket(ticket),
|
||||||
);
|
);
|
||||||
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
|
this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`);
|
||||||
|
// ONE CAR = ONE TICKET: a ticket is now out for the car at this barrier. Disarm +
|
||||||
|
// stamp the cooldown so a repeat press (held button / mashing) issues no second
|
||||||
|
// ticket. PRESENCE mode re-arms when the loop clears (car drove in); COOLDOWN mode
|
||||||
|
// re-allows after entryCooldownSec. Done on the print success, NOT the open.
|
||||||
|
const guard = this.#guardState(resolved);
|
||||||
|
guard.lastTicketAt = Date.now();
|
||||||
|
guard.armed = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
|
// HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the
|
||||||
// failed attempt is in the tamper-evident record for the operator.
|
// failed attempt is in the tamper-evident record for the operator.
|
||||||
@@ -105,48 +330,179 @@ export class EntryFlow {
|
|||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
identity: ticketId,
|
identity: ticketId,
|
||||||
payload: { reason: `entry held — ticket not printed: ${reason}`, ticketPrinted: false },
|
payload: { ...reasonPayload("entry.held.noTicket", { detail: reason }), ticketPrinted: false },
|
||||||
});
|
});
|
||||||
|
// Capture who is held at the barrier (evidence for the operator handling the car).
|
||||||
|
this.#fireSnapshot("entry", ticketId);
|
||||||
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||||
return;
|
return { ok: false, reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||||
|
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
||||||
|
// later reprices the same way at exit. Today every transient takes the SITE
|
||||||
|
// default category (operator policy, site_config.default_vehicle_category;
|
||||||
|
// falls back to the shared DEFAULT_VEHICLE_CATEGORY).
|
||||||
|
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const category =
|
||||||
|
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
||||||
|
? cfg.defaultVehicleCategory
|
||||||
|
: DEFAULT_VEHICLE_CATEGORY;
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "vehicle_entry",
|
type: "vehicle_entry",
|
||||||
direction: "entry",
|
direction: "entry",
|
||||||
source: "ticket",
|
source: opts.source,
|
||||||
identity: ticketId,
|
identity: ticketId,
|
||||||
payload: { sessionRef: ticketId, ticketPrinted: true },
|
payload: {
|
||||||
|
sessionRef: ticketId,
|
||||||
|
ticketPrinted: true,
|
||||||
|
category,
|
||||||
|
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
|
||||||
|
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
|
||||||
|
...(opts.presenceBypassed && opts.presenceBypassed.length > 0
|
||||||
|
? { presenceBypassed: opts.presenceBypassed }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
occurredAt: issuedAt,
|
occurredAt: issuedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
|
||||||
|
// always leaves a red-flag row in the tamper-evident record for reconciliation.
|
||||||
|
if (operatorInitiated) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: ticketId,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
|
||||||
|
source: "booth",
|
||||||
|
operatorInitiated: true,
|
||||||
|
...(opts.operator ? { operator: opts.operator } : {}),
|
||||||
|
...(opts.overCapacity ? { lotFull: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
||||||
const access = this.#buildAccess(resolved.controller);
|
const access = this.#buildAccess(resolved.controller);
|
||||||
if (access) await access.pulseOpen(resolved.relay);
|
let opened = false;
|
||||||
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
if (access) {
|
||||||
|
await access.pulseOpen(resolved.relay);
|
||||||
|
opened = true;
|
||||||
|
} else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||||
|
|
||||||
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
|
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; a
|
||||||
// a camera failure must not delay or block the already-open barrier).
|
// camera failure must not delay or block the already-open barrier). This is ALSO
|
||||||
void snapshotAsync({
|
// what records the plate that plate-reconciliation reads at exit.
|
||||||
db: this.#db,
|
this.#fireSnapshot("entry", ticketId);
|
||||||
direction: "entry",
|
|
||||||
identity: ticketId,
|
|
||||||
logger: this.#logger,
|
|
||||||
}).catch((err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`));
|
|
||||||
|
|
||||||
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
|
||||||
// just a fast read-model, never the source of truth).
|
|
||||||
try {
|
try {
|
||||||
this.#db
|
this.#db
|
||||||
.insert(sessions)
|
.insert(sessions)
|
||||||
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
|
||||||
.run();
|
.run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
|
||||||
// can be rebuilt. Log it; don't fail the (already-open) entry.
|
|
||||||
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
return { ok: true, ticketId, opened };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
|
||||||
|
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
|
||||||
|
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
|
||||||
|
* presence is this flow's own per-relay guard state. If a site has no presence loop the
|
||||||
|
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
|
||||||
|
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
|
||||||
|
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
|
||||||
|
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
|
||||||
|
* See wiki/concepts/operator-issued-entry.md.
|
||||||
|
*/
|
||||||
|
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
|
||||||
|
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
|
||||||
|
> {
|
||||||
|
const resolved = firstRelayByDirection(this.#db, "entry");
|
||||||
|
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
|
||||||
|
|
||||||
|
// PRESENCE GATE — normally require BOTH radar/loop presence AND camera detection. An
|
||||||
|
// admin may BYPASS a signal when its device is faulty (site_config, signed config_change);
|
||||||
|
// the bypassed signal is dropped as a requirement and RECORDED on the issued ticket.
|
||||||
|
const bypass = this.#presenceBypass();
|
||||||
|
const bypassed: PresenceSignal[] = [];
|
||||||
|
|
||||||
|
// Radar/loop side. A configured loop is only mandatory while radar is still REQUIRED;
|
||||||
|
// if radar is bypassed we skip the loop entirely (a dead loop is exactly why they bypass).
|
||||||
|
const radarRequired = !bypass.radar;
|
||||||
|
let radarPresent: boolean | null = null;
|
||||||
|
if (radarRequired) {
|
||||||
|
if (typeof resolved.presenceInput !== "number") {
|
||||||
|
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable (or bypass radar)" };
|
||||||
|
}
|
||||||
|
radarPresent = this.#guardState(resolved).present;
|
||||||
|
} else {
|
||||||
|
bypassed.push("radar");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera side.
|
||||||
|
const cameraRequired = !bypass.camera;
|
||||||
|
if (!cameraRequired) bypassed.push("camera");
|
||||||
|
|
||||||
|
// Refuse only when a STILL-REQUIRED signal fails to confirm a vehicle.
|
||||||
|
const radarOk = !radarRequired || radarPresent === true;
|
||||||
|
const cameraOk = !cameraRequired || cameraBusy;
|
||||||
|
if (!radarOk || !cameraOk) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("entry.issue.noPresence", { operator }),
|
||||||
|
source: "booth",
|
||||||
|
operator,
|
||||||
|
radarPresent,
|
||||||
|
cameraBusy,
|
||||||
|
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(
|
||||||
|
`operator entry refused by ${operator}: no vehicle present (radar=${radarPresent}, camera=${cameraBusy}, bypassed=[${bypassed.join(",")}])`,
|
||||||
|
);
|
||||||
|
return { ok: false, reason: "no vehicle detected at the entry" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `operator-issue:${this.#relayKey(resolved)}`;
|
||||||
|
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
const occ = getOccupancy(this.#db);
|
||||||
|
const res = await this.#issueTicket(resolved, {
|
||||||
|
source: "manual",
|
||||||
|
operator,
|
||||||
|
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
|
||||||
|
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) return res;
|
||||||
|
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
||||||
|
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
|
||||||
|
* held car is exactly when the operator wants the photo. */
|
||||||
|
#fireSnapshot(direction: "entry", identity: string): void {
|
||||||
|
// `log` lets the ANPR ride-along flag a duplicate-plate entry (a signed anomaly) —
|
||||||
|
// still fire-and-forget; recognition never gates the open. See snapshot.ts.
|
||||||
|
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision, log: this.#log }).catch(
|
||||||
|
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current admin presence-gate bypass (site_config), read LIVE so a toggle takes effect
|
||||||
|
* with no restart. Default: nothing bypassed (the normal both-required gate). */
|
||||||
|
#presenceBypass(): { radar: boolean; camera: boolean } {
|
||||||
|
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return { radar: cfg?.bypassPresenceRadar ?? false, camera: cfg?.bypassPresenceCamera ?? false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a live access adapter from a resolved controller row, or null. */
|
/** Build a live access adapter from a resolved controller row, or null. */
|
||||||
@@ -168,7 +524,7 @@ export class EntryFlow {
|
|||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
if (!driver) continue;
|
if (!driver) continue;
|
||||||
const cfg = row.config as Record<string, unknown>;
|
const cfg = row.config as Record<string, unknown>;
|
||||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
const role = printerRoleOf(cfg);
|
||||||
try {
|
try {
|
||||||
out.push({
|
out.push({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -182,9 +538,77 @@ export class EntryFlow {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Park identity for the ticket header, from site_config (all fields optional;
|
||||||
|
* the driver prints only what's set). See wiki/concepts/site-metadata.md. */
|
||||||
|
#ticketHeader(): TicketHeader | undefined {
|
||||||
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (!row) return undefined;
|
||||||
|
return {
|
||||||
|
parkName: row.parkName,
|
||||||
|
operatorName: row.operatorName,
|
||||||
|
nius: row.nius,
|
||||||
|
address: row.address,
|
||||||
|
phone: row.phone,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). */
|
/**
|
||||||
|
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
|
||||||
|
*
|
||||||
|
* Format: 11 digits = 10 cryptographically-random digits + 1 trailing Luhn check
|
||||||
|
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
|
||||||
|
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
|
||||||
|
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
|
||||||
|
* — the anti-fraud property the wiki settles.
|
||||||
|
*
|
||||||
|
* Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the
|
||||||
|
* Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN
|
||||||
|
* ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while
|
||||||
|
* being two digits (≈2 barcode modules) narrower than the old 13. Collisions are
|
||||||
|
* negligible at lot scale; the unique constraints on ledger_events.index / sessions.id
|
||||||
|
* are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.)
|
||||||
|
* The Luhn digit lets a manual entry reject a typo (validateTicketCode) instead of
|
||||||
|
* failing as "session not found".
|
||||||
|
*/
|
||||||
function newTicketId(): string {
|
function newTicketId(): string {
|
||||||
return `T-${randomUUID()}`;
|
let body = "";
|
||||||
|
for (let i = 0; i < 10; i += 1) body += String(randomInt(10));
|
||||||
|
return body + luhnCheckDigit(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Luhn (mod-10) check digit for an all-digit string. */
|
||||||
|
function luhnCheckDigit(digits: string): string {
|
||||||
|
let sum = 0;
|
||||||
|
// Walk right-to-left; the check digit sits at position 0 from the right, so the
|
||||||
|
// last body digit is an "even" position that gets doubled.
|
||||||
|
let double = true;
|
||||||
|
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
||||||
|
let d = digits.charCodeAt(i) - 48;
|
||||||
|
if (double) {
|
||||||
|
d *= 2;
|
||||||
|
if (d > 9) d -= 9;
|
||||||
|
}
|
||||||
|
sum += d;
|
||||||
|
double = !double;
|
||||||
|
}
|
||||||
|
return String((10 - (sum % 10)) % 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum.
|
||||||
|
* Lets a manual-entry path (operator types the code off the ticket when readers are
|
||||||
|
* down) reject a typo up front. A scanned/looked-up id that predates this format
|
||||||
|
* (e.g. legacy `T-<uuid>`) won't pass — callers should only gate MANUAL entry on it,
|
||||||
|
* never reject an id that already exists in the ledger. See ticket-encoding.md.
|
||||||
|
*/
|
||||||
|
export function validateTicketCode(code: string): boolean {
|
||||||
|
// Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest.
|
||||||
|
// Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation
|
||||||
|
// (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps
|
||||||
|
// a stray short/long string from being mistaken for a ticket. See ticket-encoding.md.
|
||||||
|
if (!/^\d{10,14}$/.test(code)) return false;
|
||||||
|
const body = code.slice(0, -1);
|
||||||
|
return luhnCheckDigit(body) === code[code.length - 1];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, siteConfig, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The entry presence gate normally requires BOTH radar/loop presence AND camera detection.
|
||||||
|
// An admin may BYPASS a signal when its device is faulty (site_config, set via a signed
|
||||||
|
// endpoint). These tests pin the GATE decision in EntryFlow.issueForOperator under each
|
||||||
|
// bypass combination: a still-required-but-absent signal refuses (+ signs an anomaly); a
|
||||||
|
// bypassed signal is dropped and recorded. We assert the gate outcome via the refuse path
|
||||||
|
// (deterministic, no printer needed); the allow path is proven by getting PAST the gate
|
||||||
|
// (it then fails at printing — a different reason — which is exactly "the gate opened").
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let flow: EntryFlow;
|
||||||
|
|
||||||
|
const CTL = "ctl-entry";
|
||||||
|
const PRESENCE_INPUT = 2;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
// A controller with an entry barrier (R1), a presence loop on input 2, and an entry button
|
||||||
|
// on input 1 — the shape device-resolve expects (relays[] + inputs[]).
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: {
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 1, role: "button", relay: 1 },
|
||||||
|
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "loop" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
flow = new EntryFlow(db, makeLog(db), silentLogger());
|
||||||
|
});
|
||||||
|
|
||||||
|
function setBypass(patch: { radar?: boolean; camera?: boolean }) {
|
||||||
|
db.insert(siteConfig)
|
||||||
|
.values({ id: 1, bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: siteConfig.id,
|
||||||
|
set: { bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive a presence loop edge so the flow's per-relay guard marks a car present/clear. */
|
||||||
|
async function setRadarPresent(present: boolean) {
|
||||||
|
await flow.onInput({
|
||||||
|
driverId: "stub-access",
|
||||||
|
deviceId: CTL,
|
||||||
|
input: PRESENCE_INPUT,
|
||||||
|
edge: present ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const anomalies = () =>
|
||||||
|
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||||
|
|
||||||
|
describe("entry presence-gate bypass", () => {
|
||||||
|
it("no bypass + no vehicle → refuses and signs a noPresence anomaly", async () => {
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(anomalies()).toHaveLength(1);
|
||||||
|
expect(anomalies()[0].payload).toMatchObject({ reasonCode: "entry.issue.noPresence" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera bypassed + radar present → gate OPENS (no refuse anomaly)", async () => {
|
||||||
|
setBypass({ camera: true });
|
||||||
|
await setRadarPresent(true);
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false); // camera absent but bypassed
|
||||||
|
// Gate passed: no noPresence refusal. (It then proceeds to print — no printer configured,
|
||||||
|
// so it HOLDS with a print reason, not a presence reason. Either way the gate opened.)
|
||||||
|
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusals).toHaveLength(0);
|
||||||
|
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar bypassed + camera busy → gate OPENS even with NO presence loop reading", async () => {
|
||||||
|
setBypass({ radar: true });
|
||||||
|
// radar NOT set present; camera busy=true → radar dropped, camera satisfies.
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||||
|
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusals).toHaveLength(0);
|
||||||
|
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera bypassed but radar STILL required and absent → refuses (only the faulty signal is dropped)", async () => {
|
||||||
|
setBypass({ camera: true });
|
||||||
|
await setRadarPresent(false); // radar required (not bypassed) and clear
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
const refusal = anomalies().find((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusal, "the still-required radar gates the button").toBeTruthy();
|
||||||
|
// The refusal records which signal was bypassed (audit).
|
||||||
|
expect(refusal!.payload).toMatchObject({ presenceBypassed: ["camera"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("both bypassed → gate OPENS with no radar and no camera (press-to-print)", async () => {
|
||||||
|
setBypass({ radar: true, camera: true });
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||||
|
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusals).toHaveLength(0);
|
||||||
|
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, siteConfig, ledgerEvents, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { registry, type PrinterDevice } from "@parking/devices";
|
||||||
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The PHYSICAL entry button's press gate (#suppressReason), layered (2026-07-04):
|
||||||
|
// CAMERA — with an entry camera configured, a press is live only while the entry lane
|
||||||
|
// camera confirms a vehicle (the button lamp's SOLID state). Blink (radar-only) prints
|
||||||
|
// nothing. Camera-less sites skip this; the admin camera bypass drops it.
|
||||||
|
// PRESENCE — one-car-one-ticket off the loop (unchanged).
|
||||||
|
// COOLDOWN — now a BACKSTOP behind presence, not an alternative: a motion radar drops a
|
||||||
|
// stationary car (no doppler return), spuriously re-arming the guard; the cooldown bounds
|
||||||
|
// how fast that re-armed press can mint a second ticket for the same car.
|
||||||
|
// A suppressed press is unsigned telemetry (entrySuppressed), never a ledger anomaly.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let flow: EntryFlow;
|
||||||
|
|
||||||
|
const CTL = "ctl-entry";
|
||||||
|
const BUTTON_INPUT = 1;
|
||||||
|
const PRESENCE_INPUT = 2;
|
||||||
|
|
||||||
|
// A no-op printer that always succeeds, so the happy path reaches the signed
|
||||||
|
// vehicle_entry (the real drivers need hardware). Registered once (registry is global).
|
||||||
|
const noopPrinter: PrinterDevice = {
|
||||||
|
driverId: "test-printer-ok",
|
||||||
|
connect: async () => {},
|
||||||
|
disconnect: async () => {},
|
||||||
|
healthCheck: async () => ({ status: "ready" as const }),
|
||||||
|
printTicket: async () => {},
|
||||||
|
printReport: async () => {},
|
||||||
|
printSubscriptionCard: async () => {},
|
||||||
|
printReceipt: async () => {},
|
||||||
|
printWindowChargeNotice: async () => {},
|
||||||
|
};
|
||||||
|
if (!registry.get("test-printer-ok")) {
|
||||||
|
registry.register({
|
||||||
|
id: "test-printer-ok",
|
||||||
|
category: "printer",
|
||||||
|
label: "Test printer",
|
||||||
|
description: "always-succeeds stub for tests",
|
||||||
|
transports: [],
|
||||||
|
configFields: [],
|
||||||
|
create: () => noopPrinter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: {
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: BUTTON_INPUT, role: "button", relay: 1 },
|
||||||
|
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "printer-entry",
|
||||||
|
category: "printer",
|
||||||
|
driverId: "test-printer-ok",
|
||||||
|
config: { direction: "entry" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
flow = new EntryFlow(db, makeLog(db), silentLogger());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Add an entry camera row. The driver never builds (unknown id) — only its EXISTENCE
|
||||||
|
* matters to the press gate; snapshot capture failing is the normal fire-and-forget path. */
|
||||||
|
function addEntryCamera() {
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "cam-entry",
|
||||||
|
category: "camera",
|
||||||
|
driverId: "no-such-camera-driver",
|
||||||
|
config: { direction: "entry" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCameraBypass(on: boolean) {
|
||||||
|
db.insert(siteConfig)
|
||||||
|
.values({ id: 1, bypassPresenceCamera: on })
|
||||||
|
.onConflictDoUpdate({ target: siteConfig.id, set: { bypassPresenceCamera: on } })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function edge(input: number, edge: "on" | "off") {
|
||||||
|
await flow.onInput({
|
||||||
|
driverId: "stub-access",
|
||||||
|
deviceId: CTL,
|
||||||
|
input,
|
||||||
|
edge,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const press = () => edge(BUTTON_INPUT, "on");
|
||||||
|
const radar = (present: boolean) => edge(PRESENCE_INPUT, present ? "on" : "off");
|
||||||
|
|
||||||
|
const entries = () =>
|
||||||
|
db.select().from(ledgerEvents).all().filter((r) => r.type === "vehicle_entry");
|
||||||
|
const suppressed = () =>
|
||||||
|
db.select().from(deviceEventsTable).all()
|
||||||
|
.map((r) => r.detail as { entrySuppressed?: boolean; reason?: string })
|
||||||
|
.filter((d) => d.entrySuppressed === true);
|
||||||
|
|
||||||
|
describe("entry press gate — camera (blink vs solid)", () => {
|
||||||
|
it("BLINK state (radar present, no camera confirmation) → press suppressed, nothing signed", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
await radar(true); // lamp would blink: radar sees something, camera does not
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(0);
|
||||||
|
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // no anomaly either — telemetry only
|
||||||
|
expect(suppressed()).toHaveLength(1);
|
||||||
|
expect(suppressed()[0].reason).toMatch(/camera/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SOLID state (radar present + camera busy) → press prints and signs a vehicle_entry", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
await radar(true);
|
||||||
|
flow.onLaneStatus({ entry: true, exit: false }); // camera confirms → SOLID
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
expect(suppressed()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera-less site → the camera gate does not apply (radar-only, as before)", async () => {
|
||||||
|
await radar(true); // no camera row; lane state irrelevant
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera bypassed (faulty camera) → press prints without camera confirmation", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
setCameraBypass(true);
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no car at all (radar clear too) → suppressed even with the camera bypassed", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
setCameraBypass(true);
|
||||||
|
await press(); // radar never went on
|
||||||
|
expect(entries()).toHaveLength(0);
|
||||||
|
expect(suppressed()[0].reason).toMatch(/presence loop clear/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("entry press gate — cooldown backstop behind presence", () => {
|
||||||
|
/** Same lane but the button carries a cooldown, making it a backstop behind the loop. */
|
||||||
|
function setButtonCooldown(sec: number) {
|
||||||
|
db.delete(devices).run();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: {
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: BUTTON_INPUT, role: "button", relay: 1, cooldownSec: sec },
|
||||||
|
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "printer-entry",
|
||||||
|
category: "printer",
|
||||||
|
driverId: "test-printer-ok",
|
||||||
|
config: { direction: "entry" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("radar dropout re-arm + quick re-press → caught by the cooldown (one ticket)", async () => {
|
||||||
|
setButtonCooldown(60);
|
||||||
|
await radar(true);
|
||||||
|
await press(); // ticket 1 (no camera configured — radar-only site)
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
// The motion radar loses the STATIONARY car and re-fires: off (re-arms!) then on.
|
||||||
|
await radar(false);
|
||||||
|
await radar(true);
|
||||||
|
await press(); // presence gate says yes (present + re-armed) — the backstop must catch it
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
expect(suppressed().some((d) => /cooldown/.test(d.reason ?? ""))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without a cooldown the dropout re-press mints a second ticket (the documented residual risk)", async () => {
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
await radar(false);
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still-present car re-pressing (no dropout) stays suppressed by one-car-one-ticket", async () => {
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
await press(); // car never left the loop → not re-armed
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
expect(suppressed().some((d) => /already issued/.test(d.reason ?? ""))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { eq, subscriptions, type Db } from "@parking/db";
|
||||||
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
|
|
||||||
|
// READ-TIME event enrichment. The signed ledger stays minimal and stable; some fields
|
||||||
|
// are nice to SHOW but must not be signed (they can change, or depend on other tables).
|
||||||
|
// We resolve them when serializing an event for the API / WS feed — never on the
|
||||||
|
// signed record itself.
|
||||||
|
//
|
||||||
|
// Today: a subscription occurrence's identity is an opaque `SUBSESS-…` key. The human
|
||||||
|
// who matters is the subscription HOLDER, whose name lives on the subscriptions row
|
||||||
|
// (mutable master data — NOT signed into the event). We resolve payload.permitId →
|
||||||
|
// holder_name so the feed reads "Aqif Kopertoni" rather than "SUBSESS-08cd1c52e219".
|
||||||
|
|
||||||
|
/** Fallback label when a subscription has no holder name (or was deleted). Matches the
|
||||||
|
* i18n key `booth.subscriberFallback`; kept here in English for the API/log layer. */
|
||||||
|
const SUBSCRIBER_FALLBACK = "Subscriber";
|
||||||
|
|
||||||
|
/** Tiny holder-name cache. Single-writer SQLite; a subscription rename is rare and the
|
||||||
|
* feed is not security-sensitive, so a short-lived cache is plenty. Invalidate by
|
||||||
|
* process lifetime — restart picks up renames; for live correctness the lookup is
|
||||||
|
* cheap enough that we just read per miss. */
|
||||||
|
const holderCache = new Map<string, string | null>();
|
||||||
|
|
||||||
|
/** Resolve a subscription id to its holder name (or null), memoized. */
|
||||||
|
function holderName(db: Db, permitId: string): string | null {
|
||||||
|
if (holderCache.has(permitId)) return holderCache.get(permitId) ?? null;
|
||||||
|
const row = db
|
||||||
|
.select({ holderName: subscriptions.holderName })
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(eq(subscriptions.id, permitId))
|
||||||
|
.get();
|
||||||
|
const name = row?.holderName?.trim() || null;
|
||||||
|
holderCache.set(permitId, name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop a cached holder name (call after a subscription create/update/delete). */
|
||||||
|
export function invalidateHolder(permitId: string): void {
|
||||||
|
holderCache.delete(permitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the whole holder cache (call on bulk subscription changes). */
|
||||||
|
export function clearHolderCache(): void {
|
||||||
|
holderCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach read-time display fields to a raw ledger row before it goes to a client:
|
||||||
|
* - `subscriberLabel` for a subscription occurrence (payload.permitId → holder name);
|
||||||
|
* - `plate` for an entry/exit event whose session has an advisory ANPR read.
|
||||||
|
* Idempotent and cheap; events without either pass through unchanged. Used by the WS
|
||||||
|
* feed (per event). For the bulk feed page prefer `enrichEvents` (one plate scan).
|
||||||
|
*/
|
||||||
|
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
|
||||||
|
let out: T = event;
|
||||||
|
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
|
||||||
|
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
|
||||||
|
if ((event.type === "vehicle_entry" || event.type === "vehicle_exit") && event.identity) {
|
||||||
|
const p = plateForIdentity(db, event.identity);
|
||||||
|
if (p) out = { ...out, plate: p.plate };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk variant for the feed page: enriches a list of events with subscriber labels AND
|
||||||
|
* plates using a SINGLE device_events scan for all the plates (instead of one per row).
|
||||||
|
* Order preserved.
|
||||||
|
*/
|
||||||
|
export function enrichEvents<T extends LedgerEvent>(db: Db, events: T[]): T[] {
|
||||||
|
// Collect identities of entry/exit events to resolve their plates in one scan.
|
||||||
|
const wanted = new Set<string>();
|
||||||
|
for (const e of events) {
|
||||||
|
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) wanted.add(e.identity);
|
||||||
|
}
|
||||||
|
const plates = wanted.size ? platesForIdentities(db, wanted) : new Map();
|
||||||
|
return events.map((e) => {
|
||||||
|
let out: T = e;
|
||||||
|
const permitId = e.payload && typeof e.payload.permitId === "string" ? e.payload.permitId : null;
|
||||||
|
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
|
||||||
|
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) {
|
||||||
|
const p = plates.get(e.identity);
|
||||||
|
if (p) out = { ...out, plate: p.plate };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||||
|
import { EventLog, canonicalize, hashEvent } from "./event-log.js";
|
||||||
|
import { SoftwareSigner, buildVerifier } from "./signer.js";
|
||||||
|
|
||||||
|
// The append-only, hash-chained, signed event log is THE anti-fraud primitive
|
||||||
|
// (threat model: the operator at the booth). These tests pin every integrity rule:
|
||||||
|
// monotonic index, prevHash linkage, payload-in-signature, and that verifyChain()
|
||||||
|
// catches each class of tamper (content edit, reorder, deletion gap, forged sig,
|
||||||
|
// missing key). No live DB is touched — a fresh in-memory SQLite per test.
|
||||||
|
|
||||||
|
const SECRET = "test-event-signing-key-0123456789";
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = new EventLog(db, new SoftwareSigner(SECRET), buildVerifier);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
describe("EventLog.append — chain construction", () => {
|
||||||
|
it("assigns a monotonic index starting at 1", async () => {
|
||||||
|
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
|
||||||
|
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
|
||||||
|
expect(a.index).toBe(1);
|
||||||
|
expect(b.index).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("genesis event has a null prevHash; the next chains to it", async () => {
|
||||||
|
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
|
||||||
|
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
|
||||||
|
expect(a.prevHash).toBeNull();
|
||||||
|
expect(b.prevHash).toBe(hashEvent(canonicalize(a)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs each row under the active keyId", async () => {
|
||||||
|
const row = await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 100 } });
|
||||||
|
expect(row.keyId).toBe("sw-hmac-v2");
|
||||||
|
expect(new SoftwareSigner(SECRET).verify(canonicalize(row), row.signature)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent appends without index collisions", async () => {
|
||||||
|
const rows = await Promise.all(
|
||||||
|
Array.from({ length: 25 }, (_, i) => log.append({ type: "vehicle_entry", identity: `T${i}` })),
|
||||||
|
);
|
||||||
|
const indices = rows.map((r) => r.index).sort((a, b) => a - b);
|
||||||
|
expect(indices).toEqual(Array.from({ length: 25 }, (_, i) => i + 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EventLog.verifyChain — integrity", () => {
|
||||||
|
async function seed() {
|
||||||
|
await log.append({ type: "vehicle_entry", identity: "T1", direction: "entry" });
|
||||||
|
await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 200, tariffVersionId: "tv1" } });
|
||||||
|
await log.append({ type: "vehicle_exit", identity: "T1", direction: "exit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts an untampered chain", async () => {
|
||||||
|
await seed();
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an empty chain", () => {
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a tampered payload (the money amount)", async () => {
|
||||||
|
await seed();
|
||||||
|
// Rewrite the payment amount directly in the DB — exactly the booth-operator
|
||||||
|
// fraud the signed payload defends against.
|
||||||
|
db.update(ledgerEvents).set({ payload: { amountMinor: 1, tariffVersionId: "tv1" } }).where(eq(ledgerEvents.index, 2)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.index).toBe(2);
|
||||||
|
expect(r.reason).toMatch(/signature invalid/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a deleted row as an index gap", async () => {
|
||||||
|
await seed();
|
||||||
|
db.delete(ledgerEvents).where(eq(ledgerEvents.index, 2)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/index gap/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a broken prevHash link (reordering / re-chaining)", async () => {
|
||||||
|
await seed();
|
||||||
|
db.update(ledgerEvents).set({ prevHash: "0".repeat(64) }).where(eq(ledgerEvents.index, 3)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.index).toBe(3);
|
||||||
|
expect(r.reason).toMatch(/prevHash/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects an event signed under a key that is no longer configured", async () => {
|
||||||
|
await seed();
|
||||||
|
// Re-sign row 2 under an unknown keyId — buildVerifier can't resolve it.
|
||||||
|
db.update(ledgerEvents).set({ keyId: "atecc608-slot9" }).where(eq(ledgerEvents.index, 2)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/no signer for keyId/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canonicalize — byte-stability", () => {
|
||||||
|
it("is independent of payload key order (sorted recursively)", () => {
|
||||||
|
const base = { index: 1, type: "payment", direction: null, source: null, identity: "T1", occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
|
||||||
|
const a = canonicalize({ ...base, payload: { amountMinor: 100, tariffVersionId: "tv1" } });
|
||||||
|
const b = canonicalize({ ...base, payload: { tariffVersionId: "tv1", amountMinor: 100 } });
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when any signed field changes", () => {
|
||||||
|
const base = { index: 1, type: "payment" as const, direction: null, source: null, identity: "T1", payload: { amountMinor: 100 }, occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
|
||||||
|
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, payload: { amountMinor: 101 } }));
|
||||||
|
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, identity: "T2" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,15 +81,34 @@ export function hashEvent(canonical: string): string {
|
|||||||
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier).
|
||||||
|
* Returns undefined when the key that signed an event is not available. */
|
||||||
|
export type SignerResolver = (keyId: string) => Signer | undefined;
|
||||||
|
|
||||||
export class EventLog {
|
export class EventLog {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #signer: Signer;
|
readonly #signer: Signer;
|
||||||
|
/** Picks the verifying signer per event keyId; lets a chain span key rotations
|
||||||
|
* (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for
|
||||||
|
* callers that don't pass one (single-key chains, tests). */
|
||||||
|
readonly #resolveVerifier: SignerResolver;
|
||||||
|
/** Optional read-side notification, fired AFTER a row is durably inserted. Used
|
||||||
|
* to fan the event out to live booth clients (WS). It is best-effort and must
|
||||||
|
* NOT influence the append/sign/chain path — a throwing/absent sink is ignored. */
|
||||||
|
readonly #onAppended?: (row: LedgerEventRow) => void;
|
||||||
/** Serialize appends: each waits for the previous to finish. */
|
/** Serialize appends: each waits for the previous to finish. */
|
||||||
#tail: Promise<unknown> = Promise.resolve();
|
#tail: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
constructor(db: Db, signer: Signer) {
|
constructor(
|
||||||
|
db: Db,
|
||||||
|
signer: Signer,
|
||||||
|
resolveVerifier?: SignerResolver,
|
||||||
|
onAppended?: (row: LedgerEventRow) => void,
|
||||||
|
) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#signer = signer;
|
this.#signer = signer;
|
||||||
|
this.#resolveVerifier = resolveVerifier ?? (() => signer);
|
||||||
|
this.#onAppended = onAppended;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
/** Append one event to the chain. Returns the persisted row. Serialized. */
|
||||||
@@ -97,7 +116,16 @@ export class EventLog {
|
|||||||
const run = this.#tail.then(() => this.#appendNow(input));
|
const run = this.#tail.then(() => this.#appendNow(input));
|
||||||
// Keep the chain going even if one append rejects (don't wedge the lock).
|
// Keep the chain going even if one append rejects (don't wedge the lock).
|
||||||
this.#tail = run.catch(() => undefined);
|
this.#tail = run.catch(() => undefined);
|
||||||
return run;
|
// Read-side notification, AFTER the row is durably written. Wrapped so a
|
||||||
|
// failing sink can never reject the append or break the chain lock above.
|
||||||
|
return run.then((row) => {
|
||||||
|
try {
|
||||||
|
this.#onAppended?.(row);
|
||||||
|
} catch {
|
||||||
|
// best-effort fan-out only — swallow.
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#appendNow(input: AppendInput): LedgerEventRow {
|
#appendNow(input: AppendInput): LedgerEventRow {
|
||||||
@@ -146,7 +174,13 @@ export class EventLog {
|
|||||||
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
|
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
|
||||||
* first detected break, or { ok: true }. This is what reconciliation and an
|
* first detected break, or { ok: true }. This is what reconciliation and an
|
||||||
* integrity self-check call. Catches: tampered content, reordering, a deleted
|
* integrity self-check call. Catches: tampered content, reordering, a deleted
|
||||||
* row (index gap), and a forged/invalid signature.
|
* row (index gap), a forged/invalid signature, and an event signed under a key
|
||||||
|
* that is no longer configured.
|
||||||
|
*
|
||||||
|
* Each row is verified against the signer for ITS OWN `keyId`, not the current
|
||||||
|
* append signer — so a chain that spans a key rotation (e.g. early events under
|
||||||
|
* the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still
|
||||||
|
* verifies end to end. See signer.buildVerifier.
|
||||||
*/
|
*/
|
||||||
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
|
||||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
@@ -159,8 +193,16 @@ export class EventLog {
|
|||||||
if ((row.prevHash ?? null) !== prevHash) {
|
if ((row.prevHash ?? null) !== prevHash) {
|
||||||
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
|
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
|
||||||
}
|
}
|
||||||
|
const verifier = this.#resolveVerifier(row.keyId);
|
||||||
|
if (!verifier) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
index: row.index,
|
||||||
|
reason: `no signer for keyId "${row.keyId}" (key not configured)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
const canonical = canonicalize(row);
|
const canonical = canonicalize(row);
|
||||||
if (!this.#signer.verify(canonical, row.signature)) {
|
if (!verifier.verify(canonical, row.signature)) {
|
||||||
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
|
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
|
||||||
}
|
}
|
||||||
prevHash = hashEvent(canonical);
|
prevHash = hashEvent(canonical);
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { PayStation } from "./pay-station.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The exit flow is the anti-fraud GATE: no car leaves without a covering payment within
|
||||||
|
// the walk-back grace (the no-unpaid-bypass + no-free-overstay rules), and the booth has
|
||||||
|
// no bypass. With no relay configured a clean exit returns { opened:false } — we assert
|
||||||
|
// the DECISION (refuse vs. sign the exit), not the hardware open.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
let exit: ExitFlow;
|
||||||
|
let pay: PayStation;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = makeLog(db);
|
||||||
|
exit = new ExitFlow(db, log, silentLogger());
|
||||||
|
pay = new PayStation(db, log, silentLogger());
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
|
||||||
|
}
|
||||||
|
function exitsSigned(identity: string) {
|
||||||
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
||||||
|
}
|
||||||
|
function anomalies(reason?: string) {
|
||||||
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all()
|
||||||
|
.filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason));
|
||||||
|
}
|
||||||
|
/** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the
|
||||||
|
* plate-reconciliation check can see this identity's plate against open sessions. */
|
||||||
|
function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) {
|
||||||
|
db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run();
|
||||||
|
db.insert(deviceEventsTable).values({
|
||||||
|
id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt,
|
||||||
|
detail: { identity, direction: "entry", plate, confidence },
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("exitForBooth — refusal gates", () => {
|
||||||
|
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
||||||
|
const r = await exit.exitForBooth("ghost");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "no_session" });
|
||||||
|
const anomalies = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all();
|
||||||
|
expect(anomalies).toHaveLength(1);
|
||||||
|
expect(exitsSigned("ghost")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an UNPAID open session — no exit signed (no-unpaid-bypass)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const r = await exit.exitForBooth("T1");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "unpaid" });
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(0); // the car did NOT leave
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a paid session whose walk-back grace has EXPIRED (no free overstay)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(200));
|
||||||
|
// A payment made 60 min ago → its 15-min walk-back grace lapsed long ago.
|
||||||
|
await log.append({
|
||||||
|
type: "payment", source: "manual", identity: "T1", occurredAt: minutesAgo(60),
|
||||||
|
payload: { sessionRef: "T1", amountMinor: 10000, currency: "ALL", tender: "cash", graceExitMin: 15 },
|
||||||
|
});
|
||||||
|
const r = await exit.exitForBooth("T1");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "grace_expired" });
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exitForBooth — valid exit signs the vehicle_exit", () => {
|
||||||
|
it("a paid session within grace signs an exit (opened:false — no relay in tests)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
await pay.pay("T1", "cash"); // fresh payment → within grace
|
||||||
|
const r = await exit.exitForBooth("T1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.opened).toBe(false); // signed, but no barrier resolves in tests
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(1); // the exit IS on the chain
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// NB: a subscriber's normal exit runs through SubscriptionFlow (the reader/credential
|
||||||
|
// path), not exitForBooth — the booth's transient exit has no subscription bypass and
|
||||||
|
// applies the same paid/grace gate to any identity it's handed. Asserting that here so
|
||||||
|
// the boundary is explicit: handing a bare occurrence to exitForBooth is refused, and a
|
||||||
|
// subscriber leaves via reopenBarrier (assist) or the subscription reader flow instead.
|
||||||
|
it("does NOT give the booth transient-exit path a subscription bypass", async () => {
|
||||||
|
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
|
||||||
|
const r = await exit.exitForBooth("SUBSESS-1");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "unpaid" });
|
||||||
|
expect(exitsSigned("SUBSESS-1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a prepaid subscriber out via the assist (reopenBarrier) path", async () => {
|
||||||
|
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
|
||||||
|
const r = await exit.reopenBarrier("SUBSESS-1", "op1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(exitsSigned("SUBSESS-1")).toHaveLength(1); // assist closes the open occurrence
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reopenBarrier — no unpaid re-open", () => {
|
||||||
|
it("refuses to re-open an unpaid transient session", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const r = await exit.reopenBarrier("T1", "op1");
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-opening a paid OPEN session also closes it (signs the exit)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
await pay.pay("T1", "cash");
|
||||||
|
const r = await exit.reopenBarrier("T1", "op1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
// The open session is closed by the human-intervention exit so it leaves the list.
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => {
|
||||||
|
// The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside".
|
||||||
|
// The plate is the invariant — the exiting car's plate is already open under the old ticket.
|
||||||
|
it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
// Original car entered on 1234, plate AA123BB, still open (never paid/exited).
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||||
|
// A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit.
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" });
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(0); // NOT let out
|
||||||
|
expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237", { override: true, operator: "op1" });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1); // released
|
||||||
|
const ov = anomalies("released a suspected ticket-swap");
|
||||||
|
expect(ov.length).toBe(1);
|
||||||
|
expect((ov[0].payload as { operator?: string }).operator).toBe("op1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r.ok).toBe(true); // no warning — exits normally
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1237", minutesAgo(90));
|
||||||
|
seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { eq, ledgerEvents, sessions, type Db, type DeviceRow } from "@parking/db";
|
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
||||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
import type { ResolvedRelay } from "./device-resolve.js";
|
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
import { snapshotAsync } from "./snapshot.js";
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
import type { LedgerPayload } from "@parking/shared";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
@@ -30,23 +32,284 @@ interface SessionView {
|
|||||||
readonly enteredAt: string;
|
readonly enteredAt: string;
|
||||||
readonly open: boolean; // no vehicle_exit yet
|
readonly open: boolean; // no vehicle_exit yet
|
||||||
readonly paidAt: string | null; // latest payment time, if any
|
readonly paidAt: string | null; // latest payment time, if any
|
||||||
|
/** A SUBSCRIPTION occurrence (prepaid; entry payload permit:true). Authorized to
|
||||||
|
* exit / re-open without a `payment`. */
|
||||||
|
readonly subscription: boolean;
|
||||||
readonly graceExitMin: number | null; // from the payment's tariff context, if known
|
readonly graceExitMin: number | null; // from the payment's tariff context, if known
|
||||||
|
// Within the FREE entry-grace window (a quick in-and-out that the tariff prices at
|
||||||
|
// 0). When true the exit opens without a pay-station visit — we mint a $0 payment so
|
||||||
|
// the ledger's "an exit is covered by a payment" invariant still holds. Null when no
|
||||||
|
// active tariff resolves (then we fall back to the normal paid check).
|
||||||
|
readonly freeGrace: { tariffVersionId: string; currency: string; graceExitMin: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of a booth-driven exit (POST /api/exit). `ok=false` = validation rejected
|
||||||
|
* (nothing signed beyond an anomaly). `ok=true, opened=false` = exit IS signed but
|
||||||
|
* the barrier didn't open (payment stands; operator opens manually). */
|
||||||
|
export type BoothExitResult =
|
||||||
|
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||||
|
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
|
||||||
|
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
|
||||||
|
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
|
||||||
|
// See wiki/concepts/plate-reconciliation.md.
|
||||||
|
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
|
||||||
|
| { ok: true; opened: true }
|
||||||
|
| { ok: true; opened: false; reason: string };
|
||||||
|
|
||||||
|
/** Result of a human-intervention barrier re-open (POST /api/barrier/reopen).
|
||||||
|
* `ok=false` = refused (no session / unpaid). `ok=true, opened=false` = the
|
||||||
|
* intervention was recorded (signed anomaly) but the relay did not fire. */
|
||||||
|
export type BoothReopenResult =
|
||||||
|
| { ok: false; reason: string }
|
||||||
|
| { ok: true; opened: boolean; reason?: string };
|
||||||
|
|
||||||
|
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
|
||||||
|
* exiting read and the matched open session's entry read. Below this, the read is advisory-
|
||||||
|
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
|
||||||
|
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
|
||||||
|
|
||||||
export class ExitFlow {
|
export class ExitFlow {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
readonly #inFlight = new Set<string>();
|
readonly #inFlight = new Set<string>();
|
||||||
|
/** Optional vision client — passed to snapshotAsync so ANPR runs on the exit image. */
|
||||||
|
readonly #vision: VisionClient | null;
|
||||||
|
|
||||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#log = log;
|
this.#log = log;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
|
this.#vision = vision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BOOTH-driven exit: the operator (not a reader at the lane) opens the barrier for
|
||||||
|
* a ticket. Runs the SAME validation as the reader path — there is no booth-only
|
||||||
|
* bypass that admits an unpaid car (see wiki/concepts/booth-exit-flow.md +
|
||||||
|
* threat-model.md). On a valid session it signs vehicle_exit, resolves AN exit
|
||||||
|
* relay site-wide, pulses it, and fires the exit snapshot.
|
||||||
|
*
|
||||||
|
* Returns a discriminated result so the route can react precisely:
|
||||||
|
* - { ok: false, status } when validation rejects (unpaid / no session / closed)
|
||||||
|
* — nothing is signed beyond the existing anomaly; the operator takes payment.
|
||||||
|
* - { ok: true, opened: true } on a clean exit.
|
||||||
|
* - { ok: true, opened: false } when the exit IS signed but the relay open FAILED
|
||||||
|
* (offline controller / no exit relay). The signed payment + vehicle_exit STAND
|
||||||
|
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
||||||
|
* operator opens manually. Payment is never rolled back.
|
||||||
|
*/
|
||||||
|
async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise<BoothExitResult> {
|
||||||
|
const id = identity.trim();
|
||||||
|
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
||||||
|
|
||||||
|
const key = `booth:${id}`;
|
||||||
|
if (this.#inFlight.has(key)) return { ok: false, status: "invalid", reason: "exit already in progress" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
const view = this.#sessionFor(id);
|
||||||
|
|
||||||
|
// No open session — unknown/closed ticket. Sign an anomaly (same as the reader
|
||||||
|
// path) so a booth attempt on a bad ticket is auditable.
|
||||||
|
if (!view || !view.open) {
|
||||||
|
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
|
||||||
|
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
|
||||||
|
this.#fireExitSnapshot(id);
|
||||||
|
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
|
||||||
|
return { ok: false, status: view ? "closed" : "no_session", reason: rp.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAID + within grace, OR free entry-grace — the same checks the reader uses.
|
||||||
|
const freeGrace = view.paidAt == null && view.freeGrace != null;
|
||||||
|
const paid = view.paidAt != null;
|
||||||
|
const withinGrace =
|
||||||
|
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
|
||||||
|
if (!freeGrace && (!paid || !withinGrace)) {
|
||||||
|
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
|
||||||
|
await this.#log.append({ type: "anomaly", identity: id, payload: { ...rp, exitRefused: true, source: "booth" } });
|
||||||
|
this.#fireExitSnapshot(id);
|
||||||
|
this.#logger.warn(`booth exit refused (${id}): ${rp.reason}`);
|
||||||
|
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
|
||||||
|
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
|
||||||
|
// (unless they consciously override). A denial here never traps the car — exit fails
|
||||||
|
// open and the operator can override; the anomaly is the control either way.
|
||||||
|
const swap = this.#reconcilePlateAtExit(id);
|
||||||
|
if (swap) {
|
||||||
|
if (!opts?.override) {
|
||||||
|
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
|
||||||
|
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: id,
|
||||||
|
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
|
||||||
|
});
|
||||||
|
this.#fireExitSnapshot(id);
|
||||||
|
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
|
||||||
|
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
|
||||||
|
}
|
||||||
|
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: id,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||||
|
source: "booth",
|
||||||
|
plateSwapOverride: true,
|
||||||
|
plate: swap.plate,
|
||||||
|
otherIdentity: swap.otherIdentity,
|
||||||
|
...(opts.operator ? { operator: opts.operator } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
||||||
|
// reader path does.
|
||||||
|
if (freeGrace && view.freeGrace) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "payment",
|
||||||
|
identity: id,
|
||||||
|
payload: {
|
||||||
|
sessionRef: id,
|
||||||
|
amountMinor: 0,
|
||||||
|
currency: view.freeGrace.currency,
|
||||||
|
tariffVersionId: view.freeGrace.tariffVersionId,
|
||||||
|
graceExitMin: view.freeGrace.graceExitMin,
|
||||||
|
...reasonPayload("exit.freeGrace"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve AN exit barrier site-wide (no reader binding to follow at the booth).
|
||||||
|
const resolved = firstRelayByDirection(this.#db, "exit");
|
||||||
|
|
||||||
|
// Sign the vehicle_exit regardless of whether a relay resolves — the decision
|
||||||
|
// to let the car out has been made and validated. Then attempt the open.
|
||||||
|
await this.#signExit(id);
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
await this.#openFailedAnomaly(id, "no exit relay configured");
|
||||||
|
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
|
||||||
|
}
|
||||||
|
const access = this.#buildAccess(resolved.controller);
|
||||||
|
if (!access) {
|
||||||
|
await this.#openFailedAnomaly(id, "exit controller would not build");
|
||||||
|
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await access.pulseOpen(resolved.relay);
|
||||||
|
} catch (err) {
|
||||||
|
await this.#openFailedAnomaly(id, `pulseOpen failed: ${(err as Error).message}`);
|
||||||
|
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#fireExitSnapshot(id);
|
||||||
|
this.#closeSessionCache(id);
|
||||||
|
return { ok: true, opened: true };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HUMAN-INTERVENTION barrier re-open for an ACTIVE session (booth Active Sessions
|
||||||
|
* list). The barrier is unconfirmed; a car may be stuck after a damaged-ticket
|
||||||
|
* read, a dead scanner, or a phantom re-close (animal / bag / box). The operator
|
||||||
|
* opens the barrier with a signed trace.
|
||||||
|
*
|
||||||
|
* Guard: requires a PAYMENT — no payment, no re-open (the no-unpaid-bypass rule;
|
||||||
|
* the UI also hides the button). It re-pulses the exit relay and signs an `anomaly`
|
||||||
|
* ("manual barrier open", attributed). Idempotent-safe per identity via #inFlight.
|
||||||
|
*
|
||||||
|
* CLOSING THE SESSION (fix 2026-06-18): if the session is still OPEN (no
|
||||||
|
* `vehicle_exit` yet), the manual re-open *is* this car leaving — so we also sign a
|
||||||
|
* `vehicle_exit` (attributed as human-intervention). Without it the paid session
|
||||||
|
* would linger in the Active Sessions list FOREVER, since the grace-expiry eviction
|
||||||
|
* only applies to already-exited sessions (the T-397815c0 bug). If the session is
|
||||||
|
* already CLOSED (a prior exit exists — the phantom re-close case), we do NOT sign a
|
||||||
|
* second exit (that would double-count occupancy): anomaly only, as before.
|
||||||
|
* See wiki/concepts/booth-exit-flow.md.
|
||||||
|
*/
|
||||||
|
async reopenBarrier(identity: string, operator?: string): Promise<BoothReopenResult> {
|
||||||
|
const id = identity.trim();
|
||||||
|
if (!id) return { ok: false, reason: "ticket id required" };
|
||||||
|
|
||||||
|
const view = this.#sessionFor(id);
|
||||||
|
if (!view) return { ok: false, reason: "no session for ticket" };
|
||||||
|
// Authorization to re-open: a SUBSCRIPTION occurrence (prepaid — exactly the case
|
||||||
|
// the operator must assist when the exit reader / card fails) OR a transient whose
|
||||||
|
// payment is STILL WITHIN the walk-back grace window. A stale payment does NOT
|
||||||
|
// authorize a free open: a car that paid once and then sat inside past grace owes a
|
||||||
|
// top-up for the extra time — letting it out on the old payment is the overstay-fraud
|
||||||
|
// path. So we mirror the exit flow's grace check here (not just in the UI): an
|
||||||
|
// unpaid OR grace-expired transient takes the pay/exit (top-up) flow instead.
|
||||||
|
// The no-unpaid-bypass + no-free-overstay-exit rules, enforced server-side.
|
||||||
|
const paid = view.paidAt != null;
|
||||||
|
const withinGrace =
|
||||||
|
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
if (!view.subscription && (!paid || !withinGrace)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: paid ? "walk-back grace expired — take a top-up payment first" : "session not paid — no barrier open without payment",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `reopen:${id}`;
|
||||||
|
if (this.#inFlight.has(key)) return { ok: false, reason: "re-open already in progress" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
const resolved = firstRelayByDirection(this.#db, "exit");
|
||||||
|
// Sign the audited anomaly FIRST (the intervention is recorded whether or not
|
||||||
|
// the physical open succeeds).
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: id,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("exit.manualOpen"),
|
||||||
|
source: "booth",
|
||||||
|
barrierReopen: true,
|
||||||
|
...(operator ? { operator } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close an OPEN session: the re-open is the exit. Sign the vehicle_exit so the
|
||||||
|
// session leaves the active list + occupancy settles. Skip when already exited
|
||||||
|
// (no double-count). Recorded as a human-intervention exit for the audit trail.
|
||||||
|
if (view.open) {
|
||||||
|
await this.#signExit(id, "manual");
|
||||||
|
this.#closeSessionCache(id);
|
||||||
|
this.#fireExitSnapshot(id);
|
||||||
|
this.#logger.info(`barrier re-open also closed open session ${id} (human-intervention exit)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
this.#logger.warn(`barrier re-open for ${id}: no exit relay configured`);
|
||||||
|
return { ok: true, opened: false, reason: renderReasonEn("exit.open.noBarrier") };
|
||||||
|
}
|
||||||
|
const access = this.#buildAccess(resolved.controller);
|
||||||
|
if (!access) {
|
||||||
|
this.#logger.warn(`barrier re-open for ${id}: exit controller would not build`);
|
||||||
|
return { ok: true, opened: false, reason: renderReasonEn("exit.open.unavailable") };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await access.pulseOpen(resolved.relay);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`barrier re-open pulseOpen failed (${id}): ${(err as Error).message}`);
|
||||||
|
return { ok: true, opened: false, reason: renderReasonEn("exit.open.failed") };
|
||||||
|
}
|
||||||
|
this.#logger.info(`manual barrier open for ${id}${operator ? ` by ${operator}` : ""}`);
|
||||||
|
return { ok: true, opened: true };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
|
/** Handle a transient-ticket read at an exit barrier (the relay pre-resolved by the
|
||||||
* read dispatcher from the reader's binding, which has ruled out a permit match). */
|
* read dispatcher from the reader's binding, which has ruled out a subscription match). */
|
||||||
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
const key = `${e.deviceId}:${e.value}`;
|
const key = `${e.deviceId}:${e.value}`;
|
||||||
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||||
@@ -66,14 +329,37 @@ export class ExitFlow {
|
|||||||
|
|
||||||
// No matching open session — unknown/duplicate ticket. Reject + log.
|
// No matching open session — unknown/duplicate ticket. Reject + log.
|
||||||
if (!view || !view.open) {
|
if (!view || !view.open) {
|
||||||
const reason = view ? "exit refused — session already closed" : "exit refused — no open session for credential";
|
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
identity: e.value,
|
identity: e.value,
|
||||||
payload: { reason, exitRefused: true },
|
payload: { ...rp, exitRefused: true },
|
||||||
});
|
});
|
||||||
|
this.#fireExitSnapshot(e.value);
|
||||||
this.#logger.warn(`exit refused: no open session for ${e.value}`);
|
this.#logger.warn(`exit refused: no open session for ${e.value}`);
|
||||||
return { accepted: false, direction: "exit", reason };
|
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// FREE entry-grace: a quick in-and-out the tariff prices at 0 exits at the gate
|
||||||
|
// with no pay-station visit. Mint a signed $0 `payment` first so the ledger keeps
|
||||||
|
// its "an exit is covered by a payment" invariant, then fall through to open.
|
||||||
|
// Only when NOT already paid (a real payment, walk-back grace, takes precedence).
|
||||||
|
if (view.paidAt == null && view.freeGrace) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "payment",
|
||||||
|
// No `source` (not operator-keyed nor a read) — the payload reason marks it.
|
||||||
|
identity: e.value,
|
||||||
|
payload: {
|
||||||
|
sessionRef: e.value,
|
||||||
|
amountMinor: 0,
|
||||||
|
currency: view.freeGrace.currency,
|
||||||
|
tariffVersionId: view.freeGrace.tariffVersionId,
|
||||||
|
graceExitMin: view.freeGrace.graceExitMin,
|
||||||
|
...reasonPayload("exit.freeGrace"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.info(`exit free within entry-grace (${e.value})`);
|
||||||
|
return this.#signExitAndOpen(resolved, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PAID + within walk-back grace?
|
// PAID + within walk-back grace?
|
||||||
@@ -84,49 +370,141 @@ export class ExitFlow {
|
|||||||
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
|
||||||
if (!paid || !withinGrace) {
|
if (!paid || !withinGrace) {
|
||||||
const reason = !paid
|
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
|
||||||
? "exit refused — not paid (pay at the station)"
|
|
||||||
: "exit refused — walk-back grace expired (top-up required)";
|
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "anomaly",
|
type: "anomaly",
|
||||||
identity: e.value,
|
identity: e.value,
|
||||||
payload: { reason, exitRefused: true, sessionRef: e.value },
|
payload: { ...rp, exitRefused: true, sessionRef: e.value },
|
||||||
});
|
});
|
||||||
this.#logger.warn(`exit refused (${e.value}): ${reason}`);
|
this.#fireExitSnapshot(e.value);
|
||||||
return { accepted: false, direction: "exit", reason };
|
this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
|
||||||
|
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid: sign the exit BEFORE opening, then open, then update the cache.
|
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
|
||||||
await this.#log.append({
|
// automated lane to make the override decision, and exit fails open for safety, so we
|
||||||
type: "vehicle_exit",
|
// sign the suspicion anomaly (the control here) and still let the car out. The booth
|
||||||
direction: "exit",
|
// path (operator-mediated) is where the hold + override lives.
|
||||||
source: e.kind === "plate" ? "lpr" : "ticket",
|
const swap = this.#reconcilePlateAtExit(e.value);
|
||||||
identity: e.value,
|
if (swap) {
|
||||||
payload: { sessionRef: e.value },
|
await this.#log.append({
|
||||||
});
|
type: "anomaly",
|
||||||
|
identity: e.value,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||||
|
plateSwapSuspected: true,
|
||||||
|
plate: swap.plate,
|
||||||
|
otherIdentity: swap.otherIdentity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid (a real payment within walk-back grace): sign + open.
|
||||||
|
return this.#signExitAndOpen(resolved, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign the vehicle_exit BEFORE opening, then open, snapshot, and update the cache.
|
||||||
|
* Shared by the paid-exit and free-entry-grace paths. The caller has already
|
||||||
|
* established the session is allowed out (and, for grace, minted the $0 payment). */
|
||||||
|
async #signExitAndOpen(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
await this.#signExit(e.value, e.kind === "plate" ? "lpr" : "ticket");
|
||||||
|
|
||||||
const access = this.#buildAccess(resolved.controller);
|
const access = this.#buildAccess(resolved.controller);
|
||||||
if (access) await access.pulseOpen(resolved.relay);
|
if (access) await access.pulseOpen(resolved.relay);
|
||||||
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
|
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
|
||||||
|
|
||||||
// SNAPSHOT — fire the exit camera(s), never awaited (evidence, not a gate).
|
this.#fireExitSnapshot(e.value);
|
||||||
|
this.#closeSessionCache(e.value);
|
||||||
|
return { accepted: true, direction: "exit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append the signed vehicle_exit. `source`: "ticket" (booth/reader), "lpr" (plate),
|
||||||
|
* or "manual" (a human-intervention barrier re-open that closes an open session —
|
||||||
|
* see reopenBarrier). */
|
||||||
|
async #signExit(identity: string, source: "ticket" | "lpr" | "manual" = "ticket"): Promise<void> {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "vehicle_exit",
|
||||||
|
direction: "exit",
|
||||||
|
source,
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
...(source === "manual" ? reasonPayload("exit.manualOpen") : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire the exit camera(s); never awaited (evidence, not a gate). */
|
||||||
|
#fireExitSnapshot(identity: string): void {
|
||||||
void snapshotAsync({
|
void snapshotAsync({
|
||||||
db: this.#db,
|
db: this.#db,
|
||||||
direction: "exit",
|
direction: "exit",
|
||||||
identity: e.value,
|
identity,
|
||||||
logger: this.#logger,
|
logger: this.#logger,
|
||||||
|
vision: this.#vision,
|
||||||
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
|
}).catch((err) => this.#logger.error(`exit snapshot error: ${(err as Error).message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the (rebuildable) session projection cache to closed. */
|
||||||
|
#closeSessionCache(identity: string): void {
|
||||||
try {
|
try {
|
||||||
this.#db
|
this.#db
|
||||||
.update(sessions)
|
.update(sessions)
|
||||||
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
||||||
.where(eq(sessions.id, e.value))
|
.where(eq(sessions.id, identity))
|
||||||
.run();
|
.run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`session-cache close failed for ${e.value}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
return { accepted: true, direction: "exit" };
|
}
|
||||||
|
|
||||||
|
/** Record an audited anomaly when an exit was signed but the barrier didn't open.
|
||||||
|
* The payment + exit STAND; this tells the operator to open manually. */
|
||||||
|
async #openFailedAnomaly(identity: string, detail: string): Promise<void> {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity,
|
||||||
|
payload: { ...reasonPayload("exit.open.failed"), detail, source: "booth", exitOpenFailed: true },
|
||||||
|
});
|
||||||
|
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
|
||||||
|
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
|
||||||
|
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
|
||||||
|
* or two tickets were mixed up. We compare the EXITING plate against every open session's
|
||||||
|
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
|
||||||
|
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
|
||||||
|
* or null. See wiki/concepts/plate-reconciliation.md.
|
||||||
|
*/
|
||||||
|
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
|
||||||
|
// The exiting car's plate: prefer its own exit read, else its entry read.
|
||||||
|
const mine = plateForIdentity(this.#db, exitingId);
|
||||||
|
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
|
||||||
|
const wanted = mine.plate.trim().toUpperCase();
|
||||||
|
|
||||||
|
// All currently-open sessions (from the projection cache — a fast read-model; the check
|
||||||
|
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
|
||||||
|
const openIds = this.#db
|
||||||
|
.select({ id: sessions.id })
|
||||||
|
.from(sessions)
|
||||||
|
.where(eq(sessions.state, "open"))
|
||||||
|
.all()
|
||||||
|
.map((r) => r.id)
|
||||||
|
.filter((id) => id !== exitingId);
|
||||||
|
if (openIds.length === 0) return null;
|
||||||
|
|
||||||
|
const plates = platesForIdentities(this.#db, openIds);
|
||||||
|
for (const [otherId, pv] of plates) {
|
||||||
|
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
|
||||||
|
if (pv.plate.trim().toUpperCase() !== wanted) continue;
|
||||||
|
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
|
||||||
|
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
|
||||||
|
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||||
@@ -141,7 +519,9 @@ export class ExitFlow {
|
|||||||
|
|
||||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
const exited = rows.some((r) => r.type === "vehicle_exit");
|
// A `void` (cancelled ticket) closes the session like an exit, so a voided ticket
|
||||||
|
// presented at exit reads as "already closed" — never re-opens. See void-flow.ts.
|
||||||
|
const exited = rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
|
||||||
|
|
||||||
let paidAt: string | null = null;
|
let paidAt: string | null = null;
|
||||||
let graceExitMin: number | null = null;
|
let graceExitMin: number | null = null;
|
||||||
@@ -153,15 +533,57 @@ export class ExitFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Free entry-grace: if the tariff prices entry→now at 0 (a quick in-and-out),
|
||||||
|
// the exit may open at the gate. Resolve against the tariff in force at entry,
|
||||||
|
// same as the pay station. Null when no payment is needed yet and no tariff
|
||||||
|
// resolves — then exit falls back to the normal paid check.
|
||||||
|
let freeGrace: SessionView["freeGrace"] = null;
|
||||||
|
if (!exited && paidAt == null) {
|
||||||
|
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||||
|
if (tv) {
|
||||||
|
const structure = tv.structure as unknown as TariffStructure;
|
||||||
|
// Same frozen-at-entry category the pay station uses, so the free-grace
|
||||||
|
// check agrees with the booth quote for V2 category tariffs.
|
||||||
|
const category = (entry.payload as { category?: string } | null)?.category;
|
||||||
|
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
|
||||||
|
if (fee === 0) {
|
||||||
|
freeGrace = {
|
||||||
|
tariffVersionId: tv.id,
|
||||||
|
currency: tv.currency,
|
||||||
|
graceExitMin: structure.gracePeriodExitMin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
const subscription = entryPl.permit === true || entryPl.permitId != null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
identity,
|
identity,
|
||||||
enteredAt: entry.occurredAt,
|
enteredAt: entry.occurredAt,
|
||||||
open: !exited,
|
open: !exited,
|
||||||
paidAt,
|
paidAt,
|
||||||
|
subscription,
|
||||||
graceExitMin,
|
graceExitMin,
|
||||||
|
freeGrace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
|
||||||
|
* (single, for now) active site tariff. Mirrors PayStation#tariffVersionFor. */
|
||||||
|
#tariffVersionFor(at: string) {
|
||||||
|
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||||
|
if (!tariff) return null;
|
||||||
|
const versions = this.#db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all();
|
||||||
|
return versions.find((v) => v.effectiveFrom <= at) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a live access adapter from a resolved controller row, or null. */
|
/** Build a live access adapter from a resolved controller row, or null. */
|
||||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { LanePresence } from "./lane-presence.js";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// LanePresence: a vehicle-presence INPUT edge (loop/radar) on an entry/exit barrier marks
|
||||||
|
// that lane "present" — the same signal that blinks the physical button lamp (relay 3). It
|
||||||
|
// resolves the edge via relayForPresence (the SAME path relay 3 + the entry gate use), and
|
||||||
|
// emits a lane-presence change only when a lane's present/clear state actually flips.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CTL = "ctl-1";
|
||||||
|
const ENTRY_RADAR = 2;
|
||||||
|
const EXIT_RADAR = 5;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
// Entry relay 1 with a radar on I2; exit relay 2 with a radar on I5.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{ input: ENTRY_RADAR, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
{ input: EXIT_RADAR, role: "presence", relay: 2, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
function edge(input: number, on: boolean): void {
|
||||||
|
const e: DeviceInputEvent = {
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CTL,
|
||||||
|
input,
|
||||||
|
edge: on ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
};
|
||||||
|
deviceEvents.emitInput(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect lane-presence emissions while running `fn`. */
|
||||||
|
function capture(fn: () => void): LanePresenceEvent[] {
|
||||||
|
const seen: LanePresenceEvent[] = [];
|
||||||
|
const off = deviceEvents.onLanePresence((p) => seen.push(p));
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LanePresence", () => {
|
||||||
|
it("starts clear and snapshots clear", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
expect(lp.snapshot()).toEqual({ entry: false, exit: false });
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ENTRY radar edge marks the entry lane present, then clears", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(ENTRY_RADAR, true);
|
||||||
|
edge(ENTRY_RADAR, false);
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ entry: true, exit: false },
|
||||||
|
{ entry: false, exit: false },
|
||||||
|
]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an EXIT radar edge marks the exit lane independently", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(EXIT_RADAR, true);
|
||||||
|
});
|
||||||
|
expect(events).toEqual([{ entry: false, exit: true }]);
|
||||||
|
expect(lp.snapshot()).toEqual({ entry: false, exit: true });
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes: a second 'on' from another presence input on the same lane emits once", () => {
|
||||||
|
// Two radars both serving the entry lane.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
{ input: 3, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CTL))
|
||||||
|
.run();
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(2, true); // entry → present (emit)
|
||||||
|
edge(3, true); // still present (no emit — same lane)
|
||||||
|
edge(2, false); // still present via I3 (no emit)
|
||||||
|
edge(3, false); // now clear (emit)
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ entry: true, exit: false },
|
||||||
|
{ entry: false, exit: false },
|
||||||
|
]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a non-presence input (e.g. a button terminal)", () => {
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CTL))
|
||||||
|
.run();
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => edge(1, true));
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||||
|
import { presenceLaneOf } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Per-lane RADAR presence for the booth's barrier lights. A vehicle-presence INPUT
|
||||||
|
// (loop/radar) shorted at an entry/exit barrier means "something is in the lane vicinity"
|
||||||
|
// BEFORE the camera confirms a vehicle. This is the SAME signal that makes the physical
|
||||||
|
// button lamp (relay 3) blink — see button-light.ts (#onInput) — so the on-screen light
|
||||||
|
// and the lamp stay in lockstep: both react to a presence edge resolved the SAME way
|
||||||
|
// (relayForPresence, on an entry/both relay). ADVISORY ONLY: it gates nothing.
|
||||||
|
//
|
||||||
|
// A radar serving an entry (or "both") barrier marks the ENTRY lane present; an exit radar
|
||||||
|
// marks EXIT. The lane is resolved via `presenceLaneOf` (direction-agnostic — unlike the
|
||||||
|
// entry-gated `relayForPresence` the one-car-one-ticket gate uses), so both lanes blink.
|
||||||
|
|
||||||
|
export class LanePresence {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
/** Active presence terminals per lane, keyed `${deviceId}:${input}` (several radars may
|
||||||
|
* serve one lane). A lane is "present" while its set is non-empty. */
|
||||||
|
readonly #entry = new Set<string>();
|
||||||
|
readonly #exit = new Set<string>();
|
||||||
|
#unsub: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to presence input edges. */
|
||||||
|
start(): void {
|
||||||
|
this.#unsub = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current snapshot (for the WS hello). */
|
||||||
|
snapshot(): LanePresenceEvent {
|
||||||
|
return { entry: this.#entry.size > 0, exit: this.#exit.size > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
const lane = presenceLaneOf(this.#db, e.deviceId, e.input);
|
||||||
|
if (!lane) return; // not a presence terminal on a barrier relay
|
||||||
|
const key = `${e.deviceId}:${e.input}`;
|
||||||
|
const set = lane === "entry" ? this.#entry : this.#exit;
|
||||||
|
const before = set.size > 0;
|
||||||
|
if (e.edge === "on") set.add(key);
|
||||||
|
else set.delete(key);
|
||||||
|
const after = set.size > 0;
|
||||||
|
if (before !== after) {
|
||||||
|
this.#logger.info(`lane-presence: ${lane} -> ${after ? "present" : "clear"}`);
|
||||||
|
deviceEvents.emitLanePresence(this.snapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe on shutdown. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsub?.();
|
||||||
|
this.#unsub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { LaneStatus } from "./lane-status.js";
|
||||||
|
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// LaneStatus: a camera's vehicle detection marks its bound lane busy, then auto-clears
|
||||||
|
// after a timeout (this camera class sends no leave signal). Advisory; emits a
|
||||||
|
// lane-status change only when the busy/free state actually flips.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Seed a controller (relay 1=entry, 2=exit, 3=both) + a camera bound to the relay
|
||||||
|
* whose direction we want, so directionOf resolves from the real bound relay. */
|
||||||
|
function seedCamera(direction: "entry" | "exit" | "both"): string {
|
||||||
|
const controllerId = randomUUID();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: controllerId,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
{ relay: 3, direction: "both" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const relay = direction === "entry" ? 1 : direction === "exit" ? 2 : 3;
|
||||||
|
const camId = randomUUID();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: camId,
|
||||||
|
category: "camera",
|
||||||
|
driverId: "hikvision",
|
||||||
|
config: { host: "10.0.0.9", controllerId, relay },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
return camId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture lane-status events emitted during `fn`. */
|
||||||
|
function captureEmits(fn: () => void): LaneStatusEvent[] {
|
||||||
|
const got: LaneStatusEvent[] = [];
|
||||||
|
const off = deviceEvents.onLaneStatus((e) => got.push(e));
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
return got;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LaneStatus", () => {
|
||||||
|
it("marks the camera's bound lane busy on a vehicle detection, free until then", () => {
|
||||||
|
const cam = seedCamera("entry");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
|
||||||
|
|
||||||
|
const emits = captureEmits(() => lane.vehicleDetected(cam));
|
||||||
|
expect(lane.snapshot()).toEqual({ entry: true, exit: false });
|
||||||
|
expect(emits).toEqual([{ entry: true, exit: false }]); // emitted on the flip
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-clears to free after the TTL (no leave signal from the camera)", () => {
|
||||||
|
const cam = seedCamera("entry");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
expect(lane.snapshot().entry).toBe(true);
|
||||||
|
|
||||||
|
const emits = captureEmits(() => vi.advanceTimersByTime(90_001));
|
||||||
|
expect(lane.snapshot().entry).toBe(false);
|
||||||
|
expect(emits).toEqual([{ entry: false, exit: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-arms the timer on each detection (a parked car keeps the lane busy)", () => {
|
||||||
|
const cam = seedCamera("entry");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
// Re-fire just before the TTL — should NOT clear, and should push the clear out.
|
||||||
|
vi.advanceTimersByTime(80_000);
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
vi.advanceTimersByTime(80_000); // 160s total, but only 80s since the last detect
|
||||||
|
expect(lane.snapshot().entry).toBe(true);
|
||||||
|
// Now let it lapse fully.
|
||||||
|
vi.advanceTimersByTime(90_001);
|
||||||
|
expect(lane.snapshot().entry).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT re-emit on a repeat detection while already busy (only state flips)", () => {
|
||||||
|
const cam = seedCamera("entry");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected(cam); // flip -> emits
|
||||||
|
const emits = captureEmits(() => {
|
||||||
|
lane.vehicleDetected(cam); // already busy -> no emit
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
});
|
||||||
|
expect(emits).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 'both'-direction camera marks BOTH lanes busy", () => {
|
||||||
|
const cam = seedCamera("both");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
expect(lane.snapshot()).toEqual({ entry: true, exit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exit camera marks only the exit lane", () => {
|
||||||
|
const cam = seedCamera("exit");
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected(cam);
|
||||||
|
expect(lane.snapshot()).toEqual({ entry: false, exit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unknown device id", () => {
|
||||||
|
const lane = new LaneStatus(db, silentLogger(), 90_000);
|
||||||
|
lane.vehicleDetected("nope");
|
||||||
|
expect(lane.snapshot()).toEqual({ entry: false, exit: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { deviceEvents, type LaneStatusEvent } from "./device-events.js";
|
||||||
|
import { directionOf } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Lane busy/free, driven by a camera's vehicle detection. ADVISORY ONLY — a detection
|
||||||
|
// is a hint the booth shows as barrier lights; it never gates a ticket or opens a
|
||||||
|
// barrier (see wiki/entities/lpr-camera.md, the advisory-only rule).
|
||||||
|
//
|
||||||
|
// A vehicle `active` event on a camera bound to entry/exit marks THAT lane busy and
|
||||||
|
// (re)arms an auto-clear timer. This camera class sends NO leave/`inactive` signal, so
|
||||||
|
// "free" is timeout-driven: the camera re-fires `active` while a car sits in the zone
|
||||||
|
// (each refreshing the timer); once the car leaves, the actives stop and the lane
|
||||||
|
// flips free after BUSY_TTL_MS. A "both"-direction camera marks BOTH lanes.
|
||||||
|
|
||||||
|
/** How long after the last vehicle detection a lane stays "busy" before clearing.
|
||||||
|
* Must exceed the camera's `active` re-fire interval so a still-present car keeps the
|
||||||
|
* lane busy. MEASURED on the test unit (controlled in/out test): the re-fire rate is
|
||||||
|
* MOVEMENT-driven, not a fixed rate — ~1-3s apart while the car moves, but stretching
|
||||||
|
* to ~15-25s when it sits MOTIONLESS in the zone. So the TTL must clear the still-car
|
||||||
|
* gap (~25s) or a parked car flickers free. The camera has ~no dwell lag (it goes
|
||||||
|
* silent within a second of the car leaving), so 30s clears promptly after departure
|
||||||
|
* while keeping a motionless car solidly busy. Override with LANE_BUSY_TTL_MS. */
|
||||||
|
export function busyTtlMs(): number {
|
||||||
|
const raw = Number(process.env.LANE_BUSY_TTL_MS ?? 30_000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LaneStatus {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #ttlMs: number;
|
||||||
|
#entry = false;
|
||||||
|
#exit = false;
|
||||||
|
#entryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
#exitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, ttlMs = busyTtlMs()) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#ttlMs = ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current snapshot (for the WS hello). */
|
||||||
|
snapshot(): LaneStatusEvent {
|
||||||
|
return { entry: this.#entry, exit: this.#exit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A vehicle was detected by camera `deviceId`. Resolves the camera's bound direction
|
||||||
|
* and marks that lane busy + (re)arms its auto-clear. Best-effort: an unknown camera
|
||||||
|
* or a non-vehicle caller is the caller's concern — this only handles a confirmed
|
||||||
|
* vehicle detection. Emits a lane-status change only when the state actually flips.
|
||||||
|
*/
|
||||||
|
vehicleDetected(deviceId: string): void {
|
||||||
|
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
|
if (!row) return;
|
||||||
|
const dir = directionOf(this.#db, row);
|
||||||
|
if (dir === "entry" || dir === "both") this.#mark("entry");
|
||||||
|
if (dir === "exit" || dir === "both") this.#mark("exit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#mark(lane: "entry" | "exit"): void {
|
||||||
|
const was = lane === "entry" ? this.#entry : this.#exit;
|
||||||
|
if (lane === "entry") this.#entry = true;
|
||||||
|
else this.#exit = true;
|
||||||
|
|
||||||
|
// (Re)arm the auto-clear — each detection pushes the free-flip further out.
|
||||||
|
const existing = lane === "entry" ? this.#entryTimer : this.#exitTimer;
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
const timer = setTimeout(() => this.#clear(lane), this.#ttlMs);
|
||||||
|
timer.unref?.(); // never hold the process open
|
||||||
|
if (lane === "entry") this.#entryTimer = timer;
|
||||||
|
else this.#exitTimer = timer;
|
||||||
|
|
||||||
|
if (!was) {
|
||||||
|
this.#logger.info(`lane-status: ${lane} -> busy`);
|
||||||
|
this.#emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#clear(lane: "entry" | "exit"): void {
|
||||||
|
if (lane === "entry") {
|
||||||
|
this.#entry = false;
|
||||||
|
this.#entryTimer = null;
|
||||||
|
} else {
|
||||||
|
this.#exit = false;
|
||||||
|
this.#exitTimer = null;
|
||||||
|
}
|
||||||
|
this.#logger.info(`lane-status: ${lane} -> free`);
|
||||||
|
this.#emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
#emit(): void {
|
||||||
|
deviceEvents.emitLaneStatus(this.snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear timers on shutdown. */
|
||||||
|
stop(): void {
|
||||||
|
if (this.#entryTimer) clearTimeout(this.#entryTimer);
|
||||||
|
if (this.#exitTimer) clearTimeout(this.#exitTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { appLogs, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { LogService, pinoDbStream } from "./log-service.js";
|
||||||
|
|
||||||
|
// pinoDbStream feeds backend warn+ lines into app_logs. Since 2026-07-04 the logger
|
||||||
|
// emits level NAMES ("warn") instead of pino's numeric codes (40) — for human-readable
|
||||||
|
// container logs — and the stream must accept BOTH encodings (numeric covers any
|
||||||
|
// default-configured pino). A level the tee can't resolve falls back to info → not
|
||||||
|
// persisted, never a crash.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let stream: { write: (line: string) => void };
|
||||||
|
let teed: string[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
teed = [];
|
||||||
|
stream = pinoDbStream(new LogService(db), {
|
||||||
|
write: (line: string) => {
|
||||||
|
teed.push(line);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
} as unknown as NodeJS.WritableStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = () => db.select().from(appLogs).all();
|
||||||
|
|
||||||
|
describe("pinoDbStream level encodings", () => {
|
||||||
|
it("persists a LABEL-level warn line (the current logger format)", () => {
|
||||||
|
stream.write(`{"level":"warn","time":"2026-07-04T18:14:11.453Z","msg":"label warn"}\n`);
|
||||||
|
expect(rows()).toHaveLength(1);
|
||||||
|
expect(rows()[0]).toMatchObject({ level: "warn", source: "backend", message: "label warn" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still persists a NUMERIC-level error line (legacy/default pino)", () => {
|
||||||
|
stream.write(`{"level":50,"time":1783179038453,"msg":"numeric error"}\n`);
|
||||||
|
expect(rows()[0]).toMatchObject({ level: "error", message: "numeric error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("info stays stdout-only in both encodings (teed, not persisted)", () => {
|
||||||
|
stream.write(`{"level":"info","msg":"label info"}\n`);
|
||||||
|
stream.write(`{"level":30,"msg":"numeric info"}\n`);
|
||||||
|
expect(rows()).toHaveLength(0);
|
||||||
|
expect(teed).toHaveLength(2); // stdout tee always happens
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unresolvable level falls back to info (dropped), never throws", () => {
|
||||||
|
stream.write(`{"level":"loud","msg":"weird"}\n`);
|
||||||
|
stream.write(`not json at all\n`);
|
||||||
|
expect(rows()).toHaveLength(0);
|
||||||
|
expect(teed).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Storm coalescing: a line identical to the LAST persisted row (level+source+message+
|
||||||
|
// path), arriving within 5 min of its previous occurrence, UPDATES that row (bumping
|
||||||
|
// context._repeat) instead of inserting — one screaming device can't evict unrelated
|
||||||
|
// history. The row's createdAt tracks the LATEST occurrence; the first is preserved in
|
||||||
|
// context._firstAt.
|
||||||
|
describe("storm coalescing", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds a burst of identical error lines into ONE row with a repeat counter", () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
stream.write(`{"level":"error","msg":"button-light setAux failed (ctl R3): send ENETUNREACH"}\n`);
|
||||||
|
}
|
||||||
|
const all = rows();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].context).toMatchObject({ _repeat: 200 });
|
||||||
|
expect(teed).toHaveLength(200); // stdout still gets every line
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps first-occurrence time in _firstAt while createdAt tracks the latest", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||||
|
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:02:00.000Z"));
|
||||||
|
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||||
|
const [row] = rows();
|
||||||
|
expect(row.createdAt).toBe("2026-07-08T10:02:00.000Z");
|
||||||
|
expect(row.context).toMatchObject({ _repeat: 2, _firstAt: "2026-07-08T10:00:00.000Z" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a different message (or level) breaks the run — separate rows", () => {
|
||||||
|
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||||
|
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||||
|
stream.write(`{"level":"error","msg":"boom B"}\n`);
|
||||||
|
stream.write(`{"level":"warn","msg":"boom B"}\n`);
|
||||||
|
expect(rows()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an occurrence past the 5-minute window starts a fresh row", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||||
|
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:06:00.000Z"));
|
||||||
|
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||||
|
expect(rows()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a CONTINUOUS storm stays one row past the window (each hit refreshes it)", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let t = new Date("2026-07-08T10:00:00.000Z").getTime();
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
vi.setSystemTime(new Date(t));
|
||||||
|
stream.write(`{"level":"error","msg":"storm"}\n`);
|
||||||
|
t += 240_000; // 4 min apart — each inside the window of the PREVIOUS hit
|
||||||
|
}
|
||||||
|
const all = rows();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].context).toMatchObject({ _repeat: 10 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { and, appLogs, desc, eq, sql, type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
LOG_LEVEL_ORDER,
|
||||||
|
type AppLogRecord,
|
||||||
|
type ClientLogInput,
|
||||||
|
type LogLevel,
|
||||||
|
type LogSource,
|
||||||
|
} from "@parking/shared";
|
||||||
|
|
||||||
|
// Application/diagnostic LOG SINK — the host-side store behind the third log stream
|
||||||
|
// (app_logs), distinct from the signed ledger and device telemetry. It persists:
|
||||||
|
// - BACKEND warn/error/fatal, fed by a pino stream (see pinoDbStream) so any
|
||||||
|
// app.log.warn/error lands in the DB without changing call sites.
|
||||||
|
// - FRONTEND errors POSTed to /api/logs (failed requests, uncaught errors).
|
||||||
|
// Everything here is UNSIGNED + prunable. Pruned by age AND a row cap so an offline
|
||||||
|
// appliance with finite disk can't be filled by a log storm. See
|
||||||
|
// wiki/concepts/app-logs.md, decisions/event-streams-split.md.
|
||||||
|
|
||||||
|
/** Only warn and above are persisted from the backend (info/debug stay stdout-only). */
|
||||||
|
const BACKEND_PERSIST_MIN: LogLevel = "warn";
|
||||||
|
|
||||||
|
/** Defensive caps so one runaway log can't bloat a row (chars). */
|
||||||
|
const MAX_MESSAGE = 4_000;
|
||||||
|
const MAX_STACK = 16_000;
|
||||||
|
const MAX_CONTEXT_JSON = 16_000;
|
||||||
|
|
||||||
|
/** Storm coalescing: a line identical to the LAST persisted one (level+source+message+
|
||||||
|
* path) within this window of its previous occurrence UPDATES that row (bumping a
|
||||||
|
* `_repeat` counter in its context) instead of inserting a new one. A continuous storm
|
||||||
|
* keeps refreshing the window, so it stays ONE row however long it rages — repeated
|
||||||
|
* errors can't evict unrelated history or grind the appliance disk (field incident
|
||||||
|
* 2026-07-07: one unreachable controller ≈ hundreds of identical rows/minute). */
|
||||||
|
const COALESCE_WINDOW_MS = 300_000;
|
||||||
|
|
||||||
|
export interface LogRetention {
|
||||||
|
/** Delete logs older than this many days. */
|
||||||
|
readonly maxAgeDays: number;
|
||||||
|
/** Hard cap on total rows — the oldest beyond this are pruned. */
|
||||||
|
readonly maxRows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_RETENTION: LogRetention = {
|
||||||
|
// 60 days (~2 months) — the operator's chosen diagnostic window (2026-07-04),
|
||||||
|
// matched by the container-log rotation caps in docker-compose.prod.yml. The row
|
||||||
|
// cap below still bounds a burst regardless of age.
|
||||||
|
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 60),
|
||||||
|
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(s: string | null | undefined, max: number): string | null {
|
||||||
|
if (s == null) return null;
|
||||||
|
return s.length > max ? s.slice(0, max) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize context to JSON, bounded — never throw on a circular/huge object. */
|
||||||
|
function safeContext(ctx: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
|
||||||
|
if (ctx == null) return null;
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(ctx);
|
||||||
|
if (json.length <= MAX_CONTEXT_JSON) return ctx;
|
||||||
|
return { _truncated: true, preview: json.slice(0, MAX_CONTEXT_JSON) };
|
||||||
|
} catch {
|
||||||
|
return { _unserializable: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LogService {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #retention: LogRetention;
|
||||||
|
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
||||||
|
#writing = false;
|
||||||
|
/** The last persisted row, for storm coalescing (in-memory only; a restart just
|
||||||
|
* starts a fresh row — best-effort, like everything in this sink). */
|
||||||
|
#last: {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
count: number;
|
||||||
|
firstAt: string;
|
||||||
|
lastAtMs: number;
|
||||||
|
baseContext: Record<string, unknown> | null;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#retention = retention;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Low-level insert. Best-effort: a logging failure must never break a request or
|
||||||
|
* recurse (a DB error here would otherwise log → insert → error → log …). */
|
||||||
|
#insert(row: {
|
||||||
|
level: LogLevel;
|
||||||
|
source: LogSource;
|
||||||
|
message: string;
|
||||||
|
context?: Record<string, unknown> | null;
|
||||||
|
httpStatus?: number | null;
|
||||||
|
path?: string | null;
|
||||||
|
stack?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
}): void {
|
||||||
|
if (this.#writing) return;
|
||||||
|
this.#writing = true;
|
||||||
|
try {
|
||||||
|
const createdAt = row.createdAt ?? new Date().toISOString();
|
||||||
|
const message = clamp(row.message, MAX_MESSAGE) ?? "";
|
||||||
|
const path = clamp(row.path, 512);
|
||||||
|
const key = `${row.level}|${row.source}|${message}|${path ?? ""}`;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
// Storm coalescing: identical to the last persisted row, within the window →
|
||||||
|
// bump that row instead of inserting. createdAt moves to the LATEST occurrence
|
||||||
|
// (keeps the storm visible at the top of the newest-first viewer); the first
|
||||||
|
// occurrence's time is preserved in context._firstAt.
|
||||||
|
const last = this.#last;
|
||||||
|
if (last && last.key === key && nowMs - last.lastAtMs <= COALESCE_WINDOW_MS) {
|
||||||
|
const res = this.#db
|
||||||
|
.update(appLogs)
|
||||||
|
.set({
|
||||||
|
context: { ...(last.baseContext ?? {}), _repeat: last.count + 1, _firstAt: last.firstAt },
|
||||||
|
createdAt,
|
||||||
|
})
|
||||||
|
.where(eq(appLogs.id, last.id))
|
||||||
|
.run();
|
||||||
|
if ((res.changes ?? 0) > 0) {
|
||||||
|
last.count += 1;
|
||||||
|
last.lastAtMs = nowMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The row was pruned out from under us — fall through to a fresh insert.
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const baseContext = safeContext(row.context);
|
||||||
|
this.#db
|
||||||
|
.insert(appLogs)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
level: row.level,
|
||||||
|
source: row.source,
|
||||||
|
message,
|
||||||
|
context: baseContext,
|
||||||
|
httpStatus: row.httpStatus ?? null,
|
||||||
|
path,
|
||||||
|
stack: clamp(row.stack, MAX_STACK),
|
||||||
|
userId: row.userId ?? null,
|
||||||
|
userAgent: clamp(row.userAgent, 512),
|
||||||
|
createdAt,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
this.#last = { id, key, count: 1, firstAt: createdAt, lastAtMs: nowMs, baseContext };
|
||||||
|
} catch {
|
||||||
|
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
||||||
|
// it; that's the recursion we're guarding against.)
|
||||||
|
} finally {
|
||||||
|
this.#writing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a BACKEND log line (called by the pino stream). Below warn is dropped. */
|
||||||
|
recordBackend(level: LogLevel, message: string, context?: Record<string, unknown> | null): void {
|
||||||
|
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
||||||
|
this.#insert({ level, source: "backend", message, context });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a FRONTEND-reported log (from POST /api/logs). The server stamps the
|
||||||
|
* user + receive time; the client supplies level/message/context. */
|
||||||
|
recordClient(
|
||||||
|
input: ClientLogInput,
|
||||||
|
meta: { userId?: string | null; userAgent?: string | null },
|
||||||
|
): void {
|
||||||
|
this.#insert({
|
||||||
|
level: input.level,
|
||||||
|
source: "frontend",
|
||||||
|
message: input.message,
|
||||||
|
context: input.context ?? null,
|
||||||
|
httpStatus: input.httpStatus ?? null,
|
||||||
|
path: input.path ?? null,
|
||||||
|
stack: input.stack ?? null,
|
||||||
|
userId: meta.userId ?? null,
|
||||||
|
userAgent: meta.userAgent ?? null,
|
||||||
|
// Keep the client's capture time in context for ordering; createdAt is server time.
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read recent logs, newest first, with optional level/source/since filters. */
|
||||||
|
query(opts: {
|
||||||
|
limit: number;
|
||||||
|
level?: LogLevel;
|
||||||
|
source?: LogSource;
|
||||||
|
since?: string;
|
||||||
|
}): AppLogRecord[] {
|
||||||
|
const conds = [];
|
||||||
|
if (opts.level) conds.push(eq(appLogs.level, opts.level));
|
||||||
|
if (opts.source) conds.push(eq(appLogs.source, opts.source));
|
||||||
|
if (opts.since) conds.push(sql`${appLogs.createdAt} >= ${opts.since}`);
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(appLogs)
|
||||||
|
.where(conds.length ? and(...conds) : undefined)
|
||||||
|
.orderBy(desc(appLogs.createdAt))
|
||||||
|
.limit(opts.limit)
|
||||||
|
.all();
|
||||||
|
return rows as unknown as AppLogRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prune by age then by row cap. Returns how many rows were deleted. Safe to call
|
||||||
|
* on a timer; cheap (indexed on created_at). */
|
||||||
|
prune(): number {
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
const cutoff = new Date(Date.now() - this.#retention.maxAgeDays * 86_400_000).toISOString();
|
||||||
|
const byAge = this.#db.delete(appLogs).where(sql`${appLogs.createdAt} < ${cutoff}`).run();
|
||||||
|
deleted += byAge.changes ?? 0;
|
||||||
|
|
||||||
|
// Row cap: keep the newest maxRows, delete the rest. One subquery — find the
|
||||||
|
// created_at boundary of the keep-window, delete older.
|
||||||
|
const total = this.#db.select({ c: sql<number>`count(*)` }).from(appLogs).get();
|
||||||
|
const count = total?.c ?? 0;
|
||||||
|
if (count > this.#retention.maxRows) {
|
||||||
|
const boundary = this.#db
|
||||||
|
.select({ createdAt: appLogs.createdAt })
|
||||||
|
.from(appLogs)
|
||||||
|
.orderBy(desc(appLogs.createdAt))
|
||||||
|
.limit(1)
|
||||||
|
.offset(this.#retention.maxRows - 1)
|
||||||
|
.get();
|
||||||
|
if (boundary) {
|
||||||
|
const byCap = this.#db
|
||||||
|
.delete(appLogs)
|
||||||
|
.where(sql`${appLogs.createdAt} < ${boundary.createdAt}`)
|
||||||
|
.run();
|
||||||
|
deleted += byCap.changes ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
|
||||||
|
* Pino writes one JSON object per line to this stream; we parse, resolve the level
|
||||||
|
* (name or numeric encoding), and persist. Returned as `{ write }` so it can be passed
|
||||||
|
* as pino's stream. stdout still receives the same line (we tee), so console logging is
|
||||||
|
* unchanged.
|
||||||
|
*/
|
||||||
|
export function pinoDbStream(
|
||||||
|
service: LogService,
|
||||||
|
tee: NodeJS.WritableStream,
|
||||||
|
): { write: (line: string) => void } {
|
||||||
|
const NUM_TO_LEVEL: Record<number, LogLevel> = {
|
||||||
|
10: "trace",
|
||||||
|
20: "debug",
|
||||||
|
30: "info",
|
||||||
|
40: "warn",
|
||||||
|
50: "error",
|
||||||
|
60: "fatal",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
write(line: string): void {
|
||||||
|
// Always tee to the original destination first (don't lose stdout logging).
|
||||||
|
try {
|
||||||
|
tee.write(line);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line) as {
|
||||||
|
level?: number | string;
|
||||||
|
msg?: string;
|
||||||
|
err?: { stack?: string; message?: string };
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
// The logger emits level NAMES (formatters.level in server.ts, for human-
|
||||||
|
// readable container logs); a default pino config emits numbers. Accept both.
|
||||||
|
const level: LogLevel =
|
||||||
|
typeof obj.level === "string" && obj.level in LOG_LEVEL_ORDER
|
||||||
|
? (obj.level as LogLevel)
|
||||||
|
: NUM_TO_LEVEL[typeof obj.level === "number" ? obj.level : 30] ?? "info";
|
||||||
|
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
||||||
|
// Strip pino's noisy standard fields from the persisted context.
|
||||||
|
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
|
||||||
|
service.recordBackend(level, typeof msg === "string" ? msg : "", rest);
|
||||||
|
} catch {
|
||||||
|
// A non-JSON line (shouldn't happen with pino) — ignore for persistence.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "./server.js";
|
||||||
|
import { seedUser, login } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// Venue modules — entitled ∩ activated, enforced server-side (wiki/decisions/
|
||||||
|
// venue-modules.md). Boots the real app over an in-memory DB and drives it with
|
||||||
|
// app.inject, like routes.test.ts.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
const savedEnv = process.env.MODULES_ENTITLED;
|
||||||
|
|
||||||
|
async function boot(): Promise<void> {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
delete process.env.MODULES_ENTITLED;
|
||||||
|
await boot();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
if (savedEnv === undefined) delete process.env.MODULES_ENTITLED;
|
||||||
|
else process.env.MODULES_ENTITLED = savedEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function admin() {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
return login(app, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("defaults (no env, nothing activated)", () => {
|
||||||
|
it("every registered module is entitled, activated and effective; /me carries the set", async () => {
|
||||||
|
const { cookie } = await admin();
|
||||||
|
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||||
|
expect(cfg.statusCode).toBe(200);
|
||||||
|
const body = cfg.json();
|
||||||
|
expect(body.modulesEntitled).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
expect(body.modulesActivated).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
expect(body.modules).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().modules).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
|
||||||
|
// A module route answers normally while the module is on.
|
||||||
|
const programs = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
|
||||||
|
expect(programs.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("activation (site admin)", () => {
|
||||||
|
it("deactivating validation 403s its routes with module_disabled, signs a config_change, and is reversible", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
expect(put.json().modules).toEqual(["parking"]);
|
||||||
|
expect(put.json().modulesActivated).toEqual(["parking"]);
|
||||||
|
|
||||||
|
// The merchant scan routes are the module → 403; the PROGRAM routes are core (the
|
||||||
|
// discount engine serves Car Wash too) → still 200 with validation off.
|
||||||
|
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
|
||||||
|
expect(off.statusCode).toBe(403);
|
||||||
|
expect(off.json().code).toBe("module_disabled");
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
|
||||||
|
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().modules).toEqual(["parking"]);
|
||||||
|
|
||||||
|
// The flip is on the signed ledger, attributed.
|
||||||
|
const events = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||||
|
expect(events.statusCode).toBe(200);
|
||||||
|
const list = (events.json().events ?? events.json()) as Array<{ type: string; payload: Record<string, unknown> }>;
|
||||||
|
const flip = list.find((e) => e.type === "config_change" && e.payload?.setting === "modules.validation");
|
||||||
|
expect(flip).toBeTruthy();
|
||||||
|
expect(flip!.payload).toMatchObject({ value: false, prev: true, operator: "boss" });
|
||||||
|
|
||||||
|
// Nothing was deleted: re-enable and the route is back.
|
||||||
|
const back = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "validation"] },
|
||||||
|
});
|
||||||
|
expect(back.json().modules).toEqual(["parking", "validation"]);
|
||||||
|
const on = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
|
||||||
|
expect(on.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("required modules cannot be deactivated (parking is always included)", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: [] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
expect(put.json().modules).toEqual(["parking"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown ids with 400", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "bar"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carwash runs without the validation module (the discount engine is core)", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "carwash"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
expect(put.json().modules).toEqual(["parking", "carwash"]);
|
||||||
|
// The wash's sponsorship program is still composable and readable.
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie } })).statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dependency rule: a module cannot be on while a module it depends on is off", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
// Every non-required module depends on parking, and parking is required — so the rule
|
||||||
|
// is exercised through the effective-set helper directly.
|
||||||
|
const shared = await import("@parking/shared");
|
||||||
|
expect(shared.resolveModuleActivation(["parking", "validation", "carwash"], ["carwash"])).toMatchObject({ ok: true });
|
||||||
|
expect(shared.effectiveModules(["parking", "carwash"], ["parking", "carwash"])).toEqual(["parking", "carwash"]);
|
||||||
|
expect(cookie && csrf).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a no-op resave signs nothing", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const before = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||||
|
const countBefore = ((before.json().events ?? before.json()) as unknown[]).length;
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "validation", "carwash"] },
|
||||||
|
});
|
||||||
|
const after = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||||
|
expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("entitlement (vendor env)", () => {
|
||||||
|
it("MODULES_ENTITLED=parking: validation is neither offered nor activatable, and its routes 403", async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
process.env.MODULES_ENTITLED = "parking";
|
||||||
|
await boot();
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
|
||||||
|
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||||
|
expect(cfg.json().modulesEntitled).toEqual(["parking"]);
|
||||||
|
expect(cfg.json().modules).toEqual(["parking"]);
|
||||||
|
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "validation"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(400);
|
||||||
|
expect(put.json().error).toMatch(/not entitled/);
|
||||||
|
|
||||||
|
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
|
||||||
|
expect(off.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("required modules are entitled even when the env omits them; unknown ids are ignored", async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
process.env.MODULES_ENTITLED = "validation,bogus";
|
||||||
|
await boot();
|
||||||
|
const { cookie } = await admin();
|
||||||
|
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||||
|
expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]);
|
||||||
|
expect(cfg.json().modules).toEqual(["parking", "validation"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permissions matrix helpers (venue-modules.md §Permissions matrix)", async () => {
|
||||||
|
const shared = await import("@parking/shared");
|
||||||
|
it("each till is guarded by its own module's permissions", () => {
|
||||||
|
expect(shared.tillGuards("booth")).toEqual({ read: "shift:read", shift: "shift:create", cash: "drawer:create" });
|
||||||
|
expect(shared.tillGuards("carwash")).toEqual({ read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" });
|
||||||
|
const wash = new Set(["carwash:read", "carwash:cash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p))).toEqual(["carwash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p), "shift")).toEqual(["carwash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => p === "carwash:read", "shift")).toEqual([]);
|
||||||
|
// Module off → its till is not even addressable.
|
||||||
|
expect(shared.tillsFor(["parking"], () => true)).toEqual(["booth"]);
|
||||||
|
});
|
||||||
|
it("the live feed admits by watch permission and filters ledger events by their module", () => {
|
||||||
|
expect(shared.watchPermissions(["parking", "validation", "carwash"])).toEqual(
|
||||||
|
expect.arrayContaining(["event:read", "session:read", "device:read", "carwash:read"]),
|
||||||
|
);
|
||||||
|
expect(shared.watchPermissions(["parking", "validation", "carwash"])).not.toContain("report:read");
|
||||||
|
expect(shared.watchPermissions(["parking"])).not.toContain("carwash:read");
|
||||||
|
expect(shared.feedPermissionFor("carwash_payment")).toBe("carwash:read");
|
||||||
|
expect(shared.feedPermissionFor("payment")).toBe("event:read");
|
||||||
|
expect(shared.feedPermissionFor("validation")).toBe("event:read");
|
||||||
|
});
|
||||||
|
it("every job's permissions exist in the grid", () => {
|
||||||
|
for (const m of shared.MODULES) for (const j of m.jobs) for (const p of j.permissions) expect(shared.PERMISSIONS).toContain(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
effectiveModules,
|
||||||
|
isModuleId,
|
||||||
|
isTillId,
|
||||||
|
parseEntitledModules,
|
||||||
|
tillGuards,
|
||||||
|
tillsFor,
|
||||||
|
tillsOf,
|
||||||
|
type ModuleId,
|
||||||
|
type TillGuards,
|
||||||
|
type TillId,
|
||||||
|
} from "@parking/shared";
|
||||||
|
import { requireAuth, roleHasPermissions } from "./auth.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
/** Set by requireTill(): the till this request addresses (already authorized). */
|
||||||
|
till?: TillId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Venue modules — the server side of "entitled ∩ activated" (registry + rules live in
|
||||||
|
// @parking/shared; design in wiki/decisions/venue-modules.md).
|
||||||
|
//
|
||||||
|
// entitled MODULES_ENTITLED env (vendor, Komodo stack) — unset = everything.
|
||||||
|
// activated site_config.modules_json (site admin, Setup → Site) — null = everything
|
||||||
|
// entitled.
|
||||||
|
// effective what requireModule() enforces and what /api/auth/me + /api/site-config
|
||||||
|
// hand the SPA so it can hide nav. The web only HIDES; this file ENFORCES.
|
||||||
|
//
|
||||||
|
// Both inputs are re-read per request: one env read and one single-row SELECT on the
|
||||||
|
// site_config singleton — cheap, and it means a change takes effect on the next request
|
||||||
|
// with no cache to invalidate (the same reason the presence-bypass flags aren't cached).
|
||||||
|
|
||||||
|
/** The modules this deployment is entitled to. Unknown ids in the env are ignored
|
||||||
|
* (logged once at boot by registerModules). */
|
||||||
|
export function entitledModules(): ModuleId[] {
|
||||||
|
return parseEntitledModules(process.env.MODULES_ENTITLED).entitled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the persisted activation list off a site_config row. null = never set. A
|
||||||
|
* corrupt/unknown value is treated as "never set" rather than locking modules off. */
|
||||||
|
export function activatedModulesOf(row: { modulesJson?: string | null } | undefined): ModuleId[] | null {
|
||||||
|
const raw = row?.modulesJson;
|
||||||
|
if (raw == null) return null;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
return parsed.filter(isModuleId);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The effective set for this site right now. */
|
||||||
|
export function effectiveModulesFor(db: Db): ModuleId[] {
|
||||||
|
const row = db.select({ modulesJson: siteConfig.modulesJson }).from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return effectiveModules(entitledModules(), activatedModulesOf(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tills available at this site right now: the booth, plus each effective
|
||||||
|
* money-taking module's own till (registry order). */
|
||||||
|
export function effectiveTillsFor(db: Db): TillId[] {
|
||||||
|
return tillsOf(effectiveModulesFor(db));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tills a role may SEE (default) or WORK (`shift` / `cash`) here: the effective
|
||||||
|
* tills whose module guard the role holds (each desk's money is guarded by that desk's
|
||||||
|
* own permissions — venue-modules.md §"Permissions matrix"). */
|
||||||
|
export function tillsReadableBy(db: Db, roleId: string, kind: keyof TillGuards = "read"): TillId[] {
|
||||||
|
return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p]), kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** preHandler factory for the shift/drawer routes: authenticate, parse the `till`
|
||||||
|
* (query on GET, body on POST; absent = booth; 400 `bad_till` when unknown or its
|
||||||
|
* module is off), then require the role to hold THAT TILL's guard for `kind` (403
|
||||||
|
* `till_forbidden`). The authorized till lands on `req.till`. The permission is thus
|
||||||
|
* resolved from the till, never fixed: the booth checks `shift:read`/`shift:create`/
|
||||||
|
* `drawer:create`, the wash `carwash:read`/`carwash:cash`. */
|
||||||
|
export function requireTill(db: Db, kind: keyof TillGuards, from: "query" | "body") {
|
||||||
|
return async (req: FastifyRequest, reply: FastifyReply): Promise<void | FastifyReply> => {
|
||||||
|
await requireAuth(req, reply);
|
||||||
|
const raw = from === "query" ? (req.query as { till?: unknown } | undefined)?.till : (req.body as { till?: unknown } | undefined)?.till;
|
||||||
|
const till = parseTill(db, raw);
|
||||||
|
if (!till) {
|
||||||
|
await reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
if (!roleHasPermissions(req.user.roleId, [tillGuards(till)[kind]])) {
|
||||||
|
await reply
|
||||||
|
.code(403)
|
||||||
|
.send({ error: `your role cannot ${kind === "read" ? "see" : "work"} the ${till} till`, code: "till_forbidden", till });
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
req.till = till;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a till from a query/body value. Absent/blank = the booth. Unknown, or a till
|
||||||
|
* whose module is not effective here, → null (the caller answers 400). */
|
||||||
|
export function parseTill(db: Db, raw: unknown): TillId | null {
|
||||||
|
if (raw == null || raw === "") return "booth";
|
||||||
|
if (!isTillId(raw)) return null;
|
||||||
|
return effectiveTillsFor(db).includes(raw) ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** preHandler: reject the call when `id` is not effective at this site. Compose it
|
||||||
|
* BEFORE requirePermission in a preHandler array so a disabled module answers the
|
||||||
|
* same way for every role — 403 with code "module_disabled" — and never reaches
|
||||||
|
* the permission/CSRF path. */
|
||||||
|
export function requireModule(db: Db, id: ModuleId) {
|
||||||
|
return async (_req: FastifyRequest, _reply: FastifyReply): Promise<void> => {
|
||||||
|
if (!effectiveModulesFor(db).includes(id)) {
|
||||||
|
throw Object.assign(new Error(`module disabled: ${id}`), { statusCode: 403, code: "module_disabled" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { deviceEvents, type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../../server.js";
|
||||||
|
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
|
||||||
|
|
||||||
|
// Car Wash module, end to end over the real app (wiki/decisions/venue-modules.md):
|
||||||
|
// settings → intake against an open parking session → done applies the sponsorship
|
||||||
|
// validation → bay payment settles the parking session at zero (what the exit reader
|
||||||
|
// checks) / booth payment carries the wash as a charge line → module off = 403.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
delete process.env.MODULES_ENTITLED;
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
type Auth = { cookie: string; csrf: string };
|
||||||
|
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||||
|
|
||||||
|
async function admin(): Promise<Auth> {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
return login(app, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An open transient session that has been parked long enough to owe money. */
|
||||||
|
async function openSession(identity: string, enteredMinutesAgo = 90): Promise<void> {
|
||||||
|
await makeLog(db).append({
|
||||||
|
type: "vehicle_entry",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
occurredAt: minutesAgo(enteredMinutesAgo),
|
||||||
|
payload: { sessionRef: identity, category: "default" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedSettings(a: Auth) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||||
|
payload: {
|
||||||
|
categories: [{ name: "Car" }, { name: "SUV" }],
|
||||||
|
services: [{ name: "Standard" }, { name: "Inside" }],
|
||||||
|
prices: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const s = res.json();
|
||||||
|
const car = s.categories.find((c: { name: string }) => c.name === "Car").id;
|
||||||
|
const suv = s.categories.find((c: { name: string }) => c.name === "SUV").id;
|
||||||
|
const std = s.services.find((c: { name: string }) => c.name === "Standard").id;
|
||||||
|
const inside = s.services.find((c: { name: string }) => c.name === "Inside").id;
|
||||||
|
const priced = await app.inject({
|
||||||
|
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||||
|
payload: {
|
||||||
|
categories: s.categories, services: s.services,
|
||||||
|
prices: [
|
||||||
|
{ categoryId: car, serviceId: std, priceMinor: 50000 },
|
||||||
|
{ categoryId: suv, serviceId: std, priceMinor: 70000 },
|
||||||
|
{ categoryId: car, serviceId: inside, priceMinor: 30000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(priced.statusCode).toBe(200);
|
||||||
|
expect(priced.json().prices).toHaveLength(3);
|
||||||
|
return { car, suv, std, inside };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flip the site's wash-payment policy (Setup → Car wash). */
|
||||||
|
async function setPayAt(a: Auth, payAt: "booth" | "bay") {
|
||||||
|
const res = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().payAt).toBe(payAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedSponsorship(a: Auth, mode: "comp" | "percent" | "doneTolerance" | "washPrice" = "comp", minutes: number | null = null) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||||
|
payload: { name: "Lavazh", mode, percent: mode === "percent" ? 50 : null, minutes, active: true, userIds: [] },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBeLessThan(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function events(a: Auth) {
|
||||||
|
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
||||||
|
return (r.json().events ?? r.json()) as Array<{ id: string; type: string; identity: string | null; payload: Record<string, unknown> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
it("round-trips categories, services and the price matrix; signs a config_change; unknown pairs are refused", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
const get = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } });
|
||||||
|
expect(get.json().categories.map((c: { name: string }) => c.name)).toEqual(["Car", "SUV"]);
|
||||||
|
expect(get.json().prices.find((p: { categoryId: string; serviceId: string }) => p.categoryId === ids.suv && p.serviceId === ids.std).priceMinor).toBe(70000);
|
||||||
|
const bad = await app.inject({
|
||||||
|
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||||
|
payload: { prices: [{ categoryId: "nope", serviceId: ids.std, priceMinor: 1 }] },
|
||||||
|
});
|
||||||
|
expect(bad.statusCode).toBe(400);
|
||||||
|
const flips = (await events(a)).filter((e) => e.type === "config_change" && e.payload.setting === "carwash.settings");
|
||||||
|
expect(flips.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("orders", () => {
|
||||||
|
it("intake needs an open session and a priced pair; the queue is oldest-first", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
const noSession = await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-NONE", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
expect(noSession.statusCode).toBe(404);
|
||||||
|
|
||||||
|
await openSession("T-1");
|
||||||
|
const noPrice = await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.inside },
|
||||||
|
});
|
||||||
|
expect(noPrice.statusCode).toBe(409);
|
||||||
|
expect(noPrice.json().code).toBe("no_price");
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(201);
|
||||||
|
expect(created.json()).toMatchObject({ identity: "T-1", categoryName: "SUV", serviceName: "Standard", priceMinor: 70000, payAt: "booth", status: "open", closed: false });
|
||||||
|
|
||||||
|
await openSession("T-2");
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-2", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||||
|
expect(queue.json().orders.map((o: { identity: string }) => o.identity)).toEqual(["T-1", "T-2"]);
|
||||||
|
|
||||||
|
const chain = (await events(a)).filter((e) => e.type === "carwash_order");
|
||||||
|
expect(chain).toHaveLength(2);
|
||||||
|
expect(chain[0]!.payload).toMatchObject({ action: "created", operator: "boss" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pay at BOOTH: the wash rides the parking quote as a charge line and is marked paid by the booth payment", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-B");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-B", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
|
||||||
|
const look = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||||
|
const s = look.json();
|
||||||
|
expect(s.chargeLines).toHaveLength(1);
|
||||||
|
expect(s.chargeLines[0]).toMatchObject({ module: "carwash", ref: order.id, amountMinor: 50000 });
|
||||||
|
expect(s.chargesMinor).toBe(50000);
|
||||||
|
expect(s.amountMinor).toBeGreaterThan(50000); // parking fee + the wash
|
||||||
|
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||||
|
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(a), payload: { identity: "T-B", tender: "cash" } });
|
||||||
|
expect(pay.statusCode).toBeLessThan(300);
|
||||||
|
|
||||||
|
const payment = (await events(a)).find((e) => e.type === "payment" && e.identity === "T-B")!;
|
||||||
|
expect(payment.payload.chargesMinor).toBe(50000);
|
||||||
|
expect((payment.payload.chargeLines as unknown[]).length).toBe(1);
|
||||||
|
expect(payment.payload.amountMinor).toBe((payment.payload.parkingMinor as number) + 50000);
|
||||||
|
|
||||||
|
const recent = await app.inject({ method: "GET", url: "/api/carwash/orders?scope=recent", headers: { cookie: a.cookie } });
|
||||||
|
const o = recent.json().orders.find((x: { id: string }) => x.id === order.id);
|
||||||
|
expect(o.paidAt).toBeTruthy();
|
||||||
|
expect(o.paymentEventId).toBeUndefined(); // not exposed on the view
|
||||||
|
expect(o.tender).toBe("cash");
|
||||||
|
// A second lookup no longer carries the line (it's settled).
|
||||||
|
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||||
|
expect(again.json().chargeLines).toEqual([]);
|
||||||
|
|
||||||
|
// The booth's Z-report: the wash money is inside cash (it is in the drawer) but
|
||||||
|
// OUT of the ticket bucket, under its own module — Bileta is parking money only.
|
||||||
|
const parking = payment.payload.parkingMinor as number;
|
||||||
|
const z = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||||||
|
expect(z).toMatchObject({ till: "booth", cashTotalMinor: parking + 50000, ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
|
||||||
|
expect(z.ticketTotalMinor + z.subscriptionTotalMinor + 50000).toBe(z.cashTotalMinor + z.cardTotalMinor);
|
||||||
|
const summary = (await app.inject({ method: "GET", url: "/api/shifts", headers: { cookie: a.cookie } })).json().shifts[0];
|
||||||
|
expect(summary).toMatchObject({ till: "booth", ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
|
||||||
|
const signed = (await events(a)).find((e) => e.type === "shift_z_report")!;
|
||||||
|
expect(signed.payload.chargesByModuleMinor).toEqual({ carwash: 50000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "comp");
|
||||||
|
await openSession("T-Y");
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-Y", categoryId: ids.suv, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
|
||||||
|
// Bay money needs an open CARWASH shift — the booth's shift does not count (tills).
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||||
|
const noShift = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||||
|
expect(noShift.statusCode).toBe(409);
|
||||||
|
expect(noShift.json()).toMatchObject({ code: "no_shift", till: "carwash" });
|
||||||
|
const openWash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||||
|
expect(openWash.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const done = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
expect(done.statusCode).toBe(200);
|
||||||
|
expect(done.json().status).toBe("done");
|
||||||
|
expect(done.json().validationEventId).toBeTruthy();
|
||||||
|
// Sponsorship applied → the parking quote is now zero-due (comp), but NOT yet paid.
|
||||||
|
const mid = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||||
|
expect(mid.json().amountMinor).toBe(0);
|
||||||
|
expect(mid.json().paidAt).toBeNull();
|
||||||
|
|
||||||
|
const paid = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "card" } });
|
||||||
|
expect(paid.statusCode).toBe(200);
|
||||||
|
expect(paid.json().closed).toBe(true);
|
||||||
|
|
||||||
|
const evs = await events(a);
|
||||||
|
const bay = evs.find((e) => e.type === "carwash_payment")!;
|
||||||
|
expect(bay.payload).toMatchObject({ orderId: order.id, amountMinor: 70000, tender: "card", operator: "boss", till: "carwash" });
|
||||||
|
// The wash Z-report carries the bay money; the booth's carries none of it.
|
||||||
|
const washZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a), payload: { till: "carwash" } })).json();
|
||||||
|
expect(washZ).toMatchObject({ till: "carwash", cardTotalMinor: 70000, cashTotalMinor: 0, paymentCount: 1 });
|
||||||
|
const boothZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||||||
|
expect(boothZ.till).toBe("booth");
|
||||||
|
expect(boothZ.cardTotalMinor).toBe(0);
|
||||||
|
expect(boothZ.paymentCount).toBe(1); // the $0 parking settlement is booth money
|
||||||
|
// The $0 parking payment exists → the exit reader's paid+grace check passes.
|
||||||
|
const parkingPay = evs.find((e) => e.type === "payment" && e.identity === "T-Y")!;
|
||||||
|
expect(parkingPay).toBeTruthy();
|
||||||
|
expect(parkingPay.payload.amountMinor).toBe(0);
|
||||||
|
const after = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||||
|
expect(after.json().paidAt).toBeTruthy();
|
||||||
|
expect(after.json().withinGrace).toBe(true);
|
||||||
|
|
||||||
|
// The queue is empty (done + paid = closed).
|
||||||
|
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||||
|
expect(queue.json().orders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pay at BAY with a PARTIAL sponsorship leaves the remainder for the booth (no $0 payment)", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "percent");
|
||||||
|
await openSession("T-P");
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-P", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const s = (await app.inject({ method: "GET", url: "/api/session/T-P", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(s.paidAt).toBeNull();
|
||||||
|
expect(s.amountMinor).toBeGreaterThan(0);
|
||||||
|
expect(s.discountMinor).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("void takes back a live sponsorship; a paid order cannot be voided", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "comp");
|
||||||
|
await openSession("T-V");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-V", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const before = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(before.validationLines).toHaveLength(1);
|
||||||
|
|
||||||
|
const voided = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/void`, headers: hdrs(a), payload: { reason: "customer left" } });
|
||||||
|
expect(voided.statusCode).toBe(200);
|
||||||
|
expect(voided.json().status).toBe("void");
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.validationLines).toEqual([]);
|
||||||
|
expect(after.chargeLines).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wash-only discount modes", () => {
|
||||||
|
it("doneTolerance credits only the WASH WINDOW (+ tolerance), never the parking before the order", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
// 100.00 per 60-min increment, no entry grace; parked 95 min → 2 increments.
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "doneTolerance", 15);
|
||||||
|
await openSession("T-D", 95);
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-D", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
const before = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(before.amountMinor).toBe(20000);
|
||||||
|
// Done right away: the wash window is ~0 min, so the credit is just the tolerance.
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-D")!;
|
||||||
|
expect(v.payload.mode).toBe("timeCredit");
|
||||||
|
expect(v.payload.programMode).toBe("doneTolerance");
|
||||||
|
expect(v.payload.minutes as number).toBeGreaterThanOrEqual(15);
|
||||||
|
expect(v.payload.minutes as number).toBeLessThanOrEqual(17);
|
||||||
|
// 95 − ~15 min still spans 2 increments → the long stay is NOT comped away.
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.amountMinor).toBe(20000);
|
||||||
|
expect(after.discountMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doneTolerance with a tolerance that covers the whole stay does comp it (the credit is real)", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "doneTolerance", 120);
|
||||||
|
await openSession("T-D2", 95);
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-D2", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-D2", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.amountMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("washPrice: the wash price comes off the parking fee, floored at zero", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
// 1000.00/h, parked 95 min → 2 increments = 200000 owed. Car·Standard wash = 50000.
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 100000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "washPrice");
|
||||||
|
await openSession("T-W", 95);
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-W", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
const before = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.discountMinor).toBe(50000);
|
||||||
|
expect(after.amountMinor).toBe(before.amountMinor - 50000);
|
||||||
|
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-W")!;
|
||||||
|
expect(v.payload).toMatchObject({ mode: "fixed", programMode: "washPrice", amountMinor: 50000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a merchant scan cannot apply a wash-only program", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
await seedSponsorship(a, "washPrice");
|
||||||
|
// Bind the admin to it so the binding check passes and the MODE check is what refuses.
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||||
|
payload: { name: "Lavazh", mode: "washPrice", active: true, userIds: [(await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: a.cookie } })).json().id] },
|
||||||
|
});
|
||||||
|
await openSession("T-M");
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(a), payload: { identity: "T-M", programId: "carwash" } });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toMatch(/car wash order/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module gate", () => {
|
||||||
|
it("with carwash deactivated every route 403s and the booth quote carries no wash lines", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-G");
|
||||||
|
await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-G", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
const off = await app.inject({ method: "PUT", url: "/api/site-config", headers: hdrs(a), payload: { modules: ["parking", "validation"] } });
|
||||||
|
expect(off.json().modules).toEqual(["parking", "validation"]);
|
||||||
|
const q = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||||
|
expect(q.statusCode).toBe(403);
|
||||||
|
expect(q.json().code).toBe("module_disabled");
|
||||||
|
const look = (await app.inject({ method: "GET", url: "/api/session/T-G", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(look.chargeLines).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("where the money is taken is a SITE setting", () => {
|
||||||
|
it("defaults to the booth, persists, signs a config_change, and freezes on each order", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json().payAt).toBe("booth");
|
||||||
|
await openSession("T-S1");
|
||||||
|
const o1 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S1", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||||
|
expect(o1.payAt).toBe("booth");
|
||||||
|
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const cfg = (await events(a)).find((e) => e.type === "config_change" && e.payload.setting === "carwash.payAt")!;
|
||||||
|
expect(cfg.payload).toMatchObject({ value: "bay", prev: "booth", operator: "boss" });
|
||||||
|
await openSession("T-S2");
|
||||||
|
const o2 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||||
|
expect(o2.payAt).toBe("bay");
|
||||||
|
expect(o1.payAt).toBe("booth"); // earlier order keeps the policy it was created under
|
||||||
|
|
||||||
|
// A stale client insisting on the other place is refused, never silently overridden.
|
||||||
|
const stale = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std, payAt: "booth" } });
|
||||||
|
expect(stale.statusCode).toBe(409);
|
||||||
|
expect(stale.json().code).toBe("pay_at_policy");
|
||||||
|
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt: "pocket" } });
|
||||||
|
expect(bad.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tills are gated by the module permission", () => {
|
||||||
|
it("a wash-only role works the carwash till and never the booth's; a booth role the reverse", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
await seedSettings(a);
|
||||||
|
// The wash-operator JOB: no shift:* / drawer:* at all — the wash till is guarded by
|
||||||
|
// carwash:read / carwash:cash (venue-modules.md §"Permissions matrix").
|
||||||
|
const washer = await seedUser(db, {
|
||||||
|
username: "lavazhier", roleId: "washer",
|
||||||
|
permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
|
||||||
|
});
|
||||||
|
const w = await login(app, washer.username, washer.password);
|
||||||
|
// The desk's category/service pickers come from the settings read — the job has no
|
||||||
|
// site:read, so the module permission must open it (found on park dev, 2026-09-06).
|
||||||
|
const list = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: w.cookie } });
|
||||||
|
expect(list.statusCode).toBe(200);
|
||||||
|
expect(list.json().categories.length).toBeGreaterThan(0);
|
||||||
|
expect((await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(w), payload: { payAt: "bay" } })).statusCode).toBe(403);
|
||||||
|
// What the UI offers: only the wash till.
|
||||||
|
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
|
||||||
|
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
|
||||||
|
// The booth's shift is refused outright (the role holds no shift:*).
|
||||||
|
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
|
||||||
|
expect(booth.statusCode).toBe(403);
|
||||||
|
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
|
||||||
|
const boothState = await app.inject({ method: "GET", url: "/api/shift/current", headers: { cookie: w.cookie } });
|
||||||
|
expect(boothState.statusCode).toBe(403);
|
||||||
|
const boothCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100 } });
|
||||||
|
expect(boothCash.statusCode).toBe(403);
|
||||||
|
// The wash till works.
|
||||||
|
const wash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w), payload: { till: "carwash" } });
|
||||||
|
expect(wash.statusCode).toBe(200);
|
||||||
|
expect(wash.json().till).toBe("carwash");
|
||||||
|
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
|
||||||
|
expect(washCash.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// A wash user who may look (carwash:read) but not work the till (no carwash:cash)
|
||||||
|
// sees the state and gets canWork=false; opening is refused.
|
||||||
|
const looker = await seedUser(db, { username: "looker", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||||
|
const l = await login(app, looker.username, looker.password);
|
||||||
|
const lookTills = (await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: l.cookie } })).json();
|
||||||
|
expect(lookTills.tills).toMatchObject([{ till: "carwash", canWork: false }]);
|
||||||
|
expect((await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(l), payload: { till: "carwash" } })).statusCode).toBe(403);
|
||||||
|
|
||||||
|
// A booth operator (shift:*, no carwash:*) cannot touch the wash till.
|
||||||
|
const booth1 = await seedUser(db, {
|
||||||
|
username: "boothie", roleId: "booth-op",
|
||||||
|
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
|
||||||
|
});
|
||||||
|
const b = await login(app, booth1.username, booth1.password);
|
||||||
|
const noWash = await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(b), payload: { till: "carwash" } });
|
||||||
|
expect(noWash.statusCode).toBe(403);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a role reassignment takes effect without re-login", () => {
|
||||||
|
it("a user moved from a look-only role to the wash-operator role can create an order on the next request", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-R");
|
||||||
|
const looker = await seedUser(db, { username: "moved", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||||
|
// Materialise the target role (seedUser creates the role rows; the user itself is a throwaway).
|
||||||
|
await seedUser(db, { username: "throwaway", roleId: "wash-op", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] });
|
||||||
|
const l = await login(app, looker.username, looker.password);
|
||||||
|
const before = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||||
|
expect(before.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie: a.cookie } })).json();
|
||||||
|
const id = list.users.find((u: { username: string }) => u.username === "moved").id;
|
||||||
|
const moved = await app.inject({ method: "PUT", url: `/api/users/${id}`, headers: hdrs(a), payload: { roleId: "wash-op" } });
|
||||||
|
expect(moved.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// Same cookie, no re-login: the token's pinned role is refreshed per request.
|
||||||
|
const after = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||||
|
expect(after.statusCode).toBe(201);
|
||||||
|
const me = (await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: l.cookie } })).json();
|
||||||
|
expect(me.roleId).toBe("wash-op");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a shift's activity log is per till", () => {
|
||||||
|
it("/api/events?till= applies tillOfEvent; a feed-only role reads its module's events and nothing else", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-L");
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-L", categoryId: ids.suv, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||||
|
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 500, till: "carwash" } });
|
||||||
|
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 700 } });
|
||||||
|
|
||||||
|
const types = async (qs: string, auth: Auth = a) => {
|
||||||
|
const r = await app.inject({ method: "GET", url: `/api/events?limit=200${qs}`, headers: { cookie: auth.cookie } });
|
||||||
|
expect(r.statusCode).toBe(200);
|
||||||
|
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).map((e) => `${e.type}${e.payload?.till ? `@${e.payload.till}` : ""}`);
|
||||||
|
};
|
||||||
|
// The wash till's log: its shift, its order (no money moved, but wash-desk activity),
|
||||||
|
// its bay payment and its voucher — none of the booth's.
|
||||||
|
const wash = await types("&till=carwash");
|
||||||
|
expect(wash).toEqual(expect.arrayContaining(["shift_open@carwash", "carwash_order", "carwash_payment@carwash", "cash_in@carwash"]));
|
||||||
|
expect(wash.some((t) => t.startsWith("vehicle_entry") || t === "shift_open@booth" || t === "cash_in@booth")).toBe(false);
|
||||||
|
// The booth's log: entry, its shift, its voucher — and no wash-desk activity.
|
||||||
|
const booth = await types("&till=booth");
|
||||||
|
expect(booth).toEqual(expect.arrayContaining(["vehicle_entry", "shift_open@booth", "cash_in@booth"]));
|
||||||
|
expect(booth.some((t) => t.startsWith("carwash_") || t.endsWith("@carwash"))).toBe(false);
|
||||||
|
// No till → everything (unchanged).
|
||||||
|
const all = await types("");
|
||||||
|
expect(all.length).toBe(wash.length + booth.length);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/events?till=bar", headers: { cookie: a.cookie } })).statusCode).toBe(400);
|
||||||
|
|
||||||
|
// A wash operator holds carwash:read but not event:read: the log opens for them
|
||||||
|
// with ONLY the module's own event types (the live-socket rule, feedPermissionFor).
|
||||||
|
const washer = await seedUser(db, { username: "lavazhier", roleId: "washer", permissions: ["carwash:read", "carwash:cash"] });
|
||||||
|
const w = await login(app, washer.username, washer.password);
|
||||||
|
const mine = await types("&till=carwash", w);
|
||||||
|
expect(mine).toEqual(expect.arrayContaining(["carwash_order", "carwash_payment@carwash"]));
|
||||||
|
expect(mine.every((t) => t.startsWith("carwash_"))).toBe(true);
|
||||||
|
// A role with neither event:read nor any module feed permission reads nothing.
|
||||||
|
const clerk = await seedUser(db, { username: "clerk", roleId: "clerk", permissions: ["session:read"] });
|
||||||
|
const c = await login(app, clerk.username, clerk.password);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/events", headers: { cookie: c.cookie } })).statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vision category — advisory, flagged, never authoritative", () => {
|
||||||
|
/** What snapshot.ts records when vision classifies the entry frame. */
|
||||||
|
function seeVehicle(identity: string, bodyType: string, bodyConfidence: number) {
|
||||||
|
db.insert(deviceEvents).values({
|
||||||
|
id: `read-${identity}-${bodyType}`, deviceId: "cam-1", category: "camera", kind: "read",
|
||||||
|
detail: { identity, direction: "entry", bodyType, bodyConfidence, snapshotId: "snap-1", source: "entry-exit-snapshot" },
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
async function mapClasses(a: Auth, ids: { car: string; suv: string }) {
|
||||||
|
const cur = (await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json();
|
||||||
|
const r = await app.inject({
|
||||||
|
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||||
|
payload: {
|
||||||
|
categories: cur.categories.map((c: { id: string }) => ({ ...c, visionClasses: c.id === ids.suv ? ["suv", "pickup"] : c.id === ids.car ? ["car", "sedan", "hatchback"] : [] })),
|
||||||
|
visionThreshold: 0.75,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.statusCode).toBe(200);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("Setup maps the vocabulary onto site categories; the lookup suggests the mapped category", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
const saved = await mapClasses(a, ids);
|
||||||
|
expect(saved.categories.find((c: { id: string }) => c.id === ids.suv).visionClasses).toEqual(["suv", "pickup"]);
|
||||||
|
expect(saved.visionThreshold).toBe(0.75);
|
||||||
|
expect((await events(a)).some((e) => e.type === "config_change" && e.payload.setting === "carwash.visionThreshold")).toBe(true);
|
||||||
|
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { categories: [{ id: ids.car, name: "Car", visionClasses: ["spaceship"] }] } });
|
||||||
|
expect(bad.statusCode).toBe(400);
|
||||||
|
|
||||||
|
await openSession("T-V1");
|
||||||
|
seeVehicle("T-V1", "suv", 0.91);
|
||||||
|
const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(look.vision).toMatchObject({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" });
|
||||||
|
expect(look.suggestedCategoryId).toBe(ids.suv);
|
||||||
|
// Unmapped class → shown, nothing suggested.
|
||||||
|
await openSession("T-V2");
|
||||||
|
seeVehicle("T-V2", "bus", 0.99);
|
||||||
|
const look2 = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V2", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(look2.vision.bodyType).toBe("bus");
|
||||||
|
expect(look2.suggestedCategoryId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a confident downgrade signs an anomaly with both categories and the snapshot; equal, upgrade or unsure reads do not; the order is never blocked", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await mapClasses(a, ids);
|
||||||
|
const order = async (identity: string, categoryId: string) => {
|
||||||
|
const r = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity, categoryId, serviceId: ids.std } });
|
||||||
|
expect(r.statusCode).toBe(201);
|
||||||
|
return r.json();
|
||||||
|
};
|
||||||
|
// Camera: SUV (0.91) — operator picks Car (cheaper) → flagged, recorded, still created.
|
||||||
|
await openSession("T-D1"); seeVehicle("T-D1", "suv", 0.91);
|
||||||
|
const down = await order("T-D1", ids.car);
|
||||||
|
expect(down).toMatchObject({ visionClass: "suv", visionConfidence: 0.91, visionCategoryId: ids.suv, categoryId: ids.car });
|
||||||
|
expect(down.downgradeEventId).toBeTruthy();
|
||||||
|
const flag = (await events(a)).find((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")!;
|
||||||
|
expect(flag).toBeTruthy();
|
||||||
|
expect(flag.payload).toMatchObject({
|
||||||
|
visionClass: "suv", visionCategoryName: "SUV", chosenCategoryName: "Car", operator: "boss",
|
||||||
|
visionPriceMinor: 70000, chosenPriceMinor: 50000, snapshotId: "snap-1",
|
||||||
|
});
|
||||||
|
// Same category as the camera → nothing.
|
||||||
|
await openSession("T-D2"); seeVehicle("T-D2", "suv", 0.91);
|
||||||
|
expect((await order("T-D2", ids.suv)).downgradeEventId).toBeNull();
|
||||||
|
// Upgrade (camera Car, operator SUV) → recorded on the order, no anomaly.
|
||||||
|
await openSession("T-D3"); seeVehicle("T-D3", "sedan", 0.95);
|
||||||
|
const up = await order("T-D3", ids.suv);
|
||||||
|
expect(up).toMatchObject({ visionClass: "sedan", visionCategoryId: ids.car, downgradeEventId: null });
|
||||||
|
// Below the site threshold → shown, never flagged.
|
||||||
|
await openSession("T-D4"); seeVehicle("T-D4", "suv", 0.6);
|
||||||
|
expect((await order("T-D4", ids.car)).downgradeEventId).toBeNull();
|
||||||
|
// No read at all → nulls.
|
||||||
|
await openSession("T-D5");
|
||||||
|
expect(await order("T-D5", ids.car)).toMatchObject({ visionClass: null, visionCategoryId: null, downgradeEventId: null });
|
||||||
|
expect((await events(a)).filter((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { deviceEvents } from "../../device-events.js";
|
||||||
|
import type { ServerModule } from "../index.js";
|
||||||
|
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||||
|
import { carwashRoutes } from "./routes.js";
|
||||||
|
import { CarwashService } from "./service.js";
|
||||||
|
|
||||||
|
// Car Wash — the pilot venue module (wiki/decisions/venue-modules.md). Everything the
|
||||||
|
// module is lives in this folder: its service (master data, the order queue, the bay
|
||||||
|
// payment, the parking sponsorship + settlement), its routes, and the booth charge
|
||||||
|
// provider it registers with the core's PayStation. The core knows it only through the
|
||||||
|
// registry line in ../index.ts and the manifest in @parking/shared.
|
||||||
|
export const carwashModule: ServerModule = {
|
||||||
|
id: "carwash",
|
||||||
|
async register(app, deps) {
|
||||||
|
// The review outbox (wiki/concepts/vision-review-outbox.md): on when the stack env
|
||||||
|
// names a collector URL, a per-booth token and a pseudonymous booth id; off = no
|
||||||
|
// queueing at all. One-way, background, never on the intake path.
|
||||||
|
const cfg = reviewUploadConfigFromEnv();
|
||||||
|
const outbox = new ReviewOutbox(deps.db, app.log, cfg);
|
||||||
|
app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off");
|
||||||
|
outbox.start();
|
||||||
|
// Entry-stream sampling: one in N entry vehicle reads goes to the reviewer as pure
|
||||||
|
// training material (the gate view, no order attached). The core announces the read;
|
||||||
|
// the module decides. Off unless CARWASH_REVIEW_ENTRY_SAMPLE is set.
|
||||||
|
const offVehicleRead = deviceEvents.onVehicleRead((e) => {
|
||||||
|
if (e.direction === "entry" && outbox.sampleEntry()) void outbox.enqueueEntry(e.read);
|
||||||
|
});
|
||||||
|
app.addHook("onClose", async () => offVehicleRead());
|
||||||
|
app.addHook("onClose", async () => outbox.stop());
|
||||||
|
const service = new CarwashService(deps, app.log, outbox);
|
||||||
|
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
|
||||||
|
// the core calls back after the payment is signed so the order is marked paid.
|
||||||
|
deps.payStation.registerChargeProvider(service.chargeProvider());
|
||||||
|
await carwashRoutes(app, deps, service, outbox);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { deviceEvents as deviceEventBus } from "../../device-events.js";
|
||||||
|
import { buildServer } from "../../server.js";
|
||||||
|
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
|
||||||
|
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||||
|
|
||||||
|
// The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred
|
||||||
|
// vehicle crop + the operator's choice, queued off the intake path, drained one-way with
|
||||||
|
// backoff, never blocking the wash, never naming the site.
|
||||||
|
|
||||||
|
/** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */
|
||||||
|
async function frame(): Promise<Buffer> {
|
||||||
|
return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } })
|
||||||
|
.composite([
|
||||||
|
{ input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 },
|
||||||
|
{ input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 },
|
||||||
|
])
|
||||||
|
.jpeg()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 };
|
||||||
|
const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 };
|
||||||
|
|
||||||
|
/** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a
|
||||||
|
* blurred plate drops far below 255 as the red bleeds in. */
|
||||||
|
async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise<number> {
|
||||||
|
const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true });
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 1; i < data.length; i += info.channels) sum += data[i]!;
|
||||||
|
return sum / (data.length / info.channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("makeReviewCrop", () => {
|
||||||
|
it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => {
|
||||||
|
const shot = await frame();
|
||||||
|
const crop = await makeReviewCrop(shot, CAR, PLATE);
|
||||||
|
expect(crop.plateBlurred).toBe(true);
|
||||||
|
// Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling.
|
||||||
|
expect(crop.width).toBeGreaterThanOrEqual(228);
|
||||||
|
expect(crop.width).toBeLessThanOrEqual(236);
|
||||||
|
expect(crop.height).toBeGreaterThanOrEqual(135);
|
||||||
|
// The white plate is gone: over the plate strip (crop coords: the frame's 170..230 ×
|
||||||
|
// 190..206 shifted by the crop origin 84,90) the same region cut straight from the
|
||||||
|
// frame is white, the review crop is the red bleeding in.
|
||||||
|
const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer();
|
||||||
|
const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 };
|
||||||
|
expect(await meanGreen(plain, strip)).toBeGreaterThan(240);
|
||||||
|
expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180);
|
||||||
|
// Without a plate box: same crop, nothing blurred.
|
||||||
|
const noPlate = await makeReviewCrop(shot, CAR, null);
|
||||||
|
expect(noPlate.plateBlurred).toBe(false);
|
||||||
|
// A big frame is capped to the max edge.
|
||||||
|
const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer();
|
||||||
|
const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null);
|
||||||
|
expect(Math.max(capped.width, capped.height)).toBe(640);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("config + pseudonyms", () => {
|
||||||
|
it("needs url, token and booth id together; the operator ref is a keyed hash", () => {
|
||||||
|
expect(reviewUploadConfigFromEnv({})).toBeNull();
|
||||||
|
expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull();
|
||||||
|
const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" });
|
||||||
|
expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default
|
||||||
|
expect(operatorRef("b7", "lavazhier")).toHaveLength(16);
|
||||||
|
expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier"));
|
||||||
|
expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("queue + drain", () => {
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60, entrySample: 0 };
|
||||||
|
const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE };
|
||||||
|
const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", categoryClasses: ["car", "sedan"], serviceName: "Standard", visionCategoryId: "car", downgraded: false };
|
||||||
|
|
||||||
|
async function seed(): Promise<void> {
|
||||||
|
db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||||
|
db.insert(carwashOrders).values({
|
||||||
|
id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard",
|
||||||
|
priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => {
|
||||||
|
await seed();
|
||||||
|
const calls: { url: string; init: RequestInit }[] = [];
|
||||||
|
const fetchFn = vi.fn(async (url: string, init: RequestInit) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
return new Response("ok", { status: 200 });
|
||||||
|
});
|
||||||
|
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||||
|
expect(await ob.enqueue(item, read)).toBe(true);
|
||||||
|
const row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(row.status).toBe("queued");
|
||||||
|
expect(row.image!.length).toBeGreaterThan(500);
|
||||||
|
expect(row.payload).toMatchObject({ v: 1, kind: "wash", booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan"] }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
|
||||||
|
expect(JSON.stringify(row.payload)).not.toContain("lavazhier");
|
||||||
|
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 });
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.url).toBe(cfg.url);
|
||||||
|
expect((calls[0]!.init.headers as Record<string, string>).authorization).toBe("Bearer secret-1");
|
||||||
|
const form = calls[0]!.init.body as FormData;
|
||||||
|
expect(JSON.parse(form.get("meta") as string).item).toBe(row.id);
|
||||||
|
expect((form.get("image") as File).type).toBe("image/jpeg");
|
||||||
|
const after = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(after.status).toBe("sent");
|
||||||
|
expect(after.image).toBeNull();
|
||||||
|
expect(after.sentAt).toBeTruthy();
|
||||||
|
expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => {
|
||||||
|
await seed();
|
||||||
|
let status = 503;
|
||||||
|
const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status })));
|
||||||
|
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||||
|
await ob.enqueue(item, read);
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||||
|
let row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" });
|
||||||
|
expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000);
|
||||||
|
// Not due yet → untouched.
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 });
|
||||||
|
// Due again: a network error defers too; a 422 abandons.
|
||||||
|
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||||
|
status = 0;
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||||
|
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||||
|
status = 422;
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||||
|
row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" });
|
||||||
|
expect(row.image).toBeNull();
|
||||||
|
|
||||||
|
// A voided order is not a sample.
|
||||||
|
status = 200;
|
||||||
|
await ob.enqueue({ ...item, orderId: "o-1" }, read);
|
||||||
|
db.update(carwashOrders).set({ status: "void" }).run();
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||||
|
// Expired items are abandoned without a request.
|
||||||
|
await ob.enqueue(item, read);
|
||||||
|
db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run();
|
||||||
|
db.update(carwashOrders).set({ status: "open" }).run();
|
||||||
|
const before = fetchFn.mock.calls.length;
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||||
|
expect(fetchFn.mock.calls.length).toBe(before);
|
||||||
|
expect(ob.status().failed).toBe(3);
|
||||||
|
|
||||||
|
// Entry sampling: one in N entry reads becomes a package with the crop and the
|
||||||
|
// camera's class only — no order, no operator, no category.
|
||||||
|
const sampler = new ReviewOutbox(db, silentLogger(), { ...cfg, entrySample: 3 }, fetchFn);
|
||||||
|
expect([sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry()]).toEqual([false, false, true, false]);
|
||||||
|
expect(ob.sampleEntry()).toBe(false); // entrySample 0 = off
|
||||||
|
expect(await sampler.enqueueEntry(read)).toBe(true);
|
||||||
|
const entryRow = db.select().from(carwashReviewOutbox).where(eq(carwashReviewOutbox.orderId, "entry:snap-1")).get()!;
|
||||||
|
expect(entryRow.payload).toMatchObject({ v: 1, kind: "entry", booth: "booth-7", vision: { class: "car", confidence: 0.86 }, image: { plateBlurred: true } });
|
||||||
|
expect(entryRow.payload).not.toHaveProperty("operator");
|
||||||
|
expect(entryRow.payload).not.toHaveProperty("operatorCategory");
|
||||||
|
expect(entryRow.image!.length).toBeGreaterThan(500);
|
||||||
|
|
||||||
|
// No vehicle box, no snapshot, or upload off → nothing queued.
|
||||||
|
expect(await ob.enqueue(item, { ...read, box: null })).toBe(false);
|
||||||
|
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false);
|
||||||
|
expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
import { eq } from "@parking/db";
|
||||||
|
|
||||||
|
describe("through the app", () => {
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
const saved = { ...process.env };
|
||||||
|
beforeEach(async () => {
|
||||||
|
delete process.env.MODULES_ENTITLED;
|
||||||
|
process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest";
|
||||||
|
process.env.CARWASH_REVIEW_TOKEN = "tok";
|
||||||
|
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
|
||||||
|
process.env.CARWASH_REVIEW_ENTRY_SAMPLE = "1";
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID", "CARWASH_REVIEW_ENTRY_SAMPLE"]) {
|
||||||
|
if (saved[k] === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = saved[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const a = await login(app, username, password);
|
||||||
|
const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf };
|
||||||
|
seedTariff(db);
|
||||||
|
const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json();
|
||||||
|
const cat = s.categories[0].id, svc = s.services[0].id;
|
||||||
|
await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } });
|
||||||
|
await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } });
|
||||||
|
db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||||
|
db.insert(deviceEvents).values({
|
||||||
|
id: "read-r", deviceId: "cam-1", category: "camera", kind: "read",
|
||||||
|
detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE },
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } });
|
||||||
|
expect(order.statusCode).toBe(201);
|
||||||
|
// Enqueue is fire-and-forget: give the crop a moment.
|
||||||
|
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1));
|
||||||
|
const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0, entrySample: 1 });
|
||||||
|
|
||||||
|
// An ENTRY vehicle read announced by the core (snapshot.ts) is sampled by the module
|
||||||
|
// (1 in 1 here) into an entry package; an exit read is not.
|
||||||
|
deviceEventBus.emitVehicleRead({ identity: "T-X", direction: "exit", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
|
||||||
|
deviceEventBus.emitVehicleRead({ identity: "T-R", direction: "entry", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
|
||||||
|
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(2));
|
||||||
|
const rows = db.select().from(carwashReviewOutbox).all();
|
||||||
|
expect(rows.map((r) => (r.payload as { kind: string }).kind).sort()).toEqual(["entry", "wash"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { and, asc, carwashOrders, carwashReviewOutbox, eq, isNull, lte, or, snapshots, sql, type Db } from "@parking/db";
|
||||||
|
import type { NormBox, VehicleRead } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
|
||||||
|
// The Car Wash REVIEW OUTBOX — booth side (wiki/concepts/vision-review-outbox.md).
|
||||||
|
//
|
||||||
|
// The operator's category choice at intake is a HYPOTHESIS, not truth (the threat model:
|
||||||
|
// the operator may err or cheat). So every wash order that has a vehicle read queues a
|
||||||
|
// small package for a trusted remote reviewer: the vehicle CROP cut out of the entry
|
||||||
|
// snapshot with the plate BLURRED, the operator's choice, and what the camera thought.
|
||||||
|
// The reviewer's verdict becomes the training label for the body-type classifier (phase
|
||||||
|
// B) and, per operator, the honest-mistake / fraud rate.
|
||||||
|
//
|
||||||
|
// Rules that shape this file:
|
||||||
|
// - OFFLINE-FIRST: the wash never waits. Enqueue is fire-and-forget off the intake path;
|
||||||
|
// a background loop drains the queue when the private overlay (Netbird) is up, with
|
||||||
|
// backoff, and gives up loudly after EXPIRE_DAYS.
|
||||||
|
// - ONE-WAY: the booth POSTs; nothing ever comes back into the booth's decisions. The
|
||||||
|
// signed ledger stays the only record of what happened at the wash.
|
||||||
|
// - NOTHING THAT NAMES THE SITE LEAVES: only the crop (no walls, no camera OSD, no
|
||||||
|
// bystanders), the plate blurred in place, a per-booth pseudonymous id set at deploy,
|
||||||
|
// the operator as a keyed hash. The mapping back to people and places stays with the
|
||||||
|
// reviewer, off the collector.
|
||||||
|
// - THE NETWORK IS NOT THE AUTH: a per-booth bearer token on top of the overlay; the
|
||||||
|
// booth can do nothing at the collector but this one POST.
|
||||||
|
|
||||||
|
export interface ReviewUploadConfig {
|
||||||
|
/** The collector's ingest URL (reachable only over the overlay). */
|
||||||
|
readonly url: string;
|
||||||
|
/** Per-booth bearer token. */
|
||||||
|
readonly token: string;
|
||||||
|
/** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
|
||||||
|
readonly boothId: string;
|
||||||
|
readonly intervalSec: number;
|
||||||
|
/** Queue one in N ENTRY vehicle reads (no order attached) for the reviewer — the gate
|
||||||
|
* view is exactly what the classifier is trained on, and the entry stream is many times
|
||||||
|
* the wash stream. 0 = off. */
|
||||||
|
readonly entrySample: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */
|
||||||
|
export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ReviewUploadConfig | null {
|
||||||
|
const url = (env.CARWASH_REVIEW_URL ?? "").trim();
|
||||||
|
const token = (env.CARWASH_REVIEW_TOKEN ?? "").trim();
|
||||||
|
const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim();
|
||||||
|
if (!url || !token || !boothId) return null;
|
||||||
|
const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 60);
|
||||||
|
const sample = Number(env.CARWASH_REVIEW_ENTRY_SAMPLE ?? 0);
|
||||||
|
return {
|
||||||
|
url, token, boothId,
|
||||||
|
intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60,
|
||||||
|
entrySample: Number.isInteger(sample) && sample > 0 ? sample : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The crop's longest edge, in pixels — enough for a reviewer and a classifier, small
|
||||||
|
* enough that a day of washes is a few megabytes. */
|
||||||
|
export const CROP_MAX_EDGE = 640;
|
||||||
|
/** Margin around the detector's box, as a fraction of the box (context for the reviewer). */
|
||||||
|
const CROP_MARGIN = 0.08;
|
||||||
|
/** Items older than this are abandoned (failed "expired") — a booth cut off for two weeks
|
||||||
|
* should not resurface a fortnight of crops in one burst. */
|
||||||
|
export const EXPIRE_DAYS = 14;
|
||||||
|
/** Backoff: 1 min · 2^attempts, capped. */
|
||||||
|
const BACKOFF_BASE_MS = 60_000;
|
||||||
|
const BACKOFF_CAP_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const UPLOAD_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
/** What one order contributes to the package (the service hands this over at intake). */
|
||||||
|
export interface ReviewItemInput {
|
||||||
|
readonly orderId: string;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly createdBy: string;
|
||||||
|
readonly categoryId: string;
|
||||||
|
readonly categoryName: string;
|
||||||
|
/** The vision classes the chosen category covers at this site (its mapping) — lets the
|
||||||
|
* reviewer's class be judged against the operator's category without the site's setup. */
|
||||||
|
readonly categoryClasses: readonly string[];
|
||||||
|
readonly serviceName: string;
|
||||||
|
readonly visionCategoryId: string | null;
|
||||||
|
readonly downgraded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut the vehicle out of the snapshot and blur the plate inside it. Boxes are fractions
|
||||||
|
* of the frame, so this works on the stored (downscaled) copy. Returns a JPEG.
|
||||||
|
*/
|
||||||
|
export async function makeReviewCrop(
|
||||||
|
snapshotBytes: Buffer,
|
||||||
|
box: NormBox,
|
||||||
|
plateBox: NormBox | null | undefined,
|
||||||
|
): Promise<{ bytes: Buffer; width: number; height: number; plateBlurred: boolean }> {
|
||||||
|
const img = sharp(snapshotBytes, { failOn: "none" }).rotate();
|
||||||
|
const meta = await img.metadata();
|
||||||
|
const W = meta.width ?? 0;
|
||||||
|
const H = meta.height ?? 0;
|
||||||
|
if (!W || !H) throw new Error("snapshot has no dimensions");
|
||||||
|
const px = (b: NormBox) => ({
|
||||||
|
left: Math.round(b.x1 * W), top: Math.round(b.y1 * H),
|
||||||
|
right: Math.round(b.x2 * W), bottom: Math.round(b.y2 * H),
|
||||||
|
});
|
||||||
|
const v = px(box);
|
||||||
|
const mw = Math.round((v.right - v.left) * CROP_MARGIN);
|
||||||
|
const mh = Math.round((v.bottom - v.top) * CROP_MARGIN);
|
||||||
|
const left = Math.max(0, v.left - mw);
|
||||||
|
const top = Math.max(0, v.top - mh);
|
||||||
|
const right = Math.min(W, v.right + mw);
|
||||||
|
const bottom = Math.min(H, v.bottom + mh);
|
||||||
|
const width = right - left;
|
||||||
|
const height = bottom - top;
|
||||||
|
if (width < 8 || height < 8) throw new Error("vehicle box too small to crop");
|
||||||
|
|
||||||
|
let crop = img.clone().extract({ left, top, width, height });
|
||||||
|
let plateBlurred = false;
|
||||||
|
if (plateBox) {
|
||||||
|
// The plate region, in CROP coordinates, padded a little so the blur eats the edges.
|
||||||
|
const p = px(plateBox);
|
||||||
|
const pad = Math.round(Math.max(p.right - p.left, p.bottom - p.top) * 0.25);
|
||||||
|
const pl = Math.max(0, p.left - pad - left);
|
||||||
|
const pt = Math.max(0, p.top - pad - top);
|
||||||
|
const pr = Math.min(width, p.right + pad - left);
|
||||||
|
const pb = Math.min(height, p.bottom + pad - top);
|
||||||
|
if (pr - pl >= 2 && pb - pt >= 2) {
|
||||||
|
const region = await sharp(await crop.clone().toBuffer())
|
||||||
|
.extract({ left: pl, top: pt, width: pr - pl, height: pb - pt })
|
||||||
|
.blur(Math.max(6, Math.round((pr - pl) / 6)))
|
||||||
|
.toBuffer();
|
||||||
|
crop = sharp(await crop.toBuffer()).composite([{ input: region, left: pl, top: pt }]);
|
||||||
|
plateBlurred = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = await crop
|
||||||
|
.resize({ width: CROP_MAX_EDGE, height: CROP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 85, mozjpeg: true })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
return { bytes: out.data, width: out.info.width, height: out.info.height, plateBlurred };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The operator as a keyed hash — stable per booth so the reviewer can count per person,
|
||||||
|
* meaningless anywhere else. */
|
||||||
|
export function operatorRef(boothId: string, username: string): string {
|
||||||
|
return createHash("sha256").update(`${boothId}:${username}`).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
export interface OutboxStatus {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly boothId: string | null;
|
||||||
|
readonly queued: number;
|
||||||
|
readonly sent: number;
|
||||||
|
readonly failed: number;
|
||||||
|
readonly lastSentAt: string | null;
|
||||||
|
readonly lastError: string | null;
|
||||||
|
/** 0 = entry sampling off; N = one in N entry reads is queued. */
|
||||||
|
readonly entrySample: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReviewOutbox {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #cfg: ReviewUploadConfig | null;
|
||||||
|
readonly #fetch: FetchLike;
|
||||||
|
#timer: NodeJS.Timeout | null = null;
|
||||||
|
#draining = false;
|
||||||
|
#entrySeen = 0;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#cfg = cfg;
|
||||||
|
this.#fetch = fetchFn ?? ((input, init) => fetch(input, init));
|
||||||
|
}
|
||||||
|
|
||||||
|
get enabled(): boolean {
|
||||||
|
return this.#cfg != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue one order's package. Fire-and-forget: the caller does NOT await this on the
|
||||||
|
* intake path; every failure is logged, none is thrown. Skipped when there is no
|
||||||
|
* vehicle box (nothing to crop — a frame without a detected vehicle is no training
|
||||||
|
* sample) or when upload is not configured (an unbounded queue nobody drains). */
|
||||||
|
async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> {
|
||||||
|
if (!this.#cfg) return false;
|
||||||
|
return this.#queue(item.orderId, read, (id, crop) => ({
|
||||||
|
v: 1,
|
||||||
|
kind: "wash",
|
||||||
|
booth: this.#cfg!.boothId,
|
||||||
|
item: id,
|
||||||
|
order: item.orderId,
|
||||||
|
at: item.createdAt,
|
||||||
|
operator: operatorRef(this.#cfg!.boothId, item.createdBy),
|
||||||
|
operatorCategory: { id: item.categoryId, name: item.categoryName, classes: [...item.categoryClasses] },
|
||||||
|
service: item.serviceName,
|
||||||
|
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
|
||||||
|
downgraded: item.downgraded,
|
||||||
|
image: crop,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every Nth entry read is a sample (N = entrySample); the caller queues it. Counted
|
||||||
|
* in-process, so "1 in 5" is exactly that across a booth's day. */
|
||||||
|
sampleEntry(): boolean {
|
||||||
|
const n = this.#cfg?.entrySample ?? 0;
|
||||||
|
if (n <= 0) return false;
|
||||||
|
this.#entrySeen += 1;
|
||||||
|
return this.#entrySeen % n === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue an ENTRY sample: the crop and the camera's class only — no order, no operator,
|
||||||
|
* no category. Pure training material in the gate view; the reviewer labels it. */
|
||||||
|
async enqueueEntry(read: VehicleRead): Promise<boolean> {
|
||||||
|
if (!this.#cfg) return false;
|
||||||
|
return this.#queue(`entry:${read.snapshotId ?? "?"}`, read, (id, crop) => ({
|
||||||
|
v: 1,
|
||||||
|
kind: "entry",
|
||||||
|
booth: this.#cfg!.boothId,
|
||||||
|
item: id,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
vision: { class: read.bodyType, confidence: read.confidence },
|
||||||
|
image: crop,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #queue(
|
||||||
|
ref: string,
|
||||||
|
read: VehicleRead,
|
||||||
|
build: (id: string, image: { width: number; height: number; plateBlurred: boolean }) => Record<string, unknown>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!read.box || !read.snapshotId) return false;
|
||||||
|
try {
|
||||||
|
const snap = this.#db.select().from(snapshots).where(eq(snapshots.id, read.snapshotId)).get();
|
||||||
|
if (!snap) {
|
||||||
|
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — ${ref} not queued`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox);
|
||||||
|
const id = randomUUID();
|
||||||
|
const payload = build(id, { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred });
|
||||||
|
this.#db
|
||||||
|
.insert(carwashReviewOutbox)
|
||||||
|
.values({ id, orderId: ref, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload })
|
||||||
|
.run();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.warn(`carwash review: could not queue ${ref}: ${(err as Error).message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (!this.#cfg || this.#timer) return;
|
||||||
|
const tick = () => {
|
||||||
|
void this.drain().catch((err) => this.#logger.warn(`carwash review: drain failed: ${(err as Error).message}`));
|
||||||
|
};
|
||||||
|
this.#timer = setInterval(tick, this.#cfg.intervalSec * 1000);
|
||||||
|
this.#timer.unref?.();
|
||||||
|
setTimeout(tick, 5_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.#timer) clearInterval(this.#timer);
|
||||||
|
this.#timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send what is due, oldest first. Returns the tally; never throws for a single item. */
|
||||||
|
async drain(limit = 20): Promise<{ sent: number; failed: number; deferred: number }> {
|
||||||
|
const tally = { sent: 0, failed: 0, deferred: 0 };
|
||||||
|
if (!this.#cfg || this.#draining) return tally;
|
||||||
|
this.#draining = true;
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const due = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashReviewOutbox)
|
||||||
|
.where(and(eq(carwashReviewOutbox.status, "queued"), or(isNull(carwashReviewOutbox.nextAttemptAt), lte(carwashReviewOutbox.nextAttemptAt, now))))
|
||||||
|
.orderBy(asc(carwashReviewOutbox.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.all();
|
||||||
|
for (const row of due) {
|
||||||
|
const outcome = await this.#send(row);
|
||||||
|
tally[outcome] += 1;
|
||||||
|
}
|
||||||
|
if (tally.sent || tally.failed) this.#logger.info(`carwash review: sent ${tally.sent}, failed ${tally.failed}, deferred ${tally.deferred}`);
|
||||||
|
} finally {
|
||||||
|
this.#draining = false;
|
||||||
|
}
|
||||||
|
return tally;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #send(row: typeof carwashReviewOutbox.$inferSelect): Promise<"sent" | "failed" | "deferred"> {
|
||||||
|
const cfg = this.#cfg!;
|
||||||
|
const ageMs = Date.now() - Date.parse(row.createdAt);
|
||||||
|
if (ageMs > EXPIRE_DAYS * 24 * 60 * 60 * 1000) return this.#fail(row, `expired after ${EXPIRE_DAYS} days`);
|
||||||
|
// A wash voided before delivery is not a sample (and not a decision to review).
|
||||||
|
const order = this.#db.select({ status: carwashOrders.status }).from(carwashOrders).where(eq(carwashOrders.id, row.orderId)).get();
|
||||||
|
if (order?.status === "void") return this.#fail(row, "order voided");
|
||||||
|
if (!row.image) return this.#fail(row, "image missing");
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("meta", JSON.stringify(row.payload));
|
||||||
|
form.set("image", new Blob([new Uint8Array(row.image)], { type: "image/jpeg" }), `${row.id}.jpg`);
|
||||||
|
const ac = new AbortController();
|
||||||
|
const t = setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await this.#fetch(cfg.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${cfg.token}`, "x-booth-id": cfg.boothId },
|
||||||
|
body: form,
|
||||||
|
signal: ac.signal,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
this.#db
|
||||||
|
.update(carwashReviewOutbox)
|
||||||
|
.set({ status: "sent", sentAt: new Date().toISOString(), image: null, lastError: null, attempts: row.attempts + 1 })
|
||||||
|
.where(eq(carwashReviewOutbox.id, row.id))
|
||||||
|
.run();
|
||||||
|
return "sent";
|
||||||
|
}
|
||||||
|
// The collector refused the package itself → no retry will help.
|
||||||
|
if ([400, 404, 413, 415, 422].includes(res.status)) return this.#fail(row, `rejected: HTTP ${res.status}`);
|
||||||
|
// Everything else (auth not yet fixed, throttled, collector down) → try again later.
|
||||||
|
return this.#defer(row, `HTTP ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
return this.#defer(row, (err as Error).name === "AbortError" ? "timeout" : (err as Error).message);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#fail(row: typeof carwashReviewOutbox.$inferSelect, why: string): "failed" {
|
||||||
|
this.#db
|
||||||
|
.update(carwashReviewOutbox)
|
||||||
|
.set({ status: "failed", lastError: why, image: null, attempts: row.attempts + 1 })
|
||||||
|
.where(eq(carwashReviewOutbox.id, row.id))
|
||||||
|
.run();
|
||||||
|
this.#logger.warn(`carwash review: item ${row.id} (order ${row.orderId}) abandoned — ${why}`);
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
#defer(row: typeof carwashReviewOutbox.$inferSelect, why: string): "deferred" {
|
||||||
|
const attempts = row.attempts + 1;
|
||||||
|
const wait = Math.min(BACKOFF_BASE_MS * 2 ** Math.min(attempts, 20), BACKOFF_CAP_MS);
|
||||||
|
this.#db
|
||||||
|
.update(carwashReviewOutbox)
|
||||||
|
.set({ attempts, lastError: why, nextAttemptAt: new Date(Date.now() + wait).toISOString() })
|
||||||
|
.where(eq(carwashReviewOutbox.id, row.id))
|
||||||
|
.run();
|
||||||
|
return "deferred";
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): OutboxStatus {
|
||||||
|
const count = (s: "queued" | "sent" | "failed") =>
|
||||||
|
this.#db.select({ n: sql<number>`count(*)` }).from(carwashReviewOutbox).where(eq(carwashReviewOutbox.status, s)).get()?.n ?? 0;
|
||||||
|
const lastSent = this.#db.select({ at: sql<string | null>`max(${carwashReviewOutbox.sentAt})` }).from(carwashReviewOutbox).get()?.at ?? null;
|
||||||
|
const lastErr = this.#db
|
||||||
|
.select({ e: carwashReviewOutbox.lastError })
|
||||||
|
.from(carwashReviewOutbox)
|
||||||
|
.where(sql`${carwashReviewOutbox.lastError} is not null`)
|
||||||
|
.orderBy(sql`coalesce(${carwashReviewOutbox.sentAt}, ${carwashReviewOutbox.nextAttemptAt}, ${carwashReviewOutbox.createdAt}) desc`)
|
||||||
|
.limit(1)
|
||||||
|
.get()?.e ?? null;
|
||||||
|
return {
|
||||||
|
enabled: this.enabled,
|
||||||
|
boothId: this.#cfg?.boothId ?? null,
|
||||||
|
entrySample: this.#cfg?.entrySample ?? 0,
|
||||||
|
queued: count("queued"),
|
||||||
|
sent: count("sent"),
|
||||||
|
failed: count("failed"),
|
||||||
|
lastSentAt: lastSent,
|
||||||
|
lastError: lastErr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
|
import type { Tender } from "@parking/shared";
|
||||||
|
import { requireAnyPermission, requirePermission } from "../../auth.js";
|
||||||
|
import { requireModule } from "../../modules.js";
|
||||||
|
import { NoShiftOpenError } from "../../shift-service.js";
|
||||||
|
import type { ServerModuleDeps } from "../index.js";
|
||||||
|
import type { ReviewOutbox } from "./review-outbox.js";
|
||||||
|
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
|
||||||
|
|
||||||
|
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
|
||||||
|
// FIRST (403 module_disabled), then a permission:
|
||||||
|
// settings (master data) site:read / site:update — the site admin's job
|
||||||
|
// queue / ticket lookup carwash:read — the wash desk
|
||||||
|
// intake carwash:create
|
||||||
|
// done / bay payment / void carwash:update
|
||||||
|
// The sponsorship PROGRAM itself is a validation program row (id "carwash") and is
|
||||||
|
// composed through the existing /api/validation/programs/:id route (site:update).
|
||||||
|
|
||||||
|
function sendError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||||
|
if (err instanceof CarwashError) {
|
||||||
|
return reply.code(err.status).send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
||||||
|
}
|
||||||
|
if (err instanceof NoShiftOpenError) {
|
||||||
|
// The bay takes money on the CARWASH till: the wash operator's own shift must be
|
||||||
|
// open (the booth's does not count). The desk shows its shift control on this code.
|
||||||
|
return reply.code(409).send({ error: err.message, code: "no_shift", till: err.till });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService, outbox?: ReviewOutbox): Promise<void> {
|
||||||
|
const moduleOn = requireModule(deps.db, "carwash");
|
||||||
|
// The price list is the desk's working data as much as Setup's: the wash operator
|
||||||
|
// reads it under the module's own permission (the Wash operator job holds no site:*).
|
||||||
|
const settingsRead = [moduleOn, requireAnyPermission("carwash:read", "site:read")];
|
||||||
|
const settingsWrite = [moduleOn, requirePermission("site:update")];
|
||||||
|
const read = [moduleOn, requirePermission("carwash:read")];
|
||||||
|
const create = [moduleOn, requirePermission("carwash:create")];
|
||||||
|
const update = [moduleOn, requirePermission("carwash:update")];
|
||||||
|
|
||||||
|
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
|
||||||
|
// The review outbox's health (Setup → Car wash): how many decisions wait for the
|
||||||
|
// reviewer, how many went, the last error. Site admin's read.
|
||||||
|
app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () =>
|
||||||
|
outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null, entrySample: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.saveSettings(req.body ?? {}, req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { identity: string } }>("/api/carwash/session/:identity", { preHandler: read }, async (req) =>
|
||||||
|
service.lookup(req.params.identity),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Querystring: { scope?: string; limit?: string } }>("/api/carwash/orders", { preHandler: read }, async (req) => {
|
||||||
|
if (req.query.scope === "recent") return { orders: service.recentOrders(Number(req.query.limit) || 100) };
|
||||||
|
return { orders: service.openOrders() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: { identity?: string; categoryId?: string; serviceId?: string; payAt?: string } }>(
|
||||||
|
"/api/carwash/orders",
|
||||||
|
{ preHandler: create },
|
||||||
|
async (req, reply) => {
|
||||||
|
const b = req.body ?? {};
|
||||||
|
// payAt is a SITE setting now; the desk no longer sends it. Accept it only when it
|
||||||
|
// matches (the service refuses a mismatch) so a stale client cannot pick the till.
|
||||||
|
if (b.payAt !== undefined && !isPayAt(b.payAt)) return reply.code(400).send({ error: "payAt must be booth|bay" });
|
||||||
|
try {
|
||||||
|
const order = await service.createOrder({
|
||||||
|
identity: String(b.identity ?? ""),
|
||||||
|
categoryId: String(b.categoryId ?? ""),
|
||||||
|
serviceId: String(b.serviceId ?? ""),
|
||||||
|
...(b.payAt !== undefined ? { payAt: b.payAt } : {}),
|
||||||
|
actor: req.user.username,
|
||||||
|
});
|
||||||
|
return reply.code(201).send(order);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>("/api/carwash/orders/:id/done", { preHandler: update }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.markDone(req.params.id, req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { tender?: Tender } }>(
|
||||||
|
"/api/carwash/orders/:id/pay",
|
||||||
|
{ preHandler: update },
|
||||||
|
async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.payAtBay(req.params.id, (req.body?.tender ?? "cash") as Tender, req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { reason?: string } }>(
|
||||||
|
"/api/carwash/orders/:id/void",
|
||||||
|
{ preHandler: update },
|
||||||
|
async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.voidOrder(req.params.id, String(req.body?.reason ?? "").trim(), req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,765 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
carwashCategories,
|
||||||
|
carwashConfig,
|
||||||
|
carwashOrders,
|
||||||
|
carwashPrices,
|
||||||
|
carwashServices,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
type CarwashOrderRow,
|
||||||
|
type Db,
|
||||||
|
} from "@parking/db";
|
||||||
|
import {
|
||||||
|
CARWASH_PAY_AT,
|
||||||
|
CARWASH_PAY_AT_DEFAULT,
|
||||||
|
CARWASH_PROGRAM_ID,
|
||||||
|
type CarWashPayAt,
|
||||||
|
type CarwashOrderView,
|
||||||
|
type CarwashSettingsView,
|
||||||
|
type ChargeLine,
|
||||||
|
CARWASH_VISION_THRESHOLD_DEFAULT,
|
||||||
|
isVehicleClass,
|
||||||
|
reasonPayload,
|
||||||
|
type VehicleClass,
|
||||||
|
type VehicleRead,
|
||||||
|
type Tender,
|
||||||
|
type TillId,
|
||||||
|
} from "@parking/shared";
|
||||||
|
import type { EventLog } from "../../event-log.js";
|
||||||
|
import { vehicleForIdentity } from "../../plate-lookup.js";
|
||||||
|
import type { ReviewOutbox } from "./review-outbox.js";
|
||||||
|
import { effectiveModulesFor } from "../../modules.js";
|
||||||
|
import type { ChargeProvider, PayStation } from "../../pay-station.js";
|
||||||
|
import type { ShiftService } from "../../shift-service.js";
|
||||||
|
import { applyValidation, liveValidations } from "../../validations.js";
|
||||||
|
import type { ServerModuleDeps } from "../index.js";
|
||||||
|
|
||||||
|
// Car Wash — the module's whole behaviour (wiki/decisions/venue-modules.md, "Car Wash —
|
||||||
|
// the pilot module" + "v1 answers"). Master data is mutable rows; every order freezes
|
||||||
|
// what it sold (names + price) and signs its life onto the ledger; money at the bay is
|
||||||
|
// a signed `carwash_payment`; money at the booth rides the parking `payment` as a
|
||||||
|
// charge line (ChargeProvider below). The parking sponsorship is the site's "carwash"
|
||||||
|
// VALIDATION program, applied through the shared applyValidation() when a wash is done
|
||||||
|
// — the wash never touches parking code, it talks to the core through ServerModuleDeps.
|
||||||
|
|
||||||
|
/** A refusal the route maps to an HTTP status. */
|
||||||
|
/** The till bay money lands on — declared by the module manifest (MODULES). */
|
||||||
|
const CARWASH_TILL: TillId = "carwash";
|
||||||
|
|
||||||
|
export class CarwashError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: 400 | 404 | 409,
|
||||||
|
message: string,
|
||||||
|
readonly code?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CarwashError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsBody {
|
||||||
|
categories?: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[];
|
||||||
|
services?: { id?: string; name?: string; active?: boolean }[];
|
||||||
|
prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[];
|
||||||
|
/** Where wash money is taken at this site (site-level policy). */
|
||||||
|
payAt?: unknown;
|
||||||
|
visionThreshold?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderInput {
|
||||||
|
identity: string;
|
||||||
|
categoryId: string;
|
||||||
|
serviceId: string;
|
||||||
|
/** Optional — the SITE policy decides; a stale client that sends a different value
|
||||||
|
* is refused (409 pay_at_policy) rather than silently overridden. */
|
||||||
|
payAt?: CarWashPayAt;
|
||||||
|
actor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketLookup {
|
||||||
|
identity: string;
|
||||||
|
found: boolean;
|
||||||
|
open: boolean;
|
||||||
|
subscription: boolean;
|
||||||
|
plate: string | null;
|
||||||
|
enteredAt: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
orders: CarwashOrderView[];
|
||||||
|
/** What the camera saw at entry (advisory) and the category the site mapping
|
||||||
|
* suggests for it — the desk pre-selects it; the operator may change it. */
|
||||||
|
vision: VehicleRead | null;
|
||||||
|
suggestedCategoryId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
|
|
||||||
|
/** Stable slug for a new master-data row: from the name, else a random id. */
|
||||||
|
function slugify(name: string): string {
|
||||||
|
const s = name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 40);
|
||||||
|
return s || randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CarwashService {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #pay: PayStation;
|
||||||
|
readonly #shift: ShiftService;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #outbox: ReviewOutbox | null;
|
||||||
|
|
||||||
|
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger, outbox: ReviewOutbox | null = null) {
|
||||||
|
this.#db = deps.db;
|
||||||
|
this.#log = deps.eventLog;
|
||||||
|
this.#pay = deps.payStation;
|
||||||
|
this.#shift = deps.shiftService;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#outbox = outbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
#enabled(): boolean {
|
||||||
|
return effectiveModulesFor(this.#db).includes("carwash");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Settings (master data) -------------------------------------------------
|
||||||
|
|
||||||
|
settings(): CarwashSettingsView {
|
||||||
|
const categories = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashCategories)
|
||||||
|
.where(isNull(carwashCategories.deletedAt))
|
||||||
|
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
|
||||||
|
.all()
|
||||||
|
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active, visionClasses: r.visionClasses.filter(isVehicleClass) }));
|
||||||
|
const services = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashServices)
|
||||||
|
.where(isNull(carwashServices.deletedAt))
|
||||||
|
.orderBy(asc(carwashServices.sortOrder), asc(carwashServices.name))
|
||||||
|
.all()
|
||||||
|
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
|
||||||
|
const live = new Set([...categories.map((c) => c.id), ...services.map((s) => s.id)]);
|
||||||
|
const prices = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashPrices)
|
||||||
|
.all()
|
||||||
|
.filter((p) => live.has(p.categoryId) && live.has(p.serviceId))
|
||||||
|
.map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor }));
|
||||||
|
return { categories, services, prices, currency: this.#currency(), payAt: this.payAt(), visionThreshold: this.visionThreshold() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */
|
||||||
|
payAt(): CarWashPayAt {
|
||||||
|
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
|
||||||
|
return row?.payAt ?? CARWASH_PAY_AT_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confidence floor for a vision class to flag a category downgrade (site config). */
|
||||||
|
visionThreshold(): number {
|
||||||
|
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
|
||||||
|
return row?.visionThreshold ?? CARWASH_VISION_THRESHOLD_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The category the site mapping suggests for a vision class (first active category
|
||||||
|
* listing it, in display order), or null when unmapped. */
|
||||||
|
#categoryForClass(cls: VehicleClass): { id: string; name: string } | null {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashCategories)
|
||||||
|
.where(isNull(carwashCategories.deletedAt))
|
||||||
|
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
|
||||||
|
.all();
|
||||||
|
const hit = rows.find((r) => r.active && r.visionClasses.includes(cls));
|
||||||
|
return hit ? { id: hit.id, name: hit.name } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The site's currency = the active tariff's (the wash is priced in the same money
|
||||||
|
* the booth takes). null when no tariff is published yet. */
|
||||||
|
#currency(): string | null {
|
||||||
|
try {
|
||||||
|
// Any open session's quote carries it; without one, fall back to the tariff table.
|
||||||
|
const row = this.#db.select().from(carwashOrders).orderBy(desc(carwashOrders.createdAt)).limit(1).get();
|
||||||
|
if (row) return row.currency;
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
return this.#pay.activeCurrency();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full-replacement save of the three lists. Rows missing from the body are
|
||||||
|
* soft-deleted (orders already reference names + prices by value, so nothing
|
||||||
|
* historical changes). Signs one config_change. */
|
||||||
|
async saveSettings(body: SettingsBody, actor: string): Promise<CarwashSettingsView> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const upsertList = (
|
||||||
|
table: typeof carwashCategories | typeof carwashServices,
|
||||||
|
items: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[] | undefined,
|
||||||
|
label: string,
|
||||||
|
): string[] => {
|
||||||
|
if (items === undefined) {
|
||||||
|
return this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all().map((r) => r.id);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(items)) throw new CarwashError(400, `${label} must be an array`);
|
||||||
|
const keep: string[] = [];
|
||||||
|
let sort = 0;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const it of items) {
|
||||||
|
const name = String(it?.name ?? "").trim();
|
||||||
|
if (!name) throw new CarwashError(400, `${label}: every item needs a name`);
|
||||||
|
let id = typeof it.id === "string" && it.id.trim() ? it.id.trim() : slugify(name);
|
||||||
|
if (!ID_RE.test(id)) throw new CarwashError(400, `${label}: bad id "${id}"`);
|
||||||
|
// Two new items slugging to the same id → disambiguate rather than merge.
|
||||||
|
while (seen.has(id)) id = `${id}-${sort}`;
|
||||||
|
seen.add(id);
|
||||||
|
const active = it.active !== false;
|
||||||
|
// Vision mapping lives on CATEGORIES only; absent = keep what the row has.
|
||||||
|
let visionClasses: string[] | undefined;
|
||||||
|
if (table === carwashCategories && it.visionClasses !== undefined) {
|
||||||
|
if (!Array.isArray(it.visionClasses) || !it.visionClasses.every(isVehicleClass)) {
|
||||||
|
throw new CarwashError(400, `${label}: visionClasses must be an array of vehicle classes`);
|
||||||
|
}
|
||||||
|
visionClasses = [...new Set(it.visionClasses as string[])];
|
||||||
|
}
|
||||||
|
const existing = this.#db.select().from(table).where(eq(table.id, id)).get();
|
||||||
|
if (existing) {
|
||||||
|
this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null, ...(visionClasses ? { visionClasses } : {}) }).where(eq(table.id, id)).run();
|
||||||
|
} else {
|
||||||
|
this.#db.insert(table).values({ id, name, sortOrder: sort, active, ...(visionClasses ? { visionClasses } : {}) }).run();
|
||||||
|
}
|
||||||
|
keep.push(id);
|
||||||
|
sort += 1;
|
||||||
|
}
|
||||||
|
const live = this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all();
|
||||||
|
for (const r of live) {
|
||||||
|
if (!keep.includes(r.id)) {
|
||||||
|
this.#db.update(table).set({ deletedAt: now, deletedBy: actor }).where(eq(table.id, r.id)).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keep;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryIds = upsertList(carwashCategories, body.categories, "categories");
|
||||||
|
const serviceIds = upsertList(carwashServices, body.services, "services");
|
||||||
|
|
||||||
|
if (body.prices !== undefined) {
|
||||||
|
if (!Array.isArray(body.prices)) throw new CarwashError(400, "prices must be an array");
|
||||||
|
const rows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
|
||||||
|
for (const p of body.prices) {
|
||||||
|
const categoryId = String(p?.categoryId ?? "");
|
||||||
|
const serviceId = String(p?.serviceId ?? "");
|
||||||
|
const priceMinor = p?.priceMinor;
|
||||||
|
if (!categoryIds.includes(categoryId)) throw new CarwashError(400, `prices: unknown category "${categoryId}"`);
|
||||||
|
if (!serviceIds.includes(serviceId)) throw new CarwashError(400, `prices: unknown service "${serviceId}"`);
|
||||||
|
if (!Number.isInteger(priceMinor) || (priceMinor as number) < 0) {
|
||||||
|
throw new CarwashError(400, "prices: priceMinor must be a non-negative integer");
|
||||||
|
}
|
||||||
|
rows.push({ categoryId, serviceId, priceMinor: priceMinor as number });
|
||||||
|
}
|
||||||
|
this.#db.delete(carwashPrices).run();
|
||||||
|
for (const r of rows) this.#db.insert(carwashPrices).values(r).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#log.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: "module:carwash",
|
||||||
|
payload: {
|
||||||
|
setting: "carwash.settings",
|
||||||
|
value: { categories: categoryIds.length, services: serviceIds.length, prices: body.prices?.length ?? null },
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Where the money is taken — a site policy, signed on its own when it flips (it
|
||||||
|
// decides which till the cash lands on and whether the booth barrier or the exit
|
||||||
|
// reader releases the car; fraud-relevant, so it is attributed like other config).
|
||||||
|
if (body.payAt !== undefined) {
|
||||||
|
if (!isPayAt(body.payAt)) throw new CarwashError(400, "payAt must be booth|bay");
|
||||||
|
const prev = this.payAt();
|
||||||
|
if (body.payAt !== prev) {
|
||||||
|
this.#db
|
||||||
|
.insert(carwashConfig)
|
||||||
|
.values({ id: 1, payAt: body.payAt, updatedAt: now, updatedBy: actor })
|
||||||
|
.onConflictDoUpdate({ target: carwashConfig.id, set: { payAt: body.payAt, updatedAt: now, updatedBy: actor } })
|
||||||
|
.run();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: "module:carwash",
|
||||||
|
payload: { setting: "carwash.payAt", value: body.payAt, prev, operator: actor },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.visionThreshold !== undefined) {
|
||||||
|
const v = Number(body.visionThreshold);
|
||||||
|
if (!Number.isFinite(v) || v < 0 || v > 1) throw new CarwashError(400, "visionThreshold must be between 0 and 1");
|
||||||
|
const prev = this.visionThreshold();
|
||||||
|
if (v !== prev) {
|
||||||
|
this.#db
|
||||||
|
.insert(carwashConfig)
|
||||||
|
.values({ id: 1, visionThreshold: v, updatedAt: now, updatedBy: actor })
|
||||||
|
.onConflictDoUpdate({ target: carwashConfig.id, set: { visionThreshold: v, updatedAt: now, updatedBy: actor } })
|
||||||
|
.run();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: "module:carwash",
|
||||||
|
payload: { setting: "carwash.visionThreshold", value: v, prev, operator: actor },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.settings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Orders ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
#view(r: CarwashOrderRow): CarwashOrderView {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
identity: r.identity,
|
||||||
|
plate: r.plate,
|
||||||
|
categoryId: r.categoryId,
|
||||||
|
categoryName: r.categoryName,
|
||||||
|
serviceId: r.serviceId,
|
||||||
|
serviceName: r.serviceName,
|
||||||
|
priceMinor: r.priceMinor,
|
||||||
|
currency: r.currency,
|
||||||
|
payAt: r.payAt,
|
||||||
|
status: r.status,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
createdBy: r.createdBy,
|
||||||
|
doneAt: r.doneAt,
|
||||||
|
doneBy: r.doneBy,
|
||||||
|
paidAt: r.paidAt,
|
||||||
|
paidBy: r.paidBy,
|
||||||
|
tender: (r.tender as Tender | null) ?? null,
|
||||||
|
closed: r.status === "void" || (r.status === "done" && r.paidAt != null),
|
||||||
|
validationEventId: r.validationEventId,
|
||||||
|
voidBy: r.voidBy,
|
||||||
|
voidReason: r.voidReason,
|
||||||
|
visionClass: isVehicleClass(r.visionClass) ? r.visionClass : null,
|
||||||
|
visionConfidence: r.visionConfidence,
|
||||||
|
visionCategoryId: r.visionCategoryId,
|
||||||
|
downgradeEventId: r.downgradeEventId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#row(id: string): CarwashOrderRow {
|
||||||
|
const r = this.#db.select().from(carwashOrders).where(eq(carwashOrders.id, id)).get();
|
||||||
|
if (!r) throw new CarwashError(404, "order not found");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The desk's queue: every order still needing something, oldest first. */
|
||||||
|
openOrders(): CarwashOrderView[] {
|
||||||
|
return this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashOrders)
|
||||||
|
.where(inArray(carwashOrders.status, ["open", "done"]))
|
||||||
|
.orderBy(asc(carwashOrders.createdAt))
|
||||||
|
.all()
|
||||||
|
.map((r) => this.#view(r))
|
||||||
|
.filter((o) => !o.closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recent history (closed included), newest first. */
|
||||||
|
recentOrders(limit = 100): CarwashOrderView[] {
|
||||||
|
return this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashOrders)
|
||||||
|
.orderBy(desc(carwashOrders.createdAt))
|
||||||
|
.limit(Math.min(Math.max(limit, 1), 500))
|
||||||
|
.all()
|
||||||
|
.map((r) => this.#view(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
#ordersFor(identity: string): CarwashOrderView[] {
|
||||||
|
return this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashOrders)
|
||||||
|
.where(eq(carwashOrders.identity, identity))
|
||||||
|
.orderBy(asc(carwashOrders.createdAt))
|
||||||
|
.all()
|
||||||
|
.map((r) => this.#view(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticket → session facts the desk needs (the parking ticket IS the customer). */
|
||||||
|
lookup(identity: string): TicketLookup {
|
||||||
|
const id = identity.trim();
|
||||||
|
const s = this.#pay.lookup(id);
|
||||||
|
const vision = s.found ? vehicleForIdentity(this.#db, id) : null;
|
||||||
|
return {
|
||||||
|
identity: id,
|
||||||
|
found: s.found,
|
||||||
|
open: s.open,
|
||||||
|
subscription: s.subscription,
|
||||||
|
plate: s.plate,
|
||||||
|
enteredAt: s.enteredAt,
|
||||||
|
currency: s.currency,
|
||||||
|
orders: this.#ordersFor(id),
|
||||||
|
vision,
|
||||||
|
suggestedCategoryId: vision ? (this.#categoryForClass(vision.bodyType)?.id ?? null) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrder(input: CreateOrderInput): Promise<CarwashOrderView> {
|
||||||
|
const identity = input.identity.trim();
|
||||||
|
if (!identity) throw new CarwashError(400, "identity (ticket) required");
|
||||||
|
|
||||||
|
const s = this.#pay.lookup(identity);
|
||||||
|
if (!s.found) throw new CarwashError(404, "no session for ticket");
|
||||||
|
if (!s.open) throw new CarwashError(409, "session is closed");
|
||||||
|
if (s.subscription) throw new CarwashError(409, "subscription sessions: order the wash with payAt=bay", "subscription");
|
||||||
|
|
||||||
|
const category = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashCategories)
|
||||||
|
.where(and(eq(carwashCategories.id, input.categoryId), isNull(carwashCategories.deletedAt)))
|
||||||
|
.get();
|
||||||
|
if (!category || !category.active) throw new CarwashError(404, "category not found or inactive");
|
||||||
|
const service = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashServices)
|
||||||
|
.where(and(eq(carwashServices.id, input.serviceId), isNull(carwashServices.deletedAt)))
|
||||||
|
.get();
|
||||||
|
if (!service || !service.active) throw new CarwashError(404, "service not found or inactive");
|
||||||
|
const price = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashPrices)
|
||||||
|
.where(and(eq(carwashPrices.categoryId, category.id), eq(carwashPrices.serviceId, service.id)))
|
||||||
|
.get();
|
||||||
|
if (!price) throw new CarwashError(409, `no price for ${category.name} · ${service.name}`, "no_price");
|
||||||
|
// The SITE decides where wash money is taken (Setup → Car wash); the order freezes
|
||||||
|
// the policy in force. A client that still sends a different value is stale.
|
||||||
|
const payAt = this.payAt();
|
||||||
|
if (input.payAt !== undefined && input.payAt !== payAt) {
|
||||||
|
throw new CarwashError(409, `this site takes wash money at the ${payAt === "bay" ? "bay" : "booth"}`, "pay_at_policy");
|
||||||
|
}
|
||||||
|
const currency = s.currency ?? this.#pay.activeCurrency();
|
||||||
|
if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff");
|
||||||
|
|
||||||
|
// Vision, advisory: what the camera saw at entry and the category the site maps it
|
||||||
|
// to. A DOWNGRADE — the operator chose a category that prices LOWER than the mapped
|
||||||
|
// one for this service, with the read above the site threshold — is signed as an
|
||||||
|
// anomaly for the reviewer (both categories, operator, snapshot). Recorded only:
|
||||||
|
// never blocks, no reason prompt (user, 2026-09-06).
|
||||||
|
const vision = vehicleForIdentity(this.#db, identity);
|
||||||
|
const visionCategory = vision ? this.#categoryForClass(vision.bodyType) : null;
|
||||||
|
let downgradeEventId: string | null = null;
|
||||||
|
if (vision && visionCategory && visionCategory.id !== category.id && vision.confidence >= this.visionThreshold()) {
|
||||||
|
const visionPrice = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashPrices)
|
||||||
|
.where(and(eq(carwashPrices.categoryId, visionCategory.id), eq(carwashPrices.serviceId, service.id)))
|
||||||
|
.get();
|
||||||
|
if (visionPrice && visionPrice.priceMinor > price.priceMinor) {
|
||||||
|
const ev = await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("carwash.categoryDowngrade", {
|
||||||
|
visionClass: vision.bodyType,
|
||||||
|
visionCategory: visionCategory.name,
|
||||||
|
operator: input.actor,
|
||||||
|
chosenCategory: category.name,
|
||||||
|
}),
|
||||||
|
sessionRef: identity,
|
||||||
|
visionClass: vision.bodyType,
|
||||||
|
visionConfidence: vision.confidence,
|
||||||
|
visionCategoryId: visionCategory.id,
|
||||||
|
visionCategoryName: visionCategory.name,
|
||||||
|
chosenCategoryId: category.id,
|
||||||
|
chosenCategoryName: category.name,
|
||||||
|
serviceName: service.name,
|
||||||
|
visionPriceMinor: visionPrice.priceMinor,
|
||||||
|
chosenPriceMinor: price.priceMinor,
|
||||||
|
currency,
|
||||||
|
snapshotId: vision.snapshotId,
|
||||||
|
operator: input.actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
downgradeEventId = ev.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const row: CarwashOrderRow = {
|
||||||
|
id: randomUUID(),
|
||||||
|
identity,
|
||||||
|
plate: s.plate,
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryName: category.name,
|
||||||
|
serviceId: service.id,
|
||||||
|
serviceName: service.name,
|
||||||
|
priceMinor: price.priceMinor,
|
||||||
|
currency,
|
||||||
|
payAt,
|
||||||
|
status: "open",
|
||||||
|
createdAt: now,
|
||||||
|
createdBy: input.actor,
|
||||||
|
doneAt: null,
|
||||||
|
doneBy: null,
|
||||||
|
paidAt: null,
|
||||||
|
paidBy: null,
|
||||||
|
tender: null,
|
||||||
|
paymentEventId: null,
|
||||||
|
validationEventId: null,
|
||||||
|
voidAt: null,
|
||||||
|
voidBy: null,
|
||||||
|
voidReason: null,
|
||||||
|
visionClass: vision?.bodyType ?? null,
|
||||||
|
visionConfidence: vision?.confidence ?? null,
|
||||||
|
visionCategoryId: visionCategory?.id ?? null,
|
||||||
|
downgradeEventId,
|
||||||
|
};
|
||||||
|
this.#db.insert(carwashOrders).values(row).run();
|
||||||
|
// Hand the decision to the remote reviewer (crop + choice), off the intake path.
|
||||||
|
if (vision && this.#outbox?.enabled) {
|
||||||
|
void this.#outbox.enqueue(
|
||||||
|
{
|
||||||
|
orderId: row.id,
|
||||||
|
createdAt: now,
|
||||||
|
createdBy: input.actor,
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryName: category.name,
|
||||||
|
categoryClasses: category.visionClasses,
|
||||||
|
serviceName: service.name,
|
||||||
|
visionCategoryId: visionCategory?.id ?? null,
|
||||||
|
downgraded: downgradeEventId != null,
|
||||||
|
},
|
||||||
|
vision,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.#log.append({
|
||||||
|
type: "carwash_order",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
orderId: row.id,
|
||||||
|
action: "created",
|
||||||
|
categoryName: row.categoryName,
|
||||||
|
serviceName: row.serviceName,
|
||||||
|
priceMinor: row.priceMinor,
|
||||||
|
currency,
|
||||||
|
payAt: row.payAt,
|
||||||
|
operator: input.actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.#view(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wash is finished: apply the site's sponsorship program to the parking session
|
||||||
|
* (if one is configured and active), then — for a bay order already paid — settle
|
||||||
|
* the parking session so the exit reader opens. */
|
||||||
|
async markDone(id: string, actor: string): Promise<CarwashOrderView> {
|
||||||
|
const r = this.#row(id);
|
||||||
|
if (r.status === "void") throw new CarwashError(409, "order is void");
|
||||||
|
if (r.status === "done") throw new CarwashError(409, "order is already done");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
let validationEventId: string | null = null;
|
||||||
|
// Wash context for the wash-only discount modes: the WASH WINDOW in minutes — from
|
||||||
|
// the order's intake to now (= done) — and the order's frozen price. NOT the time
|
||||||
|
// since entry: a car parked for hours before it asks for a wash still pays for those
|
||||||
|
// hours (found 2026-09-05 on a long-open ticket that would have been fully comped).
|
||||||
|
// The credit lands at the start of the billed period (that is how timeCredit
|
||||||
|
// folds), so for a flat tariff the money is identical; a stepped/daily-cap tariff
|
||||||
|
// may differ by an increment. See applyValidation().
|
||||||
|
const washMinutes = Math.max(0, Math.ceil((Date.now() - Date.parse(r.createdAt)) / 60_000));
|
||||||
|
const applied = await applyValidation(this.#db, this.#log, {
|
||||||
|
programId: CARWASH_PROGRAM_ID,
|
||||||
|
identity: r.identity,
|
||||||
|
actor,
|
||||||
|
wash: { washMinutes, priceMinor: r.priceMinor },
|
||||||
|
});
|
||||||
|
if (applied.ok) validationEventId = applied.eventId;
|
||||||
|
else if (applied.status !== 404 && !/already applied/.test(applied.error)) {
|
||||||
|
// A real refusal (session closed, daily cap …) — the wash is still done; the
|
||||||
|
// customer simply gets no sponsorship. Keep it visible in the log.
|
||||||
|
this.#logger.warn(`carwash sponsorship not applied for ${r.identity}: ${applied.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#db
|
||||||
|
.update(carwashOrders)
|
||||||
|
.set({ status: "done", doneAt: now, doneBy: actor, validationEventId })
|
||||||
|
.where(eq(carwashOrders.id, id))
|
||||||
|
.run();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "carwash_order",
|
||||||
|
source: "manual",
|
||||||
|
identity: r.identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: r.identity,
|
||||||
|
orderId: id,
|
||||||
|
action: "done",
|
||||||
|
categoryName: r.categoryName,
|
||||||
|
serviceName: r.serviceName,
|
||||||
|
priceMinor: r.priceMinor,
|
||||||
|
currency: r.currency,
|
||||||
|
payAt: r.payAt,
|
||||||
|
...(validationEventId ? { validationEventId } : {}),
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const updated = this.#row(id);
|
||||||
|
if (updated.payAt === "bay" && updated.paidAt != null) await this.#settleParkingIfFree(updated, actor);
|
||||||
|
return this.#view(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Money taken AT THE BAY. Needs an open CARWASH shift (it is the wash operator's
|
||||||
|
* drawer money, never the booth's — wiki/concepts/shift.md "Tills"); signs a
|
||||||
|
* carwash_payment on that till; then, if the wash is also done, settles the
|
||||||
|
* parking session. */
|
||||||
|
async payAtBay(id: string, tender: Tender, actor: string): Promise<CarwashOrderView> {
|
||||||
|
const r = this.#row(id);
|
||||||
|
if (r.status === "void") throw new CarwashError(409, "order is void");
|
||||||
|
if (r.payAt !== "bay") throw new CarwashError(409, "this order is paid at the booth", "pay_at_booth");
|
||||||
|
if (r.paidAt != null) throw new CarwashError(409, "order is already paid");
|
||||||
|
if (tender !== "cash" && tender !== "card") throw new CarwashError(400, "tender must be cash|card");
|
||||||
|
this.#shift.requireOpenShift(CARWASH_TILL);
|
||||||
|
|
||||||
|
const ev = await this.#log.append({
|
||||||
|
type: "carwash_payment",
|
||||||
|
source: "manual",
|
||||||
|
identity: r.identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: r.identity,
|
||||||
|
orderId: id,
|
||||||
|
amountMinor: r.priceMinor,
|
||||||
|
currency: r.currency,
|
||||||
|
tender,
|
||||||
|
till: CARWASH_TILL,
|
||||||
|
categoryName: r.categoryName,
|
||||||
|
serviceName: r.serviceName,
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
this.#db
|
||||||
|
.update(carwashOrders)
|
||||||
|
.set({ paidAt: now, paidBy: actor, tender, paymentEventId: ev.id })
|
||||||
|
.where(eq(carwashOrders.id, id))
|
||||||
|
.run();
|
||||||
|
const updated = this.#row(id);
|
||||||
|
if (updated.status === "done") await this.#settleParkingIfFree(updated, actor, tender);
|
||||||
|
return this.#view(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bay-paid, done wash: if the sponsorship made the parking session zero-due, sign
|
||||||
|
* the $0 parking payment now — that is what the exit READER checks (a validation
|
||||||
|
* alone opens nothing; see exit-flow.ts). A remaining balance stays for the booth. */
|
||||||
|
async #settleParkingIfFree(r: CarwashOrderRow, actor: string, tender: Tender = "cash"): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = this.#pay.lookup(r.identity);
|
||||||
|
if (!s.open || s.subscription || s.paidAt != null) return;
|
||||||
|
const q = this.#pay.quote(r.identity);
|
||||||
|
if (q.amountMinor !== 0) return;
|
||||||
|
await this.#pay.pay(r.identity, tender);
|
||||||
|
this.#logger.info(`carwash: parking session ${r.identity} settled at zero after bay payment (by ${actor})`);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.warn(`carwash: could not settle parking for ${r.identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async voidOrder(id: string, reason: string, actor: string): Promise<CarwashOrderView> {
|
||||||
|
const r = this.#row(id);
|
||||||
|
if (r.status === "void") throw new CarwashError(409, "order is already void");
|
||||||
|
if (r.paidAt != null) throw new CarwashError(409, "a paid order cannot be voided", "paid");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
// Take back the sponsorship if it is still live (not consumed by a payment).
|
||||||
|
if (r.validationEventId) {
|
||||||
|
const live = liveValidations(this.#db, r.identity).find((v) => v.eventId === r.validationEventId);
|
||||||
|
if (live) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "validation",
|
||||||
|
source: "manual",
|
||||||
|
identity: r.identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: r.identity,
|
||||||
|
refId: r.validationEventId,
|
||||||
|
programId: live.programId,
|
||||||
|
programLabel: live.label,
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#db
|
||||||
|
.update(carwashOrders)
|
||||||
|
.set({ status: "void", voidAt: now, voidBy: actor, voidReason: reason || null })
|
||||||
|
.where(eq(carwashOrders.id, id))
|
||||||
|
.run();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "carwash_order",
|
||||||
|
source: "manual",
|
||||||
|
identity: r.identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: r.identity,
|
||||||
|
orderId: id,
|
||||||
|
action: "void",
|
||||||
|
categoryName: r.categoryName,
|
||||||
|
serviceName: r.serviceName,
|
||||||
|
priceMinor: r.priceMinor,
|
||||||
|
currency: r.currency,
|
||||||
|
payAt: r.payAt,
|
||||||
|
reason: reason || undefined,
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.#view(this.#row(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Booth settlement hook ------------------------------------------------------
|
||||||
|
|
||||||
|
/** Orders with payAt = "booth" ride the parking payment as charge lines; the core
|
||||||
|
* calls back after the payment is signed so they are marked paid. Off = no lines. */
|
||||||
|
chargeProvider(): ChargeProvider {
|
||||||
|
return {
|
||||||
|
lines: (identity) => {
|
||||||
|
if (!this.#enabled()) return [];
|
||||||
|
return this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashOrders)
|
||||||
|
.where(and(eq(carwashOrders.identity, identity), eq(carwashOrders.payAt, "booth"), isNull(carwashOrders.paidAt)))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.status !== "void")
|
||||||
|
.map((r) => ({
|
||||||
|
module: "carwash" as const,
|
||||||
|
ref: r.id,
|
||||||
|
label: `Lavazh — ${r.categoryName} · ${r.serviceName}`,
|
||||||
|
amountMinor: r.priceMinor,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onPaid: async (_identity, lines, payment) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const l of lines) {
|
||||||
|
if (l.module !== "carwash") continue;
|
||||||
|
this.#db
|
||||||
|
.update(carwashOrders)
|
||||||
|
.set({ paidAt: now, paidBy: payment.operator ?? "booth", tender: payment.tender, paymentEventId: payment.eventId })
|
||||||
|
.where(and(eq(carwashOrders.id, l.ref), isNull(carwashOrders.paidAt)))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type guard for the void body etc. */
|
||||||
|
export function isPayAt(v: unknown): v is CarWashPayAt {
|
||||||
|
return typeof v === "string" && (CARWASH_PAY_AT as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { MODULES, parseEntitledModules, type ModuleId } from "@parking/shared";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
|
import type { PayStation } from "../pay-station.js";
|
||||||
|
import type { ShiftService } from "../shift-service.js";
|
||||||
|
import { effectiveModulesFor } from "../modules.js";
|
||||||
|
import { carwashModule } from "./carwash/index.js";
|
||||||
|
import { validationModule } from "./validation/index.js";
|
||||||
|
|
||||||
|
// The server-side module registry. A module's routes live in its own folder
|
||||||
|
// (apps/server/src/modules/<id>/index.ts) and are registered by iterating
|
||||||
|
// @parking/shared's MODULES — so adding a module is one manifest entry + one folder +
|
||||||
|
// one line in SERVER_MODULES below, with nothing else in the core touched
|
||||||
|
// (wiki/decisions/venue-modules.md, "A module = a manifest + three folders").
|
||||||
|
//
|
||||||
|
// `parking` is registered in the manifest but has NO folder yet: its routes are still
|
||||||
|
// the flat list in server.ts. That is deliberate — the seam is drawn, the code moves
|
||||||
|
// across it subsystem by subsystem as each is touched, not in one big move.
|
||||||
|
|
||||||
|
/** What the core hands a module at registration. Modules reach the core ONLY through
|
||||||
|
* these (never by importing another module): the DB, the signed ledger, the booth
|
||||||
|
* settlement (to fold charges in / settle a session — PayStation.registerChargeProvider,
|
||||||
|
* quote, pay) and the shift service (money needs an open shift). */
|
||||||
|
export interface ServerModuleDeps {
|
||||||
|
db: Db;
|
||||||
|
eventLog: EventLog;
|
||||||
|
payStation: PayStation;
|
||||||
|
shiftService: ShiftService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerModule {
|
||||||
|
id: ModuleId;
|
||||||
|
register(app: FastifyInstance, deps: ServerModuleDeps): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVER_MODULES: Partial<Record<ModuleId, ServerModule>> = {
|
||||||
|
validation: validationModule,
|
||||||
|
carwash: carwashModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Register every folder-based module in registry order, then log what this site
|
||||||
|
* is entitled to / has effective, so a "why is X missing" question is answerable
|
||||||
|
* from the container log alone. */
|
||||||
|
export async function registerModules(app: FastifyInstance, deps: ServerModuleDeps): Promise<void> {
|
||||||
|
for (const manifest of MODULES) {
|
||||||
|
const impl = SERVER_MODULES[manifest.id];
|
||||||
|
if (impl) {
|
||||||
|
if (impl.id !== manifest.id) throw new Error(`module registry mismatch: ${impl.id} registered under ${manifest.id}`);
|
||||||
|
await impl.register(app, deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { entitled, unknown } = parseEntitledModules(process.env.MODULES_ENTITLED);
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
app.log.warn({ unknown }, "MODULES_ENTITLED names unknown module ids — ignored");
|
||||||
|
}
|
||||||
|
app.log.info(
|
||||||
|
{ entitled, effective: effectiveModulesFor(deps.db) },
|
||||||
|
"venue modules (entitled = MODULES_ENTITLED env; effective = entitled ∩ site activation)",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { validationRoutes } from "../../routes/validations.js";
|
||||||
|
import type { ServerModule } from "../index.js";
|
||||||
|
|
||||||
|
// Merchant-scan ticket validation as a venue module. Kept for the Bar until a Bar
|
||||||
|
// module absorbs it (wiki/decisions/venue-modules.md, decision 1). The routes
|
||||||
|
// themselves still live in routes/validations.ts (unchanged location, now guarded by
|
||||||
|
// requireModule("validation")); this folder is the registry hook.
|
||||||
|
export const validationModule: ServerModule = {
|
||||||
|
id: "validation",
|
||||||
|
async register(app, { db, eventLog }) {
|
||||||
|
await validationRoutes(app, db, eventLog);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||||
|
import { getOccupancy, occupancyCount, reservedSubscriberSpots } from "./occupancy.js";
|
||||||
|
|
||||||
|
// Occupancy is a FOLD over the signed ledger, never a stored counter. These tests
|
||||||
|
// pin: the entries-minus-exits count, the capacity/full gate, and the reserved-
|
||||||
|
// subscriber-spots model (its trickiest invariant — never double-count a parked
|
||||||
|
// subscriber, and never gate the subscriber's own entry).
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
// Insert a ledger row directly (these fns read raw rows; signing is event-log's job).
|
||||||
|
let idx = 0;
|
||||||
|
function entry(identity: string, payload?: Record<string, unknown>) {
|
||||||
|
idx += 1;
|
||||||
|
db.insert(ledgerEvents).values({
|
||||||
|
id: `e${idx}`, index: idx, type: "vehicle_entry", direction: "entry",
|
||||||
|
identity, payload: payload ?? null, occurredAt: new Date().toISOString(),
|
||||||
|
signature: "x", keyId: "test",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
function exit(identity: string) {
|
||||||
|
idx += 1;
|
||||||
|
db.insert(ledgerEvents).values({
|
||||||
|
id: `e${idx}`, index: idx, type: "vehicle_exit", direction: "exit",
|
||||||
|
identity, payload: null, occurredAt: new Date().toISOString(),
|
||||||
|
signature: "x", keyId: "test",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
function voidEvt(identity: string) {
|
||||||
|
idx += 1;
|
||||||
|
db.insert(ledgerEvents).values({
|
||||||
|
id: `e${idx}`, index: idx, type: "void",
|
||||||
|
identity, payload: { sessionRef: identity, voidReason: "misprint" }, occurredAt: new Date().toISOString(),
|
||||||
|
signature: "x", keyId: "test",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
|
||||||
|
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("occupancyCount", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("is 0 with no events", () => {
|
||||||
|
expect(occupancyCount(db)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts open sessions (entries minus matching exits)", () => {
|
||||||
|
entry("A"); entry("B"); entry("C");
|
||||||
|
exit("B");
|
||||||
|
expect(occupancyCount(db)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a re-entry after exit counts again", () => {
|
||||||
|
entry("A"); exit("A"); entry("A");
|
||||||
|
expect(occupancyCount(db)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a voided (cancelled) entry does NOT count inside", () => {
|
||||||
|
entry("A"); entry("B");
|
||||||
|
voidEvt("B"); // B's ticket was a misprint — cancelled
|
||||||
|
expect(occupancyCount(db)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOccupancy — capacity + full gate", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("uncapped: never full, free/effectiveFree null", () => {
|
||||||
|
setSite({ capacity: null });
|
||||||
|
entry("A");
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.full).toBe(false);
|
||||||
|
expect(o.free).toBeNull();
|
||||||
|
expect(o.effectiveFree).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("capped: full when count reaches capacity", () => {
|
||||||
|
setSite({ capacity: 2 });
|
||||||
|
entry("A");
|
||||||
|
expect(getOccupancy(db).full).toBe(false);
|
||||||
|
entry("B");
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.full).toBe(true);
|
||||||
|
expect(o.free).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reservedSubscriberSpots", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
function addSub(id: string, opts: Partial<typeof subscriptions.$inferInsert> = {}) {
|
||||||
|
db.insert(subscriptions).values({ id, status: "active", quantity: 1, period: "month", ...opts }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("is 0 when the toggle is off (default)", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: false });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds quantity spots for an active, not-parked subscription", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT double-count a subscriber already parked (holds only the rest)", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
// One of the family's two cars is inside (occurrence entry carries permitId = sub id).
|
||||||
|
entry("SUBSESS-1", { permitId: "s1" });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(1); // 2 quantity − 1 inside
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores suspended/revoked and out-of-window subscriptions", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("active", { quantity: 1 });
|
||||||
|
addSub("suspended", { quantity: 5, status: "suspended" });
|
||||||
|
addSub("expired", { quantity: 5, validTo: "2000-01-01T00:00:00.000Z" });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOccupancy — reserved tightens the transient gate", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("transient sees full once count + reserved ≥ capacity", () => {
|
||||||
|
setSite({ capacity: 3, reserveSubscriberSpots: true });
|
||||||
|
db.insert(subscriptions).values({ id: "s1", status: "active", quantity: 2, period: "month" }).run();
|
||||||
|
entry("A"); // 1 inside + 2 reserved = 3 ≥ capacity 3
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.reserved).toBe(2);
|
||||||
|
expect(o.effectiveFree).toBe(0);
|
||||||
|
expect(o.full).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||||
|
|
||||||
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
|
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
|
||||||
// with no matching vehicle_exit. Never a hand-maintained counter (which is
|
// with no matching vehicle_exit. Never a hand-maintained counter (which is
|
||||||
@@ -7,11 +7,18 @@ import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
|||||||
export interface Occupancy {
|
export interface Occupancy {
|
||||||
/** Cars currently inside (open sessions). */
|
/** Cars currently inside (open sessions). */
|
||||||
readonly count: number;
|
readonly count: number;
|
||||||
|
/** Spots HELD for active subscribers who are NOT currently parked (when the
|
||||||
|
* reserve-subscriber-spots toggle is on; 0 otherwise). Each active subscription holds
|
||||||
|
* `quantity` spots minus however many of its cars are already inside. */
|
||||||
|
readonly reserved: number;
|
||||||
/** Admin-set nominal capacity, or null = no limit. */
|
/** Admin-set nominal capacity, or null = no limit. */
|
||||||
readonly capacity: number | null;
|
readonly capacity: number | null;
|
||||||
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
|
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
|
||||||
readonly free: number | null;
|
readonly free: number | null;
|
||||||
/** True when count ≥ capacity (always false when uncapped). */
|
/** Effective free for a TRANSIENT car = capacity − count − reserved (null uncapped). */
|
||||||
|
readonly effectiveFree: number | null;
|
||||||
|
/** True when a TRANSIENT entry should be refused: count + reserved ≥ capacity
|
||||||
|
* (always false when uncapped). Subscribers are never gated by this. */
|
||||||
readonly full: boolean;
|
readonly full: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +30,11 @@ export function occupancyCount(db: Db): number {
|
|||||||
.all();
|
.all();
|
||||||
const balance = new Map<string, number>();
|
const balance = new Map<string, number>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
// A `void` (cancelled ticket) closes the session like an exit — the car never entered
|
||||||
|
// (misprint), so it must not count inside. See void-flow.ts.
|
||||||
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
|
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
|
||||||
else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
else if (r.type === "vehicle_exit" || r.type === "void")
|
||||||
|
balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
||||||
}
|
}
|
||||||
let open = 0;
|
let open = 0;
|
||||||
for (const v of balance.values()) if (v > 0) open += 1;
|
for (const v of balance.values()) if (v > 0) open += 1;
|
||||||
@@ -37,13 +47,66 @@ export function siteCapacity(db: Db): number | null {
|
|||||||
return row?.capacity ?? null;
|
return row?.capacity ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spots to RESERVE for active subscribers who aren't currently parked. Off (0) unless
|
||||||
|
* `site_config.reserve_subscriber_spots` is set. For each ACTIVE subscription (status
|
||||||
|
* active AND now ∈ [validFrom, validTo]), hold `quantity` spots minus the cars of that
|
||||||
|
* subscription already inside (so we never double-count a parked subscriber). This is
|
||||||
|
* what makes a transient see "full" sooner while the subscriber's spot is held.
|
||||||
|
*/
|
||||||
|
export function reservedSubscriberSpots(db: Db): number {
|
||||||
|
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (!cfg?.reserveSubscriberSpots) return 0;
|
||||||
|
|
||||||
|
// Cars currently inside per subscription (occurrence entries by permitId, net of exits).
|
||||||
|
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
|
const insidePerSub = new Map<string, number>();
|
||||||
|
const net = new Map<string, number>(); // occurrence identity → entries−exits
|
||||||
|
const subOf = new Map<string, string>(); // occurrence identity → subscription id
|
||||||
|
for (const r of rows) {
|
||||||
|
const id = r.identity;
|
||||||
|
if (!id) continue;
|
||||||
|
if (r.type === "vehicle_entry") {
|
||||||
|
const pl = (r.payload ?? {}) as { permitId?: string };
|
||||||
|
if (pl.permitId == null) continue; // transient
|
||||||
|
net.set(id, (net.get(id) ?? 0) + 1);
|
||||||
|
subOf.set(id, pl.permitId);
|
||||||
|
} else if (r.type === "vehicle_exit" || r.type === "void") {
|
||||||
|
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, n] of net) if (n > 0) {
|
||||||
|
const sub = subOf.get(id)!;
|
||||||
|
insidePerSub.set(sub, (insidePerSub.get(sub) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const subs = db.select().from(subscriptions).all();
|
||||||
|
let reserved = 0;
|
||||||
|
for (const s of subs) {
|
||||||
|
const active =
|
||||||
|
s.status === "active" &&
|
||||||
|
(s.validFrom == null || now >= s.validFrom) &&
|
||||||
|
(s.validTo == null || now <= s.validTo);
|
||||||
|
if (!active) continue;
|
||||||
|
const qty = s.quantity ?? 1;
|
||||||
|
const inside = insidePerSub.get(s.id) ?? 0;
|
||||||
|
reserved += Math.max(0, qty - inside); // hold only the not-yet-parked portion
|
||||||
|
}
|
||||||
|
return reserved;
|
||||||
|
}
|
||||||
|
|
||||||
export function getOccupancy(db: Db): Occupancy {
|
export function getOccupancy(db: Db): Occupancy {
|
||||||
const count = occupancyCount(db);
|
const count = occupancyCount(db);
|
||||||
const capacity = siteCapacity(db);
|
const capacity = siteCapacity(db);
|
||||||
|
const reserved = reservedSubscriberSpots(db);
|
||||||
return {
|
return {
|
||||||
count,
|
count,
|
||||||
|
reserved,
|
||||||
capacity,
|
capacity,
|
||||||
free: capacity == null ? null : capacity - count,
|
free: capacity == null ? null : capacity - count,
|
||||||
full: capacity != null && count >= capacity,
|
effectiveFree: capacity == null ? null : capacity - count - reserved,
|
||||||
|
// A transient is refused once physical cars + held subscriber spots reach capacity.
|
||||||
|
full: capacity != null && count + reserved >= capacity,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||