Compare commits
150 Commits
main
...
desktop-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 1efa77bf56 | |||
| 15d3e1ba08 | |||
| ff3b011fe0 | |||
| 5705098054 | |||
| 68d61f2d99 | |||
| 04135b27cf | |||
| 392d44d842 | |||
| f67c1ead87 | |||
| bf37106c5c | |||
| e579fe5b6e | |||
| 644bfa1462 | |||
| 3429642edb | |||
| c24d99b0f4 | |||
| b4d0dfadd6 | |||
| f18e28eeca | |||
| a8c6d6e714 | |||
| 2a36830880 | |||
| 2696d281ce | |||
| 648d3254d6 | |||
| 8c2cf93067 | |||
| 9a4c7ee27b | |||
| 8a8e74561d | |||
| 2ab5a39a57 | |||
| fa65b2df86 |
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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,122 @@
|
|||||||
|
name: Build & push images
|
||||||
|
|
||||||
|
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
|
||||||
|
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, 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.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
paths:
|
||||||
|
- 'apps/server/**'
|
||||||
|
- 'apps/web/**'
|
||||||
|
- 'apps/vision/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'docker-compose*.yml'
|
||||||
|
- '.dockerignore'
|
||||||
|
- '.gitea/workflows/build-images.yml'
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
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 VISION (ANPR)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: apps/vision
|
||||||
|
file: apps/vision/Dockerfile
|
||||||
|
push: true
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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,57 @@
|
|||||||
|
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: 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,149 @@
|
|||||||
|
name: Release desktop
|
||||||
|
|
||||||
|
# Build the signed Tauri desktop installers on a version tag and publish them as
|
||||||
|
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
|
||||||
|
# fetches these; latest.json + each installer + its .sig are what it needs.
|
||||||
|
#
|
||||||
|
# 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, and uploads
|
||||||
|
# everything to the Release for that tag.
|
||||||
|
|
||||||
|
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: 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.
|
||||||
|
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' \) \
|
||||||
|
-exec cp {} dist/ \;
|
||||||
|
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}. We point the AppImage target at
|
||||||
|
# this release's asset URL. Adjust the platform keys you actually ship.
|
||||||
|
env:
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
||||||
|
SIG=$(cat "dist/${APPIMAGE}.sig")
|
||||||
|
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
|
||||||
|
cat > dist/latest.json <<JSON
|
||||||
|
{
|
||||||
|
"version": "${VERSION}",
|
||||||
|
"notes": "Parking System ${TAG}",
|
||||||
|
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||||
|
"platforms": {
|
||||||
|
"linux-x86_64": {
|
||||||
|
"signature": "${SIG}",
|
||||||
|
"url": "${ASSET_URL}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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"
|
||||||
@@ -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
|
||||||
@@ -20,3 +22,8 @@ dist/
|
|||||||
/*.png
|
/*.png
|
||||||
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
|
# Vendor device SDKs (reference only — protocol captured in wiki, not committed)
|
||||||
/dingtian/
|
/dingtian/
|
||||||
|
/QRCode_sdk*/
|
||||||
|
|
||||||
|
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||||
|
graphify-out/
|
||||||
|
parking.sqlite*.bak-*
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 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
|
||||||
|
reverse_proxy server:3000
|
||||||
|
}
|
||||||
@@ -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,42 @@
|
|||||||
|
# @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).
|
||||||
|
|
||||||
|
## Not here (deliberately)
|
||||||
|
|
||||||
|
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, 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,28 @@
|
|||||||
|
[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"
|
||||||
|
|
||||||
|
[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,11 @@
|
|||||||
|
{
|
||||||
|
"$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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
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,21 @@
|
|||||||
|
// 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 the local Fastify server, 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())
|
||||||
|
.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.0.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' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). 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/parking_solution/releases/latest/download/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,58 @@
|
|||||||
# 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=
|
||||||
|
|
||||||
# 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)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 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 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
|
||||||
|
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,13 +9,15 @@
|
|||||||
"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:*",
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,14 +62,14 @@ 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}"`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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" }));
|
||||||
|
vi.mock("./snapshot.js", () => ({
|
||||||
|
buildCamera: () => ({ captureSnapshot }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
|
||||||
|
function seedCamera(opts: { anpr?: 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 } : {}) },
|
||||||
|
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,
|
||||||
|
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): SubscriptionFlow {
|
||||||
|
return { match: vi.fn(() => match) } 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("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("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("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,184 @@
|
|||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnprBridge {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #vision: VisionClient | null;
|
||||||
|
readonly #subscription: SubscriptionFlow;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #entryMinConfidence: number;
|
||||||
|
readonly #debounceMs: 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>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only
|
||||||
|
|
||||||
|
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
|
||||||
|
// snapshot + analyze every second.
|
||||||
|
if (this.#debounced(deviceId)) return;
|
||||||
|
this.#stamp(deviceId);
|
||||||
|
|
||||||
|
const camera = buildCamera(row);
|
||||||
|
if (!camera) {
|
||||||
|
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";
|
||||||
|
const shot = await camera.captureSnapshot({ direction });
|
||||||
|
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||||
|
if (!result || !result.plate) return; // nothing read
|
||||||
|
|
||||||
|
// Entry floor — stricter than the advisory floor (analyze() still returns the plate
|
||||||
|
// object with its confidence even when its own lowConfidence flag is set).
|
||||||
|
if (result.plate.confidence < this.#entryMinConfidence) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
|
||||||
|
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
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, 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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,9 +24,15 @@ export const TOKEN_COOKIE = "parking_token";
|
|||||||
export const CSRF_COOKIE = "parking_csrf";
|
export const CSRF_COOKIE = "parking_csrf";
|
||||||
export const CSRF_HEADER = "x-csrf-token";
|
export const CSRF_HEADER = "x-csrf-token";
|
||||||
|
|
||||||
/** Token lifetime, also used as the cookie maxAge. */
|
// Session lifetime: the JWT has NO expiry — a login is valid until explicit
|
||||||
export const TOKEN_TTL = "8h";
|
// logout. Booth reality breaks any fixed clock (relief late/absent, forced double
|
||||||
export const TOKEN_TTL_SECONDS = 8 * 60 * 60;
|
// shifts), and a shift is a separate explicit boundary, not the token's lifetime.
|
||||||
|
// See wiki/entities/local-jwt-auth.md + wiki/concepts/shift.md.
|
||||||
|
//
|
||||||
|
// The cookie still needs a maxAge so it survives a browser restart (a session
|
||||||
|
// cookie would log out an active operator on browser close — the opposite of
|
||||||
|
// "until logout"). Use a long fixed window; the server clears it on logout.
|
||||||
|
export const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the JWT signing secret, refusing to start without a strong one.
|
* Resolve the JWT signing secret, refusing to start without a strong one.
|
||||||
@@ -38,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 {
|
||||||
@@ -55,7 +86,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
|||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
secure,
|
secure,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: TOKEN_TTL_SECONDS,
|
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||||
});
|
});
|
||||||
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
|
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
|
||||||
reply.setCookie(CSRF_COOKIE, csrf, {
|
reply.setCookie(CSRF_COOKIE, csrf, {
|
||||||
@@ -63,7 +94,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
|||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
secure,
|
secure,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: TOKEN_TTL_SECONDS,
|
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,17 +121,84 @@ 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 cache. Call after ANY write to roles / role_permissions
|
||||||
|
* (or a user's roleId) so the change takes effect on the next request. */
|
||||||
|
export function bumpPermsCache(): void {
|
||||||
|
permsCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)) {
|
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 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
printWithFailover,
|
||||||
|
registry,
|
||||||
|
type PrinterDevice,
|
||||||
|
type PrinterInstance,
|
||||||
|
type ReceiptData,
|
||||||
|
type TicketHeader,
|
||||||
|
} 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 = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,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,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
// 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
|
||||||
@@ -8,22 +9,83 @@ import type { PrinterStatus } from "@parking/devices";
|
|||||||
|
|
||||||
export interface DeviceInputEvent {
|
export interface DeviceInputEvent {
|
||||||
readonly driverId: string; // e.g. "dingtian"
|
readonly driverId: string; // e.g. "dingtian"
|
||||||
readonly deviceId: string; // which configured device (lane_devices id)
|
readonly deviceId: string; // which configured device (devices id)
|
||||||
readonly input: number; // 1-based input/channel
|
readonly input: number; // 1-based input/channel
|
||||||
readonly edge: "on" | "off"; // active / inactive
|
readonly edge: "on" | "off"; // active / inactive
|
||||||
readonly at: string; // ISO-8601 (server receive time)
|
readonly at: string; // ISO-8601 (server receive time)
|
||||||
readonly source: "push" | "poll";
|
readonly source: "push" | "poll";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A credential read: a ticket scanned at exit, a plate from LPR, a card at a reader.
|
||||||
|
// Drives identity-based flows (exit validation, subscriptions, pay-station lookup). `kind`
|
||||||
|
// mirrors IdentitySource. See parking-session.md.
|
||||||
|
export interface DeviceReadEvent {
|
||||||
|
readonly driverId: string;
|
||||||
|
readonly deviceId: string; // devices id of the reader/scanner/camera
|
||||||
|
readonly value: string; // the ticket id / plate / card number
|
||||||
|
readonly kind: "ticket" | "plate" | "qr" | "card";
|
||||||
|
readonly at: string; // ISO-8601
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* device. A fire-and-forget reader simply ignores it. See wiki/entities/gee-qr-er80.md.
|
||||||
|
*/
|
||||||
|
export interface ReadOutcome {
|
||||||
|
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||||
|
readonly accepted: boolean;
|
||||||
|
/** Which way it went, when known (subscription/exit infer this). */
|
||||||
|
readonly direction?: "entry" | "exit";
|
||||||
|
/** Human-readable reason (for logs / the reader UI), esp. on reject. */
|
||||||
|
readonly reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A printer's status as tracked by the live monitor (status + identity). */
|
/** A printer's status as tracked by the live monitor (status + identity). */
|
||||||
export interface PrinterStatusEvent {
|
export interface PrinterStatusEvent {
|
||||||
readonly deviceId: string; // lane_devices id
|
readonly deviceId: string; // devices id
|
||||||
readonly lane: number;
|
|
||||||
readonly driverId: string;
|
readonly driverId: string;
|
||||||
readonly role?: string; // entry-dispenser | booth-receipt
|
readonly role?: string; // entry-dispenser | booth-receipt
|
||||||
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)
|
||||||
|
* - undetermined: null (chip shows the category alone)
|
||||||
|
*/
|
||||||
|
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | 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)
|
||||||
|
}
|
||||||
|
|
||||||
class DeviceEventBus extends EventEmitter {
|
class DeviceEventBus extends EventEmitter {
|
||||||
emitInput(event: DeviceInputEvent): void {
|
emitInput(event: DeviceInputEvent): void {
|
||||||
this.emit("input", event);
|
this.emit("input", event);
|
||||||
@@ -33,6 +95,15 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
return () => this.off("input", cb);
|
return () => this.off("input", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A credential read (ticket scan, plate, card). */
|
||||||
|
emitRead(event: DeviceReadEvent): void {
|
||||||
|
this.emit("read", event);
|
||||||
|
}
|
||||||
|
onRead(cb: (event: DeviceReadEvent) => void): () => void {
|
||||||
|
this.on("read", cb);
|
||||||
|
return () => this.off("read", cb);
|
||||||
|
}
|
||||||
|
|
||||||
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
|
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
|
||||||
emitPrinterStatus(event: PrinterStatusEvent): void {
|
emitPrinterStatus(event: PrinterStatusEvent): void {
|
||||||
this.emit("printer-status", event);
|
this.emit("printer-status", event);
|
||||||
@@ -41,6 +112,42 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process-wide device event bus. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import { isMonitorable, registry } 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";
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
*/
|
||||||
|
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": {
|
||||||
|
const dirs = new Set(relaysOf(row).map((r) => r.direction));
|
||||||
|
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";
|
||||||
|
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;
|
||||||
|
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 {
|
||||||
|
const 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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,201 @@
|
|||||||
|
import { and, eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
|
||||||
|
// Device resolution for the pool-of-spaces model — NO lane. A parking lot is one
|
||||||
|
// pool with a flexible set of entry/exit points. Direction lives on each RELAY
|
||||||
|
// inside an access controller, and readers/cameras BIND to a (controller, relay).
|
||||||
|
// See wiki/concepts/entry-exit-points.md.
|
||||||
|
|
||||||
|
/** A flow direction. "both" = one relay/barrier serving entry AND exit. */
|
||||||
|
export type Direction = "entry" | "exit" | "both";
|
||||||
|
/** A concrete flow a credential/button drives (never "both"). */
|
||||||
|
export type FlowDirection = "entry" | "exit";
|
||||||
|
|
||||||
|
/** One relay on an access controller: which barrier it opens, in which direction,
|
||||||
|
* and (optionally) the input terminals its entry button + presence loop are wired to. */
|
||||||
|
export interface RelaySpec {
|
||||||
|
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||||
|
readonly relay: number;
|
||||||
|
readonly direction: Direction;
|
||||||
|
/** 1-based input terminal of the entry button that fires this relay (transient
|
||||||
|
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
||||||
|
readonly button?: number;
|
||||||
|
/**
|
||||||
|
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
|
||||||
|
* Two modes, chosen by what barrier feedback exists at this lane:
|
||||||
|
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
|
||||||
|
* 1-based input terminal of an induction loop / barrier presence signal on THIS
|
||||||
|
* controller. 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. This
|
||||||
|
* makes one-car-one-ticket physical.
|
||||||
|
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
|
||||||
|
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
|
||||||
|
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
|
||||||
|
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
||||||
|
*/
|
||||||
|
readonly presenceInput?: number;
|
||||||
|
readonly entryCooldownSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Access controller config (the `relays[]` map + connection fields). */
|
||||||
|
interface AccessConfig {
|
||||||
|
readonly relays?: RelaySpec[];
|
||||||
|
readonly [k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reader/camera config: optional binding to a controller relay. */
|
||||||
|
interface BoundConfig {
|
||||||
|
/** The access `devices.id` this reader/camera sits at. */
|
||||||
|
readonly controllerId?: string;
|
||||||
|
/** The relay on that controller it opens. */
|
||||||
|
readonly relay?: number;
|
||||||
|
/** Fallback direction when not bound to a relay. */
|
||||||
|
readonly direction?: Direction;
|
||||||
|
readonly [k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
readonly controller: DeviceRow;
|
||||||
|
readonly relay: number;
|
||||||
|
readonly direction: Direction;
|
||||||
|
/** 1-based presence-loop input gating this relay's entry (when wired). */
|
||||||
|
readonly presenceInput?: number;
|
||||||
|
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
|
||||||
|
readonly entryCooldownSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All enabled access controller rows. */
|
||||||
|
function accessRows(db: Db): DeviceRow[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.category, "access"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The relay specs declared on an access controller (defaults to none). */
|
||||||
|
export function relaysOf(row: DeviceRow): RelaySpec[] {
|
||||||
|
const cfg = row.config as AccessConfig;
|
||||||
|
return Array.isArray(cfg.relays) ? cfg.relays : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a button press to the relay it fires: the access controller with this
|
||||||
|
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
||||||
|
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
||||||
|
*/
|
||||||
|
export function relayForButton(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 spec = relaysOf(row).find((r) => r.button === terminal);
|
||||||
|
if (!spec) return null;
|
||||||
|
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||||
|
return {
|
||||||
|
controller: row,
|
||||||
|
relay: spec.relay,
|
||||||
|
direction: spec.direction,
|
||||||
|
presenceInput: spec.presenceInput,
|
||||||
|
entryCooldownSec: spec.entryCooldownSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
|
||||||
|
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
|
||||||
|
* 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 spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
||||||
|
if (!spec) return null;
|
||||||
|
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
||||||
|
return { controller: row, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a reader/camera to the relay it opens. Preferred: its config binding
|
||||||
|
* (controllerId + relay) → exactly that barrier, direction inherited from the relay
|
||||||
|
* spec. Fallback (unbound): the device's config.direction + the first relay site-
|
||||||
|
* wide matching that direction — keeps the single-barrier case trivial. Null if
|
||||||
|
* nothing resolves (no barrier to open).
|
||||||
|
*/
|
||||||
|
export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | null {
|
||||||
|
const cfg = deviceRow.config as BoundConfig;
|
||||||
|
|
||||||
|
// Bound: follow controllerId + relay to the exact barrier.
|
||||||
|
if (cfg.controllerId && typeof cfg.relay === "number") {
|
||||||
|
const controller = db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, cfg.controllerId), eq(devices.category, "access")))
|
||||||
|
.get();
|
||||||
|
if (controller && controller.enabled) {
|
||||||
|
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||||
|
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbound: fall back to the device's declared direction + first matching relay.
|
||||||
|
const want = cfg.direction;
|
||||||
|
if (want === "entry" || want === "exit" || want === "both") {
|
||||||
|
return firstRelayByDirection(db, want === "both" ? "entry" : want);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first relay site-wide serving a direction ("both" relays match either).
|
||||||
|
* Used as the unbound fallback and where a flow only needs "an exit barrier".
|
||||||
|
*/
|
||||||
|
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||||
|
for (const controller of accessRows(db)) {
|
||||||
|
const spec = relaysOf(controller).find(
|
||||||
|
(r) => r.direction === direction || r.direction === "both",
|
||||||
|
);
|
||||||
|
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enabled devices of a category whose direction matches `want` (or is "both").
|
||||||
|
* Direction is inherited from each device's bound relay, else its config fallback.
|
||||||
|
* Used for snapshots: every entry/exit camera fires on an entry/exit. */
|
||||||
|
export function devicesByDirection(
|
||||||
|
db: Db,
|
||||||
|
category: DeviceRow["category"],
|
||||||
|
want: FlowDirection,
|
||||||
|
): DeviceRow[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.category, category))
|
||||||
|
.all()
|
||||||
|
.filter((r) => {
|
||||||
|
if (!r.enabled) return false;
|
||||||
|
const d = directionOf(db, r);
|
||||||
|
return d === want || d === "both";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The direction a reader/camera operates in (inherited from its bound relay, or
|
||||||
|
* its config fallback). "both" when undetermined → the flow infers. */
|
||||||
|
export function directionOf(db: Db, deviceRow: DeviceRow): Direction {
|
||||||
|
const resolved = relayForDevice(db, deviceRow);
|
||||||
|
if (resolved) return resolved.direction;
|
||||||
|
const cfg = deviceRow.config as BoundConfig;
|
||||||
|
return cfg.direction === "entry" || cfg.direction === "exit" ? cfg.direction : "both";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import { randomInt, randomUUID } from "node:crypto";
|
||||||
|
import { deviceEvents as deviceEventsTable, eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import {
|
||||||
|
NoPrinterAvailableError,
|
||||||
|
printWithFailover,
|
||||||
|
registry,
|
||||||
|
type AccessControlDevice,
|
||||||
|
type PrinterDevice,
|
||||||
|
type PrinterInstance,
|
||||||
|
type TicketData,
|
||||||
|
type TicketHeader,
|
||||||
|
} from "@parking/devices";
|
||||||
|
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { DeviceInputEvent } from "./device-events.js";
|
||||||
|
import { getOccupancy } from "./occupancy.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.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
|
||||||
|
// → open the barrier. The button is wired into an access controller's input; the
|
||||||
|
// admin maps that input terminal to a relay (config.relays[].button), so a press
|
||||||
|
// resolves to exactly the entry relay it should open. See entry-exit-points.md.
|
||||||
|
//
|
||||||
|
// Two invariants from the threat model + safety analysis:
|
||||||
|
// 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger
|
||||||
|
// BEFORE pulseOpen fires; an open with no matching signed event is the fraud
|
||||||
|
// signal (wiki/concepts/append-only-event-chain.md).
|
||||||
|
// 2. HOLD ON PRINT FAILURE — a transient with no ticket can't pay on exit, so if
|
||||||
|
// all printers are down we do NOT open. We sign an `anomaly` (attempt, ticket
|
||||||
|
// unprinted) and leave the barrier closed; the operator handles the held car.
|
||||||
|
// Crucially, NO vehicle_entry is written in that case — we never record an
|
||||||
|
// "entered" event for a car that didn't get in (decision 2026-06-15).
|
||||||
|
//
|
||||||
|
// Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session.
|
||||||
|
// (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.
|
||||||
|
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
|
||||||
|
// See wiki/concepts/entry-double-press.md.
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
/** Guard against double-fire from the same physical press (on edge only). */
|
||||||
|
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;
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#vision = vision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a device input edge. Two kinds of edge matter to this flow:
|
||||||
|
* (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> {
|
||||||
|
// 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
|
||||||
|
// must map to an ENTRY (or both) relay — that's an entry button. Anything else
|
||||||
|
// (reader/printer edge, exit-only relay's input) is not a transient-entry trigger.
|
||||||
|
const resolved = relayForButton(this.#db, e.deviceId, e.input);
|
||||||
|
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}`;
|
||||||
|
if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
await this.#runEntry(resolved);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`entry-flow failed: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
* PRESENCE mode is authoritative when a loop is wired; otherwise COOLDOWN; else no
|
||||||
|
* guard (legacy). The two can coexist — presence first, cooldown as a backstop. */
|
||||||
|
#suppressReason(r: ResolvedRelay): string | null {
|
||||||
|
const s = this.#guardState(r);
|
||||||
|
|
||||||
|
if (typeof r.presenceInput === "number") {
|
||||||
|
// 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";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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> {
|
||||||
|
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
||||||
|
// 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
|
||||||
|
// they aren't locked out. "Full" is a soft policy seam for valet over-
|
||||||
|
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
const occ = getOccupancy(this.#db);
|
||||||
|
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({
|
||||||
|
type: "anomaly",
|
||||||
|
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})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = newTicketId();
|
||||||
|
const issuedAt = new Date().toISOString();
|
||||||
|
const printers = this.#loadPrinters();
|
||||||
|
|
||||||
|
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||||
|
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||||
|
try {
|
||||||
|
const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) =>
|
||||||
|
d.printTicket(ticket),
|
||||||
|
);
|
||||||
|
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) {
|
||||||
|
// 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.
|
||||||
|
const reason =
|
||||||
|
err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: ticketId,
|
||||||
|
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)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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). Per-relay capture (a
|
||||||
|
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
|
||||||
|
// is the future seam — source it from `resolved` then. A V1/no-category tariff
|
||||||
|
// ignores it; only V2 category cards consult it.
|
||||||
|
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({
|
||||||
|
type: "vehicle_entry",
|
||||||
|
direction: "entry",
|
||||||
|
source: "ticket",
|
||||||
|
identity: ticketId,
|
||||||
|
payload: { sessionRef: ticketId, ticketPrinted: true, category },
|
||||||
|
occurredAt: issuedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
||||||
|
const access = this.#buildAccess(resolved.controller);
|
||||||
|
if (access) await access.pulseOpen(resolved.relay);
|
||||||
|
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;
|
||||||
|
// a camera failure must not delay or block the already-open barrier).
|
||||||
|
this.#fireSnapshot("entry", ticketId);
|
||||||
|
|
||||||
|
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
||||||
|
// just a fast read-model, never the source of truth).
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.insert(sessions)
|
||||||
|
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
||||||
|
.run();
|
||||||
|
} 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision }).catch(
|
||||||
|
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a live access adapter from a resolved controller row, or null. */
|
||||||
|
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
return driver.create(row.config as never) as AccessControlDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build live ENTRY printer instances (for failover selection). */
|
||||||
|
#loadPrinters(): PrinterInstance[] {
|
||||||
|
const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered
|
||||||
|
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 = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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).
|
||||||
|
*
|
||||||
|
* 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 {
|
||||||
|
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,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" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { desc, events, type Db, type EventRow } from "@parking/db";
|
import { desc, ledgerEvents, type Db, type LedgerEventRow } from "@parking/db";
|
||||||
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared";
|
import type { Direction, IdentitySource, LedgerEventType, LedgerPayload, Signer } from "@parking/shared";
|
||||||
|
|
||||||
// The append-only, hash-chained, signed event log — the system's core anti-fraud
|
// The append-only, hash-chained, signed event log — the system's core anti-fraud
|
||||||
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
|
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
|
||||||
@@ -16,11 +16,12 @@ import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parki
|
|||||||
// so we guard it with an in-process async lock as well.
|
// so we guard it with an in-process async lock as well.
|
||||||
|
|
||||||
export interface AppendInput {
|
export interface AppendInput {
|
||||||
readonly type: ParkingEventType;
|
readonly type: LedgerEventType;
|
||||||
readonly lane: number;
|
|
||||||
readonly direction?: Direction | null;
|
readonly direction?: Direction | null;
|
||||||
readonly source?: IdentitySource | null;
|
readonly source?: IdentitySource | null;
|
||||||
readonly identity?: string | null;
|
readonly identity?: string | null;
|
||||||
|
/** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */
|
||||||
|
readonly payload?: LedgerPayload | null;
|
||||||
/** Event time (ISO-8601). Defaults to now. */
|
/** Event time (ISO-8601). Defaults to now. */
|
||||||
readonly occurredAt?: string;
|
readonly occurredAt?: string;
|
||||||
}
|
}
|
||||||
@@ -36,9 +37,9 @@ export function canonicalize(e: {
|
|||||||
index: number;
|
index: number;
|
||||||
type: string;
|
type: string;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
lane: number;
|
|
||||||
source: string | null;
|
source: string | null;
|
||||||
identity: string | null;
|
identity: string | null;
|
||||||
|
payload: Record<string, unknown> | null;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
prevHash: string | null;
|
prevHash: string | null;
|
||||||
}): string {
|
}): string {
|
||||||
@@ -46,57 +47,107 @@ export function canonicalize(e: {
|
|||||||
e.index,
|
e.index,
|
||||||
e.type,
|
e.type,
|
||||||
e.direction ?? null,
|
e.direction ?? null,
|
||||||
e.lane,
|
|
||||||
e.source ?? null,
|
e.source ?? null,
|
||||||
e.identity ?? null,
|
e.identity ?? null,
|
||||||
|
// Payload is part of the signed form so business data is tamper-evident.
|
||||||
|
// Serialize with sorted keys for byte-stability (object key order must not
|
||||||
|
// change a signature). null when the event type carries no payload.
|
||||||
|
canonicalPayload(e.payload),
|
||||||
e.occurredAt,
|
e.occurredAt,
|
||||||
e.prevHash ?? null,
|
e.prevHash ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deterministic (key-sorted, recursive) JSON for the payload slot. */
|
||||||
|
function canonicalPayload(p: Record<string, unknown> | null | undefined): unknown {
|
||||||
|
if (p == null) return null;
|
||||||
|
const sort = (v: unknown): unknown => {
|
||||||
|
if (Array.isArray(v)) return v.map(sort);
|
||||||
|
if (v && typeof v === "object") {
|
||||||
|
return Object.keys(v as Record<string, unknown>)
|
||||||
|
.sort()
|
||||||
|
.reduce<Record<string, unknown>>((o, k) => {
|
||||||
|
o[k] = sort((v as Record<string, unknown>)[k]);
|
||||||
|
return o;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
return sort(p);
|
||||||
|
}
|
||||||
|
|
||||||
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
|
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
|
||||||
export function hashEvent(canonical: string): string {
|
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. */
|
||||||
append(input: AppendInput): Promise<EventRow> {
|
append(input: AppendInput): Promise<LedgerEventRow> {
|
||||||
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): EventRow {
|
#appendNow(input: AppendInput): LedgerEventRow {
|
||||||
const prev = this.#db
|
const prev = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(ledgerEvents)
|
||||||
.orderBy(desc(events.index))
|
.orderBy(desc(ledgerEvents.index))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
const index = (prev?.index ?? 0) + 1;
|
const index = (prev?.index ?? 0) + 1;
|
||||||
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
|
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
|
||||||
const occurredAt = input.occurredAt ?? new Date().toISOString();
|
const occurredAt = input.occurredAt ?? new Date().toISOString();
|
||||||
|
const payload = input.payload ?? null;
|
||||||
|
|
||||||
const canonical = canonicalize({
|
const canonical = canonicalize({
|
||||||
index,
|
index,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
direction: input.direction ?? null,
|
direction: input.direction ?? null,
|
||||||
lane: input.lane,
|
|
||||||
source: input.source ?? null,
|
source: input.source ?? null,
|
||||||
identity: input.identity ?? null,
|
identity: input.identity ?? null,
|
||||||
|
payload,
|
||||||
occurredAt,
|
occurredAt,
|
||||||
prevHash,
|
prevHash,
|
||||||
});
|
});
|
||||||
@@ -106,26 +157,33 @@ export class EventLog {
|
|||||||
index,
|
index,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
direction: input.direction ?? null,
|
direction: input.direction ?? null,
|
||||||
lane: input.lane,
|
|
||||||
source: input.source ?? null,
|
source: input.source ?? null,
|
||||||
identity: input.identity ?? null,
|
identity: input.identity ?? null,
|
||||||
|
payload,
|
||||||
occurredAt,
|
occurredAt,
|
||||||
prevHash,
|
prevHash,
|
||||||
signature: this.#signer.sign(canonical),
|
signature: this.#signer.sign(canonical),
|
||||||
|
keyId: this.#signer.keyId,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.#db.insert(events).values(row).run();
|
this.#db.insert(ledgerEvents).values(row).run();
|
||||||
return row as EventRow;
|
return row as LedgerEventRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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(events).orderBy(events.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
let expectedIndex = 1;
|
let expectedIndex = 1;
|
||||||
let prevHash: string | null = null;
|
let prevHash: string | null = null;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -135,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,118 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
|
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
|
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 { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// The EXIT flow (pay-on-foot model): a credential read at the exit lane → look up
|
||||||
|
// the session → validate it is PAID and within the walk-back grace → sign a
|
||||||
|
// vehicle_exit → open. Payment is decoupled from exit (it happens earlier at the
|
||||||
|
// pay station); the exit lane only VALIDATES. See wiki/concepts/parking-session.md.
|
||||||
|
//
|
||||||
|
// Validation is a fold over the SIGNED ledger (the authoritative record), not the
|
||||||
|
// projection cache: find the open vehicle_entry for this identity, then a covering
|
||||||
|
// payment within grace. The cache is updated after, for fast reads.
|
||||||
|
//
|
||||||
|
// REJECT (barrier stays closed) when unpaid / over grace — this is correct business
|
||||||
|
// logic, NOT a fail-state. "Exit fails OPEN" (fail-state-safety) is about the SYSTEM
|
||||||
|
// being unable to decide (power/host loss), not about an unpaid car; an unpaid driver
|
||||||
|
// is sent back to the pay station, the rejection is logged.
|
||||||
|
//
|
||||||
|
// NOTE: payments / the pay station don't exist yet, so no session is ever PAID — every
|
||||||
|
// transient exit currently REJECTS (logged). That's the correct end-state; it becomes
|
||||||
|
// passable once the pay-station + `payment` events land.
|
||||||
|
|
||||||
|
interface SessionView {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly enteredAt: string;
|
||||||
|
readonly open: boolean; // no vehicle_exit yet
|
||||||
|
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
|
||||||
|
// 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 }
|
||||||
|
| { 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 };
|
||||||
|
|
||||||
|
export class ExitFlow {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
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, vision: VisionClient | null = null) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
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): 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
* read dispatcher from the reader's binding, which has ruled out a subscription match). */
|
||||||
|
async handleAt(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
const key = `${e.deviceId}:${e.value}`;
|
||||||
|
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
return await this.#runExit(resolved, e);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`exit-flow failed: ${(err as Error).message}`);
|
||||||
|
return { accepted: false, reason: (err as Error).message };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runExit(resolved: ResolvedRelay, e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
const view = this.#sessionFor(e.value);
|
||||||
|
|
||||||
|
// No matching open session — unknown/duplicate ticket. Reject + log.
|
||||||
|
if (!view || !view.open) {
|
||||||
|
const rp = reasonPayload(view ? "exit.refused.closed" : "exit.refused.noSession");
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: e.value,
|
||||||
|
payload: { ...rp, exitRefused: true },
|
||||||
|
});
|
||||||
|
this.#fireExitSnapshot(e.value);
|
||||||
|
this.#logger.warn(`exit refused: no open session for ${e.value}`);
|
||||||
|
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?
|
||||||
|
const paid = view.paidAt != null;
|
||||||
|
const withinGrace =
|
||||||
|
paid &&
|
||||||
|
view.graceExitMin != null &&
|
||||||
|
Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
|
||||||
|
if (!paid || !withinGrace) {
|
||||||
|
const rp = reasonPayload(paid ? "exit.refused.graceExpired" : "exit.refused.unpaid");
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: e.value,
|
||||||
|
payload: { ...rp, exitRefused: true, sessionRef: e.value },
|
||||||
|
});
|
||||||
|
this.#fireExitSnapshot(e.value);
|
||||||
|
this.#logger.warn(`exit refused (${e.value}): ${rp.reason}`);
|
||||||
|
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
if (access) await access.pulseOpen(resolved.relay);
|
||||||
|
else this.#logger.warn(`exit signed for ${e.value} but the exit relay won't build`);
|
||||||
|
|
||||||
|
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({
|
||||||
|
db: this.#db,
|
||||||
|
direction: "exit",
|
||||||
|
identity,
|
||||||
|
logger: this.#logger,
|
||||||
|
vision: this.#vision,
|
||||||
|
}).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 {
|
||||||
|
this.#db
|
||||||
|
.update(sessions)
|
||||||
|
.set({ exitedAt: new Date().toISOString(), state: "closed" })
|
||||||
|
.where(eq(sessions.id, identity))
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`session-cache close failed for ${identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||||
|
#sessionFor(identity: string): SessionView | null {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return null;
|
||||||
|
// 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 graceExitMin: number | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type === "payment") {
|
||||||
|
paidAt = r.occurredAt;
|
||||||
|
const p = (r.payload ?? {}) as LedgerPayload & { graceExitMin?: number };
|
||||||
|
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
identity,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
open: !exited,
|
||||||
|
paidAt,
|
||||||
|
subscription,
|
||||||
|
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. */
|
||||||
|
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
return driver.create(row.config as never) as AccessControlDevice;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { laneDevices, type Db } from "@parking/db";
|
|
||||||
|
|
||||||
// Resolves a device instance id (lane_devices.id) to its lane number.
|
|
||||||
//
|
|
||||||
// Device pushes/events carry the `lane_devices` id (which device fired), not a
|
|
||||||
// lane. The event log wants the lane, so we keep a small in-memory id->lane map
|
|
||||||
// rebuilt from the DB at startup and refreshed whenever assignments change
|
|
||||||
// (assign/unassign). It's tiny (one row per device) and read on the hot path of
|
|
||||||
// every input event, so a cached map beats a per-event DB lookup.
|
|
||||||
export class LaneMap {
|
|
||||||
readonly #db: Db;
|
|
||||||
#byDeviceId = new Map<string, number>();
|
|
||||||
|
|
||||||
constructor(db: Db) {
|
|
||||||
this.#db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** (Re)load the id->lane map from the lane_devices table. */
|
|
||||||
refresh(): void {
|
|
||||||
const rows = this.#db.select().from(laneDevices).all();
|
|
||||||
const next = new Map<string, number>();
|
|
||||||
for (const r of rows) next.set(r.id, r.lane);
|
|
||||||
this.#byDeviceId = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lane for a device instance id, or null if the device isn't known. */
|
|
||||||
laneFor(deviceId: string): number | null {
|
|
||||||
return this.#byDeviceId.get(deviceId) ?? 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,236 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 30),
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
this.#db
|
||||||
|
.insert(appLogs)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
level: row.level,
|
||||||
|
source: row.source,
|
||||||
|
message: clamp(row.message, MAX_MESSAGE) ?? "",
|
||||||
|
context: safeContext(row.context),
|
||||||
|
httpStatus: row.httpStatus ?? null,
|
||||||
|
path: clamp(row.path, 512),
|
||||||
|
stack: clamp(row.stack, MAX_STACK),
|
||||||
|
userId: row.userId ?? null,
|
||||||
|
userAgent: clamp(row.userAgent, 512),
|
||||||
|
createdAt: row.createdAt ?? new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} 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, map the numeric level
|
||||||
|
* to a name, 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;
|
||||||
|
msg?: string;
|
||||||
|
err?: { stack?: string; message?: string };
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
const level = NUM_TO_LEVEL[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,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// editable + drifts) — the chain is the truth. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|
||||||
|
export interface Occupancy {
|
||||||
|
/** Cars currently inside (open sessions). */
|
||||||
|
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. */
|
||||||
|
readonly capacity: number | null;
|
||||||
|
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
|
||||||
|
readonly free: number | null;
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count cars inside: entries minus exits, per identity, over the ledger. */
|
||||||
|
export function occupancyCount(db: Db): number {
|
||||||
|
const rows = db
|
||||||
|
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.all();
|
||||||
|
const balance = new Map<string, number>();
|
||||||
|
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);
|
||||||
|
else if (r.type === "vehicle_exit" || r.type === "void")
|
||||||
|
balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
||||||
|
}
|
||||||
|
let open = 0;
|
||||||
|
for (const v of balance.values()) if (v > 0) open += 1;
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin-set capacity (null = uncapped). */
|
||||||
|
export function siteCapacity(db: Db): number | null {
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
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 {
|
||||||
|
const count = occupancyCount(db);
|
||||||
|
const capacity = siteCapacity(db);
|
||||||
|
const reserved = reservedSubscriberSpots(db);
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
reserved,
|
||||||
|
capacity,
|
||||||
|
free: capacity == null ? null : capacity - count,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||||
|
import { PayStation, NoOpenSessionError, NoTariffError } from "./pay-station.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The pay station prices an open session against the tariff frozen at entry and writes
|
||||||
|
// a SIGNED payment event (never a mutable "paid" flag). These tests pin the quote math,
|
||||||
|
// the signed-payment side effect, the no-session / no-tariff errors, and the lookup
|
||||||
|
// view the booth modal reads.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
let pay: PayStation;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = makeLog(db);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PayStation.quote", () => {
|
||||||
|
it("throws NoOpenSessionError for an unknown ticket", () => {
|
||||||
|
seedTariff(db);
|
||||||
|
expect(() => pay.quote("nope")).toThrow(NoOpenSessionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NoTariffError when no site tariff is configured", async () => {
|
||||||
|
await enter("T1", minutesAgo(120));
|
||||||
|
expect(() => pay.quote("T1")).toThrow(NoTariffError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices a stay against the frozen tariff (90min → 2 increments at 100/h = 200)", async () => {
|
||||||
|
// 90 min rounds UP to a 2nd 60-min increment; well clear of the boundary so a few
|
||||||
|
// ms of test runtime can't tip it into a 3rd increment.
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const q = pay.quote("T1");
|
||||||
|
expect(q.amountMinor).toBe(20000);
|
||||||
|
expect(q.currency).toBe("ALL");
|
||||||
|
expect(q.overstay).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices 0 within the entry grace (quick in-and-out)", async () => {
|
||||||
|
seedTariff(db, { gracePeriodEntryMin: 10 });
|
||||||
|
await enter("T1", minutesAgo(5));
|
||||||
|
expect(pay.quote("T1").amountMinor).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayStation.pay — signed payment side effect", () => {
|
||||||
|
it("appends a signed payment event carrying amount, currency, tender, grace", async () => {
|
||||||
|
const { currency } = seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
|
||||||
|
const res = await pay.pay("T1", "cash");
|
||||||
|
expect(res.amountMinor).toBe(20000);
|
||||||
|
expect(res.currency).toBe(currency);
|
||||||
|
|
||||||
|
const payments = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all();
|
||||||
|
expect(payments).toHaveLength(1);
|
||||||
|
const pl = payments[0].payload as Record<string, unknown>;
|
||||||
|
expect(pl.amountMinor).toBe(20000);
|
||||||
|
expect(pl.tender).toBe("cash");
|
||||||
|
expect(pl.graceExitMin).toBe(15);
|
||||||
|
// It must be a real signed chain event.
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours an operator override amount (lost ticket / dispute)", async () => {
|
||||||
|
seedTariff(db);
|
||||||
|
await enter("T1", minutesAgo(120));
|
||||||
|
const res = await pay.pay("T1", "card", 99900);
|
||||||
|
expect(res.amountMinor).toBe(99900);
|
||||||
|
const pl = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all()[0].payload as Record<string, unknown>;
|
||||||
|
expect(pl.amountMinor).toBe(99900);
|
||||||
|
expect(pl.reason).toBe("operator-set amount");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayStation.lookup — booth modal view", () => {
|
||||||
|
it("reports not-found for an unknown ticket", () => {
|
||||||
|
const v = pay.lookup("ghost");
|
||||||
|
expect(v.found).toBe(false);
|
||||||
|
expect(v.open).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an open unpaid transient with the amount owed", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const v = pay.lookup("T1");
|
||||||
|
expect(v.found).toBe(true);
|
||||||
|
expect(v.open).toBe(true);
|
||||||
|
expect(v.paidAt).toBeNull();
|
||||||
|
expect(v.amountMinor).toBe(20000);
|
||||||
|
expect(v.subscription).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after payment shows paid + within grace, amount cleared", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(120));
|
||||||
|
await pay.pay("T1", "cash");
|
||||||
|
const v = pay.lookup("T1");
|
||||||
|
expect(v.paidAt).not.toBeNull();
|
||||||
|
expect(v.withinGrace).toBe(true);
|
||||||
|
expect(v.overstay).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a subscription occurrence (prepaid — never a transient charge)", async () => {
|
||||||
|
seedTariff(db);
|
||||||
|
await enter("SUBSESS-1", minutesAgo(120), { permit: true, permitId: "sub-1" });
|
||||||
|
const v = pay.lookup("SUBSESS-1");
|
||||||
|
expect(v.subscription).toBe(true);
|
||||||
|
expect(v.subscriptionId).toBe("sub-1");
|
||||||
|
expect(v.amountMinor).toBeNull(); // no timeframes → nothing owed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayStation.activeSessions", () => {
|
||||||
|
it("lists open sessions newest-first and omits exited-past-grace", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("OLD", minutesAgo(200));
|
||||||
|
await enter("NEW", minutesAgo(30));
|
||||||
|
const list = pay.activeSessions();
|
||||||
|
expect(list.map((s) => s.identity)).toEqual(["NEW", "OLD"]);
|
||||||
|
expect(list.every((s) => s.open)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
|
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
|
import { windowOwedBetween } from "./subscription-window.js";
|
||||||
|
|
||||||
|
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
||||||
|
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||||
|
// 1. quote(identity) → look up the open session, price it against the tariff in
|
||||||
|
// force at entry, return the amount due (no side effect).
|
||||||
|
// 2. pay(identity, tender) → re-price, append a SIGNED `payment` event carrying
|
||||||
|
// the amount, currency, tender, tariffVersionId, and graceExitMin (so the exit
|
||||||
|
// flow can validate paid + within walk-back grace). Payment is a signed ledger
|
||||||
|
// event, never a mutable "paid" flag — an operator can't forge or delete it.
|
||||||
|
// See wiki/concepts/tariff.md, parking-session.md.
|
||||||
|
|
||||||
|
export class NoOpenSessionError extends Error {
|
||||||
|
constructor(identity: string) {
|
||||||
|
super(`no open session for ${identity}`);
|
||||||
|
this.name = "NoOpenSessionError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class NoTariffError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("no active tariff configured");
|
||||||
|
this.name = "NoTariffError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Quote {
|
||||||
|
readonly identity: string;
|
||||||
|
/** Vehicle entry time (the session's original entry; for display/audit). */
|
||||||
|
readonly enteredAt: string;
|
||||||
|
/** Start of the period being billed RIGHT NOW. For a first payment this is the
|
||||||
|
* entry. For an OVERSTAY (a paid session whose walk-back grace lapsed — the car
|
||||||
|
* re-parked / a new period began) it is the moment that grace expired: the overstay
|
||||||
|
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
||||||
|
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||||
|
readonly periodStart: string;
|
||||||
|
/** Amount owed now: the fee for [periodStart → now]. */
|
||||||
|
readonly amountMinor: number;
|
||||||
|
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
||||||
|
readonly overstay: boolean;
|
||||||
|
readonly currency: string;
|
||||||
|
readonly tariffVersionId: string;
|
||||||
|
readonly graceExitMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row in the booth Active Sessions list. A session is "active" while it is
|
||||||
|
* still open OR exited-but-within-grace — because the barrier is UNCONFIRMED, a
|
||||||
|
* paid/exited car is presumed possibly-still-present until grace expires. The
|
||||||
|
* "Open barrier" action is offered only when `paidAt != null` (no payment, no
|
||||||
|
* button — the no-unpaid-bypass rule). See wiki/concepts/booth-exit-flow.md. */
|
||||||
|
export interface ActiveSession {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly source: string | null;
|
||||||
|
readonly enteredAt: string;
|
||||||
|
/** null while still inside; set once a vehicle_exit is signed (may still be present). */
|
||||||
|
readonly exitedAt: string | null;
|
||||||
|
readonly open: boolean;
|
||||||
|
readonly paidAt: string | null;
|
||||||
|
/** Amount owed now (open + unpaid only; null otherwise / no tariff). */
|
||||||
|
readonly amountMinor: number | null;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly withinGrace: boolean;
|
||||||
|
readonly graceExpiresAt: string | null;
|
||||||
|
/** OVERSTAY = a paid transient whose walk-back grace lapsed with NO signed vehicle_exit.
|
||||||
|
* The car either re-parked (a new period began) or is faulty/abandoned — not a system
|
||||||
|
* fault, and not "stuck". It lingers in occupancy and owes a fresh period (priced from
|
||||||
|
* grace-expiry, see `quote`). We keep it listed and BADGE it OVERSTAY so the operator
|
||||||
|
* reconciles via a top-up, instead of silently aging it out. No free barrier open.
|
||||||
|
* See wiki/concepts/booth-exit-flow.md. */
|
||||||
|
readonly overstay: boolean;
|
||||||
|
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
|
||||||
|
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
|
||||||
|
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
|
||||||
|
readonly subscription: boolean;
|
||||||
|
/** The subscription id (on-chain `permitId`), when `subscription` is true. */
|
||||||
|
readonly subscriptionId: string | null;
|
||||||
|
/** The subscriber's holder name (for a friendly label instead of the raw key). */
|
||||||
|
readonly subscriptionHolder: string | null;
|
||||||
|
/** Advisory licence plate recognized for this session (ANPR-on-snapshot), shown for
|
||||||
|
* at-a-glance identification. Null when no plate was read. Never an access decision. */
|
||||||
|
readonly plate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Booth session view: everything the pay/exit modal needs in one read. */
|
||||||
|
export interface SessionLookup {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly found: boolean;
|
||||||
|
/** Open = entered, no exit yet. */
|
||||||
|
readonly open: boolean;
|
||||||
|
readonly enteredAt: string | null;
|
||||||
|
readonly exitedAt: string | null;
|
||||||
|
/** Latest payment time, if paid. */
|
||||||
|
readonly paidAt: string | null;
|
||||||
|
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
||||||
|
readonly amountMinor: number | null;
|
||||||
|
readonly currency: string | null;
|
||||||
|
/** True when paid AND still within the walk-back grace window. */
|
||||||
|
readonly withinGrace: boolean;
|
||||||
|
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||||
|
readonly graceExpiresAt: string | null;
|
||||||
|
/** OVERSTAY = paid transient, walk-back grace expired, no signed exit. A new period
|
||||||
|
* began; `amountMinor` is the fresh fee from grace-expiry — it cannot exit for free. */
|
||||||
|
readonly overstay: boolean;
|
||||||
|
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
|
||||||
|
readonly subscription: boolean;
|
||||||
|
readonly subscriptionId: string | null;
|
||||||
|
readonly subscriptionHolder: string | null;
|
||||||
|
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
||||||
|
* none. Display/audit only — never an access decision. */
|
||||||
|
readonly plate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PayStation {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
|
||||||
|
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
|
||||||
|
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
|
||||||
|
* own daily-cap ladder. This is NOT "full stay minus paid": with a daily cap the
|
||||||
|
* whole-stay gross plateaus while prior payments keep pace, so the delta collapses to
|
||||||
|
* 0 and a multi-day overstay would exit free (ticket 1245791632490). A new period
|
||||||
|
* reflects the reality and re-accrues the fee. No side effect. */
|
||||||
|
quote(identity: string): Quote {
|
||||||
|
const entry = this.#openEntry(identity);
|
||||||
|
if (!entry) throw new NoOpenSessionError(identity);
|
||||||
|
|
||||||
|
// The tariff in force is keyed to ENTRY (the version frozen for this session), even
|
||||||
|
// for an overstay period — the customer keeps the rate card they entered under.
|
||||||
|
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||||
|
if (!tv) throw new NoTariffError();
|
||||||
|
const structure = tv.structure as unknown as TariffStructure;
|
||||||
|
|
||||||
|
// Category was frozen in the signed vehicle_entry payload — pricing AND repricing
|
||||||
|
// both read it from there, so a V2 category tariff yields the same amount at the
|
||||||
|
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
|
||||||
|
const category = (entry.payload as { category?: string } | null)?.category;
|
||||||
|
|
||||||
|
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
||||||
|
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
||||||
|
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
||||||
|
const last = this.#lastPayment(identity);
|
||||||
|
const p = priceSession(
|
||||||
|
entry.occurredAt,
|
||||||
|
new Date().toISOString(),
|
||||||
|
structure,
|
||||||
|
last ? [last] : [],
|
||||||
|
category,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
periodStart: p.periodStart,
|
||||||
|
amountMinor: p.amountMinor,
|
||||||
|
overstay: p.overstay,
|
||||||
|
currency: tv.currency,
|
||||||
|
tariffVersionId: tv.id,
|
||||||
|
graceExitMin: structure.gracePeriodExitMin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The latest signed `payment` for this session (time + the grace window it granted),
|
||||||
|
* or null if never paid. Folds the append-only ledger. */
|
||||||
|
#lastPayment(identity: string): { paidAt: string; graceExitMin: number | null } | null {
|
||||||
|
const rows = this.#db
|
||||||
|
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
let last: { paidAt: string; graceExitMin: number | null } | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "payment") continue;
|
||||||
|
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
|
||||||
|
last = { paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null };
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take payment for a session and append the signed `payment` event. Re-quotes at
|
||||||
|
* the moment of payment (the customer pays for time parked SO FAR). For an OVERSTAY
|
||||||
|
* (grace lapsed) the quote prices a fresh period from grace-expiry→now (see `quote`),
|
||||||
|
* and this payment writes a new `graceExitMin` so the walk-back window restarts.
|
||||||
|
* `overrideMinor` lets the operator set an arbitrary amount (lost ticket / dispute) —
|
||||||
|
* recorded as the charged amount.
|
||||||
|
*/
|
||||||
|
async pay(
|
||||||
|
identity: string,
|
||||||
|
tender: Tender,
|
||||||
|
overrideMinor?: number,
|
||||||
|
): Promise<{ amountMinor: number; currency: string }> {
|
||||||
|
// A SUBSCRIPTION occurrence settles its out-of-window tariff-bridge charge here
|
||||||
|
// (not a transient quote — the subscription itself is prepaid). The payment is keyed
|
||||||
|
// to the occurrence so the exit gate (#windowOwed − payments) clears.
|
||||||
|
const subWindow = this.#payableSubscriptionWindow(identity);
|
||||||
|
if (subWindow) {
|
||||||
|
const amountMinor = overrideMinor ?? subWindow.dueMinor;
|
||||||
|
await this.#log.append({
|
||||||
|
type: "payment",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
amountMinor,
|
||||||
|
currency: subWindow.currency ?? undefined,
|
||||||
|
tender,
|
||||||
|
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
|
||||||
|
subscriptionWindowCharge: true,
|
||||||
|
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.info(`subscription window-charge payment ${amountMinor} ${subWindow.currency ?? ""} (${tender}) for ${identity}`);
|
||||||
|
return { amountMinor, currency: subWindow.currency ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = this.quote(identity);
|
||||||
|
const amountMinor = overrideMinor ?? q.amountMinor;
|
||||||
|
|
||||||
|
await this.#log.append({
|
||||||
|
type: "payment",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
amountMinor,
|
||||||
|
currency: q.currency,
|
||||||
|
tender,
|
||||||
|
tariffVersionId: q.tariffVersionId,
|
||||||
|
// The exit flow reads graceExitMin off the payment to validate the
|
||||||
|
// walk-back window without re-resolving the tariff.
|
||||||
|
graceExitMin: q.graceExitMin,
|
||||||
|
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the projection cache (rebuildable; not the source of truth).
|
||||||
|
try {
|
||||||
|
this.#db.update(sessions).set({ state: "paid" }).where(eq(sessions.id, identity)).run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
|
||||||
|
return { amountMinor, currency: q.currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-read session view for the booth pay/exit modal: entry/exit times, paid
|
||||||
|
* state, amount owed now, and walk-back-grace status. Read-only — folds the
|
||||||
|
* signed ledger (authoritative). A quote failure (no tariff) leaves amount null
|
||||||
|
* rather than throwing, so the modal can still show the session.
|
||||||
|
*/
|
||||||
|
lookup(identity: string): SessionLookup {
|
||||||
|
const id = identity.trim();
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, id))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) {
|
||||||
|
return {
|
||||||
|
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||||
|
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
|
||||||
|
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
|
||||||
|
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
|
||||||
|
// A `void` (cancelled ticket) closes the session like an exit — a voided ticket is no
|
||||||
|
// longer open and can't be paid/exited. See void-flow.ts.
|
||||||
|
const exitRow = rows.find((r) => r.type === "vehicle_exit" || r.type === "void");
|
||||||
|
const open = !exitRow;
|
||||||
|
|
||||||
|
let paidAt: string | null = null;
|
||||||
|
let graceExitMin: number | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type === "payment") {
|
||||||
|
paidAt = r.occurredAt;
|
||||||
|
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||||||
|
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const graceExpiresAt =
|
||||||
|
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
|
||||||
|
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
|
||||||
|
|
||||||
|
// Amount owed now (best-effort; null if no tariff resolves). For a TRANSIENT session
|
||||||
|
// it's the running tariff. For a SUBSCRIPTION it's normally null (prepaid) — EXCEPT a
|
||||||
|
// time-window plan can owe an out-of-window TARIFF-BRIDGE charge (early-entry carried
|
||||||
|
// on the entry payload + a live late-exit charge), which the booth must take so the
|
||||||
|
// exit gate clears. See wiki/entities/subscription.md.
|
||||||
|
let amountMinor: number | null = null;
|
||||||
|
let currency: string | null = null;
|
||||||
|
if (open && !isSubscription) {
|
||||||
|
try {
|
||||||
|
const q = this.quote(id);
|
||||||
|
amountMinor = q.amountMinor;
|
||||||
|
currency = q.currency;
|
||||||
|
} catch {
|
||||||
|
/* no active tariff — leave null; modal shows session without a price */
|
||||||
|
}
|
||||||
|
} else if (open && isSubscription) {
|
||||||
|
const w = this.#subscriptionWindowDue(id, subscriptionId);
|
||||||
|
if (w && w.dueMinor > 0) {
|
||||||
|
amountMinor = w.dueMinor;
|
||||||
|
currency = w.currency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace;
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: id, found: true, open,
|
||||||
|
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||||
|
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
|
||||||
|
subscription: isSubscription, subscriptionId,
|
||||||
|
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||||
|
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace
|
||||||
|
* (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present
|
||||||
|
* until grace expires). One ledger scan, grouped by identity (cheaper than N
|
||||||
|
* lookups). Sorted by entry time, newest first. Folds the SIGNED ledger
|
||||||
|
* (authoritative — not the sessions projection cache, which can drift).
|
||||||
|
* See wiki/concepts/booth-exit-flow.md.
|
||||||
|
*/
|
||||||
|
activeSessions(): ActiveSession[] {
|
||||||
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
|
|
||||||
|
// Group the relevant events per identity in one pass.
|
||||||
|
type Acc = {
|
||||||
|
enteredAt?: string;
|
||||||
|
source: string | null;
|
||||||
|
exitedAt?: string;
|
||||||
|
paidAt?: string;
|
||||||
|
graceExitMin?: number;
|
||||||
|
subscriptionId?: string | null;
|
||||||
|
};
|
||||||
|
const byId = new Map<string, Acc>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const id = r.identity;
|
||||||
|
if (!id) continue;
|
||||||
|
if (r.type === "vehicle_entry") {
|
||||||
|
const a = byId.get(id) ?? { source: r.source ?? null };
|
||||||
|
a.enteredAt = r.occurredAt;
|
||||||
|
a.source = r.source ?? a.source;
|
||||||
|
// Subscription occurrence? The entry payload carries permit:true + permitId
|
||||||
|
// (the on-chain field). Mark it so the booth never tries to charge it.
|
||||||
|
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
|
||||||
|
byId.set(id, a);
|
||||||
|
} else if (r.type === "vehicle_exit" || r.type === "void") {
|
||||||
|
// A `void` closes the session like an exit — drop it from the active list.
|
||||||
|
const a = byId.get(id);
|
||||||
|
if (a) a.exitedAt = r.occurredAt;
|
||||||
|
} else if (r.type === "payment") {
|
||||||
|
const a = byId.get(id);
|
||||||
|
if (a) {
|
||||||
|
a.paidAt = r.occurredAt;
|
||||||
|
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||||||
|
if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve advisory plates for all candidate identities in ONE device_events scan
|
||||||
|
// (cheaper than one lookup per row).
|
||||||
|
const plates = platesForIdentities(this.#db, byId.keys());
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const out: ActiveSession[] = [];
|
||||||
|
for (const [identity, a] of byId) {
|
||||||
|
if (!a.enteredAt) continue; // no entry → not a real session
|
||||||
|
const open = a.exitedAt == null;
|
||||||
|
const graceExpiresAt =
|
||||||
|
a.paidAt && a.graceExitMin != null
|
||||||
|
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
|
||||||
|
: null;
|
||||||
|
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
|
||||||
|
const paid = a.paidAt != null;
|
||||||
|
|
||||||
|
const isSubscription = a.subscriptionId !== undefined;
|
||||||
|
|
||||||
|
// ACTIVE membership:
|
||||||
|
// - exited + within grace → still shown (barrier unconfirmed, may be present);
|
||||||
|
// - exited + past grace → presumed gone, omitted;
|
||||||
|
// - open + UNPAID → always shown (a car owing money never ages out —
|
||||||
|
// it's genuinely still inside until it pays, however long that takes);
|
||||||
|
// - open + PAID + past grace → OVERSTAY. A paid transient whose walk-back grace
|
||||||
|
// lapsed with no signed vehicle_exit: the car re-parked (a new period) or is
|
||||||
|
// faulty/abandoned — not a system fault, not "stuck". It lingers in occupancy
|
||||||
|
// and owes a fresh period (priced from grace-expiry, see `quote`). We used to
|
||||||
|
// age these out (a silent display filter); now we KEEP them and flag `overstay`
|
||||||
|
// so the operator reconciles via a top-up. The signed log is untouched, and the
|
||||||
|
// barrier never opens for free on these. See booth-exit-flow.md.
|
||||||
|
if (!open && !withinGrace) continue;
|
||||||
|
const overstay =
|
||||||
|
open && paid && !isSubscription && graceExpiresAt != null && !withinGrace;
|
||||||
|
|
||||||
|
// Amount owed now: an open + unpaid TRANSIENT (first stay) OR an OVERSTAY (the new
|
||||||
|
// period's top-up). A subscription is prepaid — never quote/charge it.
|
||||||
|
let amountMinor: number | null = null;
|
||||||
|
let currency: string | null = null;
|
||||||
|
if (open && !isSubscription && (a.paidAt == null || overstay)) {
|
||||||
|
try {
|
||||||
|
const q = this.quote(identity);
|
||||||
|
amountMinor = q.amountMinor;
|
||||||
|
currency = q.currency;
|
||||||
|
} catch {
|
||||||
|
/* no active tariff — leave null */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
identity,
|
||||||
|
source: a.source,
|
||||||
|
enteredAt: a.enteredAt,
|
||||||
|
exitedAt: a.exitedAt ?? null,
|
||||||
|
open,
|
||||||
|
paidAt: a.paidAt ?? null,
|
||||||
|
amountMinor,
|
||||||
|
currency,
|
||||||
|
withinGrace,
|
||||||
|
graceExpiresAt,
|
||||||
|
overstay,
|
||||||
|
subscription: isSubscription,
|
||||||
|
subscriptionId: a.subscriptionId ?? null,
|
||||||
|
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
|
||||||
|
plate: plates.get(identity)?.plate ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest entry first.
|
||||||
|
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right
|
||||||
|
* now: the transient cost of the minutes parked OUTSIDE the plan's window over the WHOLE
|
||||||
|
* stay `[entry, now]` (one computation — covers early entry AND late exit without
|
||||||
|
* double-counting), minus whatever they've already paid against the occurrence. null
|
||||||
|
* when the plan has no timeframes / nothing is owed. Single source of truth shared with
|
||||||
|
* the exit gate so the booth quote and the gate agree.
|
||||||
|
*/
|
||||||
|
#subscriptionWindowDue(occurrenceId: string, subscriptionId: string | null): { dueMinor: number; currency: string | null } | null {
|
||||||
|
if (!subscriptionId) return null;
|
||||||
|
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||||
|
if (!sub) return null;
|
||||||
|
|
||||||
|
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
|
||||||
|
const entryRow = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entryRow) return null;
|
||||||
|
|
||||||
|
const owed = windowOwedBetween(this.#db, sub.planVersionId, entryRow.occurredAt, new Date().toISOString());
|
||||||
|
if (!owed) return null;
|
||||||
|
|
||||||
|
let paid = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "payment") continue;
|
||||||
|
const pl = (r.payload ?? {}) as { amountMinor?: number };
|
||||||
|
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dueMinor: owed.amountMinor - paid, currency: owed.currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is this identity an OPEN subscription occurrence that owes a window charge? Returns
|
||||||
|
* the due amount + currency + the tariff version that priced the late-exit charge (for
|
||||||
|
* the payment payload), or null when it's transient / nothing owed. */
|
||||||
|
#payableSubscriptionWindow(
|
||||||
|
identity: string,
|
||||||
|
): { dueMinor: number; currency: string | null; tariffVersionId: string | null } | null {
|
||||||
|
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return null;
|
||||||
|
const ep = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
if (ep.permit !== true && ep.permitId == null) return null; // transient
|
||||||
|
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out
|
||||||
|
const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null);
|
||||||
|
if (!due || due.dueMinor <= 0) return null;
|
||||||
|
// Tariff version for the payment payload = the one that priced the stay (resolved at
|
||||||
|
// entry inside windowOwedBetween).
|
||||||
|
const owed = windowOwedBetween(this.#db, this.#planVersionOf(ep.permitId ?? null), entry.occurredAt, new Date().toISOString());
|
||||||
|
return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: owed?.tariffVersionId ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The planVersionId of a subscription (for resolving its timeframes), or null. */
|
||||||
|
#planVersionOf(subscriptionId: string | null): string | null {
|
||||||
|
if (!subscriptionId) return null;
|
||||||
|
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||||
|
return row?.planVersionId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subscriber's holder name for a subscription id (for a friendly UI label),
|
||||||
|
* or null. Best-effort: a deleted subscription just yields null. */
|
||||||
|
#holderOf(subscriptionId: string | null): string | null {
|
||||||
|
if (!subscriptionId) return null;
|
||||||
|
try {
|
||||||
|
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||||
|
return row?.holderName ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
|
||||||
|
#openEntry(identity: string) {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return null;
|
||||||
|
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already closed
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
|
||||||
|
* (single, for now) active site tariff. */
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
|
||||||
|
|
||||||
|
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
|
||||||
|
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
|
||||||
|
// ANPR-on-snapshot path (snapshot.ts → recognizePlate), keyed to the session `identity`.
|
||||||
|
// It is deliberately NOT on the signed ledger (a fuzzy camera read must never become a
|
||||||
|
// signed fact). To SHOW it next to a feed event or an active session we resolve it here,
|
||||||
|
// at serialize time, the same way subscriber names are resolved (see event-enrich.ts).
|
||||||
|
//
|
||||||
|
// Preference: an ENTRY read over an exit read (the plate as it arrived identifies the
|
||||||
|
// session); within a direction, the newest read wins. Returns the plate text only —
|
||||||
|
// confidence/region detail stays on the snapshot review panel, not the at-a-glance feed.
|
||||||
|
|
||||||
|
/** The best advisory plate observed for a session, for display. */
|
||||||
|
export interface PlateView {
|
||||||
|
readonly plate: string;
|
||||||
|
readonly confidence: number | null;
|
||||||
|
readonly direction: "entry" | "exit" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadDetail {
|
||||||
|
identity?: string;
|
||||||
|
plate?: string;
|
||||||
|
confidence?: number;
|
||||||
|
direction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
|
||||||
|
export function plateForIdentity(db: Db, identity: string): PlateView | null {
|
||||||
|
const rows = db
|
||||||
|
.select({ detail: deviceEvents.detail })
|
||||||
|
.from(deviceEvents)
|
||||||
|
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||||||
|
.orderBy(desc(deviceEvents.occurredAt))
|
||||||
|
.all();
|
||||||
|
return pickBest(rows.map((r) => (r.detail ?? {}) as ReadDetail), identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve plates for MANY identities in one device_events scan (used by the active-
|
||||||
|
* sessions list and the feed page, which each carry tens–hundreds of rows). */
|
||||||
|
export function platesForIdentities(db: Db, identities: Iterable<string>): Map<string, PlateView> {
|
||||||
|
const want = new Set(identities);
|
||||||
|
const out = new Map<string, PlateView>();
|
||||||
|
if (want.size === 0) return out;
|
||||||
|
// Newest first so the first acceptable read per (identity,direction) is the freshest.
|
||||||
|
const rows = db
|
||||||
|
.select({ detail: deviceEvents.detail })
|
||||||
|
.from(deviceEvents)
|
||||||
|
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||||||
|
.orderBy(desc(deviceEvents.occurredAt))
|
||||||
|
.all();
|
||||||
|
const byId = new Map<string, ReadDetail[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const d = (r.detail ?? {}) as ReadDetail;
|
||||||
|
if (!d.identity || !d.plate || !want.has(d.identity)) continue;
|
||||||
|
let list = byId.get(d.identity);
|
||||||
|
if (!list) byId.set(d.identity, (list = []));
|
||||||
|
list.push(d);
|
||||||
|
}
|
||||||
|
for (const [id, reads] of byId) {
|
||||||
|
const best = pickBest(reads, id);
|
||||||
|
if (best) out.set(id, best);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the best read for `identity` from a NEWEST-FIRST list: an entry read beats an
|
||||||
|
* exit read; otherwise the first (newest) acceptable read wins. */
|
||||||
|
function pickBest(reads: ReadDetail[], identity: string): PlateView | null {
|
||||||
|
let fallback: ReadDetail | null = null;
|
||||||
|
for (const d of reads) {
|
||||||
|
if (d.identity !== identity || !d.plate) continue;
|
||||||
|
if (d.direction === "entry") return toView(d);
|
||||||
|
if (!fallback) fallback = d;
|
||||||
|
}
|
||||||
|
return fallback ? toView(fallback) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toView(d: ReadDetail): PlateView {
|
||||||
|
return {
|
||||||
|
plate: d.plate!.trim().toUpperCase(),
|
||||||
|
confidence: typeof d.confidence === "number" ? d.confidence : null,
|
||||||
|
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { eq, laneDevices, type Db } from "@parking/db";
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
isMonitorable,
|
isMonitorable,
|
||||||
registry,
|
registry,
|
||||||
@@ -66,8 +66,8 @@ export class PrinterMonitor {
|
|||||||
async refreshDevices(): Promise<void> {
|
async refreshDevices(): Promise<void> {
|
||||||
const rows = await this.#db
|
const rows = await this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(laneDevices)
|
.from(devices)
|
||||||
.where(eq(laneDevices.category, "printer"))
|
.where(eq(devices.category, "printer"))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -89,7 +89,6 @@ export class PrinterMonitor {
|
|||||||
build: () => driver.create(cfg as never),
|
build: () => driver.create(cfg as never),
|
||||||
meta: {
|
meta: {
|
||||||
deviceId: row.id,
|
deviceId: row.id,
|
||||||
lane: row.lane,
|
|
||||||
driverId: row.driverId,
|
driverId: row.driverId,
|
||||||
role: typeof cfg.role === "string" ? cfg.role : undefined,
|
role: typeof cfg.role === "string" ? cfg.role : undefined,
|
||||||
},
|
},
|
||||||
@@ -139,7 +138,7 @@ export class PrinterMonitor {
|
|||||||
|
|
||||||
if (!prev || statusChanged(prev.status, status)) {
|
if (!prev || statusChanged(prev.status, status)) {
|
||||||
this.#log.info(
|
this.#log.info(
|
||||||
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} (lane ${entry.meta.lane}) -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
|
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
|
||||||
);
|
);
|
||||||
deviceEvents.emitPrinterStatus(event);
|
deviceEvents.emitPrinterStatus(event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { devices, eq, type Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
|
import type { ExitFlow } from "./exit-flow.js";
|
||||||
|
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
|
import { relayForDevice } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
||||||
|
// can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the
|
||||||
|
// credential is (decision 2026-06-15):
|
||||||
|
// - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow,
|
||||||
|
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||||||
|
//
|
||||||
|
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
|
||||||
|
// resolves to exactly the barrier it sits at, and the direction is inherited from
|
||||||
|
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
|
||||||
|
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
||||||
|
// reader the exit side; "both" defers to the flow's own inference (subscription:
|
||||||
|
// session state; transient: exit).
|
||||||
|
|
||||||
|
export class ReadDispatcher {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #exit: ExitFlow;
|
||||||
|
readonly #subscription: SubscriptionFlow;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#exit = exit;
|
||||||
|
this.#subscription = subscription;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
|
||||||
|
const reader = this.#db.select().from(devices).where(eq(devices.id, e.deviceId)).get();
|
||||||
|
if (!reader || !reader.enabled) {
|
||||||
|
return { accepted: false, reason: "read from unknown/disabled device" };
|
||||||
|
}
|
||||||
|
const resolved = relayForDevice(this.#db, reader);
|
||||||
|
if (!resolved) {
|
||||||
|
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = this.#subscription.match(e);
|
||||||
|
if (sub) {
|
||||||
|
return this.#subscription.run(resolved, e, sub);
|
||||||
|
}
|
||||||
|
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
|
||||||
|
// transient exit (transient entry is the button flow, not a reader), so reject+log
|
||||||
|
// rather than treat an entry scan as an exit.
|
||||||
|
if (resolved.direction === "entry") {
|
||||||
|
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
|
||||||
|
}
|
||||||
|
return this.#exit.handleAt(resolved, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import {
|
||||||
|
eq,
|
||||||
|
isNull,
|
||||||
|
roles,
|
||||||
|
rolePermissions,
|
||||||
|
subscriptionCredentials,
|
||||||
|
subscriptionPlans,
|
||||||
|
subscriptions,
|
||||||
|
tariffs,
|
||||||
|
users,
|
||||||
|
type Db,
|
||||||
|
} from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import {
|
||||||
|
listRecycleBin,
|
||||||
|
purge,
|
||||||
|
restore,
|
||||||
|
restoreBlockedReason,
|
||||||
|
softDelete,
|
||||||
|
sweepExpired,
|
||||||
|
} from "./recycle-bin.js";
|
||||||
|
|
||||||
|
// Soft delete / recycle bin. Pins: a delete STAMPS (keeps the row), the bin lists
|
||||||
|
// soft-deleted items across kinds, restore brings them back, purge does the real
|
||||||
|
// DELETE (+ children), a restore that would collide with a live row is blocked, and the
|
||||||
|
// retention sweep purges only items past the window.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedUser(username: string): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run();
|
||||||
|
db.insert(users).values({ id, username, passwordHash: "x", roleId: "admin" }).run();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
function seedRole(name: string): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(roles).values({ id, name, builtin: 0 }).run();
|
||||||
|
db.insert(rolePermissions).values({ roleId: id, permission: "site:read" }).run();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
function seedSubscription(holder: string): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(subscriptions).values({ id, holderName: holder, period: "month" }).run();
|
||||||
|
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: "qr", value: `qr-${id}` }).run();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
function seedPlan(planId: string, versions = 2): void {
|
||||||
|
for (let i = 0; i < versions; i++) {
|
||||||
|
db.insert(subscriptionPlans).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
planId,
|
||||||
|
name: planId,
|
||||||
|
period: "month",
|
||||||
|
pricePerPeriodMinor: 100000,
|
||||||
|
currency: "ALL",
|
||||||
|
effectiveFrom: `2026-0${i + 1}-01T00:00:00.000Z`,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("softDelete + restore + purge", () => {
|
||||||
|
it("stamps the row instead of removing it, and hides it from a live query", () => {
|
||||||
|
const id = seedUser("alice");
|
||||||
|
expect(softDelete(db, "user", id, "admin-1")).toBe(true);
|
||||||
|
|
||||||
|
const row = db.select().from(users).where(eq(users.id, id)).get();
|
||||||
|
expect(row).toBeDefined(); // still there
|
||||||
|
expect(row?.deletedAt).toBeTruthy();
|
||||||
|
expect(row?.deletedBy).toBe("admin-1");
|
||||||
|
// A live-only query no longer sees it.
|
||||||
|
expect(db.select().from(users).where(isNull(users.deletedAt)).all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("soft-deleting an already-deleted row is a no-op (returns false)", () => {
|
||||||
|
const id = seedUser("bob");
|
||||||
|
expect(softDelete(db, "user", id, "a")).toBe(true);
|
||||||
|
expect(softDelete(db, "user", id, "a")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restore clears the stamps and brings the row back to the live set", () => {
|
||||||
|
const id = seedRole("valet");
|
||||||
|
softDelete(db, "role", id, "a");
|
||||||
|
expect(restore(db, "role", id)).toBe(true);
|
||||||
|
const row = db.select().from(roles).where(eq(roles.id, id)).get();
|
||||||
|
expect(row?.deletedAt).toBeNull();
|
||||||
|
expect(db.select().from(roles).where(isNull(roles.deletedAt)).all().map((r) => r.id)).toContain(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purge removes a soft-deleted row + its children; refuses a LIVE row", () => {
|
||||||
|
const id = seedSubscription("carlos");
|
||||||
|
// Cannot purge while live (purge only touches soft-deleted rows).
|
||||||
|
expect(purge(db, "subscription", id)).toBe(false);
|
||||||
|
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeDefined();
|
||||||
|
|
||||||
|
softDelete(db, "subscription", id, "a");
|
||||||
|
expect(purge(db, "subscription", id)).toBe(true);
|
||||||
|
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeUndefined();
|
||||||
|
// Children gone too.
|
||||||
|
expect(db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("versioned plans", () => {
|
||||||
|
it("soft-deletes / restores / purges ALL versions of a planId together", () => {
|
||||||
|
seedPlan("hotel-daily", 3);
|
||||||
|
expect(softDelete(db, "plan", "hotel-daily", "a")).toBe(true);
|
||||||
|
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(0);
|
||||||
|
|
||||||
|
// The bin lists the plan as ONE item, not three.
|
||||||
|
const planItems = listRecycleBin(db).filter((i) => i.kind === "plan");
|
||||||
|
expect(planItems).toHaveLength(1);
|
||||||
|
expect(planItems[0]?.id).toBe("hotel-daily");
|
||||||
|
|
||||||
|
expect(restore(db, "plan", "hotel-daily")).toBe(true);
|
||||||
|
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(3);
|
||||||
|
|
||||||
|
softDelete(db, "plan", "hotel-daily", "a");
|
||||||
|
expect(purge(db, "plan", "hotel-daily")).toBe(true);
|
||||||
|
expect(db.select().from(subscriptionPlans).all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listRecycleBin", () => {
|
||||||
|
it("collects soft-deleted items across every kind, newest-deleted first", () => {
|
||||||
|
const u = seedUser("dora");
|
||||||
|
const r = seedRole("guard");
|
||||||
|
const t = randomUUID();
|
||||||
|
db.insert(tariffs).values({ id: t, scope: "site", name: "Site" }).run();
|
||||||
|
|
||||||
|
softDelete(db, "user", u, "a");
|
||||||
|
softDelete(db, "role", r, "a");
|
||||||
|
softDelete(db, "tariff", t, "a");
|
||||||
|
|
||||||
|
const items = listRecycleBin(db);
|
||||||
|
expect(items.map((i) => i.kind).sort()).toEqual(["role", "tariff", "user"]);
|
||||||
|
// Each carries a human label + the deletedAt stamp.
|
||||||
|
expect(items.find((i) => i.kind === "user")?.label).toBe("dora");
|
||||||
|
expect(items.every((i) => i.deletedAt)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("restoreBlockedReason", () => {
|
||||||
|
// NB: the DB `username`/`name` UNIQUE spans live AND soft-deleted rows, so a live
|
||||||
|
// duplicate can't even be INSERTed while the deleted one exists (the create route
|
||||||
|
// returns a clear 409 instead — see routes/users.ts). restoreBlockedReason is a
|
||||||
|
// belt-and-suspenders guard at restore time; verify it returns null in the normal
|
||||||
|
// case (nothing colliding) so a clean restore is never wrongly blocked.
|
||||||
|
it("does not block a normal restore (no live collision)", () => {
|
||||||
|
const u = seedUser("eve");
|
||||||
|
softDelete(db, "user", u, "a");
|
||||||
|
expect(restoreBlockedReason(db, "user", u)).toBeNull();
|
||||||
|
|
||||||
|
const r = seedRole("cleaner");
|
||||||
|
softDelete(db, "role", r, "a");
|
||||||
|
expect(restoreBlockedReason(db, "role", r)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sweepExpired (retention)", () => {
|
||||||
|
it("purges items deleted longer than the window ago, keeps recent ones", () => {
|
||||||
|
const old = seedUser("old");
|
||||||
|
const fresh = seedUser("fresh");
|
||||||
|
softDelete(db, "user", old, "a");
|
||||||
|
softDelete(db, "user", fresh, "a");
|
||||||
|
// Backdate `old`'s deletion to 40 days ago.
|
||||||
|
const longAgo = new Date(Date.now() - 40 * 86_400_000).toISOString();
|
||||||
|
db.update(users).set({ deletedAt: longAgo }).where(eq(users.id, old)).run();
|
||||||
|
|
||||||
|
const purged = sweepExpired(db, 30);
|
||||||
|
expect(purged.user).toBe(1);
|
||||||
|
expect(db.select().from(users).where(eq(users.id, old)).get()).toBeUndefined();
|
||||||
|
expect(db.select().from(users).where(eq(users.id, fresh)).get()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("days <= 0 disables the sweep (keep forever)", () => {
|
||||||
|
const id = seedUser("keeper");
|
||||||
|
softDelete(db, "user", id, "a");
|
||||||
|
db.update(users).set({ deletedAt: new Date(Date.now() - 999 * 86_400_000).toISOString() }).where(eq(users.id, id)).run();
|
||||||
|
const purged = sweepExpired(db, 0);
|
||||||
|
expect(purged.user).toBe(0);
|
||||||
|
expect(db.select().from(users).where(eq(users.id, id)).get()).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
isNotNull,
|
||||||
|
isNull,
|
||||||
|
lte,
|
||||||
|
rolePermissions,
|
||||||
|
roles,
|
||||||
|
subscriptionCredentials,
|
||||||
|
subscriptionPlans,
|
||||||
|
subscriptionPlates,
|
||||||
|
subscriptions,
|
||||||
|
tariffs,
|
||||||
|
users,
|
||||||
|
type Db,
|
||||||
|
} from "@parking/db";
|
||||||
|
|
||||||
|
// Soft delete + recycle bin. Accidental hard-deletes of master data (a user, role,
|
||||||
|
// subscription, plan, tariff) used to be unrecoverable. Now a DELETE STAMPS the row
|
||||||
|
// (`deleted_at` = now, `deleted_by` = admin) instead of removing it; it disappears from
|
||||||
|
// every catalog (the list queries filter `deleted_at IS NULL`) but survives in the
|
||||||
|
// recycle bin, where an admin can RESTORE it (clear the stamps) or PURGE it (the real
|
||||||
|
// DELETE). A retention sweep auto-purges items deleted longer than the window ago.
|
||||||
|
//
|
||||||
|
// Scope: only the MUTABLE master-data tables below. The signed, append-only ledger is
|
||||||
|
// NOT here — it has no delete path by design. See wiki/concepts/soft-delete.md.
|
||||||
|
|
||||||
|
/** The soft-deletable resource kinds, as they appear in the recycle-bin API. */
|
||||||
|
export type ResourceKind = "user" | "role" | "subscription" | "plan" | "tariff";
|
||||||
|
|
||||||
|
export const RESOURCE_KINDS: ResourceKind[] = ["user", "role", "subscription", "plan", "tariff"];
|
||||||
|
|
||||||
|
/** Default retention window before a soft-deleted item is auto-purged (days). Override
|
||||||
|
* with RECYCLE_BIN_RETENTION_DAYS. 0/negative disables the sweep (keep forever). */
|
||||||
|
export function retentionDays(): number {
|
||||||
|
const raw = Number(process.env.RECYCLE_BIN_RETENTION_DAYS ?? 30);
|
||||||
|
return Number.isFinite(raw) ? raw : 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row surfaced in the recycle bin (normalised across resource kinds). */
|
||||||
|
export interface RecycleBinItem {
|
||||||
|
readonly kind: ResourceKind;
|
||||||
|
/** The id used to restore/purge. For a versioned PLAN this is the stable planId. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Human label for the list (username, role/plan/tariff name, subscriber holder). */
|
||||||
|
readonly label: string;
|
||||||
|
readonly deletedAt: string;
|
||||||
|
readonly deletedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW = () => new Date().toISOString();
|
||||||
|
|
||||||
|
// --- Per-resource helpers ----------------------------------------------------
|
||||||
|
// Subscriptions/users/roles/tariffs are 1 row per id. PLANS are versioned (N rows per
|
||||||
|
// plan_id) — stamp/clear/delete ALL versions of the plan_id together.
|
||||||
|
|
||||||
|
/** Soft-delete a row by id. Returns false if no live row matched (404). PLAN uses planId. */
|
||||||
|
export function softDelete(db: Db, kind: ResourceKind, id: string, byUserId: string): boolean {
|
||||||
|
const stamp = { deletedAt: NOW(), deletedBy: byUserId };
|
||||||
|
switch (kind) {
|
||||||
|
case "user":
|
||||||
|
return db.update(users).set(stamp).where(and(eq(users.id, id), isNull(users.deletedAt))).run().changes > 0;
|
||||||
|
case "role":
|
||||||
|
return db.update(roles).set(stamp).where(and(eq(roles.id, id), isNull(roles.deletedAt))).run().changes > 0;
|
||||||
|
case "subscription":
|
||||||
|
return db.update(subscriptions).set(stamp).where(and(eq(subscriptions.id, id), isNull(subscriptions.deletedAt))).run().changes > 0;
|
||||||
|
case "plan":
|
||||||
|
return db.update(subscriptionPlans).set(stamp).where(and(eq(subscriptionPlans.planId, id), isNull(subscriptionPlans.deletedAt))).run().changes > 0;
|
||||||
|
case "tariff":
|
||||||
|
return db.update(tariffs).set(stamp).where(and(eq(tariffs.id, id), isNull(tariffs.deletedAt))).run().changes > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore a soft-deleted row (clear the stamps). Returns false if nothing was restored. */
|
||||||
|
export function restore(db: Db, kind: ResourceKind, id: string): boolean {
|
||||||
|
const clear = { deletedAt: null, deletedBy: null };
|
||||||
|
switch (kind) {
|
||||||
|
case "user":
|
||||||
|
return db.update(users).set(clear).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0;
|
||||||
|
case "role":
|
||||||
|
return db.update(roles).set(clear).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0;
|
||||||
|
case "subscription":
|
||||||
|
return db.update(subscriptions).set(clear).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0;
|
||||||
|
case "plan":
|
||||||
|
return db.update(subscriptionPlans).set(clear).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0;
|
||||||
|
case "tariff":
|
||||||
|
return db.update(tariffs).set(clear).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if restoring would collide with a LIVE row (e.g. a user with the same username
|
||||||
|
* was re-created after the delete). The caller turns this into a 409 so the admin
|
||||||
|
* understands why restore is blocked. */
|
||||||
|
export function restoreBlockedReason(db: Db, kind: ResourceKind, id: string): string | null {
|
||||||
|
if (kind === "user") {
|
||||||
|
const row = db.select().from(users).where(eq(users.id, id)).get();
|
||||||
|
if (row && db.select().from(users).where(and(eq(users.username, row.username), isNull(users.deletedAt))).get()) {
|
||||||
|
return `a live user named "${row.username}" already exists`;
|
||||||
|
}
|
||||||
|
} else if (kind === "role") {
|
||||||
|
const row = db.select().from(roles).where(eq(roles.id, id)).get();
|
||||||
|
if (row && db.select().from(roles).where(and(eq(roles.name, row.name), isNull(roles.deletedAt))).get()) {
|
||||||
|
return `a live role named "${row.name}" already exists`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Restore ordering note --------------------------------------------------
|
||||||
|
// A restored USER points at a roleId; if that role is itself deleted, the user reappears
|
||||||
|
// with a dangling role. We don't auto-cascade (keep it predictable); the bin lists both
|
||||||
|
// and the admin restores the role too. The role guard already resolves a missing role to
|
||||||
|
// an empty permission set (safe-by-default), so a dangling role never escalates.
|
||||||
|
|
||||||
|
/** Hard-delete (purge) a soft-deleted row + its children. The real DELETE. Returns false
|
||||||
|
* if no soft-deleted row matched (so you can't purge a live row through this path). */
|
||||||
|
export function purge(db: Db, kind: ResourceKind, id: string): boolean {
|
||||||
|
switch (kind) {
|
||||||
|
case "user":
|
||||||
|
return db.delete(users).where(and(eq(users.id, id), isNotNull(users.deletedAt))).run().changes > 0;
|
||||||
|
case "role": {
|
||||||
|
// Children (role_permissions) only matter once the role row is gone; purge both.
|
||||||
|
const ok = db.delete(roles).where(and(eq(roles.id, id), isNotNull(roles.deletedAt))).run().changes > 0;
|
||||||
|
if (ok) deleteRolePermissions(db, id);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
case "subscription": {
|
||||||
|
const ok = db.delete(subscriptions).where(and(eq(subscriptions.id, id), isNotNull(subscriptions.deletedAt))).run().changes > 0;
|
||||||
|
if (ok) deleteSubscriptionChildren(db, id);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
case "plan":
|
||||||
|
return db.delete(subscriptionPlans).where(and(eq(subscriptionPlans.planId, id), isNotNull(subscriptionPlans.deletedAt))).run().changes > 0;
|
||||||
|
case "tariff":
|
||||||
|
return db.delete(tariffs).where(and(eq(tariffs.id, id), isNotNull(tariffs.deletedAt))).run().changes > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child cleanup on purge (role_permissions / subscription credentials + plates).
|
||||||
|
function deleteRolePermissions(db: Db, roleId: string): void {
|
||||||
|
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
|
||||||
|
}
|
||||||
|
function deleteSubscriptionChildren(db: Db, id: string): void {
|
||||||
|
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
|
||||||
|
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Listing the bin --------------------------------------------------------
|
||||||
|
|
||||||
|
/** All soft-deleted items across every resource kind, newest-deleted first. */
|
||||||
|
export function listRecycleBin(db: Db): RecycleBinItem[] {
|
||||||
|
const items: RecycleBinItem[] = [];
|
||||||
|
|
||||||
|
for (const r of db.select().from(users).where(isNotNull(users.deletedAt)).all()) {
|
||||||
|
items.push({ kind: "user", id: r.id, label: r.fullName || r.username, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
|
||||||
|
}
|
||||||
|
for (const r of db.select().from(roles).where(isNotNull(roles.deletedAt)).all()) {
|
||||||
|
items.push({ kind: "role", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
|
||||||
|
}
|
||||||
|
for (const r of db.select().from(subscriptions).where(isNotNull(subscriptions.deletedAt)).all()) {
|
||||||
|
items.push({ kind: "subscription", id: r.id, label: r.holderName || r.id, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
|
||||||
|
}
|
||||||
|
// Plans are versioned: collapse to one item per plan_id (the latest version's name).
|
||||||
|
const planSeen = new Set<string>();
|
||||||
|
const planRows = db.select().from(subscriptionPlans).where(isNotNull(subscriptionPlans.deletedAt)).all();
|
||||||
|
planRows.sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom));
|
||||||
|
for (const r of planRows) {
|
||||||
|
if (planSeen.has(r.planId)) continue;
|
||||||
|
planSeen.add(r.planId);
|
||||||
|
items.push({ kind: "plan", id: r.planId, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
|
||||||
|
}
|
||||||
|
for (const r of db.select().from(tariffs).where(isNotNull(tariffs.deletedAt)).all()) {
|
||||||
|
items.push({ kind: "tariff", id: r.id, label: r.name, deletedAt: r.deletedAt!, deletedBy: r.deletedBy });
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Retention sweep --------------------------------------------------------
|
||||||
|
|
||||||
|
/** Purge every soft-deleted row deleted more than `retentionDays()` ago. Returns the
|
||||||
|
* count purged per kind. Safe to call repeatedly (idempotent). */
|
||||||
|
export function sweepExpired(db: Db, days = retentionDays()): Record<ResourceKind, number> {
|
||||||
|
const out: Record<ResourceKind, number> = { user: 0, role: 0, subscription: 0, plan: 0, tariff: 0 };
|
||||||
|
if (!Number.isFinite(days) || days <= 0) return out; // keep-forever
|
||||||
|
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
||||||
|
|
||||||
|
// Collect ids first so children purge through the same path as a manual purge.
|
||||||
|
for (const r of db.select().from(users).where(and(isNotNull(users.deletedAt), lte(users.deletedAt, cutoff))).all()) {
|
||||||
|
if (purge(db, "user", r.id)) out.user++;
|
||||||
|
}
|
||||||
|
for (const r of db.select().from(roles).where(and(isNotNull(roles.deletedAt), lte(roles.deletedAt, cutoff))).all()) {
|
||||||
|
if (purge(db, "role", r.id)) out.role++;
|
||||||
|
}
|
||||||
|
for (const r of db.select().from(subscriptions).where(and(isNotNull(subscriptions.deletedAt), lte(subscriptions.deletedAt, cutoff))).all()) {
|
||||||
|
if (purge(db, "subscription", r.id)) out.subscription++;
|
||||||
|
}
|
||||||
|
const planIds = new Set(
|
||||||
|
db.select().from(subscriptionPlans).where(and(isNotNull(subscriptionPlans.deletedAt), lte(subscriptionPlans.deletedAt, cutoff))).all().map((r) => r.planId),
|
||||||
|
);
|
||||||
|
for (const planId of planIds) if (purge(db, "plan", planId)) out.plan++;
|
||||||
|
for (const r of db.select().from(tariffs).where(and(isNotNull(tariffs.deletedAt), lte(tariffs.deletedAt, cutoff))).all()) {
|
||||||
|
if (purge(db, "tariff", r.id)) out.tariff++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { sessions, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { makeLog } from "./test-helpers.js";
|
||||||
|
import { reportSummary } from "./reports.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Reports aggregation — LEDGER-FIRST. These pin that the numbers an admin sees are
|
||||||
|
// summed straight from the signed ledger (entry/exit counts + payment money, split the
|
||||||
|
// same way the shift Z-report splits it), bucketed in the SITE TIMEZONE, with duration
|
||||||
|
// stats from the closed-sessions cache and subscription counts as of the range end.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let log: EventLog;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
log = makeLog(db);
|
||||||
|
// Fix the site timezone so bucket labels are deterministic regardless of the test host.
|
||||||
|
db.insert(siteConfig).values({ id: 1, timezone: "Europe/Tirane" }).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** ISO at a UTC instant, for deterministic bucket assertions. */
|
||||||
|
function at(iso: string): string {
|
||||||
|
return new Date(iso).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entry(occurredAt: string): Promise<void> {
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity: randomUUID(), occurredAt });
|
||||||
|
}
|
||||||
|
async function exit(occurredAt: string): Promise<void> {
|
||||||
|
await log.append({ type: "vehicle_exit", direction: "exit", identity: randomUUID(), occurredAt });
|
||||||
|
}
|
||||||
|
async function payment(
|
||||||
|
occurredAt: string,
|
||||||
|
amountMinor: number,
|
||||||
|
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await log.append({
|
||||||
|
type: "payment",
|
||||||
|
occurredAt,
|
||||||
|
payload: {
|
||||||
|
amountMinor,
|
||||||
|
currency: "ALL",
|
||||||
|
tender: opts.tender ?? "cash",
|
||||||
|
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
|
||||||
|
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE = { from: at("2026-06-01T00:00:00Z"), to: at("2026-06-30T23:59:59Z") };
|
||||||
|
|
||||||
|
describe("reportSummary — ledger-first totals", () => {
|
||||||
|
it("counts entries and exits from the signed ledger", async () => {
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
await entry(at("2026-06-10T09:00:00Z"));
|
||||||
|
await exit(at("2026-06-10T18:00:00Z"));
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.entries).toBe(2);
|
||||||
|
expect(r.totals.exits).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes events outside [from, to)", async () => {
|
||||||
|
await entry(at("2026-05-31T23:00:00Z")); // before
|
||||||
|
await entry(at("2026-06-15T10:00:00Z")); // inside
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.entries).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums payment money and splits cash vs card", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 20000, { tender: "cash" });
|
||||||
|
await payment(at("2026-06-12T11:00:00Z"), 5000, { tender: "card" });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.payments).toBe(2);
|
||||||
|
expect(r.totals.revenueMinor).toBe(25000);
|
||||||
|
expect(r.totals.cashMinor).toBe(20000);
|
||||||
|
expect(r.totals.cardMinor).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits revenue into ticket / subscription-sale / out-of-window, mirroring the Z-report", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 10000); // transient ticket
|
||||||
|
await payment(at("2026-06-12T10:05:00Z"), 30000, { subscriptionSale: true });
|
||||||
|
await payment(at("2026-06-12T10:06:00Z"), 1500, { subscriptionWindowCharge: true });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.ticketMinor).toBe(10000);
|
||||||
|
expect(r.totals.subscriptionSalesMinor).toBe(30000);
|
||||||
|
expect(r.totals.subscriptionWindowMinor).toBe(1500);
|
||||||
|
// The three add up to the gross revenue.
|
||||||
|
expect(r.totals.revenueMinor).toBe(41500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up the currency from a payment in range", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 10000);
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.currency).toBe("ALL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reportSummary — time bucketing (site timezone)", () => {
|
||||||
|
it("buckets by local day; a 23:30 UTC event lands on the NEXT local day in Tirane (UTC+2/3)", async () => {
|
||||||
|
// 2026-06-15T23:30Z is 2026-06-16 01:30 local (summer, UTC+2) → the 16th bucket.
|
||||||
|
await entry(at("2026-06-15T23:30:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
const point = r.series.find((p) => p.entries > 0);
|
||||||
|
expect(point?.bucket).toBe("2026-06-16");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("series points are sorted and carry per-bucket entries/exits/revenue", async () => {
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
await payment(at("2026-06-10T09:00:00Z"), 7000);
|
||||||
|
await entry(at("2026-06-12T08:00:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
const labels = r.series.map((p) => p.bucket);
|
||||||
|
expect(labels).toEqual([...labels].sort());
|
||||||
|
const d10 = r.series.find((p) => p.bucket === "2026-06-10");
|
||||||
|
expect(d10?.entries).toBe(1);
|
||||||
|
expect(d10?.revenueMinor).toBe(7000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entriesByHour is a 24-slot local-hour histogram", async () => {
|
||||||
|
// 06:00Z = 08:00 local (summer) → hour slot 8.
|
||||||
|
await entry(at("2026-06-10T06:00:00Z"));
|
||||||
|
await entry(at("2026-06-11T06:00:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.entriesByHour).toHaveLength(24);
|
||||||
|
expect(r.entriesByHour[8]).toBe(2);
|
||||||
|
expect(r.entriesByHour.reduce((a, b) => a + b, 0)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
||||||
|
it("computes parked-minute stats from closed sessions whose exit fell in range", async () => {
|
||||||
|
// 60-min and 120-min stays → avg 90, median 90.
|
||||||
|
db.insert(sessions).values({
|
||||||
|
id: "s1",
|
||||||
|
identity: "t1",
|
||||||
|
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||||
|
exitedAt: at("2026-06-10T09:00:00Z"),
|
||||||
|
state: "closed",
|
||||||
|
}).run();
|
||||||
|
db.insert(sessions).values({
|
||||||
|
id: "s2",
|
||||||
|
identity: "t2",
|
||||||
|
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||||
|
exitedAt: at("2026-06-10T10:00:00Z"),
|
||||||
|
state: "closed",
|
||||||
|
}).run();
|
||||||
|
// An OPEN session (no exit) must not count.
|
||||||
|
db.insert(sessions).values({ id: "s3", identity: "t3", enteredAt: at("2026-06-10T08:00:00Z"), state: "open" }).run();
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.closedSessions).toBe(2);
|
||||||
|
expect(r.totals.totalParkedMinutes).toBe(180);
|
||||||
|
expect(r.totals.avgParkedMinutes).toBe(90);
|
||||||
|
expect(r.totals.medianParkedMinutes).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts subscriptions by status and currently-valid coverage as of `to`", async () => {
|
||||||
|
const base = { holderName: "x", period: "month" as const, createdAt: at("2026-06-01T00:00:00Z") };
|
||||||
|
// active + valid window covering `to`, quantity 2.
|
||||||
|
db.insert(subscriptions).values({
|
||||||
|
id: "a", status: "active", quantity: 2,
|
||||||
|
validFrom: at("2026-06-01T00:00:00Z"), validTo: at("2026-07-01T00:00:00Z"), ...base,
|
||||||
|
}).run();
|
||||||
|
// active but EXPIRED before `to` → not currently valid.
|
||||||
|
db.insert(subscriptions).values({
|
||||||
|
id: "b", status: "active", quantity: 1,
|
||||||
|
validFrom: at("2026-05-01T00:00:00Z"), validTo: at("2026-06-05T00:00:00Z"), ...base,
|
||||||
|
}).run();
|
||||||
|
// suspended.
|
||||||
|
db.insert(subscriptions).values({ id: "c", status: "suspended", quantity: 1, ...base }).run();
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.subscriptions.active).toBe(2);
|
||||||
|
expect(r.subscriptions.suspended).toBe(1);
|
||||||
|
expect(r.subscriptions.revoked).toBe(0);
|
||||||
|
expect(r.subscriptions.currentlyValid).toBe(1);
|
||||||
|
expect(r.subscriptions.coveredCars).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
lte,
|
||||||
|
ledgerEvents,
|
||||||
|
sessions,
|
||||||
|
subscriptions,
|
||||||
|
tariffVersions,
|
||||||
|
tariffs,
|
||||||
|
type Db,
|
||||||
|
} from "@parking/db";
|
||||||
|
import { siteTz } from "./subscription-window.js";
|
||||||
|
|
||||||
|
// Admin reporting — LEDGER-FIRST aggregation (decision 2026-06-22). The numbers an
|
||||||
|
// admin sees on the Reports page are summed from the SIGNED, hash-chained
|
||||||
|
// ledger_events (vehicle_entry/exit + payment), the same source the shift Z-report
|
||||||
|
// reconciles against — so a chart total always ties out to the drawer. Only the
|
||||||
|
// duration/occupancy view leans on the derived `sessions` cache, where the ledger is
|
||||||
|
// awkward (you'd have to pair every entry with its exit by hand); that's flagged as a
|
||||||
|
// cache, not the financial truth. See wiki/concepts/reports.md, event-streams-split.md.
|
||||||
|
//
|
||||||
|
// All bucketing is in the SITE TIMEZONE (siteConfig.timezone) — a "day" is a local
|
||||||
|
// calendar day, not a UTC one, so a 01:00-local payment lands on the right date and the
|
||||||
|
// peak-hour chart reads in wall-clock. Pure date math on the stored ISO strings; no
|
||||||
|
// floats (money is integer minor units throughout).
|
||||||
|
|
||||||
|
export type Bucket = "hour" | "day" | "month";
|
||||||
|
|
||||||
|
export interface ReportQuery {
|
||||||
|
/** Inclusive lower bound (ISO instant). */
|
||||||
|
readonly from: string;
|
||||||
|
/** Exclusive upper bound (ISO instant). */
|
||||||
|
readonly to: string;
|
||||||
|
/** Time grain for the series. Default "day". */
|
||||||
|
readonly bucket: Bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One point in a time series, keyed by its local-time bucket label (e.g. "2026-06-22"
|
||||||
|
* for a day, "2026-06-22 14" for an hour). */
|
||||||
|
export interface SeriesPoint {
|
||||||
|
readonly bucket: string;
|
||||||
|
readonly entries: number;
|
||||||
|
readonly exits: number;
|
||||||
|
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
||||||
|
readonly revenueMinor: number;
|
||||||
|
/** Payment COUNT in the bucket (transactions, not amount). */
|
||||||
|
readonly payments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTotals {
|
||||||
|
readonly entries: number;
|
||||||
|
readonly exits: number;
|
||||||
|
readonly payments: number;
|
||||||
|
readonly revenueMinor: number;
|
||||||
|
readonly cashMinor: number;
|
||||||
|
readonly cardMinor: number;
|
||||||
|
/** Revenue split by what was sold. ticket = transient parking; subscriptionSales =
|
||||||
|
* new/renewed subscriptions; subscriptionWindow = out-of-window tariff-bridge charges. */
|
||||||
|
readonly ticketMinor: number;
|
||||||
|
readonly subscriptionSalesMinor: number;
|
||||||
|
readonly subscriptionWindowMinor: number;
|
||||||
|
/** Closed transient sessions in range + their parked-minutes stats (from the cache). */
|
||||||
|
readonly closedSessions: number;
|
||||||
|
readonly totalParkedMinutes: number;
|
||||||
|
readonly avgParkedMinutes: number;
|
||||||
|
readonly medianParkedMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionStats {
|
||||||
|
readonly active: number;
|
||||||
|
readonly suspended: number;
|
||||||
|
readonly revoked: number;
|
||||||
|
/** Active subscriptions whose window covers `to` (the report's "now"). */
|
||||||
|
readonly currentlyValid: number;
|
||||||
|
/** Cars covered by currently-valid subscriptions (Σ quantity). */
|
||||||
|
readonly coveredCars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSummary {
|
||||||
|
readonly from: string;
|
||||||
|
readonly to: string;
|
||||||
|
readonly bucket: Bucket;
|
||||||
|
readonly tz: string;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly totals: ReportTotals;
|
||||||
|
readonly series: SeriesPoint[];
|
||||||
|
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
||||||
|
readonly entriesByHour: number[];
|
||||||
|
readonly subscriptions: SubscriptionStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
||||||
|
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
|
||||||
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
||||||
|
return {
|
||||||
|
y: Number(parts.year),
|
||||||
|
mo: Number(parts.month),
|
||||||
|
d: Number(parts.day),
|
||||||
|
h: Number(parts.hour),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket label for an instant at the chosen grain, in local time. Sorts lexically. */
|
||||||
|
function bucketLabel(iso: string, tz: string, bucket: Bucket): string {
|
||||||
|
const p = localParts(iso, tz);
|
||||||
|
const mo = String(p.mo).padStart(2, "0");
|
||||||
|
const d = String(p.d).padStart(2, "0");
|
||||||
|
const h = String(p.h).padStart(2, "0");
|
||||||
|
if (bucket === "month") return `${p.y}-${mo}`;
|
||||||
|
if (bucket === "hour") return `${p.y}-${mo}-${d} ${h}`;
|
||||||
|
return `${p.y}-${mo}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentPayload {
|
||||||
|
amountMinor?: number;
|
||||||
|
currency?: string;
|
||||||
|
tender?: "cash" | "card";
|
||||||
|
subscriptionSale?: boolean;
|
||||||
|
subscriptionWindowCharge?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(sorted: number[]): number {
|
||||||
|
if (sorted.length === 0) return 0;
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
const hi = sorted[mid] ?? 0;
|
||||||
|
if (sorted.length % 2) return hi;
|
||||||
|
const lo = sorted[mid - 1] ?? 0;
|
||||||
|
return Math.round((lo + hi) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the admin report summary for [from, to) at the chosen grain. Entry/exit counts
|
||||||
|
* and money are summed from the signed ledger; duration stats from the closed sessions
|
||||||
|
* in range; subscription counts from the subscriptions table as of `to`.
|
||||||
|
*/
|
||||||
|
export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||||
|
const tz = siteTz(db);
|
||||||
|
|
||||||
|
// --- Ledger: entry/exit/payment in range, oldest-first so the series builds in order.
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(and(gte(ledgerEvents.occurredAt, q.from), lte(ledgerEvents.occurredAt, q.to)))
|
||||||
|
.orderBy(asc(ledgerEvents.index))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Currency for display: money everywhere is { minorUnits, currency }; payments carry
|
||||||
|
// the currency they were taken in, so take it from a payment in range (then fall back
|
||||||
|
// to the active tariff version). Reports never mix currencies (single-currency site).
|
||||||
|
let currency: string | null = null;
|
||||||
|
|
||||||
|
const seriesMap = new Map<string, SeriesPoint>();
|
||||||
|
const entriesByHour = new Array<number>(24).fill(0);
|
||||||
|
const totals = {
|
||||||
|
entries: 0,
|
||||||
|
exits: 0,
|
||||||
|
payments: 0,
|
||||||
|
revenueMinor: 0,
|
||||||
|
cashMinor: 0,
|
||||||
|
cardMinor: 0,
|
||||||
|
ticketMinor: 0,
|
||||||
|
subscriptionSalesMinor: 0,
|
||||||
|
subscriptionWindowMinor: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function point(label: string): SeriesPoint {
|
||||||
|
let p = seriesMap.get(label);
|
||||||
|
if (!p) {
|
||||||
|
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
|
||||||
|
seriesMap.set(label, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-pass: identities cancelled by a `void` in range. A voided entry was a wrongly-
|
||||||
|
// printed ticket (no car entered), so it must NOT inflate the "entries" stat. (The void's
|
||||||
|
// entry is normally in the same window; this skips it when both are in range.)
|
||||||
|
const voided = new Set<string>();
|
||||||
|
for (const row of rows) if (row.type === "void" && row.identity) voided.add(row.identity);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
||||||
|
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
||||||
|
if (row.type === "vehicle_entry") {
|
||||||
|
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||||
|
totals.entries++;
|
||||||
|
p.entries++;
|
||||||
|
const h = localParts(row.occurredAt, tz).h;
|
||||||
|
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
|
||||||
|
} else if (row.type === "vehicle_exit") {
|
||||||
|
totals.exits++;
|
||||||
|
p.exits++;
|
||||||
|
} else if (row.type === "payment") {
|
||||||
|
const pl = (row.payload ?? {}) as PaymentPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (!currency && typeof pl.currency === "string") currency = pl.currency;
|
||||||
|
totals.payments++;
|
||||||
|
totals.revenueMinor += amt;
|
||||||
|
p.payments++;
|
||||||
|
p.revenueMinor += amt;
|
||||||
|
if (pl.tender === "card") totals.cardMinor += amt;
|
||||||
|
else totals.cashMinor += amt;
|
||||||
|
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
||||||
|
// (the rest is) transient ticket revenue.
|
||||||
|
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
||||||
|
else if (pl.subscriptionWindowCharge === true) totals.subscriptionWindowMinor += amt;
|
||||||
|
else totals.ticketMinor += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
||||||
|
|
||||||
|
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
||||||
|
// zero-revenue range still labels its money column.
|
||||||
|
if (!currency) {
|
||||||
|
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||||
|
if (tariff) {
|
||||||
|
const tv = db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.get();
|
||||||
|
currency = tv?.currency ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Duration: closed transient sessions whose EXIT fell in range (the cache; flagged).
|
||||||
|
const closed = db
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(and(gte(sessions.exitedAt, q.from), lte(sessions.exitedAt, q.to)))
|
||||||
|
.all();
|
||||||
|
const durations: number[] = [];
|
||||||
|
for (const s of closed) {
|
||||||
|
if (!s.enteredAt || !s.exitedAt) continue;
|
||||||
|
const mins = Math.max(0, Math.round((Date.parse(s.exitedAt) - Date.parse(s.enteredAt)) / 60000));
|
||||||
|
durations.push(mins);
|
||||||
|
}
|
||||||
|
durations.sort((a, b) => a - b);
|
||||||
|
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
||||||
|
const subs = db.select().from(subscriptions).all();
|
||||||
|
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
||||||
|
for (const s of subs) {
|
||||||
|
if (s.status === "active") subStats.active++;
|
||||||
|
else if (s.status === "suspended") subStats.suspended++;
|
||||||
|
else if (s.status === "revoked") subStats.revoked++;
|
||||||
|
const validNow =
|
||||||
|
s.status === "active" &&
|
||||||
|
(!s.validFrom || s.validFrom <= q.to) &&
|
||||||
|
(!s.validTo || s.validTo >= q.to);
|
||||||
|
if (validNow) {
|
||||||
|
subStats.currentlyValid++;
|
||||||
|
subStats.coveredCars += s.quantity ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: q.from,
|
||||||
|
to: q.to,
|
||||||
|
bucket: q.bucket,
|
||||||
|
tz,
|
||||||
|
currency,
|
||||||
|
totals: {
|
||||||
|
...totals,
|
||||||
|
closedSessions: durations.length,
|
||||||
|
totalParkedMinutes,
|
||||||
|
avgParkedMinutes: durations.length ? Math.round(totalParkedMinutes / durations.length) : 0,
|
||||||
|
medianParkedMinutes: median(durations),
|
||||||
|
},
|
||||||
|
series,
|
||||||
|
entriesByHour,
|
||||||
|
subscriptions: subStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, users, type Db } from "@parking/db";
|
import { eq, roles, users, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
TOKEN_TTL,
|
|
||||||
clearAuthCookies,
|
clearAuthCookies,
|
||||||
newCsrfToken,
|
newCsrfToken,
|
||||||
requireRole,
|
permissionsFor,
|
||||||
|
requireAuth,
|
||||||
setAuthCookies,
|
setAuthCookies,
|
||||||
} from "../auth.js";
|
} from "../auth.js";
|
||||||
|
|
||||||
@@ -17,6 +17,75 @@ interface LoginBody {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LANGS = ["sq", "en"] as const;
|
||||||
|
type Lang = (typeof LANGS)[number];
|
||||||
|
interface LanguageBody {
|
||||||
|
language: Lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEMES = ["dark", "light"] as const;
|
||||||
|
type Theme = (typeof THEMES)[number];
|
||||||
|
interface ThemeBody {
|
||||||
|
theme: Theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-service profile: a signed-in user edits their OWN display name + email. This is
|
||||||
|
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
|
||||||
|
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
|
||||||
|
// other account. "" clears a field (→ null). See wiki/entities/local-jwt-auth.md.
|
||||||
|
interface ProfileBody {
|
||||||
|
fullName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-service password change: the user proves they hold the CURRENT password before
|
||||||
|
// setting a new one — unlike the admin reset (users.ts), which sets it outright. This is
|
||||||
|
// why it lives here and not behind a permission: it's account-self-care, not admin power.
|
||||||
|
interface PasswordBody {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_PASSWORD = 8;
|
||||||
|
|
||||||
|
/** Trim a self-service profile string; "" (or whitespace) → null (clear the field).
|
||||||
|
* Returns undefined for an absent key so an update only touches what was sent. */
|
||||||
|
function cleanProfileField(v: string | null | undefined): string | null | undefined {
|
||||||
|
if (v === undefined) return undefined;
|
||||||
|
const trimmed = typeof v === "string" ? v.trim() : "";
|
||||||
|
return trimmed === "" ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session shape the SPA bootstraps from: identity + role + its permission
|
||||||
|
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||||
|
* permissions are the source of truth. */
|
||||||
|
function sessionView(
|
||||||
|
db: Db,
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
roleId: string;
|
||||||
|
language: string;
|
||||||
|
theme: string;
|
||||||
|
fullName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||||
|
const permissions = [...permissionsFor(user.roleId)];
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
roleId: user.roleId,
|
||||||
|
roleName: role?.name ?? user.roleId,
|
||||||
|
permissions,
|
||||||
|
language: user.language,
|
||||||
|
theme: user.theme,
|
||||||
|
fullName: user.fullName ?? null,
|
||||||
|
email: user.email ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
|
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
|
||||||
const { username, password } = req.body ?? {};
|
const { username, password } = req.body ?? {};
|
||||||
@@ -29,17 +98,26 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
// Always run a bcrypt compare to avoid leaking which usernames exist (timing).
|
// Always run a bcrypt compare to avoid leaking which usernames exist (timing).
|
||||||
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
||||||
const ok = await bcrypt.compare(password, hash);
|
const ok = await bcrypt.compare(password, hash);
|
||||||
if (!user || !ok) {
|
// A soft-deleted user (in the recycle bin) cannot log in — treat as invalid, with no
|
||||||
|
// distinct error so a deleted account isn't enumerable.
|
||||||
|
if (!user || !ok || user.deletedAt) {
|
||||||
return reply.code(401).send({ error: "invalid credentials" });
|
return reply.code(401).send({ error: "invalid credentials" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const csrf = newCsrfToken();
|
const csrf = newCsrfToken();
|
||||||
const token = await reply.jwtSign(
|
// No expiresIn: the token is valid until explicit logout (see auth.ts). The
|
||||||
{ sub: user.id, username: user.username, role: user.role, csrf },
|
// token carries roleId (not the permission list) — perms resolve per-request,
|
||||||
{ expiresIn: TOKEN_TTL },
|
// so a role edit applies immediately with no re-login.
|
||||||
);
|
const token = await reply.jwtSign({
|
||||||
|
sub: user.id,
|
||||||
|
username: user.username,
|
||||||
|
roleId: user.roleId,
|
||||||
|
csrf,
|
||||||
|
});
|
||||||
setAuthCookies(reply, token, csrf);
|
setAuthCookies(reply, token, csrf);
|
||||||
return { id: user.id, username: user.username, role: user.role };
|
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
||||||
|
// read from the DB, so changing it needs no token refresh.
|
||||||
|
return sessionView(db, user);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/logout", async (_req, reply) => {
|
app.post("/api/auth/logout", async (_req, reply) => {
|
||||||
@@ -47,13 +125,97 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Who am I — used by the SPA to bootstrap session state on load.
|
// Who am I — used by the SPA to bootstrap session state on load. Reads the live
|
||||||
|
// `language` preference from the DB (not the token).
|
||||||
app.get(
|
app.get(
|
||||||
"/api/auth/me",
|
"/api/auth/me",
|
||||||
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
|
{ preHandler: requireAuth },
|
||||||
async (req) => {
|
async (req, reply) => {
|
||||||
const { sub, username, role } = req.user;
|
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
|
||||||
return { id: sub, username, role };
|
if (!row) {
|
||||||
|
// The user was deleted while their cookie was still valid — clear it.
|
||||||
|
clearAuthCookies(reply);
|
||||||
|
return reply.code(401).send({ error: "session no longer valid" });
|
||||||
|
}
|
||||||
|
return sessionView(db, row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change MY own UI language preference (any signed-in user). Persisted to the
|
||||||
|
// users row so it's restored on the next login, from any booth. See i18n.md.
|
||||||
|
app.put<{ Body: LanguageBody }>(
|
||||||
|
"/api/auth/language",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (req, reply) => {
|
||||||
|
const language = req.body?.language;
|
||||||
|
if (!language || !LANGS.includes(language)) {
|
||||||
|
return reply.code(400).send({ error: `language must be one of: ${LANGS.join(", ")}` });
|
||||||
|
}
|
||||||
|
await db.update(users).set({ language }).where(eq(users.id, req.user.sub)).run();
|
||||||
|
return { language };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change MY own UI theme preference (any signed-in user). Persisted to the users
|
||||||
|
// row like `language`, so it's restored on the next login from any booth.
|
||||||
|
app.put<{ Body: ThemeBody }>(
|
||||||
|
"/api/auth/theme",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (req, reply) => {
|
||||||
|
const theme = req.body?.theme;
|
||||||
|
if (!theme || !THEMES.includes(theme)) {
|
||||||
|
return reply.code(400).send({ error: `theme must be one of: ${THEMES.join(", ")}` });
|
||||||
|
}
|
||||||
|
await db.update(users).set({ theme }).where(eq(users.id, req.user.sub)).run();
|
||||||
|
return { theme };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Edit MY own display name / email (any signed-in user; no permission needed — it only
|
||||||
|
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
|
||||||
|
app.put<{ Body: ProfileBody }>(
|
||||||
|
"/api/auth/profile",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (req, reply) => {
|
||||||
|
const fullName = cleanProfileField(req.body?.fullName);
|
||||||
|
const email = cleanProfileField(req.body?.email);
|
||||||
|
const patch: Record<string, string | null> = {};
|
||||||
|
if (fullName !== undefined) patch.fullName = fullName;
|
||||||
|
if (email !== undefined) patch.email = email;
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
return reply.code(400).send({ error: "nothing to update" });
|
||||||
|
}
|
||||||
|
await db.update(users).set(patch).where(eq(users.id, req.user.sub)).run();
|
||||||
|
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
|
||||||
|
if (!row) return reply.code(401).send({ error: "session no longer valid" });
|
||||||
|
return sessionView(db, row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change MY own password — must prove the CURRENT one first (defends against a walked-up,
|
||||||
|
// already-logged-in booth: a passerby can't silently re-key the account). New password
|
||||||
|
// >= MIN_PASSWORD. Distinct from the admin reset (users.ts), which needs no current pw.
|
||||||
|
app.put<{ Body: PasswordBody }>(
|
||||||
|
"/api/auth/password",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (req, reply) => {
|
||||||
|
const currentPassword = req.body?.currentPassword ?? "";
|
||||||
|
const newPassword = req.body?.newPassword ?? "";
|
||||||
|
if (newPassword.length < MIN_PASSWORD) {
|
||||||
|
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
|
||||||
|
}
|
||||||
|
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
|
||||||
|
if (!row) {
|
||||||
|
clearAuthCookies(reply);
|
||||||
|
return reply.code(401).send({ error: "session no longer valid" });
|
||||||
|
}
|
||||||
|
const ok = await bcrypt.compare(currentPassword, row.passwordHash);
|
||||||
|
if (!ok) {
|
||||||
|
return reply.code(403).send({ error: "current password is incorrect" });
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
await db.update(users).set({ passwordHash }).where(eq(users.id, req.user.sub)).run();
|
||||||
|
return { ok: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { DeviceMonitor } from "../device-monitor.js";
|
||||||
|
|
||||||
|
// Unified device-status snapshot for the booth footer. The DeviceMonitor polls all
|
||||||
|
// configured devices (relays/readers/cameras via healthCheck, printers via their
|
||||||
|
// rich readStatus) in the background; this exposes its cache. Live updates ride the
|
||||||
|
// booth WebSocket (kind:"device-status") — this REST route is the initial load /
|
||||||
|
// fallback. Any authenticated role may read (operational, not a setup action).
|
||||||
|
// See wiki/concepts/device-status-monitoring.md, booth-console.md.
|
||||||
|
|
||||||
|
export async function deviceStatusRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
monitor: DeviceMonitor,
|
||||||
|
): Promise<void> {
|
||||||
|
const guard = requirePermission("device:read");
|
||||||
|
|
||||||
|
app.get("/api/devices/status", { preHandler: guard }, async () => ({
|
||||||
|
devices: monitor.snapshot(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { eq, laneDevices, type Db } from "@parking/db";
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
import { deviceEvents } from "../device-events.js";
|
import { deviceEvents } from "../device-events.js";
|
||||||
import { verifyDigest } from "../digest-auth.js";
|
import { verifyDigest } from "../digest-auth.js";
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export async function deviceRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
|
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
|
||||||
const { deviceId, n, edge } = req.params;
|
const { deviceId, n, edge } = req.params;
|
||||||
|
|
||||||
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
const cfg = row?.config as DingtianDeviceConfig | undefined;
|
const cfg = row?.config as DingtianDeviceConfig | undefined;
|
||||||
|
|
||||||
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
|
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { desc, events, type Db } from "@parking/db";
|
import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db";
|
||||||
import { requireRole } from "../auth.js";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { enrichEvents } from "../event-enrich.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
|
|
||||||
// Read access to the append-only signed event log. NO write/update/delete routes
|
// Read access to the append-only signed event log. NO write/update/delete routes
|
||||||
@@ -13,17 +15,38 @@ export async function eventRoutes(
|
|||||||
db: Db,
|
db: Db,
|
||||||
eventLog: EventLog,
|
eventLog: EventLog,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Any authenticated role may read the log (it's the audit trail).
|
// Reading the log (the audit trail).
|
||||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
const guard = requirePermission("event:read");
|
||||||
|
|
||||||
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
||||||
app.get<{ Querystring: { limit?: string } }>(
|
// Optional `since` (ISO) scopes to events at/after that instant — the booth passes
|
||||||
|
// the current shift's start so the live feed shows ONLY this shift's activity. An
|
||||||
|
// optional `until` (ISO) closes the upper bound — the shift-history screen passes a
|
||||||
|
// selected shift's [start, end] to show just that shift's signed activity log.
|
||||||
|
// (logs are per-shift, not all history). See wiki/concepts/shift.md.
|
||||||
|
app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>(
|
||||||
"/api/events",
|
"/api/events",
|
||||||
{ preHandler: guard },
|
{ preHandler: guard },
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||||
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all();
|
const since = (req.query.since ?? "").trim();
|
||||||
return { events: rows };
|
const until = (req.query.until ?? "").trim();
|
||||||
|
const bounds = [
|
||||||
|
since ? gte(ledgerEvents.occurredAt, since) : undefined,
|
||||||
|
until ? lte(ledgerEvents.occurredAt, until) : undefined,
|
||||||
|
].filter(Boolean);
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(bounds.length ? and(...bounds) : undefined)
|
||||||
|
.orderBy(desc(ledgerEvents.index))
|
||||||
|
.limit(limit)
|
||||||
|
.all();
|
||||||
|
// Attach read-time display fields (subscriber name, advisory plate) without
|
||||||
|
// touching the signed record. One plate scan for the whole page (enrichEvents).
|
||||||
|
// The cast bridges the Drizzle row to the shared LedgerEvent.
|
||||||
|
const events = enrichEvents(db, rows as unknown as LedgerEvent[]);
|
||||||
|
return { events };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -32,7 +55,7 @@ export async function eventRoutes(
|
|||||||
// reconciliation job / "is the log intact?" check calls.
|
// reconciliation job / "is the log intact?" check calls.
|
||||||
app.get(
|
app.get(
|
||||||
"/api/events/verify",
|
"/api/events/verify",
|
||||||
{ preHandler: requireRole("admin") },
|
{ preHandler: requirePermission("event:read") },
|
||||||
async () => eventLog.verifyChain(),
|
async () => eventLog.verifyChain(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import Fastify, { type FastifyInstance as RawFastify } from "fastify";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { hikvisionAlarmRoutes } from "./hikvision-alarm.js";
|
||||||
|
import type { AnprBridge } from "../anpr-entry.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle-
|
||||||
|
// detection POST from the camera's configured IP is accepted, summarized (eventType /
|
||||||
|
// target / plate pulled out of the XML), and recorded verbatim as a kind:"alarm"
|
||||||
|
// device_event — while a wrong source IP or a push-disabled device is refused.
|
||||||
|
|
||||||
|
const CAM_IP = "10.0.10.121";
|
||||||
|
const CAM_ID = "cam-1";
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedHikCamera(cfg: Record<string, unknown> = {}) {
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CAM_ID,
|
||||||
|
category: "camera",
|
||||||
|
driverId: "hikvision",
|
||||||
|
config: { host: CAM_IP, alarmPushEnabled: true, ...cfg },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A representative Hikvision smart-event POST body (vehicle target). The real firmware
|
||||||
|
* payload may differ; the endpoint stores it verbatim regardless — this asserts the
|
||||||
|
* best-effort summary extraction over a plausible shape. */
|
||||||
|
const VEHICLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<EventNotificationAlert version="2.0" xmlns="http://www.hikvision.com/ver20/XMLSchema">
|
||||||
|
<ipAddress>10.0.10.121</ipAddress>
|
||||||
|
<channelID>1</channelID>
|
||||||
|
<dateTime>2026-06-22T10:15:30+02:00</dateTime>
|
||||||
|
<eventType>fielddetection</eventType>
|
||||||
|
<eventState>active</eventState>
|
||||||
|
<DetectionRegionList>
|
||||||
|
<DetectionRegionEntry><detectionTarget>vehicle</detectionTarget></DetectionRegionEntry>
|
||||||
|
</DetectionRegionList>
|
||||||
|
</EventNotificationAlert>`;
|
||||||
|
|
||||||
|
function alarmEvents(): { detail: Record<string, unknown> }[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(deviceEventsTable)
|
||||||
|
.where(and(eq(deviceEventsTable.deviceId, CAM_ID), eq(deviceEventsTable.kind, "alarm")))
|
||||||
|
.all() as { detail: Record<string, unknown> }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every recorded push for a device — accepted (kind:"alarm") AND rejected
|
||||||
|
* (kind:"alarm-rejected"). */
|
||||||
|
function allRecorded(deviceId: string): { kind: string; detail: Record<string, unknown> }[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(deviceEventsTable)
|
||||||
|
.where(and(eq(deviceEventsTable.deviceId, deviceId), inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"])))
|
||||||
|
.all() as { kind: string; detail: Record<string, unknown> }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Hikvision Alarm Server push", () => {
|
||||||
|
it("accepts a vehicle event from the camera IP and records it with a parsed summary", async () => {
|
||||||
|
seedHikCamera();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||||
|
headers: { "content-type": "application/xml" },
|
||||||
|
payload: VEHICLE_XML,
|
||||||
|
remoteAddress: CAM_IP,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const events = alarmEvents();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
const d = events[0]!.detail;
|
||||||
|
expect(d.source).toBe("hikvision-alarm-server");
|
||||||
|
expect(d.eventType).toBe("fielddetection");
|
||||||
|
expect(d.target).toBe("vehicle");
|
||||||
|
expect(d.ip).toBe(CAM_IP);
|
||||||
|
// The raw body is kept verbatim for inspection.
|
||||||
|
expect(String(d.rawHead)).toContain("EventNotificationAlert");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the legacy string \"true\" for alarmPushEnabled (setup form quirk)", async () => {
|
||||||
|
// The setup checkbox historically saved a STRING "true" instead of a boolean; the
|
||||||
|
// guard must coerce it, not silently reject a feature the admin enabled.
|
||||||
|
seedHikCamera({ alarmPushEnabled: "true" });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||||
|
headers: { "content-type": "application/xml" },
|
||||||
|
payload: VEHICLE_XML,
|
||||||
|
remoteAddress: CAM_IP,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(alarmEvents()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pulls a plate out of an ANPR-style payload when present", async () => {
|
||||||
|
seedHikCamera();
|
||||||
|
const anpr = `<EventNotificationAlert><eventType>ANPR</eventType>
|
||||||
|
<ANPR><plateNumber>AA123BB</plateNumber></ANPR></EventNotificationAlert>`;
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||||
|
headers: { "content-type": "application/xml" },
|
||||||
|
payload: anpr,
|
||||||
|
remoteAddress: CAM_IP,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(alarmEvents()[0]!.detail.plate).toBe("AA123BB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an unknown/JSON content-type as raw bytes (discovery-first)", async () => {
|
||||||
|
seedHikCamera();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||||
|
headers: { "content-type": "application/octet-stream" },
|
||||||
|
payload: Buffer.from('{"eventType":"vehicleDetection"}'),
|
||||||
|
remoteAddress: CAM_IP,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(alarmEvents()[0]!.detail.eventType).toBe("vehicleDetection");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a push from ANY source IP when skipSourceIpCheck is set (WSL rewrites it)", async () => {
|
||||||
|
// WSL mirrored mode rewrites the inbound source to the host's own IP, so the camera's
|
||||||
|
// real IP never survives and a strict check rejects every push. With the opt-out, a
|
||||||
|
// push from the 'wrong' IP is accepted.
|
||||||
|
seedHikCamera({ skipSourceIpCheck: true });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||||
|
headers: { "content-type": "application/xml" },
|
||||||
|
payload: VEHICLE_XML,
|
||||||
|
remoteAddress: "10.0.10.203", // the rewritten host IP, NOT the camera's
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(alarmEvents()).toHaveLength(1);
|
||||||
|
expect(alarmEvents()[0]!.detail.target).toBe("vehicle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a push from a DIFFERENT source IP (404, nothing recorded)", async () => {
|
||||||
|
seedHikCamera();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||||
|
headers: { "content-type": "application/xml" },
|
||||||
|
payload: VEHICLE_XML,
|
||||||
|
remoteAddress: "10.0.10.200", // not the camera
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
// No ACCEPTED alarm...
|
||||||
|
expect(alarmEvents()).toHaveLength(0);
|
||||||
|
// ...but the rejection IS recorded (with the reason), so "nothing arrived" is never
|
||||||
|
// ambiguous — you can see it came in and why it was refused.
|
||||||
|
const recorded = allRecorded(CAM_ID);
|
||||||
|
expect(recorded).toHaveLength(1);
|
||||||
|
expect(recorded[0]!.kind).toBe("alarm-rejected");
|
||||||
|
expect(String(recorded[0]!.detail.reason)).toMatch(/source IP/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when alarm push is disabled on the device", async () => {
|
||||||
|
seedHikCamera({ alarmPushEnabled: false });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||||
|
headers: { "content-type": "application/xml" },
|
||||||
|
payload: VEHICLE_XML,
|
||||||
|
remoteAddress: CAM_IP,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown device id", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/devices/hikvision/nope/event`,
|
||||||
|
headers: { "content-type": "application/xml" },
|
||||||
|
payload: VEHICLE_XML,
|
||||||
|
remoteAddress: CAM_IP,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.json().reason).toMatch(/unknown device/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/devices/hikvision/alarms lists accepted AND rejected pushes, newest first", async () => {
|
||||||
|
seedHikCamera();
|
||||||
|
// One accepted (right IP) + one rejected (wrong IP).
|
||||||
|
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: CAM_IP });
|
||||||
|
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: "10.0.10.200" });
|
||||||
|
|
||||||
|
const { username, password } = await seedUser(db, { username: "admin1", roleId: "admin" });
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms", headers: { cookie } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.count).toBe(2);
|
||||||
|
// Both accepted and rejected appear, with the accepted/reason flags.
|
||||||
|
expect(body.alarms.some((a: { accepted: boolean }) => a.accepted === true)).toBe(true);
|
||||||
|
const rejected = body.alarms.find((a: { accepted: boolean }) => a.accepted === false);
|
||||||
|
expect(rejected.reason).toMatch(/source IP/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the alarms read endpoint is gated (device:read) — 401 without a session", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The ANPR bridge is handed each vehicle detection (fire-and-forget). We register the
|
||||||
|
// routes on a bare instance with a SPY bridge to assert exactly when it's invoked —
|
||||||
|
// only on a vehicle target that isn't `inactive`. (The bridge's own logic is covered in
|
||||||
|
// anpr-entry.test.ts.)
|
||||||
|
describe("Hikvision Alarm Server → ANPR bridge wiring", () => {
|
||||||
|
let rawApp: RawFastify;
|
||||||
|
let rawDb: Db;
|
||||||
|
let rawClose: () => void;
|
||||||
|
let onVehicleDetected: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
rawDb = t.db;
|
||||||
|
rawClose = t.close;
|
||||||
|
onVehicleDetected = vi.fn(async () => {});
|
||||||
|
const bridge = { onVehicleDetected } as unknown as AnprBridge;
|
||||||
|
rawApp = Fastify();
|
||||||
|
await hikvisionAlarmRoutes(rawApp, rawDb, undefined, bridge);
|
||||||
|
await rawApp.ready();
|
||||||
|
rawDb.insert(devices).values({
|
||||||
|
id: CAM_ID,
|
||||||
|
category: "camera",
|
||||||
|
driverId: "hikvision",
|
||||||
|
config: { host: CAM_IP, alarmPushEnabled: true },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await rawApp.close();
|
||||||
|
rawClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function post(payload: string) {
|
||||||
|
return rawApp.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/devices/hikvision/${CAM_ID}/event`,
|
||||||
|
headers: { "content-type": "application/xml" },
|
||||||
|
payload,
|
||||||
|
remoteAddress: CAM_IP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("hands a vehicle (active) detection to the bridge", async () => {
|
||||||
|
const res = await post(VEHICLE_XML);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(onVehicleDetected).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onVehicleDetected).toHaveBeenCalledWith(CAM_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT call the bridge for a human target", async () => {
|
||||||
|
const human = VEHICLE_XML.replace("vehicle", "human");
|
||||||
|
await post(human);
|
||||||
|
expect(onVehicleDetected).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT call the bridge on an `inactive` (leave) vehicle event", async () => {
|
||||||
|
const leave = VEHICLE_XML.replace("<eventState>active</eventState>", "<eventState>inactive</eventState>");
|
||||||
|
await post(leave);
|
||||||
|
expect(onVehicleDetected).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { desc, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
|
import { deviceEvents } from "../device-events.js";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { verifyDigest } from "../digest-auth.js";
|
||||||
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
|
import type { AnprBridge } from "../anpr-entry.js";
|
||||||
|
|
||||||
|
// Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event →
|
||||||
|
// Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm
|
||||||
|
// Settings → Alarm Server) HTTP-POST an EventNotificationAlert to a URL we host every
|
||||||
|
// time the chosen target is detected. This is the same machine-call pattern as the
|
||||||
|
// Dingtian Input Link push (routes/devices.ts): source-IP guarded, NOT behind the SPA
|
||||||
|
// cookie/CSRF.
|
||||||
|
//
|
||||||
|
// DISCOVERY-FIRST. Hik's push format varies by model/firmware (event XML, or multipart
|
||||||
|
// with an attached JPEG, or — on some ANPR units — an <ANPR>/<plateNumber> block). So
|
||||||
|
// this endpoint is deliberately PERMISSIVE: it accepts ANY content-type as raw bytes,
|
||||||
|
// records the verbatim body as a `kind:"alarm"` device_event, and best-effort extracts a
|
||||||
|
// summary (eventType / target / plate). The goal of this first cut is to SEE exactly what
|
||||||
|
// a given camera sends — inspect via GET /api/events or the logs — before we wire it into
|
||||||
|
// the read bus / a snapshot trigger. It never opens a barrier (a plate read is advisory,
|
||||||
|
// never the sole reason; see wiki/concepts/append-only-event-chain.md).
|
||||||
|
//
|
||||||
|
// See wiki/entities/lpr-camera.md, wiki/concepts/device-input-flow.md.
|
||||||
|
|
||||||
|
interface HikDeviceConfig {
|
||||||
|
host?: string;
|
||||||
|
alarmPushEnabled?: boolean | string | number;
|
||||||
|
pushUser?: string;
|
||||||
|
pushPassword?: string;
|
||||||
|
/** Skip the source-IP guard for this device's pushes. The source IP is the primary
|
||||||
|
* LAN guard, but it's UNRELIABLE in some environments — notably WSL mirrored mode,
|
||||||
|
* which rewrites an inbound packet's source to the host's OWN address, so the camera's
|
||||||
|
* real IP never survives and a strict check rejects every push. When pushUser/
|
||||||
|
* pushPassword (Digest) are set, that auth is the real guard and source-IP adds little;
|
||||||
|
* this flag lets a deployment opt out. The signed ledger remains the anti-fraud truth. */
|
||||||
|
skipSourceIpCheck?: boolean | string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coerce a device-config flag to a boolean. The config is loosely-typed JSON from the
|
||||||
|
* setup form, which has historically stored a checkbox as the STRING "true" (a form-
|
||||||
|
* serialization quirk) — so accept true / "true" / 1 / "1" / "yes" / "on", reject the
|
||||||
|
* rest. Being lenient here means a stray "true" never silently disables a real feature. */
|
||||||
|
function isOn(v: unknown): boolean {
|
||||||
|
if (v === true) return true;
|
||||||
|
if (typeof v === "number") return v === 1;
|
||||||
|
if (typeof v === "string") return /^(1|true|yes|on)$/i.test(v.trim());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A best-effort summary pulled out of the raw push body (XML or JSON), for the device
|
||||||
|
* event detail + the log line. Absent fields just mean "not found in this firmware's
|
||||||
|
* payload" — the raw body is always stored so nothing is lost. */
|
||||||
|
interface AlarmSummary {
|
||||||
|
eventType?: string;
|
||||||
|
/** `active` (target entered the region) | `inactive` (target left). The edge that
|
||||||
|
* drives lane busy/free — see [[lpr-camera]] / hikvision-alarm.ts. */
|
||||||
|
eventState?: string;
|
||||||
|
target?: string;
|
||||||
|
plate?: string;
|
||||||
|
dateTime?: string;
|
||||||
|
channelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientIp(req: FastifyRequest): string {
|
||||||
|
return req.ip.replace(/^::ffff:/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First capture group of `re` in `s`, trimmed, or undefined. */
|
||||||
|
function pick(s: string, re: RegExp): string | undefined {
|
||||||
|
const m = re.exec(s);
|
||||||
|
return m?.[1]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort summary extraction. Hikvision event XML uses tags like <eventType>,
|
||||||
|
* <dateTime>, <channelID>; smart/ANPR events add target/plate tags whose exact names
|
||||||
|
* vary by firmware (<detectionTarget>, <targetType>, <plateNumber>, <licensePlate>).
|
||||||
|
* We probe several spellings; whatever doesn't match is simply absent. JSON bodies are
|
||||||
|
* scanned for the same keys.
|
||||||
|
*/
|
||||||
|
function summarize(body: string): AlarmSummary {
|
||||||
|
return {
|
||||||
|
eventType: pick(body, /<eventType>([^<]+)<\/eventType>/i) ?? pick(body, /"eventType"\s*:\s*"([^"]+)"/i),
|
||||||
|
eventState: pick(body, /<eventState>([^<]+)<\/eventState>/i) ?? pick(body, /"eventState"\s*:\s*"([^"]+)"/i),
|
||||||
|
target:
|
||||||
|
pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ??
|
||||||
|
pick(body, /"(?:detectionTarget|targetType|objectType)"\s*:\s*"([^"]+)"/i),
|
||||||
|
plate:
|
||||||
|
pick(body, /<(?:plateNumber|licensePlate|plateNo)>([^<]+)<\//i) ??
|
||||||
|
pick(body, /"(?:plateNumber|licensePlate|plateNo)"\s*:\s*"([^"]+)"/i),
|
||||||
|
dateTime: pick(body, /<dateTime>([^<]+)<\/dateTime>/i),
|
||||||
|
channelId: pick(body, /<channelID>([^<]+)<\/channelID>/i) ?? pick(body, /<channelId>([^<]+)<\/channelId>/i),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hikvisionAlarmRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
laneStatus?: LaneStatus,
|
||||||
|
anprBridge?: AnprBridge,
|
||||||
|
): Promise<void> {
|
||||||
|
// Accept ANY content-type as a raw Buffer (the camera may POST application/xml,
|
||||||
|
// multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415
|
||||||
|
// or empty these — we want the bytes verbatim. Scoped to THIS app instance via a
|
||||||
|
// wildcard parser; a 10 MB cap covers an event + an attached frame.
|
||||||
|
app.addContentTypeParser("*", { parseAs: "buffer", bodyLimit: 10 * 1024 * 1024 }, (_req, body, done) => {
|
||||||
|
done(null, body);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Record EVERY push (accepted or rejected) as a device_event so the read endpoint /
|
||||||
|
* DB always shows that SOMETHING arrived — the key fix: a rejected push used to log a
|
||||||
|
* warning and vanish, so "no event" was ambiguous (never sent? or sent + rejected?). */
|
||||||
|
function record(args: {
|
||||||
|
deviceId: string;
|
||||||
|
method: string;
|
||||||
|
accepted: boolean;
|
||||||
|
reason?: string;
|
||||||
|
ip: string;
|
||||||
|
contentType: string;
|
||||||
|
raw: Buffer;
|
||||||
|
summary: AlarmSummary;
|
||||||
|
}): void {
|
||||||
|
try {
|
||||||
|
db.insert(deviceEventsTable)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId: args.deviceId,
|
||||||
|
category: "camera",
|
||||||
|
kind: args.accepted ? "alarm" : "alarm-rejected",
|
||||||
|
detail: {
|
||||||
|
source: "hikvision-alarm-server",
|
||||||
|
accepted: args.accepted,
|
||||||
|
method: args.method,
|
||||||
|
...(args.reason ? { reason: args.reason } : {}),
|
||||||
|
ip: args.ip,
|
||||||
|
contentType: args.contentType,
|
||||||
|
bytes: args.raw.length,
|
||||||
|
...args.summary,
|
||||||
|
// Readable head verbatim (the XML part); truncated to keep the row small.
|
||||||
|
rawHead: args.raw.toString("utf8").slice(0, 8000),
|
||||||
|
},
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(`hik-alarm device-event insert failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => {
|
||||||
|
const { deviceId } = req.params;
|
||||||
|
const method = req.method;
|
||||||
|
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
|
const cfg = row?.config as HikDeviceConfig | undefined;
|
||||||
|
const ip = clientIp(req);
|
||||||
|
const contentType = String(req.headers["content-type"] ?? "");
|
||||||
|
const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from("");
|
||||||
|
const summary = summarize(raw.toString("utf8"));
|
||||||
|
// Log EVERY hit immediately (method + ip + size), before any guard — so even a probe
|
||||||
|
// that gets rejected is visible in the dev log the instant it arrives.
|
||||||
|
app.log.info(`[hik-alarm:${deviceId}] HIT ${method} from ${ip} (${contentType || "no-ct"} ${raw.length}B)`);
|
||||||
|
|
||||||
|
// Guard: must be a known hikvision device with alarm-push enabled, posting from its
|
||||||
|
// configured host IP. Source-IP is the primary guard on the LAN (like the Dingtian).
|
||||||
|
// On rejection we STILL record it (with the precise reason) so a push that reached us
|
||||||
|
// never silently disappears — that's what makes "is it coming?" answerable.
|
||||||
|
// The source-IP check is skipped when the device opts out (skipSourceIpCheck) — needed
|
||||||
|
// where the network rewrites the inbound source IP (e.g. WSL mirrored mode rewrites it
|
||||||
|
// to the host's own address), so a strict match can never pass. Digest auth (when set)
|
||||||
|
// and the signed ledger remain the real guards. See HikDeviceConfig.skipSourceIpCheck.
|
||||||
|
const skipIp = isOn(cfg?.skipSourceIpCheck);
|
||||||
|
let reason: string | null = null;
|
||||||
|
if (!row || !cfg) reason = "unknown device id";
|
||||||
|
else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`;
|
||||||
|
else if (!isOn(cfg.alarmPushEnabled)) reason = "alarm push not enabled on this device (tick it in Setup)";
|
||||||
|
else if (!cfg.host) reason = "device has no host IP configured";
|
||||||
|
else if (!skipIp && ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host} (set skipSourceIpCheck if the network rewrites it, e.g. WSL)`;
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
app.log.warn(`[hik-alarm:${deviceId}] REJECTED ${method} from ${ip} (${contentType} ${raw.length}B): ${reason}`);
|
||||||
|
record({ deviceId, method, accepted: false, reason, ip, contentType, raw, summary });
|
||||||
|
return reply.code(404).send({ error: "not found", reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional Digest auth — only when the admin configured push creds (some firmware
|
||||||
|
// can't authenticate the Alarm Server call; then we rely on source-IP alone).
|
||||||
|
if (cfg!.pushUser && cfg!.pushPassword) {
|
||||||
|
if (!verifyDigest(req, reply, { user: cfg!.pushUser, password: cfg!.pushPassword })) {
|
||||||
|
record({ deviceId, method, accepted: false, reason: "digest auth failed/challenge", ip, contentType, raw, summary });
|
||||||
|
return; // 401 challenge already sent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loud log so the operator can SEE the payload during testing.
|
||||||
|
app.log.info(
|
||||||
|
`[hik-alarm:${deviceId}] ACCEPTED ${method} ${ip} ${contentType} ${raw.length}B ` +
|
||||||
|
`event=${summary.eventType ?? "?"}/${summary.eventState ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
|
||||||
|
);
|
||||||
|
record({ deviceId, method, accepted: true, ip, contentType, raw, summary });
|
||||||
|
|
||||||
|
// Lane busy/free: a VEHICLE detection marks the camera's bound lane busy (advisory,
|
||||||
|
// for the booth barrier lights). Only on a vehicle target that's `active` — an
|
||||||
|
// `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a
|
||||||
|
// timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent.
|
||||||
|
const isVehicleActive =
|
||||||
|
(summary.target ?? "").toLowerCase() === "vehicle" &&
|
||||||
|
(summary.eventState ?? "active").toLowerCase() !== "inactive";
|
||||||
|
if (laneStatus && isVehicleActive) {
|
||||||
|
laneStatus.vehicleDetected(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANPR BRIDGE: on a vehicle detection, if this camera opts into ANPR (config.anpr),
|
||||||
|
// pull a snapshot → read the plate → if it matches a SUBSCRIBER, emit a plate read
|
||||||
|
// onto the bus, which the existing gated SubscriptionFlow turns into an entry/exit +
|
||||||
|
// barrier open. Fire-and-forget — NEVER awaited on the 200 path (the camera must get
|
||||||
|
// a prompt ack or it retry-storms), and fail-soft inside the bridge. See anpr-entry.ts.
|
||||||
|
if (anprBridge && isVehicleActive) {
|
||||||
|
void anprBridge.onVehicleDetected(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface on the in-process bus as a generic breadcrumb so a live listener can show
|
||||||
|
// "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving
|
||||||
|
// entry/exit) is the deliberate next step once we know the real payload.
|
||||||
|
deviceEvents.emitInput({ driverId: "hikvision", deviceId, input: 0, edge: "on", at: new Date().toISOString(), source: "push" });
|
||||||
|
|
||||||
|
// 200 so the camera considers the alarm delivered and doesn't retry-storm.
|
||||||
|
return reply.code(200).send({ ok: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for EVERY method on the event path. The camera (and its "Test" button) may
|
||||||
|
// probe with GET/HEAD/OPTIONS/PUT, not just POST — and a method we don't register gets
|
||||||
|
// Fastify's generic 404, which the camera reads as "service available" while our
|
||||||
|
// handler never runs (so nothing is recorded). Registering all methods means ANYTHING
|
||||||
|
// that hits this URL reaches `handle` and is captured (the method is logged + stored),
|
||||||
|
// so we can finally SEE exactly what the camera sends. See wiki/entities/lpr-camera.md.
|
||||||
|
// (HEAD is auto-added by Fastify alongside GET — don't register it explicitly.)
|
||||||
|
for (const method of ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"] as const) {
|
||||||
|
app.route({ method, url: "/api/devices/hikvision/:deviceId/event", handler: handle });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read endpoint: the recent alarm pushes (accepted AND rejected), newest first — so you
|
||||||
|
// can SEE in the browser whether events are arriving and why any were refused, instead
|
||||||
|
// of grepping the dev log or querying SQLite. Gated device:read (admin device view).
|
||||||
|
app.get<{ Querystring: { limit?: string } }>(
|
||||||
|
"/api/devices/hikvision/alarms",
|
||||||
|
{ preHandler: requirePermission("device:read") },
|
||||||
|
async (req) => {
|
||||||
|
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(deviceEventsTable)
|
||||||
|
.where(inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"]))
|
||||||
|
.orderBy(desc(deviceEventsTable.occurredAt))
|
||||||
|
.limit(limit)
|
||||||
|
.all();
|
||||||
|
const alarms = rows.map((r) => {
|
||||||
|
const d = (r.detail ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
at: r.occurredAt,
|
||||||
|
deviceId: r.deviceId,
|
||||||
|
accepted: d.accepted === true,
|
||||||
|
method: (d.method as string) ?? null,
|
||||||
|
reason: (d.reason as string) ?? null,
|
||||||
|
ip: (d.ip as string) ?? null,
|
||||||
|
contentType: (d.contentType as string) ?? null,
|
||||||
|
bytes: (d.bytes as number) ?? 0,
|
||||||
|
eventType: (d.eventType as string) ?? null,
|
||||||
|
eventState: (d.eventState as string) ?? null,
|
||||||
|
target: (d.target as string) ?? null,
|
||||||
|
plate: (d.plate as string) ?? null,
|
||||||
|
rawHead: (d.rawHead as string) ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { count: alarms.length, alarms };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { AppLogRecord, ClientLogInput, LogLevel } from "@parking/shared";
|
||||||
|
import { requireAuth, requirePermission } from "../auth.js";
|
||||||
|
import type { LogService } from "../log-service.js";
|
||||||
|
|
||||||
|
// Application/diagnostic logs (app_logs) — see wiki/concepts/app-logs.md. Two ends:
|
||||||
|
// - POST /api/logs : the FRONTEND ships its errors here (failed requests, uncaught
|
||||||
|
// exceptions). Any signed-in user may write (it's their own
|
||||||
|
// browser's diagnostics); CSRF still applies (mutation).
|
||||||
|
// - GET /api/logs : read the store — gated by `log:read` (admin/diagnostic role).
|
||||||
|
// Writes go through the shared LogService (bounded, best-effort, reentrancy-guarded);
|
||||||
|
// the DB sink for BACKEND warn+ is wired at the pino stream, not here.
|
||||||
|
|
||||||
|
const LEVELS: ReadonlySet<string> = new Set(["trace", "debug", "info", "warn", "error", "fatal"]);
|
||||||
|
|
||||||
|
/** Cap a single ingest batch so a misbehaving client can't flood the store. */
|
||||||
|
const MAX_BATCH = 50;
|
||||||
|
|
||||||
|
function isValidEntry(e: unknown): e is ClientLogInput {
|
||||||
|
if (!e || typeof e !== "object") return false;
|
||||||
|
const o = e as Record<string, unknown>;
|
||||||
|
return typeof o.message === "string" && typeof o.level === "string" && LEVELS.has(o.level);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logRoutes(app: FastifyInstance, logService: LogService): Promise<void> {
|
||||||
|
// INGEST — accept one entry or a small batch ({ entries: [...] }). Returns 204.
|
||||||
|
// Deliberately tolerant: it never 4xx's on a malformed entry (a client erroring
|
||||||
|
// while reporting an error shouldn't get a second error) — invalid items are skipped.
|
||||||
|
app.post<{ Body: ClientLogInput | { entries?: unknown[] } }>(
|
||||||
|
"/api/logs",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = req.body as ClientLogInput | { entries?: unknown[] };
|
||||||
|
const raw = Array.isArray((body as { entries?: unknown[] }).entries)
|
||||||
|
? (body as { entries: unknown[] }).entries
|
||||||
|
: [body];
|
||||||
|
const userId = req.user?.sub ?? null;
|
||||||
|
const userAgent = req.headers["user-agent"] ?? null;
|
||||||
|
for (const entry of raw.slice(0, MAX_BATCH)) {
|
||||||
|
if (!isValidEntry(entry)) continue;
|
||||||
|
logService.recordClient(entry, { userId, userAgent });
|
||||||
|
}
|
||||||
|
reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// READ — newest first, with optional level/source/since filters + a limit. The
|
||||||
|
// booth Logs viewer calls this. Gated by log:read.
|
||||||
|
app.get<{ Querystring: { limit?: string; level?: string; source?: string; since?: string } }>(
|
||||||
|
"/api/logs",
|
||||||
|
{ preHandler: requirePermission("log:read") },
|
||||||
|
async (req): Promise<{ logs: AppLogRecord[] }> => {
|
||||||
|
const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 2000);
|
||||||
|
const level = (req.query.level ?? "").trim();
|
||||||
|
const source = (req.query.source ?? "").trim();
|
||||||
|
const since = (req.query.since ?? "").trim();
|
||||||
|
const logs = logService.query({
|
||||||
|
limit,
|
||||||
|
level: LEVELS.has(level) ? (level as LogLevel) : undefined,
|
||||||
|
source: source === "frontend" || source === "backend" ? source : undefined,
|
||||||
|
since: since || undefined,
|
||||||
|
});
|
||||||
|
return { logs };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { NoPrinterAvailableError } from "@parking/devices";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import {
|
||||||
|
NoOpenSessionError,
|
||||||
|
NoTariffError,
|
||||||
|
type PayStation,
|
||||||
|
} from "../pay-station.js";
|
||||||
|
import type { ExitFlow } from "../exit-flow.js";
|
||||||
|
import type { VoidFlow } from "../void-flow.js";
|
||||||
|
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
||||||
|
import { printPaymentReceipt } from "../booth-print.js";
|
||||||
|
|
||||||
|
// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
|
||||||
|
// when the booth is at/near the exit — open the barrier. The payment becomes a
|
||||||
|
// signed ledger event; PCI scope stays OUT of the app (card capture is a standalone
|
||||||
|
// P2PE terminal; `tender` just records cash vs. card). The booth exit reuses the
|
||||||
|
// SAME validation as the reader path — no booth-only bypass admits an unpaid car.
|
||||||
|
// See wiki/concepts/tariff.md, parking-session.md, booth-exit-flow.md, bom.md.
|
||||||
|
|
||||||
|
interface QuoteQuery {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
interface PayBody {
|
||||||
|
identity: string;
|
||||||
|
tender: "cash" | "card";
|
||||||
|
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
|
||||||
|
overrideMinor?: number;
|
||||||
|
}
|
||||||
|
interface ExitBody {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
interface VoucherBody {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
interface ReceiptBody {
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
interface VoidBody {
|
||||||
|
identity: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function payRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
payStation: PayStation,
|
||||||
|
exitFlow: ExitFlow,
|
||||||
|
shift: ShiftService,
|
||||||
|
voidFlow: VoidFlow,
|
||||||
|
): Promise<void> {
|
||||||
|
// Reads (lookup, active sessions, quote) need session/payment read; the booth
|
||||||
|
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
|
||||||
|
// single guard covers the whole booth flow — anyone who takes payment also reads
|
||||||
|
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
|
||||||
|
const guard = requirePermission("payment:create");
|
||||||
|
const readGuard = requirePermission("session:read");
|
||||||
|
const voidGuard = requirePermission("event:void");
|
||||||
|
|
||||||
|
// Money-path gate: a shift must be open site-wide before any payment/exit/voucher/
|
||||||
|
// re-open is processed, so every taking is attributed to a shift (one operator's
|
||||||
|
// accountability period). Read-only lookups (session/active/quote) stay ungated so
|
||||||
|
// the modal can still DISPLAY the session and prompt the operator to open a shift.
|
||||||
|
// Returns 409 { error, code: "no_shift" } so the UI can show the "open a shift"
|
||||||
|
// prompt rather than a generic failure. See wiki/concepts/shift.md.
|
||||||
|
const requireShift = async (
|
||||||
|
_req: import("fastify").FastifyRequest,
|
||||||
|
reply: import("fastify").FastifyReply,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
shift.requireOpenShift();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NoShiftOpenError) {
|
||||||
|
return reply.code(409).send({ error: err.message, code: "no_shift" });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Active sessions for the booth list: still-open OR exited-but-within-grace
|
||||||
|
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until
|
||||||
|
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
|
||||||
|
app.get("/api/sessions/active", { preHandler: readGuard }, async () => ({
|
||||||
|
sessions: payStation.activeSessions(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Session lookup for the booth pay/exit modal: entry/exit times, paid state,
|
||||||
|
// amount owed now, walk-back-grace status. Read-only (no side effect).
|
||||||
|
app.get<{ Params: { identity: string } }>(
|
||||||
|
"/api/session/:identity",
|
||||||
|
{ preHandler: readGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.params.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
return payStation.lookup(identity);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Booth-driven exit: validate (paid + grace, or free entry-grace) THEN sign
|
||||||
|
// vehicle_exit + open the barrier. Maps the discriminated result to HTTP:
|
||||||
|
// - validation reject → 409 with a reason (operator takes payment first),
|
||||||
|
// - exit signed but barrier didn't open → 200 { opened:false } (payment stands;
|
||||||
|
// operator opens manually; an anomaly is already signed),
|
||||||
|
// - clean exit → 200 { opened:true }.
|
||||||
|
app.post<{ Body: ExitBody }>(
|
||||||
|
"/api/exit",
|
||||||
|
{ preHandler: [guard, requireShift] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const res = await exitFlow.exitForBooth(identity);
|
||||||
|
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
|
||||||
|
return reply.code(200).send(res);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Human-intervention barrier re-open for an ACTIVE (paid) session — damaged
|
||||||
|
// ticket / dead scanner / phantom re-close. Re-pulses the exit relay + signs an
|
||||||
|
// anomaly (attributed); NEVER a second vehicle_exit. Refused without a payment
|
||||||
|
// (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md.
|
||||||
|
app.post<{ Body: ExitBody }>(
|
||||||
|
"/api/barrier/reopen",
|
||||||
|
{ preHandler: [guard, requireShift] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const operator = req.user?.username;
|
||||||
|
const res = await exitFlow.reopenBarrier(identity, operator);
|
||||||
|
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||||
|
return reply.code(200).send(res);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event
|
||||||
|
// referencing the entry, with the operator + a REQUIRED reason — the entry itself is
|
||||||
|
// never edited/deleted (append-only). The session projection folds the void to CLOSED,
|
||||||
|
// so the voided car stops counting inside and can't be paid/exited. Opens NO barrier
|
||||||
|
// (the misprinted ticket's car never entered). Gated on event:void + an open shift
|
||||||
|
// (the booth accountability period). Refusals (subscription / already exited / already
|
||||||
|
// voided / already paid) → 409. See void-flow.ts, wiki/concepts/append-only-event-chain.md.
|
||||||
|
app.post<{ Body: VoidBody }>(
|
||||||
|
"/api/tickets/void",
|
||||||
|
{ preHandler: [voidGuard, requireShift] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
const reason = (req.body?.reason ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
if (!reason) return reply.code(400).send({ error: "a cancellation reason is required" });
|
||||||
|
const operator = req.user?.username ?? "unknown";
|
||||||
|
const res = await voidFlow.voidTicket({ identity, reason, operator });
|
||||||
|
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||||
|
return reply.code(201).send(res);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Quote: what does this session owe right now? (No side effect.)
|
||||||
|
app.get<{ Querystring: QuoteQuery }>(
|
||||||
|
"/api/pay/quote",
|
||||||
|
{ preHandler: readGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.query.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
try {
|
||||||
|
return payStation.quote(identity);
|
||||||
|
} catch (err) {
|
||||||
|
return mapError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pay: take payment and append the signed `payment` event.
|
||||||
|
app.post<{ Body: PayBody }>(
|
||||||
|
"/api/pay",
|
||||||
|
{ preHandler: [guard, requireShift] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { identity, tender, overrideMinor } = req.body ?? {};
|
||||||
|
if (!identity || (tender !== "cash" && tender !== "card")) {
|
||||||
|
return reply.code(400).send({ error: "identity and tender (cash|card) required" });
|
||||||
|
}
|
||||||
|
if (overrideMinor != null && (!Number.isInteger(overrideMinor) || overrideMinor < 0)) {
|
||||||
|
return reply.code(400).send({ error: "overrideMinor must be a non-negative integer (minor units)" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await payStation.pay(identity, tender, overrideMinor);
|
||||||
|
return reply.code(201).send(res);
|
||||||
|
} catch (err) {
|
||||||
|
return mapError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Print an exit voucher (the paid ticket id reprinted as a barcode) on the booth
|
||||||
|
// printer. Used when the booth is far from the exit — the customer self-scans the
|
||||||
|
// voucher at the exit reader, which runs the normal validated exit. Requires the
|
||||||
|
// session to be PAID (no free vouchers for unpaid sessions). See booth-exit-flow.md.
|
||||||
|
app.post<{ Body: VoucherBody }>(
|
||||||
|
"/api/voucher",
|
||||||
|
{ preHandler: [guard, requireShift] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const view = payStation.lookup(identity);
|
||||||
|
if (!view.found || !view.open) {
|
||||||
|
return reply.code(404).send({ error: "no open session for ticket" });
|
||||||
|
}
|
||||||
|
if (view.paidAt == null) {
|
||||||
|
return reply.code(409).send({ error: "session not paid — take payment before printing a voucher" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const printedBy = await printPaymentReceipt(db, identity, { voucher: true }, app.log);
|
||||||
|
return reply.code(200).send({ ok: true, printedBy });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NoPrinterAvailableError) {
|
||||||
|
return reply.code(503).send({ error: err.message });
|
||||||
|
}
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Print a standalone PAYMENT RECEIPT (transparency: entry/paid/duration/amount,
|
||||||
|
// no barcode) on the booth printer. Used (a) auto, right after a payment when no
|
||||||
|
// voucher is issued, and (b) on-demand "reprint" if the slip jammed. Requires the
|
||||||
|
// session to be PAID. See wiki/concepts/booth-exit-flow.md.
|
||||||
|
app.post<{ Body: ReceiptBody }>(
|
||||||
|
"/api/receipt",
|
||||||
|
{ preHandler: [guard, requireShift] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const view = payStation.lookup(identity);
|
||||||
|
if (!view.found) {
|
||||||
|
return reply.code(404).send({ error: "no session for ticket" });
|
||||||
|
}
|
||||||
|
if (view.paidAt == null) {
|
||||||
|
return reply.code(409).send({ error: "session not paid — nothing to receipt" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const printedBy = await printPaymentReceipt(db, identity, { voucher: false }, app.log);
|
||||||
|
return reply.code(200).send({ ok: true, printedBy });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NoPrinterAvailableError) {
|
||||||
|
return reply.code(503).send({ error: err.message });
|
||||||
|
}
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapError(reply: import("fastify").FastifyReply, err: unknown) {
|
||||||
|
if (err instanceof NoOpenSessionError) return reply.code(404).send({ error: err.message });
|
||||||
|
if (err instanceof NoTariffError) return reply.code(409).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { requireRole } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { deviceEvents } from "../device-events.js";
|
import { deviceEvents } from "../device-events.js";
|
||||||
import type { PrinterMonitor } from "../printer-monitor.js";
|
import type { PrinterMonitor } from "../printer-monitor.js";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export async function printerRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
monitor: PrinterMonitor,
|
monitor: PrinterMonitor,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const guard = requireRole("admin", "operator", "cashier", "readonly");
|
const guard = requirePermission("device:read");
|
||||||
|
|
||||||
// Current status of every monitored printer (cached — no device round-trip).
|
// Current status of every monitored printer (cached — no device round-trip).
|
||||||
app.get("/api/printers/status", { preHandler: guard }, async () => ({
|
app.get("/api/printers/status", { preHandler: guard }, async () => ({
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { eq, users, type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// Self-service profile (routes/auth.ts): /api/auth/profile + /api/auth/password. These act
|
||||||
|
// ONLY on the signed-in user, need NO `user:*` permission (any role), and the password change
|
||||||
|
// must prove the current password. Distinct from admin user-management (routes/users.ts).
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/auth/profile (self-service)", () => {
|
||||||
|
it("a permission-less user can edit their OWN name + email", async () => {
|
||||||
|
// 'viewer' role with NO user:* permission — profile is not gated on it.
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "cashier", roleId: "viewer", permissions: [],
|
||||||
|
});
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/profile",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fullName: "Mon Kukaleshi", email: "mon@example.com" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.fullName).toBe("Mon Kukaleshi");
|
||||||
|
expect(body.email).toBe("mon@example.com");
|
||||||
|
// Persisted to the caller's own row.
|
||||||
|
const row = db.select().from(users).where(eq(users.username, "cashier")).get();
|
||||||
|
expect(row?.fullName).toBe("Mon Kukaleshi");
|
||||||
|
expect(row?.email).toBe("mon@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a field when sent ""', async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "u2", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
// First set a name…
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/profile",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fullName: "Old Name" },
|
||||||
|
});
|
||||||
|
// …then clear it with whitespace (→ null).
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/profile",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fullName: " " },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().fullName).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty patch (nothing to update)", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "u3", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/profile",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a session (401 without a token)", async () => {
|
||||||
|
const res = await app.inject({ method: "PUT", url: "/api/auth/profile", payload: { fullName: "x" } });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/auth/password (self-service)", () => {
|
||||||
|
it("changes the password when the current one is correct, and the new one then logs in", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "p1", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/password",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { currentPassword: password, newPassword: "brand-new-pw-123" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// Old password no longer works; new one does.
|
||||||
|
const oldTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
|
||||||
|
expect(oldTry.statusCode).toBe(401);
|
||||||
|
const newTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password: "brand-new-pw-123" } });
|
||||||
|
expect(newTry.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the current password is wrong (403) and leaves the password unchanged", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "p2", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/password",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { currentPassword: "not-it", newPassword: "brand-new-pw-123" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
// Original password still works.
|
||||||
|
const still = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
|
||||||
|
expect(still.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a too-short new password (400)", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "p3", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/password",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { currentPassword: password, newPassword: "short" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import type { DeviceReadEvent } from "../device-events.js";
|
||||||
|
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||||
|
import type { CredentialCapture } from "../credential-capture.js";
|
||||||
|
|
||||||
|
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
||||||
|
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||||
|
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
|
||||||
|
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/gee-qr-er80.md.
|
||||||
|
//
|
||||||
|
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
|
||||||
|
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
|
||||||
|
// reply status: 1 = valid (beep 2×) / 0 = invalid (beep 1×)
|
||||||
|
// reply output: 0 = Access, 1 = WG26, 2 = WG34 (line driven on a valid read)
|
||||||
|
// reply time: UTC — syncs the device clock
|
||||||
|
//
|
||||||
|
// The "server language" set on the device only selects this URL path; we accept the
|
||||||
|
// SDK default path. No auth on the device side (it can't); the reader sits on the
|
||||||
|
// device subnet (network-isolation) and the signed ledger is the real guarantee.
|
||||||
|
|
||||||
|
interface ReaderQuery {
|
||||||
|
cardid?: string;
|
||||||
|
mjihao?: string; // device id
|
||||||
|
cjihao?: string; // device serial
|
||||||
|
status?: string; // 2 chars: high valid/invalid, low 1=in/0=out
|
||||||
|
time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function qrReaderRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
dispatcher: ReadDispatcher,
|
||||||
|
capture: CredentialCapture,
|
||||||
|
): Promise<void> {
|
||||||
|
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
||||||
|
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
||||||
|
// enters when assigning the gee-qr-reader. Returns the row id, or null if no
|
||||||
|
// reader is assigned for that serial. (Small device set → scan in JS.)
|
||||||
|
const readerRowIdForSerial = (serial: string): string | null => {
|
||||||
|
if (!serial) return null;
|
||||||
|
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
|
||||||
|
const match = rows.find((r) => r.enabled && (r.config as { serial?: string }).serial === serial);
|
||||||
|
return match?.id ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// No auth: the reader is a machine on the isolated device subnet and offers no
|
||||||
|
// auth on its side. Public route, like the Dingtian input push.
|
||||||
|
const handler = async (req: { query: ReaderQuery }, reply: import("fastify").FastifyReply) => {
|
||||||
|
const q = req.query;
|
||||||
|
// The reader sends `Connection: keep-alive` but only ACTS on our verdict (beep,
|
||||||
|
// drive output) once the socket CLOSES — every vendor demo replies
|
||||||
|
// `Connection: close` and shuts the socket. Without it the reader waits out a
|
||||||
|
// ~10 s keep-alive timeout before beeping. So force-close the connection.
|
||||||
|
// See wiki/sources/qrcode-sdk.md, entities/gee-qr-er80.md.
|
||||||
|
reply.header("connection", "close");
|
||||||
|
const cardid = (q.cardid ?? "").trim();
|
||||||
|
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
||||||
|
const serial = (q.cjihao ?? "").trim();
|
||||||
|
|
||||||
|
// Map the reader's serial → its assigned lane_devices row id (the dispatcher
|
||||||
|
// resolves the lane from that row). If unassigned, deviceId stays the serial so
|
||||||
|
// the dispatcher simply finds no lane and rejects (status:0) — never crashes.
|
||||||
|
const matchedRowId = readerRowIdForSerial(serial);
|
||||||
|
const deviceId = matchedRowId ?? serial;
|
||||||
|
|
||||||
|
let accepted = false;
|
||||||
|
if (cardid) {
|
||||||
|
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
|
||||||
|
// value for the subscription form and do NOT run the access flow (we must not
|
||||||
|
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
|
||||||
|
// Reads from the OTHER reader are untouched and dispatch normally below.
|
||||||
|
if (capture.tryConsume(deviceId, cardid)) {
|
||||||
|
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
|
||||||
|
accepted = true; // beep "ok" so the operator knows the card was read
|
||||||
|
} else {
|
||||||
|
const read: DeviceReadEvent = {
|
||||||
|
driverId: "gee-qr-reader",
|
||||||
|
deviceId,
|
||||||
|
value: cardid,
|
||||||
|
kind: "qr",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const outcome = await dispatcher.dispatch(read);
|
||||||
|
accepted = outcome.accepted;
|
||||||
|
// Per-read diagnostic: which reader (serial) sent it, which configured device
|
||||||
|
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
|
||||||
|
// the logs (e.g. an entry-side scan resolving to the exit relay).
|
||||||
|
app.log.info(
|
||||||
|
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
|
||||||
|
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
||||||
|
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply the SDK verdict. status 1 → beep 2× (valid) / 0 → beep 1× (invalid).
|
||||||
|
// output 0 = Access (drive the reader's access line on a valid read).
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
cardid,
|
||||||
|
cjihao: q.cjihao ?? 0,
|
||||||
|
mjihao,
|
||||||
|
status: accepted ? 1 : 0,
|
||||||
|
time: String(Math.floor(Date.now() / 1000)),
|
||||||
|
output: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
code: 0,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// The reader's "server language" setting (JSP/PHP/C#/ASP/CGI) selects the URL
|
||||||
|
// EXTENSION it GETs — verified on hardware: a JSP-configured unit posts
|
||||||
|
// /qa/mcardsea.jsp. Register every extension so the endpoint works whatever the
|
||||||
|
// device is set to; accept POST too in case a variant differs.
|
||||||
|
for (const ext of ["php", "jsp", "asp", "aspx", "cgi"]) {
|
||||||
|
const path = `/qa/mcardsea.${ext}`;
|
||||||
|
app.get<{ Querystring: ReaderQuery }>(path, handler);
|
||||||
|
app.post<{ Querystring: ReaderQuery }>(path, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// HTTP integration for soft delete + recycle bin: an admin DELETE soft-deletes (the user
|
||||||
|
// leaves the list, can't log in), the bin lists it, restore brings it back, and a deleted
|
||||||
|
// user can log in again. Drives the REAL app over a fresh in-memory DB via app.inject.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Log in an admin and return the auth headers for mutations. */
|
||||||
|
async function asAdmin() {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
return { cookie, csrf };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("soft delete via the resource DELETE route", () => {
|
||||||
|
it("DELETE /api/users/:id soft-deletes: user leaves the list and can't log in, but is restorable", async () => {
|
||||||
|
const { cookie, csrf } = await asAdmin();
|
||||||
|
// Create a victim user to delete.
|
||||||
|
await seedUser(db, { username: "victim", password: "victim-pass-123", roleId: "admin" });
|
||||||
|
const victim = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
|
||||||
|
.users.find((u: { username: string; id: string }) => u.username === "victim");
|
||||||
|
expect(victim).toBeDefined();
|
||||||
|
|
||||||
|
// Delete (soft).
|
||||||
|
const del = await app.inject({
|
||||||
|
method: "DELETE", url: `/api/users/${victim.id}`,
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
});
|
||||||
|
expect(del.statusCode).toBeLessThan(300);
|
||||||
|
|
||||||
|
// Gone from the live list.
|
||||||
|
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json();
|
||||||
|
expect(list.users.some((u: { username: string }) => u.username === "victim")).toBe(false);
|
||||||
|
|
||||||
|
// Can't log in.
|
||||||
|
const relogin = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
|
||||||
|
expect(relogin.statusCode).toBe(401);
|
||||||
|
|
||||||
|
// Shows in the recycle bin.
|
||||||
|
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
|
||||||
|
expect(bin.items.some((i: { kind: string; label: string }) => i.kind === "user" && i.label === "victim")).toBe(true);
|
||||||
|
|
||||||
|
// Restore → reappears + can log in.
|
||||||
|
const restore = await app.inject({
|
||||||
|
method: "POST", url: `/api/recycle-bin/user/${victim.id}/restore`,
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
});
|
||||||
|
expect(restore.statusCode).toBeLessThan(300);
|
||||||
|
const relogin2 = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
|
||||||
|
expect(relogin2.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purge permanently removes a soft-deleted user", async () => {
|
||||||
|
const { cookie, csrf } = await asAdmin();
|
||||||
|
await seedUser(db, { username: "gone", password: "gone-pass-1234", roleId: "admin" });
|
||||||
|
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
|
||||||
|
.users.find((u: { username: string }) => u.username === "gone").id;
|
||||||
|
|
||||||
|
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
|
||||||
|
const purge = await app.inject({
|
||||||
|
method: "DELETE", url: `/api/recycle-bin/user/${id}`,
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
});
|
||||||
|
expect(purge.statusCode).toBe(204);
|
||||||
|
|
||||||
|
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
|
||||||
|
expect(bin.items.some((i: { label: string }) => i.label === "gone")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the recycle bin is gated — a user without recyclebin:read is 403", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "plain", roleId: "plain", permissions: ["user:read"],
|
||||||
|
});
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recreating a user with a soft-deleted user's username gives a clear 409", async () => {
|
||||||
|
const { cookie, csrf } = await asAdmin();
|
||||||
|
await seedUser(db, { username: "dup", password: "dup-pass-12345", roleId: "admin" });
|
||||||
|
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
|
||||||
|
.users.find((u: { username: string }) => u.username === "dup").id;
|
||||||
|
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
|
||||||
|
|
||||||
|
const create = await app.inject({
|
||||||
|
method: "POST", url: "/api/users",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { username: "dup", password: "new-pass-12345", roleId: "admin" },
|
||||||
|
});
|
||||||
|
expect(create.statusCode).toBe(409);
|
||||||
|
expect(create.json().error).toMatch(/recycle bin/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { requirePermission, bumpPermsCache } from "../auth.js";
|
||||||
|
import {
|
||||||
|
listRecycleBin,
|
||||||
|
purge,
|
||||||
|
restore,
|
||||||
|
restoreBlockedReason,
|
||||||
|
retentionDays,
|
||||||
|
RESOURCE_KINDS,
|
||||||
|
type ResourceKind,
|
||||||
|
} from "../recycle-bin.js";
|
||||||
|
|
||||||
|
// Recycle bin API — view / restore / purge soft-deleted master data. The actual
|
||||||
|
// soft-delete STAMP happens in each resource's own DELETE route (users/roles/
|
||||||
|
// subscriptions/plans/tariffs); this is the way back. Admin-grade (recyclebin:*).
|
||||||
|
// See recycle-bin.ts, wiki/concepts/soft-delete.md.
|
||||||
|
|
||||||
|
function isKind(s: string): s is ResourceKind {
|
||||||
|
return (RESOURCE_KINDS as string[]).includes(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recycleBinRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
// List everything in the bin (+ the retention window so the UI can warn how long
|
||||||
|
// items survive before auto-purge).
|
||||||
|
app.get(
|
||||||
|
"/api/recycle-bin",
|
||||||
|
{ preHandler: requirePermission("recyclebin:read") },
|
||||||
|
async () => ({ items: listRecycleBin(db), retentionDays: retentionDays() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore a soft-deleted item (clear the stamps → it reappears in its catalog).
|
||||||
|
// Blocked with a 409 when a live row would collide (e.g. the username was reused).
|
||||||
|
app.post<{ Params: { kind: string; id: string } }>(
|
||||||
|
"/api/recycle-bin/:kind/:id/restore",
|
||||||
|
{ preHandler: requirePermission("recyclebin:update") },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { kind, id } = req.params;
|
||||||
|
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
|
||||||
|
|
||||||
|
const blocked = restoreBlockedReason(db, kind, id);
|
||||||
|
if (blocked) return reply.code(409).send({ error: `cannot restore: ${blocked}` });
|
||||||
|
|
||||||
|
const ok = restore(db, kind, id);
|
||||||
|
if (!ok) return reply.code(404).send({ error: "no deleted item to restore" });
|
||||||
|
// A restored role/user changes the authz picture — drop the permission cache.
|
||||||
|
if (kind === "role" || kind === "user") bumpPermsCache();
|
||||||
|
app.log.info(`recycle-bin: restored ${kind} ${id}`);
|
||||||
|
return { kind, id, restored: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Purge (permanently delete) a soft-deleted item + its children. Irreversible.
|
||||||
|
app.delete<{ Params: { kind: string; id: string } }>(
|
||||||
|
"/api/recycle-bin/:kind/:id",
|
||||||
|
{ preHandler: requirePermission("recyclebin:delete") },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { kind, id } = req.params;
|
||||||
|
if (!isKind(kind)) return reply.code(400).send({ error: `unknown resource kind: ${kind}` });
|
||||||
|
const ok = purge(db, kind, id);
|
||||||
|
if (!ok) return reply.code(404).send({ error: "no deleted item to purge" });
|
||||||
|
if (kind === "role" || kind === "user") bumpPermsCache();
|
||||||
|
app.log.warn(`recycle-bin: PURGED ${kind} ${id} (permanent)`);
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { reportSummary, type Bucket } from "../reports.js";
|
||||||
|
|
||||||
|
// Admin reporting API. Read-only aggregation over the signed ledger (+ the sessions
|
||||||
|
// cache for durations); no writes, no new event types. Gated on `report:read` — the
|
||||||
|
// same permission the events feed/occupancy use. See reports.ts, wiki/concepts/reports.md.
|
||||||
|
|
||||||
|
const BUCKETS: Bucket[] = ["hour", "day", "month"];
|
||||||
|
|
||||||
|
/** Clamp a query into a valid [from, to) + bucket. Defaults: last 30 days, daily. */
|
||||||
|
function parseQuery(q: { from?: string; to?: string; bucket?: string }): {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
bucket: Bucket;
|
||||||
|
} {
|
||||||
|
const now = Date.now();
|
||||||
|
const to = isFiniteIso(q.to) ? q.to! : new Date(now).toISOString();
|
||||||
|
const from = isFiniteIso(q.from) ? q.from! : new Date(now - 30 * 86_400_000).toISOString();
|
||||||
|
const bucket = BUCKETS.includes(q.bucket as Bucket) ? (q.bucket as Bucket) : "day";
|
||||||
|
// Guard the inversion (from after to) — swap rather than return an empty report.
|
||||||
|
return from <= to ? { from, to, bucket } : { from: to, to: from, bucket };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteIso(s: string | undefined): boolean {
|
||||||
|
return !!s && Number.isFinite(Date.parse(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const guard = requirePermission("report:read");
|
||||||
|
|
||||||
|
// The whole dashboard in one call: totals, the time series, peak-hour histogram, and
|
||||||
|
// subscription stats — aggregated server-side so the SPA just renders. Bucketed in the
|
||||||
|
// site timezone. See reports.ts.
|
||||||
|
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
|
||||||
|
"/api/reports/summary",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req) => reportSummary(db, parseQuery(req.query)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same series as CSV (one row per bucket) for spreadsheet / accountant export.
|
||||||
|
// Amounts are in MAJOR units with 2 decimals here (a CSV is for humans/Excel), unlike
|
||||||
|
// the JSON which stays in minor units. text/csv with a download filename.
|
||||||
|
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
|
||||||
|
"/api/reports/summary.csv",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const summary = reportSummary(db, parseQuery(req.query));
|
||||||
|
const lines = [
|
||||||
|
"bucket,entries,exits,payments,revenue",
|
||||||
|
...summary.series.map((p) =>
|
||||||
|
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
reply
|
||||||
|
.header("content-type", "text/csv; charset=utf-8")
|
||||||
|
.header(
|
||||||
|
"content-disposition",
|
||||||
|
`attachment; filename="parking-report-${summary.from.slice(0, 10)}_${summary.to.slice(0, 10)}.csv"`,
|
||||||
|
)
|
||||||
|
.send(lines.join("\n") + "\n");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||||
|
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||||
|
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||||
|
import { softDelete } from "../recycle-bin.js";
|
||||||
|
|
||||||
|
// Role management (admin). Roles are DATA: an admin composes a role from the
|
||||||
|
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
|
||||||
|
// role. The built-in `admin` role (id ADMIN_ROLE_ID) is PROTECTED — it can't be
|
||||||
|
// edited or deleted and always resolves to every permission in code. Every write
|
||||||
|
// here bumps the in-memory permission cache so changes take effect on the next
|
||||||
|
// request. See @parking/shared PERMISSIONS and ../auth.ts.
|
||||||
|
//
|
||||||
|
// PRIVILEGE-ESCALATION GUARD: `role:update`/`role:create` must NOT let a caller
|
||||||
|
// grant a permission they don't themselves hold — otherwise a non-admin with
|
||||||
|
// `role:*` could edit their own role to add (say) `tariff:update`, or mint a role
|
||||||
|
// that grants admin-equivalent powers, and escalate. So a non-admin caller may
|
||||||
|
// only put permissions they ALREADY hold onto a role. An admin (full set) is
|
||||||
|
// unrestricted, which is the intended behaviour.
|
||||||
|
|
||||||
|
interface RoleBody {
|
||||||
|
name: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
interface UpdateBody {
|
||||||
|
name?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID = new Set<string>(PERMISSIONS);
|
||||||
|
|
||||||
|
/** Validate + dedupe a requested permission list against the code-defined grid. */
|
||||||
|
function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | { ok: false; bad: string } {
|
||||||
|
if (!Array.isArray(input)) return { ok: false, bad: "permissions must be an array" };
|
||||||
|
const out = new Set<Permission>();
|
||||||
|
for (const p of input) {
|
||||||
|
if (typeof p !== "string" || !VALID.has(p)) return { ok: false, bad: `unknown permission: ${String(p)}` };
|
||||||
|
out.add(p as Permission);
|
||||||
|
}
|
||||||
|
return { ok: true, perms: [...out] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const readGuard = requirePermission("role:read");
|
||||||
|
const createGuard = requirePermission("role:create");
|
||||||
|
const updateGuard = requirePermission("role:update");
|
||||||
|
const deleteGuard = requirePermission("role:delete");
|
||||||
|
|
||||||
|
/** A role + its permission list + how many users hold it. */
|
||||||
|
function roleView(roleId: string) {
|
||||||
|
const role = db.select().from(roles).where(eq(roles.id, roleId)).get();
|
||||||
|
if (!role) return null;
|
||||||
|
const perms = db
|
||||||
|
.select({ permission: rolePermissions.permission })
|
||||||
|
.from(rolePermissions)
|
||||||
|
.where(eq(rolePermissions.roleId, roleId))
|
||||||
|
.all()
|
||||||
|
.map((r) => r.permission);
|
||||||
|
const userCount = db.select().from(users).where(and(eq(users.roleId, roleId), isNull(users.deletedAt))).all().length;
|
||||||
|
// The admin role always reports the full grid (it's enforced in code).
|
||||||
|
return {
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
builtin: role.builtin === 1,
|
||||||
|
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
|
||||||
|
userCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace a role's permission rows with `perms` (in a single pass). */
|
||||||
|
function setPermissions(roleId: string, perms: Permission[]): void {
|
||||||
|
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
|
||||||
|
for (const p of perms) {
|
||||||
|
db.insert(rolePermissions).values({ roleId, permission: p }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The full permission grid (for the role-composer checkbox UI) + every LIVE role.
|
||||||
|
// Soft-deleted roles live in the recycle bin, not here.
|
||||||
|
app.get("/api/roles", { preHandler: readGuard }, async () => {
|
||||||
|
const all = db.select().from(roles).where(isNull(roles.deletedAt)).all();
|
||||||
|
return {
|
||||||
|
catalog: PERMISSIONS,
|
||||||
|
roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Reject any permission in `perms` the caller does not themselves hold — so a
|
||||||
|
* non-admin can't grant privileges beyond their own. Returns the offending
|
||||||
|
* permission, or null if all are within the caller's set. (Admin holds the full
|
||||||
|
* set, so it never trips.) */
|
||||||
|
function escalates(callerRoleId: string, perms: Permission[]): Permission | null {
|
||||||
|
const held = permissionsFor(callerRoleId);
|
||||||
|
return perms.find((p) => !held.has(p)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a composable role from a name + a permission set.
|
||||||
|
app.post<{ Body: RoleBody }>("/api/roles", { preHandler: createGuard }, async (req, reply) => {
|
||||||
|
const name = (req.body?.name ?? "").trim();
|
||||||
|
if (!name) return reply.code(400).send({ error: "name required" });
|
||||||
|
if (db.select().from(roles).where(eq(roles.name, name)).get()) {
|
||||||
|
return reply.code(409).send({ error: "a role with that name already exists" });
|
||||||
|
}
|
||||||
|
const cleaned = cleanPermissions(req.body?.permissions ?? []);
|
||||||
|
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
|
||||||
|
const over = escalates(req.user.roleId, cleaned.perms);
|
||||||
|
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(roles).values({ id, name, builtin: 0 }).run();
|
||||||
|
setPermissions(id, cleaned.perms);
|
||||||
|
bumpPermsCache();
|
||||||
|
return reply.code(201).send(roleView(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit a role's name and/or permission set. The built-in admin role is locked.
|
||||||
|
app.put<{ Params: { id: string }; Body: UpdateBody }>(
|
||||||
|
"/api/roles/:id",
|
||||||
|
{ preHandler: updateGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const role = db.select().from(roles).where(eq(roles.id, id)).get();
|
||||||
|
if (!role) return reply.code(404).send({ error: "role not found" });
|
||||||
|
if (role.builtin === 1) {
|
||||||
|
return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body?.name != null) {
|
||||||
|
const name = req.body.name.trim();
|
||||||
|
if (!name) return reply.code(400).send({ error: "name cannot be empty" });
|
||||||
|
const clash = db.select().from(roles).where(eq(roles.name, name)).get();
|
||||||
|
if (clash && clash.id !== id) return reply.code(409).send({ error: "a role with that name already exists" });
|
||||||
|
db.update(roles).set({ name }).where(eq(roles.id, id)).run();
|
||||||
|
}
|
||||||
|
if (req.body?.permissions != null) {
|
||||||
|
const cleaned = cleanPermissions(req.body.permissions);
|
||||||
|
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
|
||||||
|
const over = escalates(req.user.roleId, cleaned.perms);
|
||||||
|
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||||
|
setPermissions(id, cleaned.perms);
|
||||||
|
}
|
||||||
|
bumpPermsCache();
|
||||||
|
return roleView(id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a role — SOFT (recycle bin). Refused if built-in or any LIVE user still holds
|
||||||
|
// it. The row is stamped deleted (recoverable), not removed; its permission rows are
|
||||||
|
// KEPT so a restore brings the role back intact. Restore/purge from the recycle bin.
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/api/roles/:id",
|
||||||
|
{ preHandler: deleteGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const role = db.select().from(roles).where(and(eq(roles.id, id), isNull(roles.deletedAt))).get();
|
||||||
|
if (!role) return reply.code(404).send({ error: "role not found" });
|
||||||
|
if (role.builtin === 1) {
|
||||||
|
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" });
|
||||||
|
}
|
||||||
|
// Only LIVE holders block deletion (a soft-deleted user's role assignment is moot).
|
||||||
|
const holders = db.select().from(users).where(and(eq(users.roleId, id), isNull(users.deletedAt))).all().length;
|
||||||
|
if (holders > 0) {
|
||||||
|
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
|
||||||
|
}
|
||||||
|
softDelete(db, "role", id, req.user.sub);
|
||||||
|
bumpPermsCache();
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// HTTP integration: boot the REAL Fastify app over a fresh in-memory DB (no listen —
|
||||||
|
// app.inject drives it) and exercise the auth + RBAC guards end to end. The point is the
|
||||||
|
// security seam: no token → 401, wrong permission → 403, CSRF required on mutations, and
|
||||||
|
// a correctly-scoped user passes. (vitest.config sets JWT_SECRET/EVENT_SIGNING_KEY.)
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("health + login", () => {
|
||||||
|
it("GET /health is open", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/health" });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login with bad credentials is rejected", async () => {
|
||||||
|
await seedUser(db, { username: "alice", password: "right-password" });
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "wrong" } });
|
||||||
|
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login with good credentials sets auth + csrf cookies", async () => {
|
||||||
|
await seedUser(db, { username: "alice", password: "right-password" });
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "right-password" } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const names = res.cookies.map((c) => c.name);
|
||||||
|
expect(names).toContain("parking_token");
|
||||||
|
expect(names).toContain("parking_csrf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth guard — no token", () => {
|
||||||
|
it("GET /api/occupancy without a session is 401", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/occupancy" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RBAC permission gate", () => {
|
||||||
|
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer", roleId: "viewer", permissions: ["site:read"],
|
||||||
|
});
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
// GET allowed (site:read).
|
||||||
|
const get = await app.inject({ method: "GET", url: "/api/occupancy", headers: { cookie } });
|
||||||
|
expect(get.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// PUT requires site:update — which this role lacks → 403 (with valid CSRF, so the
|
||||||
|
// 403 is the PERMISSION check, not CSRF).
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { capacity: 50 },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin user passes the same PUT", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { capacity: 50 },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBeLessThan(300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSRF double-submit on mutations", () => {
|
||||||
|
it("a mutation with the auth cookie but NO csrf header is 403", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie }, // csrf header deliberately omitted
|
||||||
|
payload: { capacity: 50 },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,33 @@
|
|||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
import { eq, devices, setupState, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
hasPreconditions,
|
hasPreconditions,
|
||||||
hasPushConfig,
|
hasPushConfig,
|
||||||
|
isCamera,
|
||||||
isDiscoverable,
|
isDiscoverable,
|
||||||
isHardenable,
|
isHardenable,
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
|
type CameraDevice,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
|
type DeviceConfig,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import { requireRole } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||||
|
import type { VisionClient } from "../vision-client.js";
|
||||||
|
|
||||||
// First-run setup API. The admin reads the driver catalog and assigns devices
|
// First-run setup API. The admin reads the driver catalog and assigns devices
|
||||||
// per lane. See wiki/concepts/first-run-setup.md.
|
// per lane. See wiki/concepts/first-run-setup.md.
|
||||||
|
|
||||||
interface AssignBody {
|
interface AssignBody {
|
||||||
lane: number;
|
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: Record<string, string | number | boolean>;
|
// Driver config (opaque JSON, validated by the driver). Carries the model's
|
||||||
|
// direction/binding: access → config.relays=[{relay,direction,button?}];
|
||||||
|
// reader/camera → config.controllerId + config.relay. See entry-exit-points.md.
|
||||||
|
config: DeviceConfig;
|
||||||
/** Optional: the backend IP the device should push to (overrides auto-pick;
|
/** Optional: the backend IP the device should push to (overrides auto-pick;
|
||||||
* matters on multi-NIC hosts). */
|
* matters on multi-NIC hosts). */
|
||||||
backendIp?: string;
|
backendIp?: string;
|
||||||
@@ -48,25 +54,147 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
||||||
|
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
||||||
|
type ConfigureOutcome =
|
||||||
|
| { config: Record<string, unknown>; warnings: string[] }
|
||||||
|
| { error: { code: number; message: string } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate + configure a device, returning the config to persist. Runs the same
|
||||||
|
* pipeline for both create and edit: validate the driver config, fix
|
||||||
|
* preconditions, harden (relay password + protocol lockdown), and set up input
|
||||||
|
* push (Digest creds + push URLs). Each step is a device write (the device
|
||||||
|
* reboots on apply). The caller owns the DB row; this never touches the DB.
|
||||||
|
*
|
||||||
|
* `id` is the assignment id (stable across an edit) — it's baked into the push
|
||||||
|
* URL, so editing in place keeps the device pushing to the same path.
|
||||||
|
* `existingConfig` carries forward secrets the client never sees on edit
|
||||||
|
* (push/relay passwords), so a PATCH that omits them doesn't wipe them.
|
||||||
|
*/
|
||||||
|
async function configureDevice(
|
||||||
|
app: FastifyInstance,
|
||||||
|
args: {
|
||||||
|
id: string;
|
||||||
|
driverId: string;
|
||||||
|
config: DeviceConfig;
|
||||||
|
backendIp?: string;
|
||||||
|
existingConfig?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): Promise<ConfigureOutcome> {
|
||||||
|
const { id, driverId, config, backendIp, existingConfig } = args;
|
||||||
|
|
||||||
|
// Start from any machine-only secrets already on the row (push/relay passwords
|
||||||
|
// are redacted out of the client's copy, so an edit would otherwise drop them),
|
||||||
|
// then layer the submitted config on top.
|
||||||
|
const fullConfig: Record<string, unknown> = { ...existingConfig, ...config };
|
||||||
|
// The web password the admin typed is a DESIRED value, not a stored fact:
|
||||||
|
// it's passed to the driver (via create(config) below) as the rotation
|
||||||
|
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
|
||||||
|
// secrets.webPassword gets saved — otherwise a failed rotation would leave
|
||||||
|
// the DB claiming a password the device never accepted (login stays old).
|
||||||
|
delete fullConfig.webPassword;
|
||||||
|
// webPasswordCurrent is an input-only credential (the OLD password used to
|
||||||
|
// authorize the change) — never persist it as typed.
|
||||||
|
delete fullConfig.webPasswordCurrent;
|
||||||
|
// Residual-risk warnings from device hardening (shown to the admin; the
|
||||||
|
// save still succeeds — these are "configured, but note X" advisories).
|
||||||
|
const hardenWarnings: string[] = [];
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(driverId, config); // validates required fields
|
||||||
|
} catch (err) {
|
||||||
|
return { error: { code: 400, message: (err as Error).message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure the device on save (before persisting, so we don't store a row
|
||||||
|
// for a device we couldn't configure):
|
||||||
|
// 1. fix preconditions (e.g. disable input_link_relay so a button press
|
||||||
|
// doesn't auto-fire its relay — host must decide first),
|
||||||
|
// 2. harden (relay password + disable unused protocol channels), and
|
||||||
|
// 3. set up input push (Digest creds + push URLs).
|
||||||
|
// Each step is a device config write (the device reboots on apply).
|
||||||
|
try {
|
||||||
|
if (hasPreconditions(device)) {
|
||||||
|
const fixed = await device.fixPreconditions();
|
||||||
|
if (!fixed.ok) {
|
||||||
|
const unfixable = fixed.issues.find((i) => !i.fixable);
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: 502,
|
||||||
|
message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHardenable(device)) {
|
||||||
|
const { secrets, warnings } = await device.harden();
|
||||||
|
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
||||||
|
// Surface residual-risk warnings (e.g. firmware that won't disable the
|
||||||
|
// password-less string protocol) so the admin can act (web-UI step).
|
||||||
|
for (const w of warnings ?? []) {
|
||||||
|
app.log.warn(`harden(${driverId} ${id}): ${w}`);
|
||||||
|
hardenWarnings.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPushConfig(device)) {
|
||||||
|
const host = String(config.host ?? "");
|
||||||
|
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
|
||||||
|
const pushHost = backendIp ?? backendIpForDevice(host);
|
||||||
|
if (!pushHost) {
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: 400,
|
||||||
|
message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pushUser = "dingtian";
|
||||||
|
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
||||||
|
// (longer is silently truncated → auth mismatch), so keep it short.
|
||||||
|
const pushPassword = randomBytes(12).toString("hex");
|
||||||
|
await device.configureInputPush({
|
||||||
|
host: pushHost,
|
||||||
|
port: backendPort(),
|
||||||
|
pathBase: `/api/devices/${driverId}/${id}/input`,
|
||||||
|
auth: { user: pushUser, password: pushPassword },
|
||||||
|
});
|
||||||
|
fullConfig.pushUser = pushUser;
|
||||||
|
fullConfig.pushPassword = pushPassword;
|
||||||
|
// Record the backend IP the device was told to push to — lets us detect
|
||||||
|
// a later mismatch if the host's IP changes.
|
||||||
|
fullConfig.backendIp = pushHost;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: fullConfig, warnings: hardenWarnings };
|
||||||
|
}
|
||||||
|
|
||||||
export async function setupRoutes(
|
export async function setupRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
// Called after the set of assignments changes (assign/unassign) so the caller
|
vision?: VisionClient | null,
|
||||||
// can refresh anything derived from it — e.g. the device id->lane map.
|
|
||||||
onAssignmentsChanged: () => void = () => {},
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
|
|
||||||
// Setup endpoints require an admin (cookie-based JWT — see ../auth.ts).
|
// Device setup is site administration — it changes which hardware the site runs
|
||||||
const adminGuard = requireRole("admin");
|
// and how readers bind to relays. Gated on site:update. See ../auth.ts.
|
||||||
|
const adminGuard = requirePermission("site:update");
|
||||||
|
|
||||||
// Catalog of selectable drivers per category (no secrets — schema only).
|
// Catalog of selectable drivers per category (no secrets — schema only).
|
||||||
// `discoverable` flags drivers that can scan the LAN.
|
// `discoverable` flags drivers that can scan the LAN; `pushCapable` flags
|
||||||
|
// drivers that push to the backend (and thus need a backend IP at assign time).
|
||||||
app.get("/api/setup/catalog", async () => {
|
app.get("/api/setup/catalog", async () => {
|
||||||
const catalog = registry.catalog();
|
const catalog = registry.catalog();
|
||||||
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
|
||||||
return { ...catalog, discoverable };
|
const pushCapable = registry.pushCapable();
|
||||||
|
return { ...catalog, discoverable, pushCapable };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scan the LAN for devices a driver can discover (UDP broadcast, etc).
|
// Scan the LAN for devices a driver can discover (UDP broadcast, etc).
|
||||||
@@ -108,7 +236,7 @@ export async function setupRoutes(
|
|||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async () => {
|
async () => {
|
||||||
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
||||||
const rows = await db.select().from(laneDevices).all();
|
const rows = await db.select().from(devices).all();
|
||||||
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
|
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
|
||||||
return { completedAt: state?.completedAt ?? null, assignments };
|
return { completedAt: state?.completedAt ?? null, assignments };
|
||||||
},
|
},
|
||||||
@@ -140,6 +268,72 @@ export async function setupRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Test ANPR end-to-end on a camera config WITHOUT saving: capture a live snapshot
|
||||||
|
// off the camera and run it through the vision (ANPR) service, reporting whether a
|
||||||
|
// plate was extracted, the read, and how long it took. Lets the admin verify the
|
||||||
|
// camera→vision pipeline before committing the camera's `anpr` opt-in. Advisory +
|
||||||
|
// fail-soft, exactly like the runtime path (snapshot.ts): a vision failure is a
|
||||||
|
// reported "no plate", never a 500. See wiki/entities/opencv-anpr-service.md.
|
||||||
|
app.post<{ Body: TestBody }>(
|
||||||
|
"/api/setup/test-anpr",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { driverId, config } = req.body;
|
||||||
|
const driver = registry.get(driverId);
|
||||||
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
if (driver.category !== "camera") {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} is not a camera` });
|
||||||
|
}
|
||||||
|
if (!vision?.enabled) {
|
||||||
|
// The vision service is off (VISION_ENABLED unset) — there's nothing to test
|
||||||
|
// against. Report it cleanly so the UI can say "enable vision first".
|
||||||
|
return reply.send({ ok: false, reason: "vision-disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(driverId, config);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!isCamera(device)) {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} cannot capture snapshots` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Grab a frame off the camera. A camera/network failure here is the failure
|
||||||
|
// we're testing for — report it, don't 500.
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let shot: Awaited<ReturnType<CameraDevice["captureSnapshot"]>>;
|
||||||
|
try {
|
||||||
|
shot = await device.captureSnapshot({ direction: "entry" });
|
||||||
|
} catch (err) {
|
||||||
|
return reply.send({
|
||||||
|
ok: false,
|
||||||
|
reason: "snapshot-failed",
|
||||||
|
detail: (err as Error).message,
|
||||||
|
tookMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Run the same advisory analyze the runtime path uses. `analyze` is fail-soft
|
||||||
|
// (null on any error/timeout) and applies the confidence floor.
|
||||||
|
const result = await vision.analyze(shot.bytes, shot.contentType);
|
||||||
|
const tookMs = Date.now() - startedAt;
|
||||||
|
if (!result || !result.plate) {
|
||||||
|
return reply.send({ ok: false, reason: "no-plate", tookMs });
|
||||||
|
}
|
||||||
|
return reply.send({
|
||||||
|
ok: true,
|
||||||
|
plate: result.plate.text.trim().toUpperCase(),
|
||||||
|
confidence: result.plate.confidence,
|
||||||
|
region: result.plate.region ?? null,
|
||||||
|
lowConfidence: result.lowConfidence,
|
||||||
|
modelVersion: result.modelVersion,
|
||||||
|
tookMs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Candidate backend IPs the device can push to, for a given device host. The
|
// Candidate backend IPs the device can push to, for a given device host. The
|
||||||
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||||
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||||
@@ -152,117 +346,84 @@ export async function setupRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assign a device to a lane. Validates the chosen driver + config, configures
|
// Assign a device. Validates the chosen driver + config, configures the device
|
||||||
// the device (fix preconditions + set up Digest-authenticated input push — no
|
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
||||||
// manual device-web-UI step by the admin), then persists. Fails the save if
|
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
||||||
// the device can't be configured. See wiki/concepts/device-input-flow.md.
|
// configured. See wiki/concepts/device-input-flow.md, entry-exit-points.md.
|
||||||
app.post<{ Body: AssignBody }>(
|
app.post<{ Body: AssignBody }>(
|
||||||
"/api/setup/assign",
|
"/api/setup/assign",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { lane, category, driverId, config, backendIp } = req.body;
|
const { category, driverId, config, backendIp } = req.body;
|
||||||
const driver = registry.get(driverId);
|
const driver = registry.get(driverId);
|
||||||
if (!driver || driver.category !== category) {
|
if (!driver || driver.category !== category) {
|
||||||
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const fullConfig: Record<string, unknown> = { ...config };
|
const outcome = await configureDevice(app, { id, driverId, config, backendIp });
|
||||||
// The web password the admin typed is a DESIRED value, not a stored fact:
|
if ("error" in outcome) {
|
||||||
// it's passed to the driver (via create(config) below) as the rotation
|
return reply.code(outcome.error.code).send({ error: outcome.error.message });
|
||||||
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
|
|
||||||
// secrets.webPassword gets saved — otherwise a failed rotation would leave
|
|
||||||
// the DB claiming a password the device never accepted (login stays old).
|
|
||||||
delete fullConfig.webPassword;
|
|
||||||
// webPasswordCurrent is an input-only credential (the OLD password used to
|
|
||||||
// authorize the change) — never persist it as typed.
|
|
||||||
delete fullConfig.webPasswordCurrent;
|
|
||||||
// Residual-risk warnings from device hardening (shown to the admin; the
|
|
||||||
// save still succeeds — these are "configured, but note X" advisories).
|
|
||||||
const hardenWarnings: string[] = [];
|
|
||||||
|
|
||||||
let device;
|
|
||||||
try {
|
|
||||||
device = registry.create(driverId, config); // validates required fields
|
|
||||||
} catch (err) {
|
|
||||||
return reply.code(400).send({ error: (err as Error).message });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure the device on save (before persisting, so we don't store a row
|
|
||||||
// for a device we couldn't configure):
|
|
||||||
// 1. fix preconditions (e.g. disable input_link_relay so a button press
|
|
||||||
// doesn't auto-fire its relay — host must decide first),
|
|
||||||
// 2. harden (relay password + disable unused protocol channels), and
|
|
||||||
// 3. set up input push (Digest creds + push URLs).
|
|
||||||
// Each step is a device config write (the device reboots on apply).
|
|
||||||
try {
|
|
||||||
if (hasPreconditions(device)) {
|
|
||||||
const fixed = await device.fixPreconditions();
|
|
||||||
if (!fixed.ok) {
|
|
||||||
const unfixable = fixed.issues.find((i) => !i.fixable);
|
|
||||||
return reply.code(502).send({
|
|
||||||
error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isHardenable(device)) {
|
|
||||||
const { secrets, warnings } = await device.harden();
|
|
||||||
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
|
||||||
// Surface residual-risk warnings (e.g. firmware that won't disable the
|
|
||||||
// password-less string protocol) so the admin can act (web-UI step).
|
|
||||||
for (const w of warnings ?? []) {
|
|
||||||
app.log.warn(`harden(${driverId} ${id}): ${w}`);
|
|
||||||
hardenWarnings.push(w);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasPushConfig(device)) {
|
|
||||||
const host = String(config.host ?? "");
|
|
||||||
// Admin-provided backend IP wins; else auto-derive (on-subnet NIC).
|
|
||||||
const pushHost = backendIp ?? backendIpForDevice(host);
|
|
||||||
if (!pushHost) {
|
|
||||||
return reply.code(400).send({
|
|
||||||
error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const pushUser = "dingtian";
|
|
||||||
// 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars
|
|
||||||
// (longer is silently truncated → auth mismatch), so keep it short.
|
|
||||||
const pushPassword = randomBytes(12).toString("hex");
|
|
||||||
await device.configureInputPush({
|
|
||||||
host: pushHost,
|
|
||||||
port: backendPort(),
|
|
||||||
pathBase: `/api/devices/${driverId}/${id}/input`,
|
|
||||||
auth: { user: pushUser, password: pushPassword },
|
|
||||||
});
|
|
||||||
fullConfig.pushUser = pushUser;
|
|
||||||
fullConfig.pushPassword = pushPassword;
|
|
||||||
// Record the backend IP the device was told to push to — lets us detect
|
|
||||||
// a later mismatch if the host's IP changes.
|
|
||||||
fullConfig.backendIp = pushHost;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
return reply
|
|
||||||
.code(502)
|
|
||||||
.send({ error: `device configuration failed: ${(err as Error).message}` });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = {
|
const row = {
|
||||||
id,
|
id,
|
||||||
lane,
|
|
||||||
category,
|
category,
|
||||||
driverId,
|
driverId,
|
||||||
config: fullConfig,
|
config: outcome.config,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
await db.insert(laneDevices).values(row);
|
await db.insert(devices).values(row);
|
||||||
onAssignmentsChanged(); // refresh derived state (device->lane map)
|
|
||||||
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
...row,
|
...row,
|
||||||
config: redactSecrets(fullConfig),
|
config: redactSecrets(outcome.config),
|
||||||
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}),
|
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Edit an assigned device in place. Same configure pipeline as assign, but it
|
||||||
|
// UPDATEs the existing row and KEEPS the id — which matters for controllers,
|
||||||
|
// since the id is baked into the device's input-push URL
|
||||||
|
// (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and
|
||||||
|
// break push until reconfigured; PATCH re-runs harden/push against the same id.
|
||||||
|
// The category and driver are fixed at create time (an edit can't change what
|
||||||
|
// KIND of device a slot is); only config changes. Admin-only.
|
||||||
|
app.patch<{ Params: { id: string }; Body: Omit<AssignBody, "category" | "driverId"> }>(
|
||||||
|
"/api/setup/assign/:id",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(eq(devices.id, req.params.id))
|
||||||
|
.get();
|
||||||
|
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
||||||
|
|
||||||
|
const { config, backendIp } = req.body;
|
||||||
|
const outcome = await configureDevice(app, {
|
||||||
|
id: existing.id,
|
||||||
|
driverId: existing.driverId,
|
||||||
|
config,
|
||||||
|
backendIp,
|
||||||
|
// Carry forward machine-only secrets the client never received, so an
|
||||||
|
// edit that omits them doesn't blank out push/relay passwords.
|
||||||
|
existingConfig: existing.config,
|
||||||
|
});
|
||||||
|
if ("error" in outcome) {
|
||||||
|
return reply.code(outcome.error.code).send({ error: outcome.error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id));
|
||||||
|
app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`);
|
||||||
|
return reply.code(200).send({
|
||||||
|
id: existing.id,
|
||||||
|
category: existing.category,
|
||||||
|
driverId: existing.driverId,
|
||||||
|
config: redactSecrets(outcome.config),
|
||||||
|
enabled: existing.enabled,
|
||||||
|
...(outcome.warnings.length ? { warnings: outcome.warnings } : {}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -283,13 +444,12 @@ export async function setupRoutes(
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(laneDevices)
|
.from(devices)
|
||||||
.where(eq(laneDevices.id, req.params.id))
|
.where(eq(devices.id, req.params.id))
|
||||||
.get();
|
.get();
|
||||||
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
|
||||||
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id));
|
await db.delete(devices).where(eq(devices.id, req.params.id));
|
||||||
onAssignmentsChanged(); // refresh derived state (device->lane map)
|
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId})`);
|
||||||
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
|
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { eq, users, type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
|
import {
|
||||||
|
InvalidCashMovementError,
|
||||||
|
NoOpenShiftError,
|
||||||
|
ShiftAlreadyOpenError,
|
||||||
|
type ShiftService,
|
||||||
|
} from "../shift-service.js";
|
||||||
|
|
||||||
|
interface CashVoucherBody {
|
||||||
|
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||||
|
* cash_out = Mandat Pagese (pay-OUT). */
|
||||||
|
type: "cash_in" | "cash_out";
|
||||||
|
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||||
|
amountMinor: number;
|
||||||
|
reason?: string;
|
||||||
|
currency?: string;
|
||||||
|
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
|
||||||
|
authorizedBy: string;
|
||||||
|
/** That admin's password — re-entered to sign off on the drawer movement. */
|
||||||
|
authorizerPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShiftsQuery {
|
||||||
|
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
||||||
|
operator?: string;
|
||||||
|
/** ISO window over shift START time. */
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||||
|
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||||
|
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||||
|
|
||||||
|
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
|
||||||
|
// Reading the shift state vs. opening/closing one's own shift.
|
||||||
|
const readGuard = requirePermission("shift:read");
|
||||||
|
const guard = requirePermission("shift:create");
|
||||||
|
|
||||||
|
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
|
||||||
|
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
|
||||||
|
// someone else's shift → disabled. Also returns the live drawer balance.
|
||||||
|
// - open: the open shift { startedAt, operator } or null (site-wide)
|
||||||
|
// - isMine: true iff the open shift belongs to the requesting operator
|
||||||
|
// - operator: the requesting user (for the UI's own identity)
|
||||||
|
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => {
|
||||||
|
const me = req.user.username;
|
||||||
|
const open = shift.currentOpenShift();
|
||||||
|
const heldBy = open?.identity ?? null;
|
||||||
|
const drawer = shift.drawerBalance();
|
||||||
|
return {
|
||||||
|
operator: me,
|
||||||
|
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
||||||
|
isMine: open != null && heldBy === me,
|
||||||
|
drawerMinor: drawer.balanceMinor,
|
||||||
|
currency: drawer.currency,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
|
||||||
|
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
|
||||||
|
// as of now. Appends nothing — it's not an accountability mark, just a projection
|
||||||
|
// (the Z-report at close is the signed record). 204 when no shift is open.
|
||||||
|
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
|
||||||
|
const report = shift.currentReport();
|
||||||
|
if (!report) return reply.code(204).send();
|
||||||
|
return report;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Completed shift history. SCOPED by permission:
|
||||||
|
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
||||||
|
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||||
|
// `operator` and a `from`/`to` time window over each shift's START.
|
||||||
|
// This keeps one operator from reading another's takings while letting admins
|
||||||
|
// reconcile across the site. The data is the signed shift_z_report chain.
|
||||||
|
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
|
||||||
|
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
|
||||||
|
const q = req.query ?? {};
|
||||||
|
// Non-admins are hard-scoped to themselves regardless of any operator param.
|
||||||
|
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
|
||||||
|
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||||
|
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||||
|
const shifts = shift.listShifts({ operator, from, to });
|
||||||
|
return { shifts, scope: canSeeAll ? "all" : "self" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||||
|
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
|
||||||
|
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
|
||||||
|
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
|
||||||
|
// (`shift:cash`) who re-enters their password. This keeps the float control —
|
||||||
|
// an operator cannot move the float alone — while letting them raise the slip.
|
||||||
|
// See wiki/concepts/shift.md.
|
||||||
|
app.post<{ Body: CashVoucherBody }>(
|
||||||
|
"/api/cash-voucher",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const b = req.body ?? ({} as CashVoucherBody);
|
||||||
|
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||||
|
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||||
|
}
|
||||||
|
const authName = (b.authorizedBy ?? "").trim();
|
||||||
|
if (!authName || !b.authorizerPassword) {
|
||||||
|
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
|
||||||
|
}
|
||||||
|
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
|
||||||
|
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
|
||||||
|
// Always run a bcrypt compare (constant-time wrt whether the user exists).
|
||||||
|
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
||||||
|
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
|
||||||
|
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
|
||||||
|
if (!authUser || !passwordOk || !isAdminGrade) {
|
||||||
|
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await shift.recordVoucher({
|
||||||
|
type: b.type,
|
||||||
|
operator: req.user.username, // who RAISED it
|
||||||
|
authorizedBy: authUser.username, // who signed off (canonical case)
|
||||||
|
amountMinor: b.amountMinor,
|
||||||
|
reason: b.reason ?? "",
|
||||||
|
currency: b.currency,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await shift.open(req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await shift.close(req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
|
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
||||||
|
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
|
||||||
|
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|
||||||
|
// Optional park-metadata text fields (all nullable). Trimmed; "" → null.
|
||||||
|
const TEXT_FIELDS = [
|
||||||
|
"parkName",
|
||||||
|
"operatorName",
|
||||||
|
"nius",
|
||||||
|
"address",
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
// IANA timezone for tariff wall-clock windows (copied into each published version).
|
||||||
|
"timezone",
|
||||||
|
// Default vehicle/customer category frozen onto each transient entry.
|
||||||
|
"defaultVehicleCategory",
|
||||||
|
] as const;
|
||||||
|
type TextField = (typeof TEXT_FIELDS)[number];
|
||||||
|
|
||||||
|
interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||||
|
/** Nominal capacity; null = no limit. */
|
||||||
|
capacity?: number | null;
|
||||||
|
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
|
||||||
|
exitVoucherDefault?: boolean;
|
||||||
|
/** Site default monthly subscription price in minor units (pre-fills the form). */
|
||||||
|
subscriptionMonthlyPriceMinor?: number | null;
|
||||||
|
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not
|
||||||
|
* parked — so transients see "full" sooner and the subscriber's spot is held. */
|
||||||
|
reserveSubscriberSpots?: boolean;
|
||||||
|
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's
|
||||||
|
* plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */
|
||||||
|
anprEntryEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||||
|
* + every metadata field. */
|
||||||
|
type SiteConfig = {
|
||||||
|
capacity: number | null;
|
||||||
|
exitVoucherDefault: boolean;
|
||||||
|
subscriptionMonthlyPriceMinor: number | null;
|
||||||
|
reserveSubscriberSpots: boolean;
|
||||||
|
anprEntryEnabled: boolean;
|
||||||
|
} & Record<TextField, string | null>;
|
||||||
|
|
||||||
|
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||||
|
const out = {
|
||||||
|
capacity: row?.capacity ?? null,
|
||||||
|
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||||
|
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||||
|
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
||||||
|
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
||||||
|
} as SiteConfig;
|
||||||
|
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trim a text field; empty string becomes null so blank input clears it. */
|
||||||
|
function normText(v: unknown): string | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
const s = String(v).trim();
|
||||||
|
return s === "" ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const readGuard = requirePermission("site:read");
|
||||||
|
const writeGuard = requirePermission("site:update");
|
||||||
|
|
||||||
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||||
|
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||||
|
|
||||||
|
// Read site config (capacity + park metadata).
|
||||||
|
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return toSiteConfig(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text
|
||||||
|
// (only the fields PRESENT in the body are updated; absent fields are untouched).
|
||||||
|
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
|
||||||
|
const body = req.body ?? ({} as SiteConfigBody);
|
||||||
|
|
||||||
|
const patch: Partial<typeof siteConfig.$inferInsert> = {};
|
||||||
|
if ("capacity" in body) {
|
||||||
|
const c = body.capacity;
|
||||||
|
if (c != null && (!Number.isInteger(c) || c < 0)) {
|
||||||
|
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
|
||||||
|
}
|
||||||
|
patch.capacity = c ?? null;
|
||||||
|
}
|
||||||
|
if ("exitVoucherDefault" in body) {
|
||||||
|
if (typeof body.exitVoucherDefault !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: "exitVoucherDefault must be a boolean" });
|
||||||
|
}
|
||||||
|
patch.exitVoucherDefault = body.exitVoucherDefault;
|
||||||
|
}
|
||||||
|
if ("subscriptionMonthlyPriceMinor" in body) {
|
||||||
|
const p = body.subscriptionMonthlyPriceMinor;
|
||||||
|
if (p != null && (!Number.isInteger(p) || p < 0)) {
|
||||||
|
return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" });
|
||||||
|
}
|
||||||
|
patch.subscriptionMonthlyPriceMinor = p ?? null;
|
||||||
|
}
|
||||||
|
if ("reserveSubscriberSpots" in body) {
|
||||||
|
if (typeof body.reserveSubscriberSpots !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: "reserveSubscriberSpots must be a boolean" });
|
||||||
|
}
|
||||||
|
patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
|
||||||
|
}
|
||||||
|
if ("anprEntryEnabled" in body) {
|
||||||
|
if (typeof body.anprEntryEnabled !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: "anprEntryEnabled must be a boolean" });
|
||||||
|
}
|
||||||
|
patch.anprEntryEnabled = body.anprEntryEnabled;
|
||||||
|
}
|
||||||
|
for (const f of TEXT_FIELDS) {
|
||||||
|
if (f in body) patch[f] = normText(body[f]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
if (existing) {
|
||||||
|
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||||
|
}
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return toSiteConfig(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
|
||||||
|
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
||||||
|
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
|
||||||
|
// tied to a signed vehicle_entry/exit by `identity`; the operator reviews them
|
||||||
|
// next to the event. Read-only — images are written only by the flows (snapshot.ts),
|
||||||
|
// never via the API.
|
||||||
|
|
||||||
|
export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const guard = requirePermission("session:read");
|
||||||
|
|
||||||
|
// Snapshot metadata for one session/credential identity (NOT the bytes), newest
|
||||||
|
// first — lets the UI show "entry/exit image" links beside an event. We also return
|
||||||
|
// FAILED capture attempts (from snapshot telemetry) so the operator can tell a
|
||||||
|
// camera that was offline from a direction that simply has no camera — otherwise a
|
||||||
|
// missing shot is a silent gap. See snapshot.ts (recordFailure).
|
||||||
|
app.get<{ Params: { identity: string } }>(
|
||||||
|
"/api/snapshots/by-identity/:identity",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req) => {
|
||||||
|
const identity = req.params.identity;
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
id: snapshots.id,
|
||||||
|
direction: snapshots.direction,
|
||||||
|
deviceId: snapshots.deviceId,
|
||||||
|
identity: snapshots.identity,
|
||||||
|
contentType: snapshots.contentType,
|
||||||
|
capturedAt: snapshots.capturedAt,
|
||||||
|
})
|
||||||
|
.from(snapshots)
|
||||||
|
.where(eq(snapshots.identity, identity))
|
||||||
|
.orderBy(desc(snapshots.capturedAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Failed attempts: kind="snapshot" telemetry whose detail.identity matches and
|
||||||
|
// detail.ok === false. There may be both a failure and (on a retry) a success
|
||||||
|
// for the same direction; we keep only failures with NO successful shot in the
|
||||||
|
// same direction, so a recovered capture doesn't show a stale warning.
|
||||||
|
const haveDir = new Set<string | null>(rows.map((r) => r.direction));
|
||||||
|
const telemetry = db
|
||||||
|
.select({ detail: deviceEvents.detail, deviceId: deviceEvents.deviceId, occurredAt: deviceEvents.occurredAt })
|
||||||
|
.from(deviceEvents)
|
||||||
|
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "snapshot")))
|
||||||
|
.orderBy(desc(deviceEvents.occurredAt))
|
||||||
|
.all();
|
||||||
|
const failures: {
|
||||||
|
direction: "entry" | "exit" | null;
|
||||||
|
deviceId: string;
|
||||||
|
error: string;
|
||||||
|
occurredAt: string;
|
||||||
|
}[] = [];
|
||||||
|
const seenFailDir = new Set<string>();
|
||||||
|
for (const row of telemetry) {
|
||||||
|
const d = (row.detail ?? {}) as { identity?: string; ok?: boolean; error?: string; direction?: string };
|
||||||
|
if (d.identity !== identity || d.ok !== false) continue;
|
||||||
|
const dir = d.direction === "entry" || d.direction === "exit" ? d.direction : null;
|
||||||
|
const dirKey = dir ?? "both";
|
||||||
|
if (haveDir.has(dir) || seenFailDir.has(dirKey)) continue; // a success exists, or already shown
|
||||||
|
seenFailDir.add(dirKey);
|
||||||
|
failures.push({
|
||||||
|
direction: dir,
|
||||||
|
deviceId: row.deviceId ?? "",
|
||||||
|
error: d.error ?? "capture failed",
|
||||||
|
occurredAt: row.occurredAt ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recognized PLATES for this session: kind="read" telemetry from the ANPR-on-
|
||||||
|
// snapshot path (snapshot.ts → recognizePlate). Advisory — a record of the plate
|
||||||
|
// observed for the session, shown beside the image. Newest first.
|
||||||
|
const plateRows = db
|
||||||
|
.select({ detail: deviceEvents.detail, occurredAt: deviceEvents.occurredAt })
|
||||||
|
.from(deviceEvents)
|
||||||
|
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||||||
|
.orderBy(desc(deviceEvents.occurredAt))
|
||||||
|
.all();
|
||||||
|
const plates: {
|
||||||
|
plate: string;
|
||||||
|
confidence: number | null;
|
||||||
|
region: string | null;
|
||||||
|
direction: "entry" | "exit" | null;
|
||||||
|
snapshotId: string | null;
|
||||||
|
at: string;
|
||||||
|
}[] = [];
|
||||||
|
for (const row of plateRows) {
|
||||||
|
const d = (row.detail ?? {}) as {
|
||||||
|
identity?: string;
|
||||||
|
plate?: string;
|
||||||
|
confidence?: number;
|
||||||
|
region?: string | null;
|
||||||
|
direction?: string;
|
||||||
|
snapshotId?: string;
|
||||||
|
};
|
||||||
|
if (d.identity !== identity || !d.plate) continue;
|
||||||
|
plates.push({
|
||||||
|
plate: d.plate,
|
||||||
|
confidence: typeof d.confidence === "number" ? d.confidence : null,
|
||||||
|
region: d.region ?? null,
|
||||||
|
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
|
||||||
|
snapshotId: d.snapshotId ?? null,
|
||||||
|
at: row.occurredAt ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { snapshots: rows, failures, plates };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stream one snapshot's image bytes by id. Returns the stored content type.
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
"/api/snapshots/:id",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
|
||||||
|
if (!row) return reply.code(404).send({ error: "no such snapshot" });
|
||||||
|
reply.header("content-type", row.contentType);
|
||||||
|
reply.header("cache-control", "private, max-age=31536000, immutable");
|
||||||
|
return reply.send(row.bytes);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { and, desc, eq, isNull, subscriptionPlans, subscriptions, type Db } from "@parking/db";
|
||||||
|
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { softDelete } from "../recycle-bin.js";
|
||||||
|
import { siteTz } from "../subscription-window.js";
|
||||||
|
|
||||||
|
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
|
||||||
|
// from (so they never type a price). Mirrors the tariff composer: plans are
|
||||||
|
// EFFECTIVE-DATED IMMUTABLE VERSIONS keyed by a stable `planId`; editing a plan
|
||||||
|
// PUBLISHES A NEW VERSION (new row, new effectiveFrom), never mutates an old one, so
|
||||||
|
// a past sale reprices identically against its recorded planVersionId. Retire =
|
||||||
|
// active=0 (soft, keeps history). Admin-only (`subscription:plan`); selling stays
|
||||||
|
// operator-grade (`subscription:create`). See wiki/entities/subscription.md.
|
||||||
|
|
||||||
|
interface PlanBody {
|
||||||
|
/** Stable identity across versions (e.g. "hotel-daily"). New on create; reused to
|
||||||
|
* publish a new version of an existing plan. Slugified server-side. */
|
||||||
|
planId?: string;
|
||||||
|
name?: string;
|
||||||
|
period?: SubscriptionPeriod;
|
||||||
|
pricePerPeriodMinor?: number;
|
||||||
|
currency?: string;
|
||||||
|
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||||
|
effectiveFrom?: string;
|
||||||
|
/** Allowed-time windows (tariff bridge); null/omitted = 24/7. */
|
||||||
|
timeframes?: PlanTimeframes | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate the optional timeframes blob (minutes-of-day 0–1439, days 0–6, sane grace). */
|
||||||
|
function validTimeframes(tf: PlanTimeframes | null | undefined): string | null {
|
||||||
|
if (tf == null) return null;
|
||||||
|
const okMin = (v: unknown) => Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439;
|
||||||
|
if (!okMin(tf.fromMin) || !okMin(tf.toMin)) return "window times must be minutes-of-day (0–1439)";
|
||||||
|
if (tf.days != null && (!Array.isArray(tf.days) || tf.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) {
|
||||||
|
return "days must be integers 0–6 (0=Sun..6=Sat)";
|
||||||
|
}
|
||||||
|
if (tf.graceMin != null && (!Number.isInteger(tf.graceMin) || tf.graceMin < 0)) return "graceMin must be ≥ 0";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase, hyphenate, strip junk — a stable slug for the plan identity. */
|
||||||
|
function slugify(s: string): string {
|
||||||
|
return s
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const readGuard = requirePermission("subscription:read");
|
||||||
|
const planGuard = requirePermission("subscription:plan");
|
||||||
|
|
||||||
|
function validate(b: PlanBody): string[] {
|
||||||
|
const errs: string[] = [];
|
||||||
|
if (!b.name?.trim()) errs.push("name is required");
|
||||||
|
if (!b.period || !SUBSCRIPTION_PERIODS.includes(b.period)) {
|
||||||
|
errs.push(`period must be one of: ${SUBSCRIPTION_PERIODS.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(b.pricePerPeriodMinor) || (b.pricePerPeriodMinor ?? 0) <= 0) {
|
||||||
|
errs.push("pricePerPeriodMinor must be a positive integer (minor units)");
|
||||||
|
}
|
||||||
|
if (!b.currency?.trim()) errs.push("currency is required");
|
||||||
|
if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) {
|
||||||
|
errs.push("effectiveFrom must be a valid ISO-8601 timestamp");
|
||||||
|
}
|
||||||
|
const tfErr = validTimeframes(b.timeframes);
|
||||||
|
if (tfErr) errs.push(tfErr);
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List plans. ?all=1 → every version (history); default → the CURRENT sellable plan
|
||||||
|
// per planId (latest active version with effectiveFrom ≤ now). Operators selling
|
||||||
|
// need the current list; the admin catalog screen asks for ?all=1.
|
||||||
|
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => {
|
||||||
|
// Exclude soft-deleted plan versions — those live in the recycle bin. (A plan is
|
||||||
|
// versioned; a soft-delete stamps every version row of the planId.)
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(subscriptionPlans)
|
||||||
|
.where(isNull(subscriptionPlans.deletedAt))
|
||||||
|
.orderBy(desc(subscriptionPlans.effectiveFrom))
|
||||||
|
.all();
|
||||||
|
if (req.query?.all) return { plans: rows };
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
// Newest-effective active version wins per planId.
|
||||||
|
const current = new Map<string, (typeof rows)[number]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.active || r.effectiveFrom > now) continue;
|
||||||
|
if (!current.has(r.planId)) current.set(r.planId, r); // rows are newest-first
|
||||||
|
}
|
||||||
|
return { plans: [...current.values()] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish a plan version (create a plan, or a new version of an existing planId).
|
||||||
|
app.post<{ Body: PlanBody }>("/api/subscription-plans", { preHandler: planGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? ({} as PlanBody);
|
||||||
|
const problems = validate(b);
|
||||||
|
if (problems.length) return reply.code(400).send({ error: "invalid plan", problems });
|
||||||
|
|
||||||
|
const planId = (b.planId?.trim() ? slugify(b.planId) : slugify(b.name!)) || randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const effectiveFrom = b.effectiveFrom?.trim() || now;
|
||||||
|
// Backdating would retroactively reprice — refuse (mirrors tariff publish).
|
||||||
|
if (Date.parse(effectiveFrom) < Date.parse(now) - 60_000) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: "effectiveFrom cannot be in the past — backdating a plan would retroactively reprice sales",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Stamp the site tz into the timeframes so the windows evaluate in the site's
|
||||||
|
// wall-clock, FROZEN in this version (mirrors how tariff V2 freezes its tz).
|
||||||
|
const timeframes =
|
||||||
|
b.timeframes != null ? { ...b.timeframes, tz: b.timeframes.tz || siteTz(db) } : null;
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: randomUUID(),
|
||||||
|
planId,
|
||||||
|
name: b.name!.trim(),
|
||||||
|
period: b.period!,
|
||||||
|
pricePerPeriodMinor: b.pricePerPeriodMinor!,
|
||||||
|
currency: b.currency!.trim(),
|
||||||
|
effectiveFrom,
|
||||||
|
timeframes,
|
||||||
|
active: true,
|
||||||
|
createdBy: req.user?.username ?? null,
|
||||||
|
};
|
||||||
|
db.insert(subscriptionPlans).values(row as typeof subscriptionPlans.$inferInsert).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retire a plan (soft): mark every version of this planId inactive so it's no longer
|
||||||
|
// sellable. History (and past sales' planVersionId) is preserved. Reactivate to revive.
|
||||||
|
app.post<{ Params: { planId: string } }>(
|
||||||
|
"/api/subscription-plans/:planId/retire",
|
||||||
|
{ preHandler: planGuard },
|
||||||
|
async (req) => {
|
||||||
|
db.update(subscriptionPlans)
|
||||||
|
.set({ active: false })
|
||||||
|
.where(eq(subscriptionPlans.planId, req.params.planId))
|
||||||
|
.run();
|
||||||
|
return { planId: req.params.planId, retired: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// REACTIVATE a retired plan: mark its versions active again so it's sellable. The
|
||||||
|
// latest-effective version becomes "in force" again. (The inverse of retire.)
|
||||||
|
app.post<{ Params: { planId: string } }>(
|
||||||
|
"/api/subscription-plans/:planId/reactivate",
|
||||||
|
{ preHandler: planGuard },
|
||||||
|
async (req) => {
|
||||||
|
db.update(subscriptionPlans)
|
||||||
|
.set({ active: true })
|
||||||
|
.where(eq(subscriptionPlans.planId, req.params.planId))
|
||||||
|
.run();
|
||||||
|
return { planId: req.params.planId, reactivated: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
|
||||||
|
// version). A referenced plan version MUST survive: a subscription's planVersionId is
|
||||||
|
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
|
||||||
|
// when in use (the admin should retire instead). SOFT delete (recycle bin): stamps all
|
||||||
|
// versions of the planId; a restore brings the plan back; purge does the real removal.
|
||||||
|
app.delete<{ Params: { planId: string } }>(
|
||||||
|
"/api/subscription-plans/:planId",
|
||||||
|
{ preHandler: planGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
// Only LIVE subscriptions block deletion (a soft-deleted subscriber's planId ref is
|
||||||
|
// itself in the bin; if it's restored later, the plan can be restored too).
|
||||||
|
const refs = db
|
||||||
|
.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(and(eq(subscriptions.planId, req.params.planId), isNull(subscriptions.deletedAt)))
|
||||||
|
.all();
|
||||||
|
if (refs.length > 0) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: "plan is in use and cannot be deleted",
|
||||||
|
code: "plan_in_use",
|
||||||
|
subscribers: refs.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ok = softDelete(db, "plan", req.params.planId, req.user.sub);
|
||||||
|
if (!ok) return reply.code(404).send({ error: "plan not found" });
|
||||||
|
return { planId: req.params.planId, deleted: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||||
|
import { NoPrinterAvailableError } from "@parking/devices";
|
||||||
|
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
|
||||||
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
|
import { softDelete } from "../recycle-bin.js";
|
||||||
|
import { invalidateHolder } from "../event-enrich.js";
|
||||||
|
import { printSubscriptionCard } from "../booth-print.js";
|
||||||
|
import type { CredentialCapture } from "../credential-capture.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
|
import type { ShiftService } from "../shift-service.js";
|
||||||
|
import { directionOf } from "../device-resolve.js";
|
||||||
|
import { priceSubscriptionSpan, resolvePlanVersion } from "../subscription-pricing.js";
|
||||||
|
|
||||||
|
// Subscription admin CRUD. A subscription is mutable master data — admins
|
||||||
|
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
|
||||||
|
// trail stays append-only (see wiki/entities/subscription.md). A subscription is an
|
||||||
|
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
|
||||||
|
// them as one unit (create/update replace the child sets; delete removes all).
|
||||||
|
//
|
||||||
|
// Pricing & THE SALE. priceMinor + period ("monthly") + currency record the recurring
|
||||||
|
// plan (e.g. 10,000 ALL / month). When a subscription is SOLD (created with a price),
|
||||||
|
// the operator collects real money — so we append a SIGNED `payment` ledger event for
|
||||||
|
// the amount actually taken (priceMinor × months for a multi-month prepay), with the
|
||||||
|
// tender the operator chose. That is the ONLY accountability mechanism: without it the
|
||||||
|
// sale leaves no trace in the live feed, the drawer, or the shift Z-report, and the
|
||||||
|
// operator could pocket the cash untraceably (the exact booth-operator-as-adversary
|
||||||
|
// gap this system exists to close). The `subscriptions` row is mutable master data and
|
||||||
|
// is NOT the financial record; the signed payment event is. See wiki/concepts/shift.md.
|
||||||
|
|
||||||
|
interface Credential {
|
||||||
|
kind: "rf" | "qr";
|
||||||
|
/** For RF: the physical card/tag id (required). For QR: optional — left blank, the
|
||||||
|
* server AUTO-GENERATES an unguessable code (the customer never picks it). */
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
interface SubscriptionBody {
|
||||||
|
holderName?: string;
|
||||||
|
contact?: string;
|
||||||
|
/** PRICED SALE: the plan the operator selected. The price is LOOKED UP from the
|
||||||
|
* plan version (periods × per-period price) — the operator never types an amount.
|
||||||
|
* Omit for a free/comp subscription (no plan, no charge). */
|
||||||
|
planId?: string | null;
|
||||||
|
/** Coverage window. For a priced sale: `validFrom` defaults to now, `validTo` is
|
||||||
|
* REQUIRED (the span priced against the plan). For a comp sub, both optional. */
|
||||||
|
validFrom?: string | null;
|
||||||
|
validTo?: string | null;
|
||||||
|
/** How many cars this subscription covers (a family pays once for N cars). Sale =
|
||||||
|
* plan span price × quantity; maxConcurrent defaults to it. ≥ 1, default 1. */
|
||||||
|
quantity?: number | null;
|
||||||
|
/** Car-count binding: cars inside at once. Default = quantity; null = unbound. */
|
||||||
|
maxConcurrent?: number | null;
|
||||||
|
status?: "active" | "suspended" | "revoked";
|
||||||
|
credentials?: Credential[];
|
||||||
|
/** Plate binding (optional): bound plates that also serve as identity. */
|
||||||
|
plates?: string[];
|
||||||
|
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
||||||
|
* plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */
|
||||||
|
tender?: Tender;
|
||||||
|
/** UPDATE-only CORRECTION: move this sub to a different VERSION of its SAME plan (e.g.
|
||||||
|
* an admin published v2 with different timeframes and wants an existing subscriber on
|
||||||
|
* it, or back on v1). Must be a version of the sub's existing planId; price/currency/
|
||||||
|
* period stay FROZEN (not a re-sale — only the access rules change going forward).
|
||||||
|
* Gated on `subscription:plan` (plan-management, stronger than subscription:update);
|
||||||
|
* ignored from a non-privileged caller. See wiki/entities/subscription.md. */
|
||||||
|
planVersionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */
|
||||||
|
interface QuoteBody {
|
||||||
|
planId?: string;
|
||||||
|
validFrom?: string;
|
||||||
|
validTo?: string;
|
||||||
|
quantity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
|
||||||
|
* delivers the full string over TCP/IP (the host-in-the-loop path), so length is
|
||||||
|
* free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */
|
||||||
|
function newQrCode(): string {
|
||||||
|
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||||
|
const bytes = randomBytes(15);
|
||||||
|
let out = "";
|
||||||
|
for (const b of bytes) out += alphabet[b % 32];
|
||||||
|
return `SUB-${out}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscriptionRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
capture: CredentialCapture,
|
||||||
|
eventLog: EventLog,
|
||||||
|
shift: ShiftService,
|
||||||
|
): Promise<void> {
|
||||||
|
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
|
||||||
|
const readGuard = requirePermission("subscription:read");
|
||||||
|
const createGuard = requirePermission("subscription:create");
|
||||||
|
const updateGuard = requirePermission("subscription:update");
|
||||||
|
const deleteGuard = requirePermission("subscription:delete");
|
||||||
|
|
||||||
|
// Validate the body; returns problems (empty = ok). Shared by create + update.
|
||||||
|
function validate(b: SubscriptionBody): string[] {
|
||||||
|
const errs: string[] = [];
|
||||||
|
if (b.maxConcurrent != null) {
|
||||||
|
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
|
||||||
|
errs.push("maxConcurrent must be a positive integer, or null for unbound");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// PRICED SALE: a plan is selected → the span must be valid and price > 0. The
|
||||||
|
// amount is derived from the plan (operator never types it), so there's no
|
||||||
|
// priceMinor to validate.
|
||||||
|
if (b.planId != null && b.planId.trim()) {
|
||||||
|
const from = b.validFrom?.trim() || new Date().toISOString();
|
||||||
|
const to = b.validTo?.trim();
|
||||||
|
if (!to) {
|
||||||
|
errs.push("validTo (end date) is required when selling a plan");
|
||||||
|
} else if (Number.isNaN(Date.parse(to)) || Number.isNaN(Date.parse(from))) {
|
||||||
|
errs.push("validFrom/validTo must be valid ISO-8601 dates");
|
||||||
|
} else if (Date.parse(to) <= Date.parse(from)) {
|
||||||
|
errs.push("validTo must be after validFrom");
|
||||||
|
} else {
|
||||||
|
// Resolve the plan version at the SALE instant (now) — the customer buys today's
|
||||||
|
// published plan/price. (validFrom is the coverage start, which may be midnight
|
||||||
|
// today and predate a plan published this afternoon.)
|
||||||
|
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
|
||||||
|
if (!plan) errs.push("no active plan found for the selected planId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (b.quantity != null && (!Number.isInteger(b.quantity) || b.quantity < 1)) {
|
||||||
|
errs.push("quantity must be a positive integer (cars covered)");
|
||||||
|
}
|
||||||
|
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||||||
|
errs.push("status must be active|suspended|revoked");
|
||||||
|
}
|
||||||
|
if (b.tender != null && b.tender !== "cash" && b.tender !== "card") {
|
||||||
|
errs.push("tender must be cash|card");
|
||||||
|
}
|
||||||
|
for (const c of b.credentials ?? []) {
|
||||||
|
if (c.kind !== "rf" && c.kind !== "qr") {
|
||||||
|
errs.push("each credential needs kind (rf|qr)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// RF must carry the physical card id; QR may be blank (server auto-generates).
|
||||||
|
if (c.kind === "rf" && !c.value?.trim()) {
|
||||||
|
errs.push("an RF credential needs a non-empty value (the card/tag id)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
|
||||||
|
errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)");
|
||||||
|
}
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAggregate(id: string) {
|
||||||
|
const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get();
|
||||||
|
if (!sub) return null;
|
||||||
|
const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all();
|
||||||
|
const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all();
|
||||||
|
return {
|
||||||
|
...sub,
|
||||||
|
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
|
||||||
|
plates: plates.map((p) => p.plate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is this credential value already used by ANY subscription? (Global uniqueness —
|
||||||
|
* a value is the lane identity, so it must resolve to one subscription.) */
|
||||||
|
function valueTaken(value: string): boolean {
|
||||||
|
return db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.value, value)).get() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh, collision-free QR code (retries on the astronomically unlikely clash). */
|
||||||
|
function mintQrCode(): string {
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
const code = newQrCode();
|
||||||
|
if (!valueTaken(code)) return code;
|
||||||
|
}
|
||||||
|
throw new Error("could not mint a unique QR code");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace a subscription's child rows (credentials + plates) from the body. QR
|
||||||
|
// credentials with no value are SERVER-GENERATED here (the customer never picks the
|
||||||
|
// code). The generated value is returned via loadAggregate so the UI can print it.
|
||||||
|
function writeChildren(id: string, b: SubscriptionBody) {
|
||||||
|
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
|
||||||
|
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
|
||||||
|
for (const c of b.credentials ?? []) {
|
||||||
|
const supplied = c.value?.trim();
|
||||||
|
// QR + blank → auto-generate; otherwise use the supplied value (RF card id, or a
|
||||||
|
// QR being preserved on edit).
|
||||||
|
const value = supplied && supplied.length > 0 ? supplied : c.kind === "qr" ? mintQrCode() : "";
|
||||||
|
if (!value) continue; // guarded by validate(); defensive
|
||||||
|
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value }).run();
|
||||||
|
}
|
||||||
|
for (const p of b.plates ?? []) {
|
||||||
|
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the coverage end: an explicit validTo (the span end the operator picked).
|
||||||
|
* Falls back to the existing value on an update that doesn't touch it. */
|
||||||
|
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
|
||||||
|
if (b.validTo !== undefined) return b.validTo ?? null;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve + price a priced sale: returns the plan version, the effective span, the
|
||||||
|
* quantity (cars covered), and the server-computed quote with the amount already
|
||||||
|
* MULTIPLIED by quantity (a family paying once for N cars). Returns null for a comp
|
||||||
|
* sub (no planId). validate() guards the happy path. */
|
||||||
|
function priceSale(
|
||||||
|
b: SubscriptionBody,
|
||||||
|
): { plan: SubscriptionPlan; validFrom: string; validTo: string; quantity: number; quote: SubscriptionQuote } | null {
|
||||||
|
if (!b.planId?.trim() || !b.validTo?.trim()) return null;
|
||||||
|
const validFrom = b.validFrom?.trim() || new Date().toISOString();
|
||||||
|
const validTo = b.validTo.trim();
|
||||||
|
// Plan version is resolved at the SALE instant (now), not validFrom (which is the
|
||||||
|
// coverage start and may predate a plan published later today).
|
||||||
|
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
|
||||||
|
if (!plan) return null;
|
||||||
|
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
|
||||||
|
const base = priceSubscriptionSpan(plan, validFrom, validTo);
|
||||||
|
// Price ×N: the whole sale covers N cars on one subscription.
|
||||||
|
const quote: SubscriptionQuote = { ...base, amountMinor: base.amountMinor * quantity };
|
||||||
|
return { plan, validFrom, validTo, quantity, quote };
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all LIVE subscriptions (with their credentials + plates). Soft-deleted ones
|
||||||
|
// live in the recycle bin, not here.
|
||||||
|
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
|
||||||
|
const rows = db.select().from(subscriptions).where(isNull(subscriptions.deletedAt)).all();
|
||||||
|
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Credential capture ("enroll a card") -------------------------------
|
||||||
|
// The operator picks a reader and presents an RFID card to it; the next read on
|
||||||
|
// that reader is captured for the form instead of opening a barrier. The OTHER
|
||||||
|
// reader keeps serving the live flow. Single-shot + TTL. See credential-capture.ts.
|
||||||
|
|
||||||
|
// The readers the operator can capture on (entry/exit by their bound relay).
|
||||||
|
app.get("/api/subscriptions/readers", { preHandler: readGuard }, async () => {
|
||||||
|
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
|
||||||
|
return {
|
||||||
|
readers: rows
|
||||||
|
.filter((r) => r.enabled)
|
||||||
|
.map((r) => ({ id: r.id, driverId: r.driverId, direction: directionOf(db, r) })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Arm capture on a reader (by devices.id). Operator-or-admin (booth action).
|
||||||
|
app.post<{ Body: { deviceId?: string } }>(
|
||||||
|
"/api/subscriptions/capture/arm",
|
||||||
|
{ preHandler: readGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const deviceId = (req.body?.deviceId ?? "").trim();
|
||||||
|
if (!deviceId) return reply.code(400).send({ error: "deviceId required" });
|
||||||
|
const reader = db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
|
if (!reader || reader.category !== "reader" || !reader.enabled) {
|
||||||
|
return reply.code(404).send({ error: "no such enabled reader" });
|
||||||
|
}
|
||||||
|
return capture.arm(deviceId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Poll the capture state (idle | armed | captured | expired). The form polls this
|
||||||
|
// and, on "captured", reads `value` into the credential field then clears it.
|
||||||
|
app.get("/api/subscriptions/capture", { preHandler: readGuard }, async () => capture.state());
|
||||||
|
|
||||||
|
// Operator cancelled / closed the form — disarm and clear any result.
|
||||||
|
app.post("/api/subscriptions/capture/cancel", { preHandler: readGuard }, async () => {
|
||||||
|
capture.cancel();
|
||||||
|
capture.clear();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a subscription.
|
||||||
|
// Price a span against a plan WITHOUT writing anything — the live quote the sell form
|
||||||
|
// shows ("3 nights · 2,400 ALL"). Server-computed so the operator can't fudge it.
|
||||||
|
app.post<{ Body: QuoteBody }>("/api/subscriptions/quote", { preHandler: readGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? {};
|
||||||
|
if (!b.planId?.trim()) return reply.code(400).send({ error: "planId is required" });
|
||||||
|
const validFrom = b.validFrom?.trim() || new Date().toISOString();
|
||||||
|
const validTo = b.validTo?.trim();
|
||||||
|
if (!validTo) return reply.code(400).send({ error: "validTo is required" });
|
||||||
|
if (Number.isNaN(Date.parse(validFrom)) || Number.isNaN(Date.parse(validTo))) {
|
||||||
|
return reply.code(400).send({ error: "validFrom/validTo must be valid ISO-8601 dates" });
|
||||||
|
}
|
||||||
|
if (Date.parse(validTo) <= Date.parse(validFrom)) {
|
||||||
|
return reply.code(400).send({ error: "validTo must be after validFrom" });
|
||||||
|
}
|
||||||
|
// Resolve at the sale instant (now), not validFrom — see priceSale.
|
||||||
|
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
|
||||||
|
if (!plan) return reply.code(404).send({ error: "no active plan for that planId" });
|
||||||
|
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
|
||||||
|
const base = priceSubscriptionSpan(plan, validFrom, validTo);
|
||||||
|
// Echo the ×quantity total so the form previews the family's combined price.
|
||||||
|
return { ...base, amountMinor: base.amountMinor * quantity, quantity, plan };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? {};
|
||||||
|
const problems = validate(b);
|
||||||
|
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
||||||
|
const id = randomUUID();
|
||||||
|
// Price is LOOKED UP from the chosen plan (periods × per-period price) — never typed
|
||||||
|
// by the operator. A comp sub (no plan) carries no price. Persist the plan + version
|
||||||
|
// so the sale reprices identically later.
|
||||||
|
const priced = priceSale(b);
|
||||||
|
db.insert(subscriptions)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
holderName: b.holderName ?? null,
|
||||||
|
contact: b.contact ?? null,
|
||||||
|
priceMinor: priced ? priced.quote.amountMinor : null,
|
||||||
|
period: priced ? priced.plan.period : "month",
|
||||||
|
currency: priced ? priced.quote.currency : null,
|
||||||
|
planId: priced ? priced.plan.planId : null,
|
||||||
|
planVersionId: priced ? priced.plan.id : null,
|
||||||
|
quantity: priced ? priced.quantity : (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
|
||||||
|
// maxConcurrent defaults to the quantity (the family's N cars can all be inside),
|
||||||
|
// unless the operator set it explicitly (null = unbound).
|
||||||
|
maxConcurrent:
|
||||||
|
b.maxConcurrent !== undefined
|
||||||
|
? b.maxConcurrent
|
||||||
|
: priced
|
||||||
|
? priced.quantity
|
||||||
|
: (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
|
||||||
|
validFrom: priced ? priced.validFrom : (b.validFrom ?? null),
|
||||||
|
validTo: priced ? priced.validTo : resolveValidTo(b, null),
|
||||||
|
status: b.status ?? "active",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
writeChildren(id, b);
|
||||||
|
const sub = loadAggregate(id);
|
||||||
|
// THE SALE: a priced subscription means the operator collected money. Append a
|
||||||
|
// SIGNED `payment` event so the takings show up in the live feed, the drawer, and
|
||||||
|
// the shift Z-report — never an untraceable cash grab. Best-effort wrt the response,
|
||||||
|
// but the append is the whole point, so a failure is logged loudly.
|
||||||
|
const sale = await recordSale(id, priced, b.tender, req.user?.username ?? "?");
|
||||||
|
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
|
||||||
|
// a print failure NEVER fails the create (the subscription + its code are saved);
|
||||||
|
// the response carries { printed, printError } so the UI can warn + offer reprint.
|
||||||
|
const printResult = await tryPrintCard(sub);
|
||||||
|
return reply.code(201).send({ ...sub, ...sale, ...printResult });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the SIGNED `payment` ledger event for a subscription sale, so the money is
|
||||||
|
* accounted for exactly like a parking payment (live feed + drawer + Z-report). The
|
||||||
|
* amount comes from the PLAN quote (periods × per-period price) — never an
|
||||||
|
* operator-typed number. No plan → no sale → nothing appended (free/comp). The event
|
||||||
|
* carries `subscriptionSale: true` + the subscription id + the plan version so the
|
||||||
|
* feed/audit can label it and the price is reproducible. We do NOT hard-require an
|
||||||
|
* open shift (a subscription can be sold outside the booth money path), but the
|
||||||
|
* operator IS recorded and the payment folds into whichever shift window contains its
|
||||||
|
* timestamp — so it can never be silently pocketed. Returns { sale } or {}.
|
||||||
|
*/
|
||||||
|
async function recordSale(
|
||||||
|
id: string,
|
||||||
|
priced: ReturnType<typeof priceSale>,
|
||||||
|
tenderIn: Tender | undefined,
|
||||||
|
operator: string,
|
||||||
|
): Promise<{ sale?: { amountMinor: number; currency: string | null; tender: Tender; periods: number; inShift: boolean } }> {
|
||||||
|
if (!priced || priced.quote.amountMinor <= 0) return {}; // free/comp — nothing collected
|
||||||
|
const { plan, quote } = priced;
|
||||||
|
const amountMinor = quote.amountMinor;
|
||||||
|
const tender: Tender = tenderIn ?? "cash";
|
||||||
|
const currency = quote.currency;
|
||||||
|
const inShift = shift.currentOpenShift() != null;
|
||||||
|
try {
|
||||||
|
await eventLog.append({
|
||||||
|
type: "payment",
|
||||||
|
source: "manual",
|
||||||
|
// Key the payment to the subscription so the feed can resolve the holder label
|
||||||
|
// and the audit can trace WHICH subscription was sold.
|
||||||
|
identity: id,
|
||||||
|
payload: {
|
||||||
|
sessionRef: id,
|
||||||
|
amountMinor,
|
||||||
|
currency,
|
||||||
|
tender,
|
||||||
|
operator,
|
||||||
|
// Flags this `payment` as a subscription SALE (not a parking payment) so the
|
||||||
|
// live feed / activity log can label it distinctly. plan + periods for audit
|
||||||
|
// and reproducible repricing.
|
||||||
|
subscriptionSale: true,
|
||||||
|
permitId: id,
|
||||||
|
planId: plan.planId,
|
||||||
|
planVersionId: plan.id,
|
||||||
|
periods: quote.periods,
|
||||||
|
...(priced.quantity > 1 ? { quantity: priced.quantity } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.log.info(
|
||||||
|
`subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}×${priced.quantity}car) for ${id} by ${operator}` +
|
||||||
|
(inShift ? "" : " [no open shift]"),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// A failed append is serious — the money would be untraceable. Surface it.
|
||||||
|
app.log.error(`subscription-sale payment append FAILED for ${id}: ${(err as Error).message}`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return { sale: { amountMinor, currency, tender, periods: quote.periods, inShift } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first QR credential's code for a subscription aggregate, or null. */
|
||||||
|
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
|
||||||
|
const cred = sub?.credentials.find((c) => c.kind === "qr");
|
||||||
|
return cred?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort print of a subscription's QR card. Returns a flag + optional error
|
||||||
|
* (never throws). No QR credential → nothing to print (printed:false, no error). */
|
||||||
|
async function tryPrintCard(
|
||||||
|
sub: ReturnType<typeof loadAggregate>,
|
||||||
|
): Promise<{ printed: boolean; printedBy?: string; printError?: string }> {
|
||||||
|
const code = qrCodeOf(sub);
|
||||||
|
if (!sub || !code) return { printed: false };
|
||||||
|
try {
|
||||||
|
const printedBy = await printSubscriptionCard(
|
||||||
|
db,
|
||||||
|
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
|
||||||
|
app.log,
|
||||||
|
);
|
||||||
|
return { printed: true, printedBy };
|
||||||
|
} catch (err) {
|
||||||
|
const printError = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
|
||||||
|
app.log.warn(`subscription card print failed for ${sub.id}: ${printError}`);
|
||||||
|
return { printed: false, printError };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a subscription (replaces fields + child sets).
|
||||||
|
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
|
||||||
|
"/api/subscriptions/:id",
|
||||||
|
{ preHandler: updateGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get();
|
||||||
|
if (!existing) return reply.code(404).send({ error: "subscription not found" });
|
||||||
|
const b = req.body ?? {};
|
||||||
|
const problems = validate(b);
|
||||||
|
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
||||||
|
|
||||||
|
// PLAN-VERSION CORRECTION (opt-in, privileged). Move the sub to a different VERSION
|
||||||
|
// of its SAME plan — e.g. an admin published v2 (different timeframes) and wants this
|
||||||
|
// subscriber on it, or back on v1. Price/currency/period stay frozen (not a re-sale).
|
||||||
|
// Guarded HERE on `subscription:plan` (stronger than the route's subscription:update),
|
||||||
|
// so a plain operator's edit can't move a version; a non-privileged caller sending it
|
||||||
|
// is rejected rather than silently ignored.
|
||||||
|
let planVersionId = existing.planVersionId;
|
||||||
|
if (b.planVersionId !== undefined && b.planVersionId !== existing.planVersionId) {
|
||||||
|
if (!req.user || !roleHasPermissions(req.user.roleId, ["subscription:plan"])) {
|
||||||
|
return reply.code(403).send({ error: "changing the plan version requires the subscription:plan permission" });
|
||||||
|
}
|
||||||
|
const target = db
|
||||||
|
.select()
|
||||||
|
.from(subscriptionPlans)
|
||||||
|
.where(eq(subscriptionPlans.id, b.planVersionId))
|
||||||
|
.get();
|
||||||
|
if (!target) return reply.code(404).send({ error: "plan version not found" });
|
||||||
|
// Must be a version of the SAME plan — this field corrects the version, never the
|
||||||
|
// plan itself (a different plan = a different price basis = a re-sale).
|
||||||
|
if (target.planId !== existing.planId) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: `plan version belongs to "${target.planId}", not this subscription's plan "${existing.planId}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
planVersionId = b.planVersionId;
|
||||||
|
req.log.info(
|
||||||
|
`subscription ${req.params.id} plan version ${existing.planVersionId} → ${b.planVersionId} (plan ${existing.planId}) by ${req.user.username ?? "?"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An update is otherwise a MASTER-DATA edit — it never re-sells or re-prices. Price,
|
||||||
|
// plan and currency are FROZEN as the original sale recorded them (a new price means a
|
||||||
|
// new sale = a new subscription). Editable here: holder/contact, car-count, the
|
||||||
|
// validity window, status, credentials/plates, and (privileged) the plan version.
|
||||||
|
db.update(subscriptions)
|
||||||
|
.set({
|
||||||
|
holderName: b.holderName ?? null,
|
||||||
|
contact: b.contact ?? null,
|
||||||
|
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
||||||
|
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
|
||||||
|
validTo: resolveValidTo(b, existing.validTo),
|
||||||
|
status: b.status ?? existing.status,
|
||||||
|
planVersionId,
|
||||||
|
})
|
||||||
|
.where(eq(subscriptions.id, req.params.id))
|
||||||
|
.run();
|
||||||
|
writeChildren(req.params.id, b);
|
||||||
|
// The holder name may have changed — drop the feed-label cache for this sub.
|
||||||
|
invalidateHolder(req.params.id);
|
||||||
|
return loadAggregate(req.params.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-print the subscription's QR card (failed auto-print, lost card, re-hand to the
|
||||||
|
// customer). Operator-or-admin (it's a booth action, not a master-data edit). 404 if
|
||||||
|
// the subscription is gone; 409 if it has no QR credential; 503 if no printer.
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
"/api/subscriptions/:id/print",
|
||||||
|
{ preHandler: readGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const sub = loadAggregate(req.params.id);
|
||||||
|
if (!sub) return reply.code(404).send({ error: "subscription not found" });
|
||||||
|
const code = qrCodeOf(sub);
|
||||||
|
if (!code) return reply.code(409).send({ error: "subscription has no QR credential to print" });
|
||||||
|
try {
|
||||||
|
const printedBy = await printSubscriptionCard(
|
||||||
|
db,
|
||||||
|
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
|
||||||
|
app.log,
|
||||||
|
);
|
||||||
|
return reply.code(200).send({ ok: true, printedBy });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NoPrinterAvailableError) return reply.code(503).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Revoke (soft): the common case — keeps the subscription + its history, just bars
|
||||||
|
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
|
||||||
|
// DELETE only to fully remove one created in error.
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
"/api/subscriptions/:id/revoke",
|
||||||
|
{ preHandler: updateGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run();
|
||||||
|
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
|
||||||
|
return loadAggregate(req.params.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a subscription — SOFT (recycle bin). The row + its credential/plate children
|
||||||
|
// are KEPT (stamped deleted) so a restore brings the subscriber back intact; it leaves
|
||||||
|
// the catalog and stops opening the barrier (the entry flow filters deleted). Past
|
||||||
|
// ledger events that reference it are untouched (append-only). Restore/purge from the
|
||||||
|
// recycle bin. (Distinct from /revoke, which BARS but keeps the subscriber visible.)
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/api/subscriptions/:id",
|
||||||
|
{ preHandler: deleteGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const ok = softDelete(db, "subscription", req.params.id, req.user.sub);
|
||||||
|
if (!ok) return reply.code(404).send({ error: "subscription not found" });
|
||||||
|
invalidateHolder(req.params.id);
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
computeFee,
|
||||||
|
isTariffV2,
|
||||||
|
priceSession,
|
||||||
|
validateTariffStructure,
|
||||||
|
type SessionPayment,
|
||||||
|
type TariffStructure,
|
||||||
|
} from "@parking/shared";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
|
||||||
|
/** Default site timezone for wall-clock tariff windows when none is configured. */
|
||||||
|
const DEFAULT_TZ = "Europe/Tirane";
|
||||||
|
|
||||||
|
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
|
||||||
|
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never
|
||||||
|
// mutates one; a session reprices against the version in force at its entry, and
|
||||||
|
// the `payment` event records the tariffVersionId. "One active tariff per site" for
|
||||||
|
// now (a single `tariffs` row, lazily created). See wiki/concepts/tariff.md.
|
||||||
|
|
||||||
|
interface PublishBody {
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||||
|
effectiveFrom?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SITE_TARIFF_NAME = "Site tariff";
|
||||||
|
|
||||||
|
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
|
||||||
|
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
|
||||||
|
* neither (uses the active version). */
|
||||||
|
interface SimulateBody {
|
||||||
|
enteredAt: string; // ISO-8601
|
||||||
|
asOf: string; // ISO-8601 (the "now"/exit instant being simulated)
|
||||||
|
payments?: SessionPayment[]; // hypothetical payment history (latest grants grace)
|
||||||
|
category?: string;
|
||||||
|
tariffVersionId?: string;
|
||||||
|
structure?: TariffStructure;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
// Reading the rate card (pay station / operator UI needs it).
|
||||||
|
const readGuard = requirePermission("tariff:read");
|
||||||
|
// Publishing a new version changes what customers are charged.
|
||||||
|
const writeGuard = requirePermission("tariff:update");
|
||||||
|
|
||||||
|
// The single site tariff row, created on first read/publish. A soft-deleted (recycle-
|
||||||
|
// bin) tariff is ignored here so a fresh one is created — the deleted one waits in the
|
||||||
|
// bin for restore/purge. (Tariffs have soft-delete support for completeness; today the
|
||||||
|
// site runs one tariff and there's no delete button — recovery is via the recycle bin.)
|
||||||
|
function ensureSiteTariff(): string {
|
||||||
|
const existing = db.select().from(tariffs).where(and(eq(tariffs.scope, "site"), isNull(tariffs.deletedAt))).get();
|
||||||
|
if (existing) return existing.id;
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current state: the active (latest-effective, ≤ now) version + the full history.
|
||||||
|
app.get("/api/tariff", { preHandler: readGuard }, async () => {
|
||||||
|
const tariffId = ensureSiteTariff();
|
||||||
|
const versions = db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariffId))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const active = versions.find((v) => v.effectiveFrom <= now) ?? null;
|
||||||
|
return { tariffId, active, versions };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish a new immutable version. Validates the structure first — a malformed
|
||||||
|
// rate card can never be published (the fee calc + the chain depend on it).
|
||||||
|
app.post<{ Body: PublishBody }>(
|
||||||
|
"/api/tariff/versions",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
|
||||||
|
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||||
|
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||||
|
}
|
||||||
|
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
||||||
|
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
|
||||||
|
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
||||||
|
let toStore: TariffStructure = structure;
|
||||||
|
if (structure && isTariffV2(structure)) {
|
||||||
|
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||||
|
toStore = { ...structure, tz };
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = validateTariffStructure(toStore);
|
||||||
|
if (problems.length) {
|
||||||
|
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveFrom must NOT be in the past. A version is selected by
|
||||||
|
// "latest effectiveFrom <= entry time", so a backdated effectiveFrom would
|
||||||
|
// retroactively reprice already-entered sessions — exactly the immutability
|
||||||
|
// the versioning exists to prevent (wiki/concepts/tariff.md). So we forbid
|
||||||
|
// backdating: a new version applies only from publish (now) forward; a future
|
||||||
|
// effectiveFrom (scheduling a price change) is allowed. A small skew tolerance
|
||||||
|
// absorbs client/server clock drift + request round-trip. Once a car has
|
||||||
|
// entered, no later publish can reprice it (no effectiveFrom can predate it).
|
||||||
|
const now = Date.now();
|
||||||
|
const SKEW_MS = 60_000; // 1 min: clock skew + round-trip slack
|
||||||
|
let effective = new Date().toISOString();
|
||||||
|
if (effectiveFrom != null) {
|
||||||
|
const t = Date.parse(effectiveFrom);
|
||||||
|
if (Number.isNaN(t)) {
|
||||||
|
return reply.code(400).send({ error: "effectiveFrom must be a valid ISO-8601 timestamp" });
|
||||||
|
}
|
||||||
|
if (t < now - SKEW_MS) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: "effectiveFrom cannot be in the past — backdating a tariff would retroactively reprice entered sessions",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
effective = new Date(t).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tariffId = ensureSiteTariff();
|
||||||
|
const id = randomUUID();
|
||||||
|
const row = {
|
||||||
|
id,
|
||||||
|
tariffId,
|
||||||
|
effectiveFrom: effective,
|
||||||
|
currency,
|
||||||
|
structure: toStore as unknown as Record<string, unknown>,
|
||||||
|
createdBy: req.user?.username ?? null,
|
||||||
|
};
|
||||||
|
db.insert(tariffVersions).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Tariff Lab (simulator) -------------------------------------------------
|
||||||
|
// Price a HYPOTHETICAL session at arbitrary times against any tariff version —
|
||||||
|
// pure, no ledger writes. Lets an admin test rates "in time" (overnight windows,
|
||||||
|
// daily caps, overstay) in seconds instead of waiting hours. Also used to quote a
|
||||||
|
// customer dispute on-site. tariff:read (admins always have it). See tariff.md.
|
||||||
|
app.post<{ Body: SimulateBody }>("/api/tariff/simulate", { preHandler: readGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? ({} as SimulateBody);
|
||||||
|
if (!b.enteredAt || !b.asOf) {
|
||||||
|
return reply.code(400).send({ error: "enteredAt and asOf (ISO-8601) required" });
|
||||||
|
}
|
||||||
|
if (!(Date.parse(b.enteredAt) <= Date.parse(b.asOf))) {
|
||||||
|
return reply.code(400).send({ error: "asOf must be at or after enteredAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the structure: an explicit version id, or the active version, or an
|
||||||
|
// inline structure (preview unpublished edits). A version carries its currency.
|
||||||
|
let structure: TariffStructure | undefined = b.structure;
|
||||||
|
let currency = b.currency ?? null;
|
||||||
|
if (b.tariffVersionId) {
|
||||||
|
const v = db.select().from(tariffVersions).where(eq(tariffVersions.id, b.tariffVersionId)).get();
|
||||||
|
if (!v) return reply.code(404).send({ error: "tariff version not found" });
|
||||||
|
structure = v.structure as unknown as TariffStructure;
|
||||||
|
currency = v.currency;
|
||||||
|
} else if (!structure) {
|
||||||
|
const tariffId = ensureSiteTariff();
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const active =
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariffId))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all()
|
||||||
|
.find((v) => v.effectiveFrom <= nowIso) ?? null;
|
||||||
|
if (!active) return reply.code(404).send({ error: "no active tariff to simulate against" });
|
||||||
|
structure = active.structure as unknown as TariffStructure;
|
||||||
|
currency = active.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = validateTariffStructure(structure);
|
||||||
|
if (problems.length) return reply.code(400).send({ error: "invalid tariff structure", problems });
|
||||||
|
|
||||||
|
const payments = Array.isArray(b.payments) ? b.payments : [];
|
||||||
|
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
||||||
|
|
||||||
|
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
||||||
|
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
||||||
|
const enteredMs = Date.parse(b.enteredAt);
|
||||||
|
const curve = SAMPLES_MIN.map((min) => ({
|
||||||
|
minutes: min,
|
||||||
|
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
||||||
|
// admin can re-evaluate an actual ticket (e.g. an overstay) at any chosen `asOf`.
|
||||||
|
app.get<{ Params: { identity: string } }>(
|
||||||
|
"/api/tariff/simulate/session/:identity",
|
||||||
|
{ preHandler: readGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const id = (req.params.identity ?? "").trim();
|
||||||
|
if (!id) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, id))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return reply.code(404).send({ error: "no session for identity" });
|
||||||
|
const payments: { paidAt: string; graceExitMin: number | null }[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "payment") continue;
|
||||||
|
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
|
||||||
|
payments.push({ paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null });
|
||||||
|
}
|
||||||
|
const exit = rows.find((r) => r.type === "vehicle_exit");
|
||||||
|
const category = (entry.payload as { category?: string } | null)?.category ?? null;
|
||||||
|
return {
|
||||||
|
identity: id,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
exitedAt: exit?.occurredAt ?? null,
|
||||||
|
payments,
|
||||||
|
category,
|
||||||
|
// The version frozen at entry — the rate card this session actually keeps.
|
||||||
|
tariffVersionId: tariffVersionIdFor(entry.occurredAt),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
|
||||||
|
function tariffVersionIdFor(whenIso: string): string | null {
|
||||||
|
const tariffId = ensureSiteTariff();
|
||||||
|
const v =
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariffId))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all()
|
||||||
|
.find((row) => row.effectiveFrom <= whenIso) ?? null;
|
||||||
|
return v?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
|
||||||
|
import { ADMIN_ROLE_ID } from "@parking/shared";
|
||||||
|
import { permissionsFor, requirePermission } from "../auth.js";
|
||||||
|
import { softDelete } from "../recycle-bin.js";
|
||||||
|
|
||||||
|
// User management (admin). Users are created/edited at runtime here — the
|
||||||
|
// install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one
|
||||||
|
// role (RBAC); the role resolves to a permission set at request time. Passwords
|
||||||
|
// are bcrypt-hashed (cost 12) and never returned. See @parking/shared PERMISSIONS.
|
||||||
|
//
|
||||||
|
// NO-LOCKOUT INVARIANT: the app refuses to delete, or move off the `admin` role,
|
||||||
|
// the LAST user still holding `admin`. Administration can therefore never be
|
||||||
|
// locked out of the appliance. See wiki/entities/local-jwt-auth.md.
|
||||||
|
//
|
||||||
|
// PRIVILEGE-ESCALATION GUARD: a non-admin caller with `user:*` must NOT be able to
|
||||||
|
// (a) ASSIGN a role whose permissions exceed their own (e.g. hand themselves or a
|
||||||
|
// peer the admin role, or any role broader than theirs), nor (b) MODIFY a user who
|
||||||
|
// already holds a role broader than the caller's (resetting an admin's password is
|
||||||
|
// account takeover; deleting an admin is sabotage). Both are blocked below by
|
||||||
|
// comparing permission SETS. An admin holds the full set, so it is unrestricted.
|
||||||
|
|
||||||
|
// Optional profile metadata accepted on create/update. All nullable; "" is treated
|
||||||
|
// as "clear" (→ null). Trimmed before persisting.
|
||||||
|
interface ProfileBody {
|
||||||
|
fullName?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
}
|
||||||
|
interface CreateBody extends ProfileBody {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
roleId: string;
|
||||||
|
}
|
||||||
|
interface UpdateBody extends ProfileBody {
|
||||||
|
username?: string;
|
||||||
|
roleId?: string;
|
||||||
|
}
|
||||||
|
interface PasswordBody {
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_PASSWORD = 8;
|
||||||
|
const PROFILE_FIELDS = ["fullName", "phone", "email", "address"] as const;
|
||||||
|
|
||||||
|
/** Pull the optional profile fields out of a body → a patch of trimmed values
|
||||||
|
* ("" → null). Absent keys are omitted (so an update only touches what's sent). */
|
||||||
|
function profilePatch(body: ProfileBody): Record<string, string | null> {
|
||||||
|
const out: Record<string, string | null> = {};
|
||||||
|
for (const k of PROFILE_FIELDS) {
|
||||||
|
const v = body[k];
|
||||||
|
if (v === undefined) continue;
|
||||||
|
const trimmed = typeof v === "string" ? v.trim() : "";
|
||||||
|
out[k] = trimmed === "" ? null : trimmed;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const readGuard = requirePermission("user:read");
|
||||||
|
const createGuard = requirePermission("user:create");
|
||||||
|
const updateGuard = requirePermission("user:update");
|
||||||
|
const deleteGuard = requirePermission("user:delete");
|
||||||
|
|
||||||
|
/** Count LIVE users currently holding the protected admin role. A soft-deleted admin
|
||||||
|
* doesn't count — they can't log in — so the no-lockout check uses live admins only. */
|
||||||
|
function adminCount(): number {
|
||||||
|
return db.select().from(users).where(and(eq(users.roleId, ADMIN_ROLE_ID), isNull(users.deletedAt))).all().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if removing/relocating `userId` from admin would leave zero admins. */
|
||||||
|
function isLastAdmin(userId: string): boolean {
|
||||||
|
const u = db.select().from(users).where(eq(users.id, userId)).get();
|
||||||
|
return u?.roleId === ADMIN_ROLE_ID && adminCount() <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A user row safe to return — never the password hash. */
|
||||||
|
function publicUser(u: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
roleId: string;
|
||||||
|
language: string;
|
||||||
|
createdAt: string;
|
||||||
|
fullName?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
roleId: u.roleId,
|
||||||
|
language: u.language,
|
||||||
|
createdAt: u.createdAt,
|
||||||
|
fullName: u.fullName ?? null,
|
||||||
|
phone: u.phone ?? null,
|
||||||
|
email: u.email ?? null,
|
||||||
|
address: u.address ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if `targetRoleId` grants any permission the caller's role does NOT hold,
|
||||||
|
* i.e. assigning or touching it would let the caller act beyond their own
|
||||||
|
* privileges. (Admin holds the full set, so it never trips.) */
|
||||||
|
function exceedsCaller(callerRoleId: string, targetRoleId: string): boolean {
|
||||||
|
if (callerRoleId === targetRoleId) return false;
|
||||||
|
const held = permissionsFor(callerRoleId);
|
||||||
|
for (const p of permissionsFor(targetRoleId)) {
|
||||||
|
if (!held.has(p)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all LIVE users (no password hashes) + their role names for display. Soft-deleted
|
||||||
|
// users live in the recycle bin, not here.
|
||||||
|
app.get("/api/users", { preHandler: readGuard }, async () => {
|
||||||
|
const rows = db.select().from(users).where(isNull(users.deletedAt)).all();
|
||||||
|
const roleRows = db.select().from(roles).all();
|
||||||
|
const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
|
||||||
|
return {
|
||||||
|
users: rows.map((u) => ({ ...publicUser(u), roleName: roleName.get(u.roleId) ?? u.roleId })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a user. Username unique; password >= 8 chars; roleId must exist.
|
||||||
|
app.post<{ Body: CreateBody }>("/api/users", { preHandler: createGuard }, async (req, reply) => {
|
||||||
|
const username = (req.body?.username ?? "").trim();
|
||||||
|
const password = req.body?.password ?? "";
|
||||||
|
const roleId = (req.body?.roleId ?? "").trim();
|
||||||
|
if (!username || !roleId) {
|
||||||
|
return reply.code(400).send({ error: "username and roleId required" });
|
||||||
|
}
|
||||||
|
if (password.length < MIN_PASSWORD) {
|
||||||
|
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
|
||||||
|
}
|
||||||
|
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
|
||||||
|
return reply.code(400).send({ error: "unknown roleId" });
|
||||||
|
}
|
||||||
|
// No-escalation: can't create a user with a role broader than your own.
|
||||||
|
if (exceedsCaller(req.user.roleId, roleId)) {
|
||||||
|
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
|
||||||
|
}
|
||||||
|
const clash = db.select().from(users).where(eq(users.username, username)).get();
|
||||||
|
if (clash) {
|
||||||
|
// The username is UNIQUE across live AND soft-deleted rows. If a DELETED user holds
|
||||||
|
// it, point the admin at the recycle bin (restore or purge) rather than a bare 409.
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: clash.deletedAt
|
||||||
|
? "username belongs to a deleted user — restore or purge it from the recycle bin first"
|
||||||
|
: "username already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const id = randomUUID();
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
db.insert(users).values({ id, username, passwordHash, roleId, ...profilePatch(req.body) }).run();
|
||||||
|
const created = db.select().from(users).where(eq(users.id, id)).get()!;
|
||||||
|
return reply.code(201).send(publicUser(created));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a user's username and/or role. Guarded against orphaning admin.
|
||||||
|
app.put<{ Params: { id: string }; Body: UpdateBody }>(
|
||||||
|
"/api/users/:id",
|
||||||
|
{ preHandler: updateGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const existing = db.select().from(users).where(eq(users.id, id)).get();
|
||||||
|
if (!existing) return reply.code(404).send({ error: "user not found" });
|
||||||
|
// No-escalation: can't modify a user who already outranks you.
|
||||||
|
if (exceedsCaller(req.user.roleId, existing.roleId)) {
|
||||||
|
return reply.code(403).send({ error: "cannot modify a user whose role exceeds your own" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: { username?: string; roleId?: string } & Record<string, string | null> = {
|
||||||
|
...profilePatch(req.body ?? {}),
|
||||||
|
};
|
||||||
|
if (req.body?.username != null) {
|
||||||
|
const username = req.body.username.trim();
|
||||||
|
if (!username) return reply.code(400).send({ error: "username cannot be empty" });
|
||||||
|
const clash = db.select().from(users).where(eq(users.username, username)).get();
|
||||||
|
if (clash && clash.id !== id) return reply.code(409).send({ error: "username already exists" });
|
||||||
|
next.username = username;
|
||||||
|
}
|
||||||
|
if (req.body?.roleId != null) {
|
||||||
|
const roleId = req.body.roleId.trim();
|
||||||
|
if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) {
|
||||||
|
return reply.code(400).send({ error: "unknown roleId" });
|
||||||
|
}
|
||||||
|
// No-escalation: can't promote a user into a role broader than your own.
|
||||||
|
if (exceedsCaller(req.user.roleId, roleId)) {
|
||||||
|
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
|
||||||
|
}
|
||||||
|
// No-lockout: don't move the last admin off the admin role.
|
||||||
|
if (roleId !== ADMIN_ROLE_ID && isLastAdmin(id)) {
|
||||||
|
return reply.code(409).send({ error: "cannot change the role of the last admin" });
|
||||||
|
}
|
||||||
|
next.roleId = roleId;
|
||||||
|
}
|
||||||
|
if (Object.keys(next).length === 0) {
|
||||||
|
return reply.code(400).send({ error: "nothing to update" });
|
||||||
|
}
|
||||||
|
db.update(users).set(next).where(eq(users.id, id)).run();
|
||||||
|
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset a user's password (admin sets a new one; >= 8 chars).
|
||||||
|
app.put<{ Params: { id: string }; Body: PasswordBody }>(
|
||||||
|
"/api/users/:id/password",
|
||||||
|
{ preHandler: updateGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const target = db.select().from(users).where(eq(users.id, id)).get();
|
||||||
|
if (!target) {
|
||||||
|
return reply.code(404).send({ error: "user not found" });
|
||||||
|
}
|
||||||
|
// No-escalation: can't reset the password of a user who outranks you
|
||||||
|
// (that would be account takeover of a more-privileged account).
|
||||||
|
if (exceedsCaller(req.user.roleId, target.roleId)) {
|
||||||
|
return reply.code(403).send({ error: "cannot reset the password of a user whose role exceeds your own" });
|
||||||
|
}
|
||||||
|
const password = req.body?.password ?? "";
|
||||||
|
if (password.length < MIN_PASSWORD) {
|
||||||
|
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
db.update(users).set({ passwordHash }).where(eq(users.id, id)).run();
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a user — SOFT (recycle bin). Refused if it's the last admin (no-lockout).
|
||||||
|
// The row is stamped deleted (recoverable), not removed; it vanishes from the list and
|
||||||
|
// can't log in. Restore/purge from the recycle bin. See recycle-bin.ts.
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/api/users/:id",
|
||||||
|
{ preHandler: deleteGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const target = db.select().from(users).where(and(eq(users.id, id), isNull(users.deletedAt))).get();
|
||||||
|
if (!target) {
|
||||||
|
return reply.code(404).send({ error: "user not found" });
|
||||||
|
}
|
||||||
|
// No-escalation: can't delete a user who outranks you.
|
||||||
|
if (exceedsCaller(req.user.roleId, target.roleId)) {
|
||||||
|
return reply.code(403).send({ error: "cannot delete a user whose role exceeds your own" });
|
||||||
|
}
|
||||||
|
if (isLastAdmin(id)) {
|
||||||
|
return reply.code(409).send({ error: "cannot delete the last admin" });
|
||||||
|
}
|
||||||
|
softDelete(db, "user", id, req.user.sub);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
import { roleHasPermissions } from "../auth.js";
|
||||||
|
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
|
||||||
|
import { enrichEvent } from "../event-enrich.js";
|
||||||
|
import type { DeviceMonitor } from "../device-monitor.js";
|
||||||
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
|
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
||||||
|
// server-pushed updates instead of polling: each signed ledger append (entry,
|
||||||
|
// exit, payment, void) is fanned out, and the recomputed occupancy rides along
|
||||||
|
// so the screen's count stays exact (occupancy is a fold over the same ledger,
|
||||||
|
// never a counter). Printer-status changes are forwarded too.
|
||||||
|
//
|
||||||
|
// Auth: the handshake is a normal GET through Fastify's lifecycle, so the same
|
||||||
|
// HttpOnly JWT cookie that guards the REST API guards this. We verify the JWT and
|
||||||
|
// role here. A browser's WebSocket constructor cannot set custom headers, so the
|
||||||
|
// CSRF double-submit header the REST mutations use is unavailable — which would
|
||||||
|
// leave the socket open to Cross-Site WebSocket Hijacking: a malicious page in the
|
||||||
|
// operator's browser could open ws://<booth>/api/ws, the browser would auto-attach
|
||||||
|
// the HttpOnly cookie, and the attacker would receive the live entry/exit/payment
|
||||||
|
// stream. The cookie alone is NOT a control here. So we replace the CSRF check with
|
||||||
|
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
|
||||||
|
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
|
||||||
|
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
|
||||||
|
|
||||||
|
/** Permission required to watch the live feed (a read-only stream of ledger +
|
||||||
|
* device status). Any role granted `report:read` may watch. */
|
||||||
|
const WATCH_PERMISSION = "report:read" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
|
||||||
|
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
|
||||||
|
* (comma-separated) for a booth UI served from a different origin. A missing or
|
||||||
|
* mismatched Origin is rejected — that is the anti-CSWSH control.
|
||||||
|
*/
|
||||||
|
function isAllowedOrigin(origin: string | undefined, host: string | undefined): boolean {
|
||||||
|
if (!origin) return false; // no Origin → not a same-origin browser request
|
||||||
|
let originHost: string;
|
||||||
|
try {
|
||||||
|
originHost = new URL(origin).host;
|
||||||
|
} catch {
|
||||||
|
return false; // malformed Origin
|
||||||
|
}
|
||||||
|
if (host && originHost === host) return true; // same-origin (any scheme/port match via host)
|
||||||
|
const allow = (process.env.WS_ALLOWED_ORIGINS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return allow.includes(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutMsg =
|
||||||
|
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
|
||||||
|
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||||
|
| { kind: "printer-status"; event: unknown }
|
||||||
|
| { kind: "device-status"; event: unknown }
|
||||||
|
| { kind: "lane-status"; lanes: LaneStatusEvent };
|
||||||
|
|
||||||
|
export async function wsRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
deviceMonitor: DeviceMonitor,
|
||||||
|
laneStatus: LaneStatus,
|
||||||
|
): Promise<void> {
|
||||||
|
app.get(
|
||||||
|
"/api/ws",
|
||||||
|
{
|
||||||
|
websocket: true,
|
||||||
|
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
|
||||||
|
// role. Reject a cross/absent origin before touching the token, so a hijack
|
||||||
|
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
|
||||||
|
preHandler: async (req) => {
|
||||||
|
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
||||||
|
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
|
||||||
|
}
|
||||||
|
await req.jwtVerify();
|
||||||
|
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) {
|
||||||
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(socket) => {
|
||||||
|
const send = (msg: OutMsg) => {
|
||||||
|
// readyState 1 = OPEN; never throw out of an event-bus callback.
|
||||||
|
if (socket.readyState === 1) {
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify(msg));
|
||||||
|
} catch {
|
||||||
|
/* drop on a broken socket */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial snapshot so the client renders immediately, before any event:
|
||||||
|
// occupancy AND the current device-status set (for the footer).
|
||||||
|
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
|
||||||
|
|
||||||
|
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||||
|
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||||
|
const offLedger = deviceEvents.onLedger((event) => {
|
||||||
|
// Enrich with read-time display fields (subscriber name) before fan-out.
|
||||||
|
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
|
||||||
|
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) });
|
||||||
|
});
|
||||||
|
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
||||||
|
send({ kind: "printer-status", event });
|
||||||
|
});
|
||||||
|
// Unified device status (all categories) for the booth footer — pushed on
|
||||||
|
// change; the initial set rode the hello above.
|
||||||
|
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
||||||
|
send({ kind: "device-status", event });
|
||||||
|
});
|
||||||
|
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
|
||||||
|
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
||||||
|
send({ kind: "lane-status", lanes });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("close", () => {
|
||||||
|
offLedger();
|
||||||
|
offPrinter();
|
||||||
|
offDevice();
|
||||||
|
offLane();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,50 @@
|
|||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import jwt from "@fastify/jwt";
|
import jwt from "@fastify/jwt";
|
||||||
|
import websocket from "@fastify/websocket";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { createDb, type Db } from "@parking/db";
|
import { randomUUID } from "node:crypto";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
|
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
|
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
||||||
import { deviceEvents } from "./device-events.js";
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { LaneMap } from "./lane-map.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { VoidFlow } from "./void-flow.js";
|
||||||
|
import { PayStation } from "./pay-station.js";
|
||||||
|
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
|
import { ShiftService } from "./shift-service.js";
|
||||||
|
import { ReadDispatcher } from "./read-dispatch.js";
|
||||||
|
import { CredentialCapture } from "./credential-capture.js";
|
||||||
import { PrinterMonitor } from "./printer-monitor.js";
|
import { PrinterMonitor } from "./printer-monitor.js";
|
||||||
import { buildSigner } from "./signer.js";
|
import { DeviceMonitor } from "./device-monitor.js";
|
||||||
|
import { buildSigner, buildVerifier } from "./signer.js";
|
||||||
|
import { LogService, pinoDbStream } from "./log-service.js";
|
||||||
|
import { logRoutes } from "./routes/logs.js";
|
||||||
|
import { VisionClient } from "./vision-client.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
|
import { userRoutes } from "./routes/users.js";
|
||||||
|
import { roleRoutes } from "./routes/roles.js";
|
||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
|
import { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
||||||
|
import { LaneStatus } from "./lane-status.js";
|
||||||
|
import { AnprBridge } from "./anpr-entry.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
|
import { reportRoutes } from "./routes/reports.js";
|
||||||
|
import { recycleBinRoutes } from "./routes/recycle-bin.js";
|
||||||
|
import { sweepExpired, retentionDays } from "./recycle-bin.js";
|
||||||
|
import { payRoutes } from "./routes/pay.js";
|
||||||
|
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||||
|
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||||
|
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||||
|
import { shiftRoutes } from "./routes/shift.js";
|
||||||
|
import { siteRoutes } from "./routes/site.js";
|
||||||
|
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||||
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
import { setupRoutes } from "./routes/setup.js";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
|
import { deviceStatusRoutes } from "./routes/device-status.js";
|
||||||
|
import { wsRoutes } from "./routes/ws.js";
|
||||||
|
import { registerSpa } from "./static-spa.js";
|
||||||
|
|
||||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
||||||
// plugins emitting onto a shared internal event bus; auth is fully local
|
// plugins emitting onto a shared internal event bus; auth is fully local
|
||||||
@@ -23,14 +55,30 @@ export interface BuildOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInstance> {
|
export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInstance> {
|
||||||
const app = Fastify({
|
// DB first — the logger's DB sink needs it before Fastify is constructed.
|
||||||
logger: { level: process.env.LOG_LEVEL ?? "info" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const db = opts.db ?? createDb();
|
const db = opts.db ?? createDb();
|
||||||
|
|
||||||
|
// Application-log store: a pino stream tees warn+ lines into app_logs (and still
|
||||||
|
// writes them to stdout), so backend warnings/errors are queryable from the booth
|
||||||
|
// alongside frontend errors. See log-service.ts + wiki/concepts/app-logs.md.
|
||||||
|
const logService = new LogService(db);
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: process.env.LOG_LEVEL ?? "info",
|
||||||
|
stream: pinoDbStream(logService, process.stdout),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire the RBAC permission resolver to this DB (route guards resolve a user's
|
||||||
|
// role → permission set through it). See auth.ts.
|
||||||
|
initAuth(db);
|
||||||
|
|
||||||
await app.register(cookie);
|
await app.register(cookie);
|
||||||
|
|
||||||
|
// WebSocket support for the live booth feed (/api/ws). Registered before the
|
||||||
|
// routes so the `{ websocket: true }` route option is available.
|
||||||
|
await app.register(websocket);
|
||||||
|
|
||||||
// Local JWT signing with a local secret — no external identity provider.
|
// Local JWT signing with a local secret — no external identity provider.
|
||||||
// Fail fast rather than fall back to a known default: a booth machine started
|
// Fail fast rather than fall back to a known default: a booth machine started
|
||||||
// without a real secret would sign tokens anyone could forge (incl. an admin
|
// without a real secret would sign tokens anyone could forge (incl. an admin
|
||||||
@@ -38,7 +86,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// The token is carried in an HttpOnly cookie (not the Authorization header).
|
// The token is carried in an HttpOnly cookie (not the Authorization header).
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
secret: requireJwtSecret(),
|
secret: requireJwtSecret(),
|
||||||
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire
|
// No expiry: a login is valid until explicit logout — a shift is a separate
|
||||||
|
// boundary, not the token lifetime (see auth.ts + wiki/concepts/shift.md).
|
||||||
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,21 +96,38 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||||
await authRoutes(app, db);
|
await authRoutes(app, db);
|
||||||
|
|
||||||
// device id -> lane resolver. Built from lane_devices at startup and refreshed
|
// RBAC administration: compose roles (role:*) + manage users (user:*). The
|
||||||
// by setupRoutes on assign/unassign, so device events can be stamped with the
|
// built-in admin role is protected; the last admin can't be removed. See auth.ts.
|
||||||
// lane the device belongs to (events carry the device id, not a lane).
|
await userRoutes(app, db);
|
||||||
const laneMap = new LaneMap(db);
|
await roleRoutes(app, db);
|
||||||
laneMap.refresh();
|
|
||||||
|
|
||||||
// Device-agnostic setup: the admin selects devices per lane from the driver
|
// Vision (ANPR) client — built early so the device monitor can include the vision
|
||||||
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
|
||||||
await setupRoutes(app, db, () => laneMap.refresh());
|
// snapshot→analyze probe on an ANPR-enabled camera. Opt-in (VISION_ENABLED) +
|
||||||
|
// fail-soft; advisory only. See wiki/entities/opencv-anpr-service.md.
|
||||||
|
const visionClient = new VisionClient(app.log);
|
||||||
|
if (visionClient.enabled) app.log.info("vision client enabled");
|
||||||
|
|
||||||
|
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||||
|
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||||
|
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||||
|
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||||
|
await setupRoutes(app, db, visionClient);
|
||||||
|
|
||||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||||
// the device's lane_devices config (written on assign).
|
// the device's lane_devices config (written on assign).
|
||||||
await deviceRoutes(app, db);
|
await deviceRoutes(app, db);
|
||||||
|
|
||||||
|
// Lane busy/free tracker: a camera's vehicle detection marks its bound lane busy
|
||||||
|
// (advisory barrier lights on the booth); auto-clears on a timeout. See lane-status.ts.
|
||||||
|
const laneStatus = new LaneStatus(db, app.log);
|
||||||
|
app.addHook("onClose", async () => laneStatus.stop());
|
||||||
|
|
||||||
|
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
||||||
|
// flows are constructed — because the ANPR bridge they carry depends on the
|
||||||
|
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
||||||
|
|
||||||
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
|
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
|
||||||
// pushes changes to the booth UI. setupRoutes() has already registered the
|
// pushes changes to the booth UI. setupRoutes() has already registered the
|
||||||
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
|
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
|
||||||
@@ -70,39 +136,177 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
app.addHook("onReady", async () => printerMonitor.start());
|
app.addHook("onReady", async () => printerMonitor.start());
|
||||||
app.addHook("onClose", async () => printerMonitor.stop());
|
app.addHook("onClose", async () => printerMonitor.stop());
|
||||||
|
|
||||||
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button
|
// Unified device-status monitor: polls EVERY configured device (relays/readers/
|
||||||
// presses) into the hash-chained, signed `events` table — the anti-fraud audit
|
// cameras via healthCheck, printers via rich readStatus) PLUS the vision service's
|
||||||
// trail. The device is NOT trusted; the host record is the source of truth, and
|
// /health, and feeds the booth's device-status footer over the WS. Read-only.
|
||||||
// a relay open with no matching signed event is itself the anomaly. We record
|
// See wiki/concepts/device-status-monitoring.md.
|
||||||
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that
|
const deviceMonitor = new DeviceMonitor(db, app.log, undefined, visionClient);
|
||||||
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md.
|
await deviceStatusRoutes(app, deviceMonitor);
|
||||||
const eventLog = new EventLog(db, buildSigner(app.log));
|
app.addHook("onReady", async () => deviceMonitor.start());
|
||||||
|
app.addHook("onClose", async () => deviceMonitor.stop());
|
||||||
|
|
||||||
|
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||||
|
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||||
|
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||||
|
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||||
|
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||||
|
// See wiki/decisions/event-streams-split.md.
|
||||||
|
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
||||||
|
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
||||||
|
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
||||||
|
deviceEvents.emitLedger(row),
|
||||||
|
);
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
|
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
||||||
|
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
|
||||||
|
await reportRoutes(app, db);
|
||||||
|
|
||||||
|
// Recycle bin: view / restore / purge soft-deleted master data (users/roles/subs/
|
||||||
|
// plans/tariffs). Gated on recyclebin:*. See routes/recycle-bin.ts, recycle-bin.ts.
|
||||||
|
await recycleBinRoutes(app, db);
|
||||||
|
|
||||||
|
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||||
|
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||||
|
await wsRoutes(app, db, deviceMonitor, laneStatus);
|
||||||
|
|
||||||
|
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||||
|
await snapshotRoutes(app, db);
|
||||||
|
|
||||||
|
// Entry flow: a button press → print ticket → signed vehicle_entry → pulseOpen.
|
||||||
|
// Subscribes to the SAME input bus as the telemetry writer below; the two are
|
||||||
|
// independent (telemetry always records; the entry flow acts only on an access
|
||||||
|
// device's rising edge). See wiki/concepts/device-input-flow.md + parking-session.md.
|
||||||
|
// The flows take the vision client so ANPR rides their entry/exit SNAPSHOT: a button
|
||||||
|
// press / QR / RFID triggers the open + snapshot, and the plate is recognized off that
|
||||||
|
// same image and recorded against the session (advisory; never changes the decision).
|
||||||
|
// No polling — recognition fires only on a real entry/exit. See snapshot.ts +
|
||||||
|
// wiki/entities/opencv-anpr-service.md.
|
||||||
|
const entryFlow = new EntryFlow(db, eventLog, app.log, visionClient);
|
||||||
|
const unsubscribeEntry = deviceEvents.onInput((e) => {
|
||||||
|
void entryFlow.onInput(e);
|
||||||
|
});
|
||||||
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
|
||||||
|
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||||
|
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||||
|
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
||||||
|
// parking-session.md.
|
||||||
|
const exitFlow = new ExitFlow(db, eventLog, app.log, visionClient);
|
||||||
|
const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log, visionClient);
|
||||||
|
const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log);
|
||||||
|
const unsubscribeRead = deviceEvents.onRead((e) => {
|
||||||
|
void readDispatcher.dispatch(e);
|
||||||
|
});
|
||||||
|
app.addHook("onClose", async () => unsubscribeRead());
|
||||||
|
|
||||||
|
// ANPR bridge: a subscriber's plate, read off the lane camera's vehicle detection,
|
||||||
|
// admits them through the SAME gated SubscriptionFlow a QR/card scan uses (it emits a
|
||||||
|
// plate read onto the bus, which the dispatcher above turns into a gated entry/exit).
|
||||||
|
// Advisory + fail-soft + subscriber-only — never the sole reason a barrier opens. Needs
|
||||||
|
// the subscriptionFlow constructed just above. See anpr-entry.ts.
|
||||||
|
const anprBridge = new AnprBridge(db, visionClient, subscriptionFlow, app.log);
|
||||||
|
|
||||||
|
// Hikvision "Alarm Server" event push: the camera POSTs an EventNotificationAlert on
|
||||||
|
// each detected target (vehicle). Source-IP guarded + optional Digest; records the raw
|
||||||
|
// payload as a `kind:"alarm"` device_event, drives lane busy/free, AND hands a vehicle
|
||||||
|
// detection to the ANPR bridge above. See routes/hikvision-alarm.ts.
|
||||||
|
await hikvisionAlarmRoutes(app, db, laneStatus, anprBridge);
|
||||||
|
|
||||||
|
// Credential capture ("enroll a card"): lets the operator present an RFID card to a
|
||||||
|
// CHOSEN reader to populate a subscription credential, without blocking the other
|
||||||
|
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
||||||
|
const credentialCapture = new CredentialCapture();
|
||||||
|
|
||||||
|
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||||
|
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
|
||||||
|
// on an armed reader for enrollment; otherwise the read routes through the
|
||||||
|
// dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
||||||
|
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
|
||||||
|
|
||||||
|
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||||
|
// (sum payments by tender, print the Z-report). Constructed before the pay routes
|
||||||
|
// because the booth money path is GATED on an open shift. See wiki/concepts/shift.md.
|
||||||
|
const shiftService = new ShiftService(db, eventLog, app.log);
|
||||||
|
|
||||||
|
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
||||||
|
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
|
||||||
|
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
|
||||||
|
const payStation = new PayStation(db, eventLog, app.log);
|
||||||
|
// Ticket-void (cancel a wrongly-printed ticket): appends a signed `void` referencing the
|
||||||
|
// entry; the session projection folds it closed. See void-flow.ts.
|
||||||
|
const voidFlow = new VoidFlow(db, eventLog, app.log);
|
||||||
|
await payRoutes(app, db, payStation, exitFlow, shiftService, voidFlow);
|
||||||
|
|
||||||
|
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||||
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||||
|
await tariffRoutes(app, db);
|
||||||
|
|
||||||
|
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
|
||||||
|
// wiki/entities/subscription.md.
|
||||||
|
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||||
|
await subscriptionPlanRoutes(app, db);
|
||||||
|
|
||||||
|
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||||
|
await shiftRoutes(app, shiftService, db);
|
||||||
|
|
||||||
|
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||||
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
await siteRoutes(app, db);
|
||||||
|
|
||||||
|
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
||||||
|
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||||
|
await logRoutes(app, logService);
|
||||||
|
|
||||||
|
// Periodic retention prune (age + row cap) so the log table stays bounded on the
|
||||||
|
// offline appliance. Runs hourly; unref'd so it never holds the process open.
|
||||||
|
const pruneTimer = setInterval(() => {
|
||||||
|
const n = logService.prune();
|
||||||
|
if (n > 0) app.log.debug(`pruned ${n} app_log rows`);
|
||||||
|
}, 60 * 60 * 1000);
|
||||||
|
pruneTimer.unref();
|
||||||
|
logService.prune(); // once at startup
|
||||||
|
app.addHook("onClose", async () => clearInterval(pruneTimer));
|
||||||
|
|
||||||
|
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
|
||||||
|
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
|
||||||
|
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
|
||||||
|
const binTimer = setInterval(() => {
|
||||||
|
const purged = sweepExpired(db);
|
||||||
|
const total = Object.values(purged).reduce((a, b) => a + b, 0);
|
||||||
|
if (total > 0) app.log.info(`recycle-bin: auto-purged ${total} expired item(s) ${JSON.stringify(purged)}`);
|
||||||
|
}, 6 * 60 * 60 * 1000);
|
||||||
|
binTimer.unref();
|
||||||
|
if (retentionDays() > 0) sweepExpired(db); // once at startup
|
||||||
|
app.addHook("onClose", async () => clearInterval(binTimer));
|
||||||
|
|
||||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
// Record every input edge as unsigned telemetry, keyed to the device that fired
|
||||||
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
// (provenance). No lane — the pool-of-spaces model has none. The entry flow
|
||||||
// faithfully (the chain is append-only) rather than silently dropped or
|
// (above) independently decides whether this edge is an entry button.
|
||||||
// mis-stamped as lane 0, which is a real lane.
|
try {
|
||||||
const lane = laneMap.laneFor(e.deviceId) ?? -1;
|
db.insert(deviceEventsTable)
|
||||||
if (lane === -1) {
|
.values({
|
||||||
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`);
|
id: randomUUID(),
|
||||||
|
deviceId: e.deviceId,
|
||||||
|
category: "access",
|
||||||
|
kind: "input",
|
||||||
|
detail: { driverId: e.driverId, input: e.input, edge: e.edge },
|
||||||
|
occurredAt: e.at,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(`device-event insert failed: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
eventLog
|
|
||||||
.append({
|
|
||||||
type: "input_received",
|
|
||||||
lane,
|
|
||||||
// `source` is an IdentitySource (wiegand/lpr/qr/ticket/manual) — how a
|
|
||||||
// VEHICLE was identified. A raw input has none, so it stays null. The
|
|
||||||
// device provenance lives in `identity` instead.
|
|
||||||
source: null,
|
|
||||||
identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`,
|
|
||||||
occurredAt: e.at,
|
|
||||||
})
|
|
||||||
.catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`));
|
|
||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeInput());
|
app.addHook("onClose", async () => unsubscribeInput());
|
||||||
|
|
||||||
// TODO: entry flow (input event → signed event → print → relay).
|
// LAST: serve the built React SPA (apps/web/dist) when present — so one container
|
||||||
|
// serves the API + the operator UI (offline-first single appliance). No-op in dev (no
|
||||||
|
// build → the Vite dev server serves the UI). Registered after every API route and
|
||||||
|
// GET-only with /api + /health excluded, so it can never shadow the backend.
|
||||||
|
// See static-spa.ts + wiki/decisions/container-deployment.md.
|
||||||
|
await registerSpa(app);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||