Compare commits
26 Commits
| 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 |
@@ -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"
|
||||||
+19
-2
@@ -31,9 +31,26 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
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)
|
- name: Build + lint (Turbo)
|
||||||
# Covers tsc typecheck, vite build, and i18n catalog type-parity (a missing
|
# Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en
|
||||||
# sq/en key fails the build). 14 tasks across the workspace.
|
# key fails the build), AND the vision service's ruff lint via uv.
|
||||||
run: pnpm turbo run build lint
|
run: pnpm turbo run build lint
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ EVENT_SIGNING_KEY=
|
|||||||
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
|
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
|
||||||
# COOKIE_SECURE=0
|
# 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=
|
||||||
@@ -37,6 +42,9 @@ EVENT_SIGNING_KEY=
|
|||||||
# The Tauri DESKTOP shell loads from tauri://localhost (Linux may also send
|
# 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
|
# http://tauri.localhost), which is NOT same-origin with the backend — add both
|
||||||
# so the desktop app's live feed connects. See apps/desktop.
|
# 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
|
WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhost
|
||||||
|
|
||||||
# Vision / ANPR (optional) -------------------------------------------------
|
# Vision / ANPR (optional) -------------------------------------------------
|
||||||
@@ -48,4 +56,10 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
|
|||||||
# VISION_ENABLED=1 # master switch — nothing runs without it
|
# 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_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_TIMEOUT_MS=1500 # per-request cap so a slow call can't hang the lane
|
||||||
# VISION_MIN_CONFIDENCE=0.5 # confidence floor; keep in sync with the service
|
# 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"]
|
||||||
Executable
+27
@@ -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 "$@"
|
||||||
@@ -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 };
|
||||||
@@ -76,6 +76,16 @@ export interface DeviceStatusEvent {
|
|||||||
readonly checkedAt: string; // ISO-8601
|
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);
|
||||||
@@ -128,6 +138,16 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("ledger", cb);
|
this.on("ledger", cb);
|
||||||
return () => this.off("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. */
|
||||||
|
|||||||
@@ -418,7 +418,9 @@ export class ExitFlow {
|
|||||||
|
|
||||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
const exited = rows.some((r) => r.type === "vehicle_exit");
|
// A `void` (cancelled ticket) closes the session like an exit, so a voided ticket
|
||||||
|
// presented at exit reads as "already closed" — never re-opens. See void-flow.ts.
|
||||||
|
const exited = rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
|
||||||
|
|
||||||
let paidAt: string | null = null;
|
let paidAt: string | null = null;
|
||||||
let graceExitMin: number | null = null;
|
let graceExitMin: number | null = null;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,14 @@ function exit(identity: string) {
|
|||||||
signature: "x", keyId: "test",
|
signature: "x", keyId: "test",
|
||||||
}).run();
|
}).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>) {
|
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
|
||||||
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
|
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
|
||||||
}
|
}
|
||||||
@@ -57,6 +65,12 @@ describe("occupancyCount", () => {
|
|||||||
entry("A"); exit("A"); entry("A");
|
entry("A"); exit("A"); entry("A");
|
||||||
expect(occupancyCount(db)).toBe(1);
|
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", () => {
|
describe("getOccupancy — capacity + full gate", () => {
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ export function occupancyCount(db: Db): number {
|
|||||||
.all();
|
.all();
|
||||||
const balance = new Map<string, number>();
|
const balance = new Map<string, number>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
// A `void` (cancelled ticket) closes the session like an exit — the car never entered
|
||||||
|
// (misprint), so it must not count inside. See void-flow.ts.
|
||||||
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
|
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
|
||||||
else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
else if (r.type === "vehicle_exit" || r.type === "void")
|
||||||
|
balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
||||||
}
|
}
|
||||||
let open = 0;
|
let open = 0;
|
||||||
for (const v of balance.values()) if (v > 0) open += 1;
|
for (const v of balance.values()) if (v > 0) open += 1;
|
||||||
@@ -68,7 +71,7 @@ export function reservedSubscriberSpots(db: Db): number {
|
|||||||
if (pl.permitId == null) continue; // transient
|
if (pl.permitId == null) continue; // transient
|
||||||
net.set(id, (net.get(id) ?? 0) + 1);
|
net.set(id, (net.get(id) ?? 0) + 1);
|
||||||
subOf.set(id, pl.permitId);
|
subOf.set(id, pl.permitId);
|
||||||
} else if (r.type === "vehicle_exit") {
|
} else if (r.type === "vehicle_exit" || r.type === "void") {
|
||||||
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
|
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,7 +282,9 @@ export class PayStation {
|
|||||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
|
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
|
||||||
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
|
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
|
||||||
const exitRow = rows.find((r) => r.type === "vehicle_exit");
|
// 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;
|
const open = !exitRow;
|
||||||
|
|
||||||
let paidAt: string | null = null;
|
let paidAt: string | null = null;
|
||||||
@@ -366,7 +368,8 @@ export class PayStation {
|
|||||||
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
|
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
|
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
|
||||||
byId.set(id, a);
|
byId.set(id, a);
|
||||||
} else if (r.type === "vehicle_exit") {
|
} 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);
|
const a = byId.get(id);
|
||||||
if (a) a.exitedAt = r.occurredAt;
|
if (a) a.exitedAt = r.occurredAt;
|
||||||
} else if (r.type === "payment") {
|
} else if (r.type === "payment") {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -183,10 +183,17 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
return 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) {
|
for (const row of rows) {
|
||||||
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
||||||
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
||||||
if (row.type === "vehicle_entry") {
|
if (row.type === "vehicle_entry") {
|
||||||
|
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||||
totals.entries++;
|
totals.entries++;
|
||||||
p.entries++;
|
p.entries++;
|
||||||
const h = localParts(row.occurredAt, tz).h;
|
const h = localParts(row.occurredAt, tz).h;
|
||||||
|
|||||||
@@ -29,6 +29,33 @@ interface ThemeBody {
|
|||||||
theme: Theme;
|
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
|
/** 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
|
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||||
* permissions are the source of truth. */
|
* permissions are the source of truth. */
|
||||||
@@ -41,6 +68,7 @@ function sessionView(
|
|||||||
language: string;
|
language: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||||
@@ -54,6 +82,7 @@ function sessionView(
|
|||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
|
email: user.email ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +98,9 @@ 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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,4 +170,52 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return { theme };
|
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,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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type PayStation,
|
type PayStation,
|
||||||
} from "../pay-station.js";
|
} from "../pay-station.js";
|
||||||
import type { ExitFlow } from "../exit-flow.js";
|
import type { ExitFlow } from "../exit-flow.js";
|
||||||
|
import type { VoidFlow } from "../void-flow.js";
|
||||||
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
||||||
import { printPaymentReceipt } from "../booth-print.js";
|
import { printPaymentReceipt } from "../booth-print.js";
|
||||||
|
|
||||||
@@ -36,6 +37,10 @@ interface VoucherBody {
|
|||||||
interface ReceiptBody {
|
interface ReceiptBody {
|
||||||
identity: string;
|
identity: string;
|
||||||
}
|
}
|
||||||
|
interface VoidBody {
|
||||||
|
identity: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function payRoutes(
|
export async function payRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
@@ -43,6 +48,7 @@ export async function payRoutes(
|
|||||||
payStation: PayStation,
|
payStation: PayStation,
|
||||||
exitFlow: ExitFlow,
|
exitFlow: ExitFlow,
|
||||||
shift: ShiftService,
|
shift: ShiftService,
|
||||||
|
voidFlow: VoidFlow,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Reads (lookup, active sessions, quote) need session/payment read; the booth
|
// Reads (lookup, active sessions, quote) need session/payment read; the booth
|
||||||
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
|
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
|
||||||
@@ -50,6 +56,7 @@ export async function payRoutes(
|
|||||||
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
|
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
|
||||||
const guard = requirePermission("payment:create");
|
const guard = requirePermission("payment:create");
|
||||||
const readGuard = requirePermission("session:read");
|
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/
|
// 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
|
// re-open is processed, so every taking is attributed to a shift (one operator's
|
||||||
@@ -125,6 +132,28 @@ export async function payRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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.)
|
// Quote: what does this session owe right now? (No side effect.)
|
||||||
app.get<{ Querystring: QuoteQuery }>(
|
app.get<{ Querystring: QuoteQuery }>(
|
||||||
"/api/pay/quote",
|
"/api/pay/quote",
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, rolePermissions, roles, users, type Db } from "@parking/db";
|
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||||
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
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
|
// Role management (admin). Roles are DATA: an admin composes a role from the
|
||||||
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
|
// code-defined PERMISSIONS grid (resource:action), and users are assigned one
|
||||||
@@ -56,7 +57,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
.where(eq(rolePermissions.roleId, roleId))
|
.where(eq(rolePermissions.roleId, roleId))
|
||||||
.all()
|
.all()
|
||||||
.map((r) => r.permission);
|
.map((r) => r.permission);
|
||||||
const userCount = db.select().from(users).where(eq(users.roleId, roleId)).all().length;
|
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).
|
// The admin role always reports the full grid (it's enforced in code).
|
||||||
return {
|
return {
|
||||||
id: role.id,
|
id: role.id,
|
||||||
@@ -75,9 +76,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The full permission grid (for the role-composer checkbox UI) + every role.
|
// 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 () => {
|
app.get("/api/roles", { preHandler: readGuard }, async () => {
|
||||||
const all = db.select().from(roles).all();
|
const all = db.select().from(roles).where(isNull(roles.deletedAt)).all();
|
||||||
return {
|
return {
|
||||||
catalog: PERMISSIONS,
|
catalog: PERMISSIONS,
|
||||||
roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
|
roles: all.map((r) => roleView(r.id)).filter((r) => r != null),
|
||||||
@@ -143,23 +145,25 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete a role. Refused if it's built-in or any user still holds it.
|
// 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 } }>(
|
app.delete<{ Params: { id: string } }>(
|
||||||
"/api/roles/:id",
|
"/api/roles/:id",
|
||||||
{ preHandler: deleteGuard },
|
{ preHandler: deleteGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const role = db.select().from(roles).where(eq(roles.id, id)).get();
|
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) return reply.code(404).send({ error: "role not found" });
|
||||||
if (role.builtin === 1) {
|
if (role.builtin === 1) {
|
||||||
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" });
|
return reply.code(409).send({ error: "the built-in admin role cannot be deleted" });
|
||||||
}
|
}
|
||||||
const holders = db.select().from(users).where(eq(users.roleId, id)).all().length;
|
// 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) {
|
if (holders > 0) {
|
||||||
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
|
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
|
||||||
}
|
}
|
||||||
db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)).run();
|
softDelete(db, "role", id, req.user.sub);
|
||||||
db.delete(roles).where(eq(roles.id, id)).run();
|
|
||||||
bumpPermsCache();
|
bumpPermsCache();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
|||||||
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not
|
/** 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. */
|
* parked — so transients see "full" sooner and the subscriber's spot is held. */
|
||||||
reserveSubscriberSpots?: boolean;
|
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
|
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||||
@@ -41,6 +44,7 @@ type SiteConfig = {
|
|||||||
exitVoucherDefault: boolean;
|
exitVoucherDefault: boolean;
|
||||||
subscriptionMonthlyPriceMinor: number | null;
|
subscriptionMonthlyPriceMinor: number | null;
|
||||||
reserveSubscriberSpots: boolean;
|
reserveSubscriberSpots: boolean;
|
||||||
|
anprEntryEnabled: boolean;
|
||||||
} & Record<TextField, string | null>;
|
} & Record<TextField, string | null>;
|
||||||
|
|
||||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||||
@@ -49,6 +53,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
|||||||
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||||
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
||||||
|
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
||||||
} as SiteConfig;
|
} as SiteConfig;
|
||||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||||
return out;
|
return out;
|
||||||
@@ -106,6 +111,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
|
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) {
|
for (const f of TEXT_FIELDS) {
|
||||||
if (f in body) patch[f] = normText(body[f]);
|
if (f in body) patch[f] = normText(body[f]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db";
|
import { and, desc, eq, isNull, subscriptionPlans, subscriptions, type Db } from "@parking/db";
|
||||||
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
|
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { softDelete } from "../recycle-bin.js";
|
||||||
import { siteTz } from "../subscription-window.js";
|
import { siteTz } from "../subscription-window.js";
|
||||||
|
|
||||||
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
|
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
|
||||||
@@ -75,7 +76,14 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
|||||||
// per planId (latest active version with effectiveFrom ≤ now). Operators selling
|
// per planId (latest active version with effectiveFrom ≤ now). Operators selling
|
||||||
// need the current list; the admin catalog screen asks for ?all=1.
|
// need the current list; the admin catalog screen asks for ?all=1.
|
||||||
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => {
|
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => {
|
||||||
const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all();
|
// 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 };
|
if (req.query?.all) return { plans: rows };
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
// Newest-effective active version wins per planId.
|
// Newest-effective active version wins per planId.
|
||||||
@@ -154,12 +162,19 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
|||||||
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
|
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
|
||||||
// version). A referenced plan version MUST survive: a subscription's planVersionId is
|
// 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
|
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
|
||||||
// when in use (the admin should retire instead). Removes all versions of the planId.
|
// 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 } }>(
|
app.delete<{ Params: { planId: string } }>(
|
||||||
"/api/subscription-plans/:planId",
|
"/api/subscription-plans/:planId",
|
||||||
{ preHandler: planGuard },
|
{ preHandler: planGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all();
|
// 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) {
|
if (refs.length > 0) {
|
||||||
return reply.code(409).send({
|
return reply.code(409).send({
|
||||||
error: "plan is in use and cannot be deleted",
|
error: "plan is in use and cannot be deleted",
|
||||||
@@ -167,7 +182,8 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
|||||||
subscribers: refs.length,
|
subscribers: refs.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run();
|
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 };
|
return { planId: req.params.planId, deleted: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||||
import { NoPrinterAvailableError } from "@parking/devices";
|
import { NoPrinterAvailableError } from "@parking/devices";
|
||||||
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
|
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
|
import { softDelete } from "../recycle-bin.js";
|
||||||
import { invalidateHolder } from "../event-enrich.js";
|
import { invalidateHolder } from "../event-enrich.js";
|
||||||
import { printSubscriptionCard } from "../booth-print.js";
|
import { printSubscriptionCard } from "../booth-print.js";
|
||||||
import type { CredentialCapture } from "../credential-capture.js";
|
import type { CredentialCapture } from "../credential-capture.js";
|
||||||
@@ -226,9 +227,10 @@ export async function subscriptionRoutes(
|
|||||||
return { plan, validFrom, validTo, quantity, quote };
|
return { plan, validFrom, validTo, quantity, quote };
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all subscriptions (with their credentials + plates).
|
// 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 () => {
|
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
|
||||||
const rows = db.select().from(subscriptions).all();
|
const rows = db.select().from(subscriptions).where(isNull(subscriptions.deletedAt)).all();
|
||||||
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
|
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -532,16 +534,17 @@ export async function subscriptionRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hard delete a subscription + its child rows. (Past ledger events that reference it
|
// Delete a subscription — SOFT (recycle bin). The row + its credential/plate children
|
||||||
// are untouched — the audit trail is append-only and independent of this row.)
|
// 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 } }>(
|
app.delete<{ Params: { id: string } }>(
|
||||||
"/api/subscriptions/:id",
|
"/api/subscriptions/:id",
|
||||||
{ preHandler: deleteGuard },
|
{ preHandler: deleteGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
|
const ok = softDelete(db, "subscription", req.params.id, req.user.sub);
|
||||||
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
|
if (!ok) return reply.code(404).send({ error: "subscription not found" });
|
||||||
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
|
|
||||||
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
|
|
||||||
invalidateHolder(req.params.id);
|
invalidateHolder(req.params.id);
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { desc, eq, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
isTariffV2,
|
isTariffV2,
|
||||||
@@ -48,9 +48,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
// Publishing a new version changes what customers are charged.
|
// Publishing a new version changes what customers are charged.
|
||||||
const writeGuard = requirePermission("tariff:update");
|
const writeGuard = requirePermission("tariff:update");
|
||||||
|
|
||||||
// The single site tariff row, created on first read/publish.
|
// 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 {
|
function ensureSiteTariff(): string {
|
||||||
const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
const existing = db.select().from(tariffs).where(and(eq(tariffs.scope, "site"), isNull(tariffs.deletedAt))).get();
|
||||||
if (existing) return existing.id;
|
if (existing) return existing.id;
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
|
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, roles, users, type Db } from "@parking/db";
|
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
|
||||||
import { ADMIN_ROLE_ID } from "@parking/shared";
|
import { ADMIN_ROLE_ID } from "@parking/shared";
|
||||||
import { permissionsFor, requirePermission } from "../auth.js";
|
import { permissionsFor, requirePermission } from "../auth.js";
|
||||||
|
import { softDelete } from "../recycle-bin.js";
|
||||||
|
|
||||||
// User management (admin). Users are created/edited at runtime here — the
|
// 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
|
// install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one
|
||||||
@@ -64,9 +65,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
const updateGuard = requirePermission("user:update");
|
const updateGuard = requirePermission("user:update");
|
||||||
const deleteGuard = requirePermission("user:delete");
|
const deleteGuard = requirePermission("user:delete");
|
||||||
|
|
||||||
/** Count users currently holding the protected admin role. */
|
/** 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 {
|
function adminCount(): number {
|
||||||
return db.select().from(users).where(eq(users.roleId, ADMIN_ROLE_ID)).all().length;
|
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. */
|
/** True if removing/relocating `userId` from admin would leave zero admins. */
|
||||||
@@ -112,9 +114,10 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all users (no password hashes) + their role names for display.
|
// 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 () => {
|
app.get("/api/users", { preHandler: readGuard }, async () => {
|
||||||
const rows = db.select().from(users).all();
|
const rows = db.select().from(users).where(isNull(users.deletedAt)).all();
|
||||||
const roleRows = db.select().from(roles).all();
|
const roleRows = db.select().from(roles).all();
|
||||||
const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
|
const roleName = new Map(roleRows.map((r) => [r.id, r.name]));
|
||||||
return {
|
return {
|
||||||
@@ -140,8 +143,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
if (exceedsCaller(req.user.roleId, roleId)) {
|
if (exceedsCaller(req.user.roleId, roleId)) {
|
||||||
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
|
return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" });
|
||||||
}
|
}
|
||||||
if (db.select().from(users).where(eq(users.username, username)).get()) {
|
const clash = db.select().from(users).where(eq(users.username, username)).get();
|
||||||
return reply.code(409).send({ error: "username already exists" });
|
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 id = randomUUID();
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
@@ -221,13 +231,15 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete a user. Refused if it's the last admin (no-lockout).
|
// 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 } }>(
|
app.delete<{ Params: { id: string } }>(
|
||||||
"/api/users/:id",
|
"/api/users/:id",
|
||||||
{ preHandler: deleteGuard },
|
{ preHandler: deleteGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const target = db.select().from(users).where(eq(users.id, id)).get();
|
const target = db.select().from(users).where(and(eq(users.id, id), isNull(users.deletedAt))).get();
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return reply.code(404).send({ error: "user not found" });
|
return reply.code(404).send({ error: "user not found" });
|
||||||
}
|
}
|
||||||
@@ -238,7 +250,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
if (isLastAdmin(id)) {
|
if (isLastAdmin(id)) {
|
||||||
return reply.code(409).send({ error: "cannot delete the last admin" });
|
return reply.code(409).send({ error: "cannot delete the last admin" });
|
||||||
}
|
}
|
||||||
db.delete(users).where(eq(users.id, id)).run();
|
softDelete(db, "user", id, req.user.sub);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { roleHasPermissions } from "../auth.js";
|
import { roleHasPermissions } from "../auth.js";
|
||||||
import { deviceEvents } from "../device-events.js";
|
import { deviceEvents, type LaneStatusEvent } from "../device-events.js";
|
||||||
import { enrichEvent } from "../event-enrich.js";
|
import { enrichEvent } from "../event-enrich.js";
|
||||||
import type { DeviceMonitor } from "../device-monitor.js";
|
import type { DeviceMonitor } from "../device-monitor.js";
|
||||||
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
import { getOccupancy } from "../occupancy.js";
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
// Live booth feed over a WebSocket. The booth UI opens ONE socket and receives
|
||||||
@@ -52,12 +53,18 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OutMsg =
|
type OutMsg =
|
||||||
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown }
|
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
|
||||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: unknown };
|
| { kind: "device-status"; event: unknown }
|
||||||
|
| { kind: "lane-status"; lanes: LaneStatusEvent };
|
||||||
|
|
||||||
export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: DeviceMonitor): Promise<void> {
|
export async function wsRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
deviceMonitor: DeviceMonitor,
|
||||||
|
laneStatus: LaneStatus,
|
||||||
|
): Promise<void> {
|
||||||
app.get(
|
app.get(
|
||||||
"/api/ws",
|
"/api/ws",
|
||||||
{
|
{
|
||||||
@@ -89,7 +96,7 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi
|
|||||||
|
|
||||||
// Initial snapshot so the client renders immediately, before any event:
|
// Initial snapshot so the client renders immediately, before any event:
|
||||||
// occupancy AND the current device-status set (for the footer).
|
// occupancy AND the current device-status set (for the footer).
|
||||||
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot() });
|
send({ kind: "hello", occupancy: getOccupancy(db), devices: deviceMonitor.snapshot(), lanes: laneStatus.snapshot() });
|
||||||
|
|
||||||
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||||
@@ -106,11 +113,16 @@ export async function wsRoutes(app: FastifyInstance, db: Db, deviceMonitor: Devi
|
|||||||
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
||||||
send({ kind: "device-status", 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", () => {
|
socket.on("close", () => {
|
||||||
offLedger();
|
offLedger();
|
||||||
offPrinter();
|
offPrinter();
|
||||||
offDevice();
|
offDevice();
|
||||||
|
offLane();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { deviceEvents } from "./device-events.js";
|
|||||||
import { EntryFlow } from "./entry-flow.js";
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { VoidFlow } from "./void-flow.js";
|
||||||
import { PayStation } from "./pay-station.js";
|
import { PayStation } from "./pay-station.js";
|
||||||
import { SubscriptionFlow } from "./subscription-flow.js";
|
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
import { ShiftService } from "./shift-service.js";
|
import { ShiftService } from "./shift-service.js";
|
||||||
@@ -24,8 +25,13 @@ import { authRoutes } from "./routes/auth.js";
|
|||||||
import { userRoutes } from "./routes/users.js";
|
import { userRoutes } from "./routes/users.js";
|
||||||
import { roleRoutes } from "./routes/roles.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 { 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 { payRoutes } from "./routes/pay.js";
|
||||||
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||||
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||||
@@ -38,6 +44,7 @@ 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 { deviceStatusRoutes } from "./routes/device-status.js";
|
||||||
import { wsRoutes } from "./routes/ws.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
|
||||||
@@ -112,6 +119,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// 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.
|
||||||
@@ -146,9 +162,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
|
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
|
||||||
await reportRoutes(app, db);
|
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
|
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||||
await wsRoutes(app, db, deviceMonitor);
|
await wsRoutes(app, db, deviceMonitor, laneStatus);
|
||||||
|
|
||||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||||
await snapshotRoutes(app, db);
|
await snapshotRoutes(app, db);
|
||||||
@@ -180,6 +200,19 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeRead());
|
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
|
// 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
|
// CHOSEN reader to populate a subscription credential, without blocking the other
|
||||||
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
||||||
@@ -200,7 +233,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
|
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
|
||||||
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
|
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
|
||||||
const payStation = new PayStation(db, eventLog, app.log);
|
const payStation = new PayStation(db, eventLog, app.log);
|
||||||
await payRoutes(app, db, payStation, exitFlow, shiftService);
|
// 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
|
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||||
@@ -232,6 +268,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
logService.prune(); // once at startup
|
logService.prune(); // once at startup
|
||||||
app.addHook("onClose", async () => clearInterval(pruneTimer));
|
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) => {
|
||||||
// Record every input edge as unsigned telemetry, keyed to the device that fired
|
// Record every input edge as unsigned telemetry, keyed to the device that fired
|
||||||
// (provenance). No lane — the pool-of-spaces model has none. The entry flow
|
// (provenance). No lane — the pool-of-spaces model has none. The entry flow
|
||||||
@@ -253,5 +301,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeInput());
|
app.addHook("onClose", async () => unsubscribeInput());
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,8 +141,9 @@ async function recognizePlate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a live camera adapter from a resolved devices row, or null. */
|
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
|
||||||
function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
|
||||||
|
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
if (!driver) return null;
|
if (!driver) return null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
// Serve the built React SPA (apps/web/dist) from the Fastify server, so ONE container
|
||||||
|
// serves both the API and the operator UI — matching the offline-first single-appliance
|
||||||
|
// model (the booth has no separate web host). This is a NO-OP in dev (the Vite dev server
|
||||||
|
// serves the SPA on its own port and no dist exists), so it never changes local behavior.
|
||||||
|
//
|
||||||
|
// Registration order matters: this is registered LAST, after every API route, and its
|
||||||
|
// catch-all is GET-only and explicitly excludes /api, /health, and the WS path — so it
|
||||||
|
// can never shadow the backend. See wiki/decisions/container-deployment.md.
|
||||||
|
|
||||||
|
/** Where the built SPA lives. Override with WEB_DIST_DIR (the container sets it). Default
|
||||||
|
* resolves relative to this file's dist location: apps/server/dist → ../../web/dist, the
|
||||||
|
* layout the image lays down (/app/dist + /app/web/dist → ../web/dist from dist). */
|
||||||
|
function resolveWebDist(): string {
|
||||||
|
const fromEnv = process.env.WEB_DIST_DIR;
|
||||||
|
if (fromEnv) return resolve(fromEnv);
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
// In the image the server runs from /app/dist and the SPA sits at /app/web/dist.
|
||||||
|
return resolve(here, "../web/dist");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register SPA static serving if a build is present. Returns true when wired, false when
|
||||||
|
* skipped (dev / no build). Serves assets from the dist dir and falls back to index.html
|
||||||
|
* for any non-API GET so client-side routing (TanStack Router) works on deep links/reload.
|
||||||
|
*/
|
||||||
|
export async function registerSpa(app: FastifyInstance): Promise<boolean> {
|
||||||
|
const root = resolveWebDist();
|
||||||
|
const indexHtml = resolve(root, "index.html");
|
||||||
|
if (!existsSync(indexHtml)) {
|
||||||
|
app.log.info(`SPA static serving disabled (no build at ${root})`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.register(fastifyStatic, { root, wildcard: false });
|
||||||
|
|
||||||
|
// SPA fallback: any GET that didn't match an API route or a real static file returns
|
||||||
|
// index.html (client routing). EXCLUDE the backend surfaces so a missing /api route
|
||||||
|
// still 404s as JSON rather than silently returning the HTML shell. WS upgrades and
|
||||||
|
// non-GET methods are never touched (this is a GET-only notFound handler path).
|
||||||
|
app.setNotFoundHandler((req, reply) => {
|
||||||
|
const url = req.raw.url ?? "/";
|
||||||
|
if (req.method !== "GET" || url.startsWith("/api") || url.startsWith("/health")) {
|
||||||
|
return reply.code(404).send({ error: "not found" });
|
||||||
|
}
|
||||||
|
return reply.sendFile("index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.log.info(`SPA static serving enabled from ${root}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -108,7 +108,10 @@ export class SubscriptionFlow {
|
|||||||
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
|
// that happens BEFORE we infer the entry/exit verb ("both" defers to entry).
|
||||||
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
|
const lane: FlowDirection = resolved.direction === "exit" ? "exit" : "entry";
|
||||||
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
|
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
|
||||||
if (!sub) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
|
// A soft-deleted (recycle-bin) subscription must NOT open the barrier — treat it as
|
||||||
|
// gone. (Its credential rows are kept for restore, so the dispatcher can still match
|
||||||
|
// it; the gate is here.)
|
||||||
|
if (!sub || sub.deletedAt) return { accepted: false, reason: await this.#reject(m, lane, "sub.refused.notFound") };
|
||||||
|
|
||||||
// Validity: active + within the coverage window.
|
// Validity: active + within the coverage window.
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||||
|
import { VoidFlow } from "./void-flow.js";
|
||||||
|
import { PayStation } from "./pay-station.js";
|
||||||
|
import { occupancyCount } from "./occupancy.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { makeLog, silentLogger, seedTariff } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// Cancel (void) a wrongly-printed ticket: a SIGNED `void` event that references the entry
|
||||||
|
// and folds the session CLOSED. The entry itself is never edited/deleted (append-only).
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
let voidFlow: VoidFlow;
|
||||||
|
let pay: PayStation;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = makeLog(db);
|
||||||
|
voidFlow = new VoidFlow(db, log, silentLogger());
|
||||||
|
pay = new PayStation(db, log, silentLogger());
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
async function enter(identity: string, payload?: Record<string, unknown>) {
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity, payload: payload ?? null });
|
||||||
|
}
|
||||||
|
function voids(identity: string) {
|
||||||
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "void");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VoidFlow.voidTicket", () => {
|
||||||
|
it("voids an open transient ticket: signs a void, closes the session, drops occupancy", async () => {
|
||||||
|
await enter("T1");
|
||||||
|
expect(occupancyCount(db)).toBe(1);
|
||||||
|
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
|
||||||
|
const v = voids("T1");
|
||||||
|
expect(v).toHaveLength(1);
|
||||||
|
const pl = v[0]!.payload as Record<string, unknown>;
|
||||||
|
expect(pl.voidReason).toBe("misprint");
|
||||||
|
expect(pl.operator).toBe("alice");
|
||||||
|
expect(pl.voidedEntryRef).toBeDefined();
|
||||||
|
expect(pl.reasonCode).toBe("void.ticketCancelled");
|
||||||
|
|
||||||
|
// Folds: not inside, not an active session, no longer "open".
|
||||||
|
expect(occupancyCount(db)).toBe(1 - 1);
|
||||||
|
expect(pay.activeSessions().some((s) => s.identity === "T1")).toBe(false);
|
||||||
|
expect(pay.lookup("T1").open).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a reason", async () => {
|
||||||
|
await enter("T1");
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "T1", reason: " ", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(voids("T1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an unknown ticket", async () => {
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "ghost", reason: "misprint", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/no such ticket/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second void (already cancelled)", async () => {
|
||||||
|
await enter("T1");
|
||||||
|
await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "T1", reason: "again", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/already cancelled/i);
|
||||||
|
expect(voids("T1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an already-exited session", async () => {
|
||||||
|
await enter("T1");
|
||||||
|
await log.append({ type: "vehicle_exit", direction: "exit", identity: "T1" });
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/already exited/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a PAID ticket (refund is a separate action)", async () => {
|
||||||
|
await enter("T1");
|
||||||
|
await log.append({ type: "payment", identity: "T1", payload: { sessionRef: "T1", amountMinor: 100, currency: "ALL" } });
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/already paid/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a subscription occurrence (closed via its own flow)", async () => {
|
||||||
|
await enter("SUBSESS-x", { permit: true, permitId: "sub-1" });
|
||||||
|
const r = await voidFlow.voidTicket({ identity: "SUBSESS-x", reason: "misprint", operator: "alice" });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/subscription/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the signed chain verifiable after a void", async () => {
|
||||||
|
seedTariff(db);
|
||||||
|
await enter("T1");
|
||||||
|
await voidFlow.voidTicket({ identity: "T1", reason: "test", operator: "alice" });
|
||||||
|
// The void is the newest signed row; the chain is intact (verifier is exercised by
|
||||||
|
// the event-log on append — a broken chain would have thrown).
|
||||||
|
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
|
const last = rows[rows.length - 1]!;
|
||||||
|
expect(last.type).toBe("void");
|
||||||
|
expect(last.prevHash).toBeTruthy();
|
||||||
|
expect(last.signature).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { eq, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||||
|
import { reasonPayload } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Cancel a wrongly-printed transient ticket by appending a SIGNED `void` event that
|
||||||
|
// references the entry. The signed ledger is append-only and hash-chained — the
|
||||||
|
// vehicle_entry is NEVER edited or deleted; the void is a new appended row that the
|
||||||
|
// session projection folds to CLOSE the session (so a voided car stops counting inside
|
||||||
|
// and can't be paid/exited). Fully traceable: the operator + a required reason are signed
|
||||||
|
// into the void payload. A misprinted ticket's car never entered, so voiding opens NO
|
||||||
|
// barrier. See wiki/concepts/append-only-event-chain.md, parking-session.md.
|
||||||
|
|
||||||
|
export interface VoidResult {
|
||||||
|
readonly ok: boolean;
|
||||||
|
/** English reason on refusal (localized client-side via the reasonCode it mirrors). */
|
||||||
|
readonly reason?: string;
|
||||||
|
/** The void event's identity on success (= the entry identity). */
|
||||||
|
readonly identity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VoidFlow {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
/** Serialize concurrent voids of the SAME ticket (double-click / double-scan). */
|
||||||
|
readonly #inFlight = new Set<string>();
|
||||||
|
|
||||||
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#log = log;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Void (cancel) a transient ticket. Guards, then appends a signed `void`. Refuses:
|
||||||
|
* unknown ticket, a subscription occurrence (use the subscription flow), an already-
|
||||||
|
* exited or already-voided session, or a session that has a payment (a paid ticket is a
|
||||||
|
* refund situation — out of scope). `reason` is REQUIRED (the route enforces non-empty).
|
||||||
|
*/
|
||||||
|
async voidTicket(args: { identity: string; reason: string; operator: string }): Promise<VoidResult> {
|
||||||
|
const identity = args.identity.trim();
|
||||||
|
const reason = args.reason.trim();
|
||||||
|
if (!identity) return { ok: false, reason: "missing ticket id" };
|
||||||
|
if (!reason) return { ok: false, reason: "a cancellation reason is required" };
|
||||||
|
|
||||||
|
if (this.#inFlight.has(identity)) return { ok: false, reason: "cancel already in flight" };
|
||||||
|
this.#inFlight.add(identity);
|
||||||
|
try {
|
||||||
|
return await this.#run(identity, reason, args.operator);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`void-flow failed (${identity}): ${(err as Error).message}`);
|
||||||
|
return { ok: false, reason: (err as Error).message };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #run(identity: string, reason: string, operator: string): Promise<VoidResult> {
|
||||||
|
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 { ok: false, reason: "no such ticket (no entry for this id)" };
|
||||||
|
|
||||||
|
// Subscriptions are closed via their own flow — ticket-void would double-mean permitId.
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
if (entryPl.permit === true || entryPl.permitId != null) {
|
||||||
|
return { ok: false, reason: "this is a subscription occurrence — cancel it via the subscription, not a ticket void" };
|
||||||
|
}
|
||||||
|
if (rows.some((r) => r.type === "vehicle_exit")) {
|
||||||
|
return { ok: false, reason: "session already exited — nothing to cancel" };
|
||||||
|
}
|
||||||
|
if (rows.some((r) => r.type === "void")) {
|
||||||
|
return { ok: false, reason: "ticket already cancelled" };
|
||||||
|
}
|
||||||
|
// A paid ticket is a refund, not a misprint cancel — out of scope.
|
||||||
|
if (rows.some((r) => r.type === "payment")) {
|
||||||
|
return { ok: false, reason: "ticket already paid — a refund is a separate action, not a cancellation" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#log.append({
|
||||||
|
type: "void",
|
||||||
|
identity,
|
||||||
|
// `sessionRef` + `voidedEntryRef` tie the void to the entry; `voidReason` + `operator`
|
||||||
|
// make it traceable. The reasonCode localizes; the free-text reason is the operator's note.
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("void.ticketCancelled", { reason }),
|
||||||
|
sessionRef: identity,
|
||||||
|
voidedEntryRef: entry.id,
|
||||||
|
voidReason: reason,
|
||||||
|
operator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Best-effort close the projection cache (the ledger fold is the truth either way).
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.update(sessions)
|
||||||
|
.set({ exitedAt: new Date().toISOString(), state: "voided" })
|
||||||
|
.where(eq(sessions.id, identity))
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`void session-cache close failed for ${identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#logger.info(`ticket ${identity} cancelled by ${operator}: ${reason}`);
|
||||||
|
// NO barrier action — the misprinted ticket's car never entered.
|
||||||
|
return { ok: true, identity };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# Parking VISION image: the Python/uv ANPR microservice. Build CONTEXT is apps/vision
|
||||||
|
# (self-contained Python package; no monorepo deps). Ships WITH the `alpr` extra (real
|
||||||
|
# fast-alpr/onnxruntime stack) but the engine is env-selected: VISION_RECOGNIZER=stub
|
||||||
|
# (default, boots anywhere) or fast_alpr (prod). See wiki/decisions/container-deployment.md,
|
||||||
|
# wiki/decisions/vision-service-packaging.md.
|
||||||
|
|
||||||
|
# uv-provided Python 3.12 (matches apps/vision/.python-version).
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base
|
||||||
|
WORKDIR /app
|
||||||
|
ENV UV_LINK_MODE=copy \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ---- deps: resolve + install the venv from the lockfile (cache-friendly) ----
|
||||||
|
# Manifests first so the heavy `uv sync` layer caches across source edits.
|
||||||
|
COPY pyproject.toml uv.lock .python-version ./
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --no-install-project --extra alpr
|
||||||
|
|
||||||
|
# ---- project source ----
|
||||||
|
COPY vision_service/ ./vision_service/
|
||||||
|
COPY README.md ./
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --extra alpr
|
||||||
|
|
||||||
|
# Non-root runtime user, created BEFORE the model pre-warm so the weights cache lands in
|
||||||
|
# this user's HOME (~/.cache) — the SAME path the runtime reads. (fast-alpr's
|
||||||
|
# open-image-models caches under $HOME/.cache/open-image-models keyed to HOME, ignoring
|
||||||
|
# HF_HOME/XDG_CACHE_HOME — so the pre-warm MUST run as the runtime user, not root.)
|
||||||
|
RUN useradd --system --create-home --uid 999 vision \
|
||||||
|
&& chown -R vision:vision /app
|
||||||
|
USER vision
|
||||||
|
|
||||||
|
# Pre-warm the fast-alpr model weights INTO the image (as the vision user → /home/vision/
|
||||||
|
# .cache) so the prod recognizer is OFFLINE-first: ALPR() downloads weights on first
|
||||||
|
# construction, which would otherwise need network on the appliance's first scan. Best-effort
|
||||||
|
# — if the build host has no network this is skipped and weights fetch lazily at runtime.
|
||||||
|
# NB: NO --mount=type=cache here — a BuildKit cache mount at ~/.cache is NOT committed to the
|
||||||
|
# image layer, so the downloaded weights would vanish. They must write to the real layer.
|
||||||
|
RUN uv run python -c "from fast_alpr import ALPR; ALPR()" \
|
||||||
|
|| echo "[build] model pre-warm skipped (no network) — weights fetch at runtime"
|
||||||
|
|
||||||
|
# Default to the stub recognizer (offline, no model load); override to fast_alpr in prod.
|
||||||
|
ENV VISION_RECOGNIZER=stub \
|
||||||
|
VISION_HOST=0.0.0.0 \
|
||||||
|
VISION_PORT=8089
|
||||||
|
EXPOSE 8089
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1
|
||||||
|
|
||||||
|
CMD ["uv", "run", "uvicorn", "vision_service.app:app", "--host", "0.0.0.0", "--port", "8089"]
|
||||||
@@ -4,6 +4,7 @@ import * as Dialog from "@radix-ui/react-dialog";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
boothExit,
|
boothExit,
|
||||||
|
can,
|
||||||
fetchSiteConfig,
|
fetchSiteConfig,
|
||||||
lookupSession,
|
lookupSession,
|
||||||
openShift,
|
openShift,
|
||||||
@@ -11,8 +12,10 @@ import {
|
|||||||
printReceipt,
|
printReceipt,
|
||||||
printVoucher,
|
printVoucher,
|
||||||
reopenBarrier,
|
reopenBarrier,
|
||||||
|
voidTicket,
|
||||||
type SessionLookup,
|
type SessionLookup,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
import { rootRoute } from "./router.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||||
@@ -52,6 +55,11 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
|
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
|
||||||
// first, then the modal reveals "Open barrier". This flips true once paid.
|
// first, then the modal reveals "Open barrier". This flips true once paid.
|
||||||
const [windowPaid, setWindowPaid] = useState(false);
|
const [windowPaid, setWindowPaid] = useState(false);
|
||||||
|
// Cancel (void) a wrongly-printed ticket: a small reason prompt, then a signed void.
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
const canVoid = can(user, "event:void");
|
||||||
|
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
||||||
|
const [voidReason, setVoidReason] = useState("");
|
||||||
|
|
||||||
const s: SessionLookup | undefined = session.data;
|
const s: SessionLookup | undefined = session.data;
|
||||||
// Checkbox default comes from config the first time it loads; operator can toggle.
|
// Checkbox default comes from config the first time it loads; operator can toggle.
|
||||||
@@ -127,6 +135,29 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A wrongly-printed ticket is cancellable only while it's a TRANSIENT, UNPAID, OPEN
|
||||||
|
// session (a subscription is closed via its own flow; a paid ticket is a refund). The
|
||||||
|
// server enforces all of this too; the UI just hides the action when it can't apply.
|
||||||
|
const canCancel = !!(canVoid && shiftReady && s?.found && s.open && !isSubscription && !alreadyPaid);
|
||||||
|
|
||||||
|
async function handleVoidTicket() {
|
||||||
|
const reason = voidReason.trim();
|
||||||
|
if (!reason) return;
|
||||||
|
setError(null);
|
||||||
|
setPhase("finishing");
|
||||||
|
try {
|
||||||
|
await voidTicket(identity, reason);
|
||||||
|
setResult(t("pay.ticketCancelled"));
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||||
|
setPhase("done");
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleReprintReceipt() {
|
async function handleReprintReceipt() {
|
||||||
setReprinting(true);
|
setReprinting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -365,6 +396,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Cancel-ticket reason prompt (revealed by the "Cancel ticket" button).
|
||||||
|
A few presets + free text; a reason is REQUIRED. Voiding appends a
|
||||||
|
signed `void` event — the entry is never edited. */}
|
||||||
|
{voiding && phase !== "done" && (
|
||||||
|
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
|
{t("pay.cancelTicketTitle")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[12px] text-term-text">{t("pay.cancelTicketHint")}</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVoidReason(t(`pay.cancelReason.${k}`))}
|
||||||
|
className={voidReason === t(`pay.cancelReason.${k}`) ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||||
|
>
|
||||||
|
{t(`pay.cancelReason.${k}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="input mt-2 w-full"
|
||||||
|
value={voidReason}
|
||||||
|
onChange={(e) => setVoidReason(e.target.value)}
|
||||||
|
placeholder={t("pay.cancelReasonPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
||||||
{result && (
|
{result && (
|
||||||
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
||||||
@@ -439,7 +500,29 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
{t("pay.assistOpenReveal")}
|
{t("pay.assistOpenReveal")}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
) : voiding ? (
|
||||||
|
// Cancel-ticket confirm (reason prompt is shown above).
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleVoidTicket}
|
||||||
|
disabled={!voidReason.trim() || phase === "finishing"}
|
||||||
|
className="btn btn-danger btn-lg"
|
||||||
|
>
|
||||||
|
{phase === "finishing" ? t("pay.cancelling") : t("pay.confirmCancelTicket")}
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Cancel a wrongly-printed ticket (transient, unpaid, open only;
|
||||||
|
gated on event:void). Reveals the reason prompt above. */}
|
||||||
|
{canCancel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVoiding(true)}
|
||||||
|
className="btn btn-ghost btn-sm text-term-red"
|
||||||
|
>
|
||||||
|
{t("pay.cancelTicket")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePayAndExit}
|
onClick={handlePayAndExit}
|
||||||
@@ -460,6 +543,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
? t("pay.payAndVoucher")
|
? t("pay.payAndVoucher")
|
||||||
: t("pay.payAndOpen")}
|
: t("pay.payAndOpen")}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -112,6 +112,44 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
|
||||||
|
* from camera detection). Advisory only; it gates nothing. */
|
||||||
|
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
|
||||||
|
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
|
||||||
|
<svg viewBox="0 0 24 24" className={`h-5 w-5 ${busy ? "text-term-red" : "text-term-green"}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="5" y1="21" x2="5" y2="9" />
|
||||||
|
<line x1="5" y1="10" x2="21" y2="6" />
|
||||||
|
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
|
||||||
|
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
|
||||||
|
{busy ? "●" : "○"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
|
||||||
|
function LaneIndicators() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const lanes = useLiveStore((s) => s.lanes);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
|
||||||
|
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function BoothScreen() {
|
export function BoothScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
|
// The site-wide shift drives the log scope: the feed shows ONLY the open shift's
|
||||||
@@ -198,10 +236,16 @@ export function BoothScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
||||||
{/* Ticket input spans both columns at the top — the operator's primary action. */}
|
{/* Ticket input spans both columns at the top — the operator's primary action.
|
||||||
|
The lane barrier lights sit beside it (live vehicle-detection busy/free). */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Panel title={t("booth.processTicket")}>
|
<Panel title={t("booth.processTicket")}>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="min-w-[260px] flex-1">
|
||||||
<TicketInput onSubmit={setActiveTicket} />
|
<TicketInput onSubmit={setActiveTicket} />
|
||||||
|
</div>
|
||||||
|
<LaneIndicators />
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { changeMyPassword, updateMyProfile, type SessionUser } from "./api.js";
|
||||||
|
|
||||||
|
// Self-service profile: the signed-in user edits their OWN display name + email and
|
||||||
|
// changes their OWN password (proving the current one). This is NOT the admin
|
||||||
|
// user-manager (UsersManager.tsx) — it never touches another account, username, or
|
||||||
|
// role, and needs no `user:*` permission. See routes/auth.ts (/api/auth/profile,
|
||||||
|
// /api/auth/password) and wiki/entities/local-jwt-auth.md.
|
||||||
|
|
||||||
|
const MIN_PASSWORD = 8;
|
||||||
|
|
||||||
|
export function Profile({
|
||||||
|
user,
|
||||||
|
setUser,
|
||||||
|
}: {
|
||||||
|
user: SessionUser;
|
||||||
|
setUser: (u: SessionUser | null) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// --- Account (name / email) ---
|
||||||
|
const [fullName, setFullName] = useState(user.fullName ?? "");
|
||||||
|
const [email, setEmail] = useState(user.email ?? "");
|
||||||
|
const [accountMsg, setAccountMsg] = useState<string | null>(null);
|
||||||
|
const [savingAccount, setSavingAccount] = useState(false);
|
||||||
|
|
||||||
|
async function saveAccount() {
|
||||||
|
setAccountMsg(null);
|
||||||
|
setSavingAccount(true);
|
||||||
|
try {
|
||||||
|
const next = await updateMyProfile({ fullName, email });
|
||||||
|
// Keep the router-context user in sync so the header reflects the change.
|
||||||
|
setUser(next);
|
||||||
|
setFullName(next.fullName ?? "");
|
||||||
|
setEmail(next.email ?? "");
|
||||||
|
setAccountMsg(t("profile.profileSaved"));
|
||||||
|
} catch (e) {
|
||||||
|
setAccountMsg((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSavingAccount(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Password ---
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
||||||
|
const [savingPw, setSavingPw] = useState(false);
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
setPwMsg(null);
|
||||||
|
if (next.length < MIN_PASSWORD) {
|
||||||
|
setPwMsg(t("profile.passwordTooShort", { min: MIN_PASSWORD }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next !== confirm) {
|
||||||
|
setPwMsg(t("profile.passwordsDontMatch"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingPw(true);
|
||||||
|
try {
|
||||||
|
await changeMyPassword(current, next);
|
||||||
|
setCurrent("");
|
||||||
|
setNext("");
|
||||||
|
setConfirm("");
|
||||||
|
setPwMsg(t("profile.passwordChanged"));
|
||||||
|
} catch (e) {
|
||||||
|
setPwMsg((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSavingPw(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-xl flex-col gap-6">
|
||||||
|
<h1 className="text-lg text-term-text">{t("profile.title")}</h1>
|
||||||
|
|
||||||
|
{/* Account: display name + email (username + role are read-only — admin-managed). */}
|
||||||
|
<section className="card flex flex-col gap-3 p-4">
|
||||||
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||||
|
{t("profile.accountSection")}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted">
|
||||||
|
<div>
|
||||||
|
<span className="block">{t("profile.username")}</span>
|
||||||
|
<span className="text-sm text-term-text">{user.username}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="block">{t("profile.role")}</span>
|
||||||
|
<span className="text-sm text-term-text">{user.roleName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||||
|
{t("profile.fullName")}
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={fullName}
|
||||||
|
placeholder={t("profile.fullNamePh")}
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||||
|
{t("profile.email")}
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
placeholder={t("profile.emailPh")}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
||||||
|
{t("profile.saveProfile")}
|
||||||
|
</button>
|
||||||
|
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Password: requires the current one (server enforces). */}
|
||||||
|
<section className="card flex flex-col gap-3 p-4">
|
||||||
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||||
|
{t("profile.passwordSection")}
|
||||||
|
</h2>
|
||||||
|
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||||
|
{t("profile.currentPassword")}
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||||
|
{t("profile.newPassword")}
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
||||||
|
{t("profile.confirmPassword")}
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={changePassword}
|
||||||
|
disabled={savingPw || !current || !next || !confirm}
|
||||||
|
>
|
||||||
|
{t("profile.changePassword")}
|
||||||
|
</button>
|
||||||
|
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
can,
|
||||||
|
fetchRecycleBin,
|
||||||
|
purgeRecycleItem,
|
||||||
|
restoreRecycleItem,
|
||||||
|
type RecycleBinItem,
|
||||||
|
type RecycleKind,
|
||||||
|
type SessionUser,
|
||||||
|
} from "./api.js";
|
||||||
|
import { qk } from "./lib/query.js";
|
||||||
|
import { formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
|
||||||
|
// Recycle bin — the way back from an accidental delete. Lists everything soft-deleted
|
||||||
|
// across users/roles/subscriptions/plans/tariffs; an admin can Restore (back to its
|
||||||
|
// catalog) or Purge (permanent). Items auto-purge after the retention window. Gated by
|
||||||
|
// recyclebin:* (read to view, update to restore, delete to purge). See
|
||||||
|
// apps/server/src/recycle-bin.ts, wiki/concepts/soft-delete.md.
|
||||||
|
|
||||||
|
const KIND_KEY: Record<RecycleKind, string> = {
|
||||||
|
user: "recycleBin.kind.user",
|
||||||
|
role: "recycleBin.kind.role",
|
||||||
|
subscription: "recycleBin.kind.subscription",
|
||||||
|
plan: "recycleBin.kind.plan",
|
||||||
|
tariff: "recycleBin.kind.tariff",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecycleBin({ user }: { user: SessionUser | null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const binQ = useQuery({ queryKey: qk.recycleBin, queryFn: fetchRecycleBin });
|
||||||
|
|
||||||
|
const canRestore = can(user, "recyclebin:update");
|
||||||
|
const canPurge = can(user, "recyclebin:delete");
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [purging, setPurging] = useState<RecycleBinItem | null>(null);
|
||||||
|
|
||||||
|
const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message);
|
||||||
|
const invalidate = () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.recycleBin });
|
||||||
|
// A restore/purge can change any catalog — refresh the ones a restore touches.
|
||||||
|
for (const key of [["users"], ["roles"], ["subscriptions"], ["subscription-plans"], ["tariff"]]) {
|
||||||
|
void qc.invalidateQueries({ queryKey: key });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreM = useMutation({
|
||||||
|
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => restoreRecycleItem(kind, id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
const purgeM = useMutation({
|
||||||
|
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => purgeRecycleItem(kind, id),
|
||||||
|
onSuccess: () => {
|
||||||
|
setPurging(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
setPurging(null);
|
||||||
|
onError(e);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = binQ.data?.items ?? [];
|
||||||
|
const retentionDays = binQ.data?.retentionDays ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<div className="mb-3 flex items-center gap-3">
|
||||||
|
<h1 className="text-base font-bold uppercase tracking-widest text-term-amber">
|
||||||
|
{t("recycleBin.title")}
|
||||||
|
</h1>
|
||||||
|
{retentionDays > 0 && (
|
||||||
|
<span className="text-[12px] text-term-muted">
|
||||||
|
{t("recycleBin.retentionNote", { days: retentionDays })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
|
||||||
|
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
|
||||||
|
|
||||||
|
{!binQ.isLoading && items.length === 0 ? (
|
||||||
|
<p className="rounded-term border border-term-border bg-term-panel p-6 text-center text-term-muted">
|
||||||
|
{t("recycleBin.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
|
||||||
|
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
|
||||||
|
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
|
||||||
|
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
|
||||||
|
<th className="py-1.5 text-right">{t("recycleBin.col.actions")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((it) => (
|
||||||
|
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
|
||||||
|
<td className="py-1.5 pr-3">
|
||||||
|
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted">
|
||||||
|
{t(KIND_KEY[it.kind])}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-3 text-term-text">{it.label}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-term-muted">
|
||||||
|
{formatRelativeDateTime(it.deletedAt, t)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right">
|
||||||
|
{canRestore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
disabled={restoreM.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
restoreM.mutate({ kind: it.kind, id: it.id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("recycleBin.restore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canPurge && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost ml-1 text-term-red"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setPurging(it);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("recycleBin.purge")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{purging && (
|
||||||
|
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
|
||||||
|
<p className="text-[13px] text-term-text">
|
||||||
|
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
disabled={purgeM.isPending}
|
||||||
|
onClick={() => purgeM.mutate({ kind: purging.kind, id: purging.id })}
|
||||||
|
>
|
||||||
|
{t("recycleBin.purge")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -331,11 +331,13 @@ function DeviceForm({
|
|||||||
|
|
||||||
// Pre-fill scalar config fields from the existing assignment when editing.
|
// Pre-fill scalar config fields from the existing assignment when editing.
|
||||||
// (relays/controllerId/relay are model fields handled by their own state below.)
|
// (relays/controllerId/relay are model fields handled by their own state below.)
|
||||||
const [config, setConfig] = useState<Record<string, string | number>>(() => {
|
// Booleans are kept as real booleans (a checkbox field) — older saved configs may
|
||||||
|
// have stored a boolean as the string "true"/"false"; normalize those on load.
|
||||||
|
const [config, setConfig] = useState<Record<string, string | number | boolean>>(() => {
|
||||||
if (!editCfg) return {};
|
if (!editCfg) return {};
|
||||||
const out: Record<string, string | number> = {};
|
const out: Record<string, string | number | boolean> = {};
|
||||||
for (const [k, v] of Object.entries(editCfg)) {
|
for (const [k, v] of Object.entries(editCfg)) {
|
||||||
if (typeof v === "string" || typeof v === "number") out[k] = v;
|
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") out[k] = v;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
@@ -415,9 +417,16 @@ function DeviceForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
|
/** Scalar config the user entered, merged over driver defaults (for test/push-IP). */
|
||||||
function mergedScalarConfig(): Record<string, string | number> {
|
function mergedScalarConfig(): Record<string, string | number | boolean> {
|
||||||
const out: Record<string, string | number> = {};
|
const out: Record<string, string | number | boolean> = {};
|
||||||
for (const f of selected?.configFields ?? []) {
|
for (const f of selected?.configFields ?? []) {
|
||||||
|
// Boolean (checkbox) fields persist a REAL boolean — always (so toggling one OFF
|
||||||
|
// on an edit actually writes false), defaulting to the field default or false.
|
||||||
|
if (f.type === "boolean") {
|
||||||
|
const cur = config[f.key];
|
||||||
|
out[f.key] = typeof cur === "boolean" ? cur : Boolean(cur ?? f.default ?? false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const v = config[f.key] ?? (f.default as string | number | undefined);
|
const v = config[f.key] ?? (f.default as string | number | undefined);
|
||||||
if (v !== undefined && v !== "") out[f.key] = v;
|
if (v !== undefined && v !== "") out[f.key] = v;
|
||||||
}
|
}
|
||||||
@@ -560,7 +569,27 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected.configFields.map((f) => (
|
{selected.configFields.map((f) =>
|
||||||
|
f.type === "boolean" ? (
|
||||||
|
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
||||||
|
// the string "true"). The label sits beside the box, with the help below.
|
||||||
|
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={Boolean(config[f.key] ?? f.default ?? false)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.checked;
|
||||||
|
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-term-text">{f.label}</span>
|
||||||
|
{f.help && <span className="hint mt-0.5 block">{f.help}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
<div key={f.key} className="field my-2 max-w-sm">
|
<div key={f.key} className="field my-2 max-w-sm">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
{f.label}
|
{f.label}
|
||||||
@@ -586,7 +615,7 @@ function DeviceForm({
|
|||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||||
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
value={(config[f.key] ?? (f.default as string | number | undefined) ?? "") as string | number}
|
||||||
placeholder={f.help}
|
placeholder={f.help}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
@@ -596,7 +625,8 @@ function DeviceForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
{/* CONTROLLER: the relay map — which relay opens which direction + entry button. */}
|
||||||
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
{isController && <RelayEditor relays={relays} onChange={setRelays} />}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
const [meta, setMeta] = useState<Record<string, string>>({});
|
const [meta, setMeta] = useState<Record<string, string>>({});
|
||||||
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
||||||
const [reserveSubs, setReserveSubs] = useState(false);
|
const [reserveSubs, setReserveSubs] = useState(false);
|
||||||
|
const [anprEntry, setAnprEntry] = useState(true);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
@@ -38,6 +39,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||||
setExitVoucherDefault(c.exitVoucherDefault);
|
setExitVoucherDefault(c.exitVoucherDefault);
|
||||||
setReserveSubs(c.reserveSubscriberSpots);
|
setReserveSubs(c.reserveSubscriberSpots);
|
||||||
|
setAnprEntry(c.anprEntryEnabled);
|
||||||
const m: Record<string, string> = {};
|
const m: Record<string, string> = {};
|
||||||
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
||||||
setMeta(m);
|
setMeta(m);
|
||||||
@@ -52,6 +54,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
capacity: raw === "" ? null : Math.round(Number(raw)),
|
capacity: raw === "" ? null : Math.round(Number(raw)),
|
||||||
exitVoucherDefault,
|
exitVoucherDefault,
|
||||||
reserveSubscriberSpots: reserveSubs,
|
reserveSubscriberSpots: reserveSubs,
|
||||||
|
anprEntryEnabled: anprEntry,
|
||||||
};
|
};
|
||||||
// Send each metadata field; "" → null is applied server-side.
|
// Send each metadata field; "" → null is applied server-side.
|
||||||
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
||||||
@@ -112,6 +115,18 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 accent-term-amber"
|
||||||
|
checked={anprEntry}
|
||||||
|
onChange={(e) => setAnprEntry(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t("site.anprEntry")}
|
||||||
|
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||||
{t("site.parkDetails")}
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export interface SessionUser {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
/** Optional display name (profile metadata); null if unset. */
|
/** Optional display name (profile metadata); null if unset. */
|
||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
|
/** Optional contact email (profile metadata); null if unset. */
|
||||||
|
email: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this session grant the permission? Central authz check for the SPA. */
|
/** Does this session grant the permission? Central authz check for the SPA. */
|
||||||
@@ -103,6 +105,29 @@ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
|
|||||||
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
|
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Edit MY own profile (display name / email). Returns the refreshed session.
|
||||||
|
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
|
||||||
|
export function updateMyProfile(patch: {
|
||||||
|
fullName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}): Promise<SessionUser> {
|
||||||
|
return apiFetch<SessionUser>("/api/auth/profile", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change MY own password — proves the current one first (server enforces). */
|
||||||
|
export function changeMyPassword(
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<{ ok: boolean }> {
|
||||||
|
return apiFetch("/api/auth/password", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns the current user, or null if not authenticated. */
|
/** Returns the current user, or null if not authenticated. */
|
||||||
export async function fetchMe(): Promise<SessionUser | null> {
|
export async function fetchMe(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
@@ -368,6 +393,39 @@ export function reportCsvUrl(from: string, to: string, bucket: ReportBucket): st
|
|||||||
return apiUrl(`/api/reports/summary.csv?${qs}`);
|
return apiUrl(`/api/reports/summary.csv?${qs}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Recycle bin (soft-deleted master data) ------------------------------
|
||||||
|
export type RecycleKind = "user" | "role" | "subscription" | "plan" | "tariff";
|
||||||
|
|
||||||
|
export interface RecycleBinItem {
|
||||||
|
kind: RecycleKind;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
deletedAt: string;
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecycleBin {
|
||||||
|
items: RecycleBinItem[];
|
||||||
|
retentionDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything currently in the recycle bin + the retention window (days). */
|
||||||
|
export function fetchRecycleBin(): Promise<RecycleBin> {
|
||||||
|
return apiFetch<RecycleBin>("/api/recycle-bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore a soft-deleted item (back to its catalog). 409 if a live row would collide. */
|
||||||
|
export function restoreRecycleItem(kind: RecycleKind, id: string): Promise<{ restored: boolean }> {
|
||||||
|
return apiFetch<{ restored: boolean }>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}/restore`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permanently purge a soft-deleted item. Irreversible. */
|
||||||
|
export function purgeRecycleItem(kind: RecycleKind, id: string): Promise<void> {
|
||||||
|
return apiFetch<void>(`/api/recycle-bin/${kind}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackendIpCandidate {
|
export interface BackendIpCandidate {
|
||||||
ip: string;
|
ip: string;
|
||||||
iface: string;
|
iface: string;
|
||||||
@@ -910,6 +968,8 @@ export interface SiteConfig {
|
|||||||
subscriptionMonthlyPriceMinor: number | null;
|
subscriptionMonthlyPriceMinor: number | null;
|
||||||
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
|
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
|
||||||
reserveSubscriberSpots: boolean;
|
reserveSubscriberSpots: boolean;
|
||||||
|
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */
|
||||||
|
anprEntryEnabled: boolean;
|
||||||
parkName: string | null;
|
parkName: string | null;
|
||||||
operatorName: string | null;
|
operatorName: string | null;
|
||||||
/** NIUS — Albanian tax/identification number. */
|
/** NIUS — Albanian tax/identification number. */
|
||||||
@@ -1050,6 +1110,13 @@ export function paySession(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event with
|
||||||
|
* the operator + a required reason; the entry itself is never edited (append-only).
|
||||||
|
* Refuses a subscription / already-exited / already-voided / paid ticket (409). */
|
||||||
|
export function voidTicket(identity: string, reason: string): Promise<{ ok: boolean; identity?: string }> {
|
||||||
|
return apiFetch("/api/tickets/void", { method: "POST", body: JSON.stringify({ identity, reason }) });
|
||||||
|
}
|
||||||
|
|
||||||
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||||||
* open (payment stands; operator opens manually). */
|
* open (payment stands; operator opens manually). */
|
||||||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||||||
|
|||||||
@@ -56,7 +56,29 @@ export const en: Catalog = {
|
|||||||
roles: "Roles",
|
roles: "Roles",
|
||||||
shifts: "Shifts",
|
shifts: "Shifts",
|
||||||
reports: "Reports",
|
reports: "Reports",
|
||||||
|
recycleBin: "Recycle bin",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
|
profile: "Profile",
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: "My profile",
|
||||||
|
accountSection: "Account",
|
||||||
|
fullName: "Full name",
|
||||||
|
fullNamePh: "First and last name",
|
||||||
|
email: "Email",
|
||||||
|
emailPh: "you@example.com",
|
||||||
|
username: "Username",
|
||||||
|
role: "Role",
|
||||||
|
saveProfile: "Save profile",
|
||||||
|
profileSaved: "Profile saved.",
|
||||||
|
passwordSection: "Change password",
|
||||||
|
currentPassword: "Current password",
|
||||||
|
newPassword: "New password",
|
||||||
|
confirmPassword: "Confirm password",
|
||||||
|
changePassword: "Change password",
|
||||||
|
passwordChanged: "Password changed.",
|
||||||
|
passwordsDontMatch: "Passwords don't match.",
|
||||||
|
passwordTooShort: "Password must be at least {{min}} characters.",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
live: "LIVE",
|
live: "LIVE",
|
||||||
@@ -94,6 +116,8 @@ export const en: Catalog = {
|
|||||||
booth: {
|
booth: {
|
||||||
processTicket: "Process ticket",
|
processTicket: "Process ticket",
|
||||||
scanPlaceholder: "Scan or type ticket number…",
|
scanPlaceholder: "Scan or type ticket number…",
|
||||||
|
laneEntry: "Entry",
|
||||||
|
laneExit: "Exit",
|
||||||
open: "Open",
|
open: "Open",
|
||||||
occupancy: "Occupancy",
|
occupancy: "Occupancy",
|
||||||
occUnavailable: "occupancy unavailable",
|
occUnavailable: "occupancy unavailable",
|
||||||
@@ -153,6 +177,7 @@ export const en: Catalog = {
|
|||||||
evtCashIn: "PAY-IN",
|
evtCashIn: "PAY-IN",
|
||||||
evtCashOut: "PAY-OUT",
|
evtCashOut: "PAY-OUT",
|
||||||
evtAnomaly: "ANOMALY",
|
evtAnomaly: "ANOMALY",
|
||||||
|
evtRefused: "REFUSED",
|
||||||
// live-feed event detail line + classification badges (computed from payload)
|
// live-feed event detail line + classification badges (computed from payload)
|
||||||
evtNoReason: "no reason recorded",
|
evtNoReason: "no reason recorded",
|
||||||
badgeEntryRefused: "entry refused",
|
badgeEntryRefused: "entry refused",
|
||||||
@@ -221,6 +246,7 @@ export const en: Catalog = {
|
|||||||
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
||||||
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
||||||
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
|
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
|
||||||
|
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
|
||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "Tariff",
|
title: "Tariff",
|
||||||
@@ -529,6 +555,8 @@ export const en: Catalog = {
|
|||||||
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
|
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
|
||||||
reserveSubs: "Reserve subscriber spots",
|
reserveSubs: "Reserve subscriber spots",
|
||||||
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
|
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
|
||||||
|
anprEntry: "Auto-open for subscriber plates (ANPR)",
|
||||||
|
anprEntryHint: "When on, a subscriber's plate read by a lane camera opens the barrier through the normal subscription gate. Off: subscribers must use their card/QR. Plate snapshots are still recorded either way.",
|
||||||
parkDetails: "Park details (optional — shown on tickets/receipts)",
|
parkDetails: "Park details (optional — shown on tickets/receipts)",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
saved: "Saved.",
|
saved: "Saved.",
|
||||||
@@ -712,6 +740,24 @@ export const en: Catalog = {
|
|||||||
subCars: "Cars covered",
|
subCars: "Cars covered",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recycleBin: {
|
||||||
|
title: "Recycle bin",
|
||||||
|
retentionNote: "Deleted items are kept for {{days}} days, then permanently removed.",
|
||||||
|
empty: "Nothing deleted. Items you delete appear here, recoverable until they expire.",
|
||||||
|
col: { type: "Type", item: "Item", deleted: "Deleted", actions: "" },
|
||||||
|
kind: {
|
||||||
|
user: "User",
|
||||||
|
role: "Role",
|
||||||
|
subscription: "Subscription",
|
||||||
|
plan: "Plan",
|
||||||
|
tariff: "Tariff",
|
||||||
|
},
|
||||||
|
restore: "Restore",
|
||||||
|
purge: "Purge",
|
||||||
|
purgeConfirmTitle: "Purge permanently?",
|
||||||
|
purgeConfirmBody: "Permanently delete “{{label}}”? It cannot be restored after this.",
|
||||||
|
purgeIrreversible: "This is irreversible.",
|
||||||
|
},
|
||||||
logs: {
|
logs: {
|
||||||
title: "System logs",
|
title: "System logs",
|
||||||
refresh: "Refresh",
|
refresh: "Refresh",
|
||||||
@@ -776,6 +822,18 @@ export const en: Catalog = {
|
|||||||
receiptReprinted: "Receipt reprinted on {{printer}}.",
|
receiptReprinted: "Receipt reprinted on {{printer}}.",
|
||||||
reprintReceipt: "Reprint receipt",
|
reprintReceipt: "Reprint receipt",
|
||||||
reprinting: "printing…",
|
reprinting: "printing…",
|
||||||
|
cancelTicket: "Cancel ticket",
|
||||||
|
cancelTicketTitle: "Cancel this ticket",
|
||||||
|
cancelTicketHint: "Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.",
|
||||||
|
cancelReason: {
|
||||||
|
misprint: "Misprint",
|
||||||
|
test: "Test",
|
||||||
|
wrongVehicle: "Wrong vehicle",
|
||||||
|
},
|
||||||
|
cancelReasonPlaceholder: "Reason for cancelling (required)…",
|
||||||
|
confirmCancelTicket: "Confirm cancellation",
|
||||||
|
cancelling: "cancelling…",
|
||||||
|
ticketCancelled: "Ticket cancelled.",
|
||||||
noSnapshots: "no snapshots",
|
noSnapshots: "no snapshots",
|
||||||
loadingSnapshots: "loading snapshots…",
|
loadingSnapshots: "loading snapshots…",
|
||||||
snapEntry: "entry",
|
snapEntry: "entry",
|
||||||
|
|||||||
@@ -58,7 +58,29 @@ export const sq = {
|
|||||||
roles: "Rolet",
|
roles: "Rolet",
|
||||||
shifts: "Turnet",
|
shifts: "Turnet",
|
||||||
reports: "Raportet",
|
reports: "Raportet",
|
||||||
|
recycleBin: "Koshi",
|
||||||
logs: "Loget",
|
logs: "Loget",
|
||||||
|
profile: "Profili",
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: "Profili im",
|
||||||
|
accountSection: "Llogaria",
|
||||||
|
fullName: "Emri i plotë",
|
||||||
|
fullNamePh: "Emri dhe mbiemri",
|
||||||
|
email: "Email",
|
||||||
|
emailPh: "ti@shembull.com",
|
||||||
|
username: "Përdoruesi",
|
||||||
|
role: "Roli",
|
||||||
|
saveProfile: "Ruaj profilin",
|
||||||
|
profileSaved: "Profili u ruajt.",
|
||||||
|
passwordSection: "Ndrysho fjalëkalimin",
|
||||||
|
currentPassword: "Fjalëkalimi aktual",
|
||||||
|
newPassword: "Fjalëkalimi i ri",
|
||||||
|
confirmPassword: "Konfirmo fjalëkalimin",
|
||||||
|
changePassword: "Ndrysho fjalëkalimin",
|
||||||
|
passwordChanged: "Fjalëkalimi u ndryshua.",
|
||||||
|
passwordsDontMatch: "Fjalëkalimet nuk përputhen.",
|
||||||
|
passwordTooShort: "Fjalëkalimi duhet të jetë të paktën {{min}} karaktere.",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
live: "LIVE",
|
live: "LIVE",
|
||||||
@@ -96,6 +118,8 @@ export const sq = {
|
|||||||
booth: {
|
booth: {
|
||||||
processTicket: "Proceso biletën",
|
processTicket: "Proceso biletën",
|
||||||
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
|
||||||
|
laneEntry: "Hyrje",
|
||||||
|
laneExit: "Dalje",
|
||||||
open: "Hap",
|
open: "Hap",
|
||||||
occupancy: "Prania",
|
occupancy: "Prania",
|
||||||
occUnavailable: "zënia e padisponueshme",
|
occUnavailable: "zënia e padisponueshme",
|
||||||
@@ -157,6 +181,7 @@ export const sq = {
|
|||||||
evtCashIn: "ARKËTIM",
|
evtCashIn: "ARKËTIM",
|
||||||
evtCashOut: "PAGESË",
|
evtCashOut: "PAGESË",
|
||||||
evtAnomaly: "ANOMALI",
|
evtAnomaly: "ANOMALI",
|
||||||
|
evtRefused: "REFUZUAR",
|
||||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||||
evtNoReason: "pa arsye të regjistruar",
|
evtNoReason: "pa arsye të regjistruar",
|
||||||
badgeEntryRefused: "hyrje e refuzuar",
|
badgeEntryRefused: "hyrje e refuzuar",
|
||||||
@@ -224,6 +249,7 @@ export const sq = {
|
|||||||
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
||||||
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
||||||
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
|
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
|
||||||
|
"void.ticketCancelled": "Bileta u anulua — {{reason}}",
|
||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "Tarifa",
|
title: "Tarifa",
|
||||||
@@ -540,6 +566,8 @@ export const sq = {
|
|||||||
printExitHint: "(klienti skanon biletën në dalje)",
|
printExitHint: "(klienti skanon biletën në dalje)",
|
||||||
reserveSubs: "Rezervo vendet e abonentëve",
|
reserveSubs: "Rezervo vendet e abonentëve",
|
||||||
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
|
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
|
||||||
|
anprEntry: "Hapje automatike për targat e abonentëve (ANPR)",
|
||||||
|
anprEntryHint: "Kur është aktiv, targa e një abonenti e lexuar nga kamera e korsisë hap barrierën përmes portës normale të abonimit. Joaktiv: abonentët duhet të përdorin kartën/QR-në. Fotot e targave regjistrohen gjithsesi.",
|
||||||
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
||||||
save: "Ruaj",
|
save: "Ruaj",
|
||||||
saved: "U ruajt.",
|
saved: "U ruajt.",
|
||||||
@@ -726,6 +754,24 @@ export const sq = {
|
|||||||
subCars: "Makina të mbuluara",
|
subCars: "Makina të mbuluara",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recycleBin: {
|
||||||
|
title: "Koshi",
|
||||||
|
retentionNote: "Artikujt e fshirë mbahen për {{days}} ditë, pastaj hiqen përgjithmonë.",
|
||||||
|
empty: "Asgjë e fshirë. Artikujt që fshini shfaqen këtu, të rikuperueshëm derisa të skadojnë.",
|
||||||
|
col: { type: "Lloji", item: "Artikulli", deleted: "Fshirë", actions: "" },
|
||||||
|
kind: {
|
||||||
|
user: "Përdorues",
|
||||||
|
role: "Rol",
|
||||||
|
subscription: "Abonim",
|
||||||
|
plan: "Plan",
|
||||||
|
tariff: "Tarifë",
|
||||||
|
},
|
||||||
|
restore: "Rikthe",
|
||||||
|
purge: "Fshi përfundimisht",
|
||||||
|
purgeConfirmTitle: "Të fshihet përfundimisht?",
|
||||||
|
purgeConfirmBody: "Të fshihet përgjithmonë “{{label}}”? Nuk mund të rikthehet pas kësaj.",
|
||||||
|
purgeIrreversible: "Ky veprim është i pakthyeshëm.",
|
||||||
|
},
|
||||||
logs: {
|
logs: {
|
||||||
title: "Loget e sistemit",
|
title: "Loget e sistemit",
|
||||||
refresh: "Rifresko",
|
refresh: "Rifresko",
|
||||||
@@ -790,6 +836,18 @@ export const sq = {
|
|||||||
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
||||||
reprintReceipt: "Riprinto faturën",
|
reprintReceipt: "Riprinto faturën",
|
||||||
reprinting: "duke printuar…",
|
reprinting: "duke printuar…",
|
||||||
|
cancelTicket: "Anulo biletën",
|
||||||
|
cancelTicketTitle: "Anulo këtë biletë",
|
||||||
|
cancelTicketHint: "Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.",
|
||||||
|
cancelReason: {
|
||||||
|
misprint: "Printim i gabuar",
|
||||||
|
test: "Test",
|
||||||
|
wrongVehicle: "Automjet i gabuar",
|
||||||
|
},
|
||||||
|
cancelReasonPlaceholder: "Arsyeja e anulimit (e detyrueshme)…",
|
||||||
|
confirmCancelTicket: "Konfirmo anulimin",
|
||||||
|
cancelling: "duke anuluar…",
|
||||||
|
ticketCancelled: "Bileta u anulua.",
|
||||||
// snapshots
|
// snapshots
|
||||||
noSnapshots: "asnjë foto",
|
noSnapshots: "asnjë foto",
|
||||||
loadingSnapshots: "duke ngarkuar fotot…",
|
loadingSnapshots: "duke ngarkuar fotot…",
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
|||||||
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
|
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
|
||||||
export type WsStatus = "connecting" | "open" | "closed";
|
export type WsStatus = "connecting" | "open" | "closed";
|
||||||
|
|
||||||
|
/** Per-lane busy/free from camera vehicle detection (advisory barrier lights). */
|
||||||
|
export interface LaneStatus {
|
||||||
|
entry: boolean; // true = busy
|
||||||
|
exit: boolean; // true = busy
|
||||||
|
}
|
||||||
|
|
||||||
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
/** Cap the in-memory live feed so a long-running booth session can't grow it
|
||||||
* unbounded — the full history is always available via the /api/events query. */
|
* unbounded — the full history is always available via the /api/events query. */
|
||||||
const MAX_FEED = 200;
|
const MAX_FEED = 200;
|
||||||
@@ -23,6 +29,8 @@ interface LiveState {
|
|||||||
/** Live device status keyed by device id (for the footer): set from the WS
|
/** Live device status keyed by device id (for the footer): set from the WS
|
||||||
* hello snapshot, then upserted per device on each device-status push. */
|
* hello snapshot, then upserted per device on each device-status push. */
|
||||||
devices: Record<string, DeviceStatus>;
|
devices: Record<string, DeviceStatus>;
|
||||||
|
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
|
||||||
|
lanes: LaneStatus | null;
|
||||||
setStatus: (s: WsStatus) => void;
|
setStatus: (s: WsStatus) => void;
|
||||||
setOccupancy: (o: Occupancy) => void;
|
setOccupancy: (o: Occupancy) => void;
|
||||||
pushEvent: (e: LedgerEvent) => void;
|
pushEvent: (e: LedgerEvent) => void;
|
||||||
@@ -30,6 +38,8 @@ interface LiveState {
|
|||||||
setDevices: (list: DeviceStatus[]) => void;
|
setDevices: (list: DeviceStatus[]) => void;
|
||||||
/** Upsert one device's status (a device-status push). */
|
/** Upsert one device's status (a device-status push). */
|
||||||
upsertDevice: (d: DeviceStatus) => void;
|
upsertDevice: (d: DeviceStatus) => void;
|
||||||
|
/** Set lane busy/free (WS hello + each lane-status push). */
|
||||||
|
setLanes: (l: LaneStatus) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +55,7 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
occupancy: null,
|
occupancy: null,
|
||||||
feed: [],
|
feed: [],
|
||||||
devices: {},
|
devices: {},
|
||||||
|
lanes: null,
|
||||||
setStatus: (status) => set({ status }),
|
setStatus: (status) => set({ status }),
|
||||||
setOccupancy: (occupancy) => set({ occupancy }),
|
setOccupancy: (occupancy) => set({ occupancy }),
|
||||||
pushEvent: (e) =>
|
pushEvent: (e) =>
|
||||||
@@ -54,5 +65,6 @@ export const useLiveStore = create<LiveState>((set) => ({
|
|||||||
})),
|
})),
|
||||||
setDevices: (list) => set({ devices: byId(list) }),
|
setDevices: (list) => set({ devices: byId(list) }),
|
||||||
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
|
||||||
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {} }),
|
setLanes: (lanes) => set({ lanes }),
|
||||||
|
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ export const qk = {
|
|||||||
deviceStatus: ["device-status"] as const,
|
deviceStatus: ["device-status"] as const,
|
||||||
report: (from: string, to: string, bucket: string) =>
|
report: (from: string, to: string, bucket: string) =>
|
||||||
["report", from, to, bucket] as const,
|
["report", from, to, bucket] as const,
|
||||||
|
recycleBin: ["recycle-bin"] as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||||
import { qk } from "./query.js";
|
import { qk } from "./query.js";
|
||||||
import { useLiveStore } from "./live-store.js";
|
import { useLiveStore, type LaneStatus } from "./live-store.js";
|
||||||
import { wsUrl } from "./origin.js";
|
import { wsUrl } from "./origin.js";
|
||||||
|
|
||||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||||
@@ -14,15 +14,16 @@ import { wsUrl } from "./origin.js";
|
|||||||
|
|
||||||
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||||
type WsMessage =
|
type WsMessage =
|
||||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[] }
|
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus }
|
||||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: DeviceStatus };
|
| { kind: "device-status"; event: DeviceStatus }
|
||||||
|
| { kind: "lane-status"; lanes: LaneStatus };
|
||||||
|
|
||||||
|
|
||||||
export function useLiveFeed(): void {
|
export function useLiveFeed(): void {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice } = useLiveStore();
|
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes } = useLiveStore();
|
||||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
// double-invoke and unmount.
|
// double-invoke and unmount.
|
||||||
const sockRef = useRef<WebSocket | null>(null);
|
const sockRef = useRef<WebSocket | null>(null);
|
||||||
@@ -54,8 +55,11 @@ export function useLiveFeed(): void {
|
|||||||
setOccupancy(msg.occupancy);
|
setOccupancy(msg.occupancy);
|
||||||
// Initial device-status snapshot for the footer.
|
// Initial device-status snapshot for the footer.
|
||||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||||
|
if (msg.lanes) setLanes(msg.lanes);
|
||||||
} else if (msg.kind === "device-status") {
|
} else if (msg.kind === "device-status") {
|
||||||
upsertDevice(msg.event);
|
upsertDevice(msg.event);
|
||||||
|
} else if (msg.kind === "lane-status") {
|
||||||
|
setLanes(msg.lanes);
|
||||||
} else if (msg.kind === "ledger") {
|
} else if (msg.kind === "ledger") {
|
||||||
setOccupancy(msg.occupancy);
|
setOccupancy(msg.occupancy);
|
||||||
pushEvent(msg.event);
|
pushEvent(msg.event);
|
||||||
|
|||||||
+42
-4
@@ -30,6 +30,8 @@ import { UsersManager } from "./UsersManager.js";
|
|||||||
import { RolesManager } from "./RolesManager.js";
|
import { RolesManager } from "./RolesManager.js";
|
||||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
|
import { RecycleBin } from "./RecycleBin.js";
|
||||||
|
import { Profile } from "./Profile.js";
|
||||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||||
// initial bundle and only downloads when an admin opens /setup/reports.
|
// initial bundle and only downloads when an admin opens /setup/reports.
|
||||||
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
|
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
|
||||||
@@ -44,7 +46,9 @@ export interface RouterContext {
|
|||||||
setUser: (u: SessionUser | null) => void;
|
setUser: (u: SessionUser | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
// Exported so a deep component (e.g. the booth pay modal) can read the signed-in user
|
||||||
|
// from route context without prop-threading through every layer.
|
||||||
|
export const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,6 +92,7 @@ function SetupLayout() {
|
|||||||
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||||||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||||
|
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -387,6 +392,7 @@ function RootLayout() {
|
|||||||
show("site:read") ||
|
show("site:read") ||
|
||||||
show("user:read") ||
|
show("user:read") ||
|
||||||
show("role:read") ||
|
show("role:read") ||
|
||||||
|
show("recyclebin:read") ||
|
||||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
@@ -394,9 +400,15 @@ function RootLayout() {
|
|||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
<StatusDot />
|
<StatusDot />
|
||||||
<span className="text-[11px] text-term-muted">
|
{user && (
|
||||||
{user?.username} · {user?.roleName}
|
<Link
|
||||||
</span>
|
to="/profile"
|
||||||
|
title={t("nav.profile")}
|
||||||
|
className="text-[11px] text-term-muted hover:text-term-text [&.active]:text-term-amber"
|
||||||
|
>
|
||||||
|
{user.username} · {user.roleName}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
@@ -513,6 +525,7 @@ const SETUP_TABS: { to: string; perm: Permission }[] = [
|
|||||||
{ to: "/setup/site", perm: "site:read" },
|
{ to: "/setup/site", perm: "site:read" },
|
||||||
{ to: "/setup/users", perm: "user:read" },
|
{ to: "/setup/users", perm: "user:read" },
|
||||||
{ to: "/setup/roles", perm: "role:read" },
|
{ to: "/setup/roles", perm: "role:read" },
|
||||||
|
{ to: "/setup/recycle-bin", perm: "recyclebin:read" },
|
||||||
{ to: "/shifts", perm: "shift:read" },
|
{ to: "/shifts", perm: "shift:read" },
|
||||||
{ to: "/setup/logs", perm: "log:read" },
|
{ to: "/setup/logs", perm: "log:read" },
|
||||||
];
|
];
|
||||||
@@ -610,6 +623,17 @@ const rolesRoute = createRoute({
|
|||||||
// (Shift history lives at the standalone /shifts route — see shiftRoute. It was
|
// (Shift history lives at the standalone /shifts route — see shiftRoute. It was
|
||||||
// removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
|
// removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
|
||||||
|
|
||||||
|
// Recycle bin — restore/purge soft-deleted master data. Gated by recyclebin:read.
|
||||||
|
const recycleBinRoute = createRoute({
|
||||||
|
getParentRoute: () => setupRoute,
|
||||||
|
path: "recycle-bin",
|
||||||
|
beforeLoad: ({ context }) => requirePerm("recyclebin:read")(context),
|
||||||
|
component: function RecycleBinRoute() {
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
return <RecycleBin user={user} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
|
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
|
||||||
const logsRoute = createRoute({
|
const logsRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
@@ -618,10 +642,23 @@ const logsRoute = createRoute({
|
|||||||
component: LogsViewer,
|
component: LogsViewer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
|
||||||
|
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
|
||||||
|
const profileRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "profile",
|
||||||
|
component: function ProfileRoute() {
|
||||||
|
const { user, setUser } = rootRoute.useRouteContext();
|
||||||
|
if (!user) return null;
|
||||||
|
return <Profile user={user} setUser={setUser} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
boothRoute,
|
boothRoute,
|
||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
|
profileRoute,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
reportsRoute,
|
reportsRoute,
|
||||||
subscriptionsRoute.addChildren([
|
subscriptionsRoute.addChildren([
|
||||||
@@ -635,6 +672,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
siteRoute,
|
siteRoute,
|
||||||
usersRoute,
|
usersRoute,
|
||||||
rolesRoute,
|
rolesRoute,
|
||||||
|
recycleBinRoute,
|
||||||
logsRoute,
|
logsRoute,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -26,6 +26,27 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
|||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A refused-ACTION event is a benign WARNING, not a red-flag anomaly. The ledger type is
|
||||||
|
* `anomaly` for both (immutable history), but a refused exit / refused subscription /
|
||||||
|
* refused entry (e.g. a double card-scan, an at-capacity subscriber, an already-closed
|
||||||
|
* session) is an EXPECTED outcome — not fraud. We classify it from the payload flags the
|
||||||
|
* flows already sign (`exitRefused` / `entryRefused` / `permitRefused`) and show it as an
|
||||||
|
* amber "REFUZUAR / REFUSED" warning, reserving red "ANOMALI" for genuine anomalies
|
||||||
|
* (barrier-open failure, opened-without-ticket, …). Display-only — no ledger change.
|
||||||
|
*/
|
||||||
|
export function isRefusedWarning(e: LedgerEvent): boolean {
|
||||||
|
if (e.type !== "anomaly") return false;
|
||||||
|
const p = e.payload;
|
||||||
|
return !!(p && (p.exitRefused || p.entryRefused || p.permitRefused));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The label key + colour to render for an event, applying the refused-warning split. */
|
||||||
|
export function eventStyleFor(e: LedgerEvent): { labelKey: string; color: string } {
|
||||||
|
if (isRefusedWarning(e)) return { labelKey: "booth.evtRefused", color: "text-term-amber" };
|
||||||
|
return EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
|
||||||
|
}
|
||||||
|
|
||||||
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
|
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
|
||||||
function hhmmss(iso: string): string {
|
function hhmmss(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -79,9 +100,12 @@ export function displayIdentity(e: LedgerEvent): string {
|
|||||||
* its own row, indented under the identity column. */
|
* its own row, indented under the identity column. */
|
||||||
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const style = EVENT_STYLE[e.type];
|
const style = eventStyleFor(e);
|
||||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
|
||||||
const isAnomaly = e.type === "anomaly";
|
// A refused-action event is a benign WARNING (amber), distinct from a genuine red
|
||||||
|
// anomaly. Only true anomalies get the red row tint + the "no reason" fallback.
|
||||||
|
const refusedWarning = isRefusedWarning(e);
|
||||||
|
const isAnomaly = e.type === "anomaly" && !refusedWarning;
|
||||||
const p = e.payload;
|
const p = e.payload;
|
||||||
const reason = renderReason(p, t);
|
const reason = renderReason(p, t);
|
||||||
const amount = paymentSummary(p);
|
const amount = paymentSummary(p);
|
||||||
@@ -95,11 +119,11 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(e)}
|
onClick={() => onOpen(e)}
|
||||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
||||||
isAnomaly ? "bg-term-red/5" : ""
|
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||||
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||||
{e.plate && (
|
{e.plate && (
|
||||||
@@ -152,12 +176,12 @@ function DetailRow({ label, children }: { label: string; children: ReactNode })
|
|||||||
* this only DISPLAYS the signed record. */
|
* this only DISPLAYS the signed record. */
|
||||||
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const style = EVENT_STYLE[e.type];
|
const style = eventStyleFor(e);
|
||||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
|
||||||
const p = e.payload;
|
const p = e.payload;
|
||||||
const reason = renderReason(p, t);
|
const reason = renderReason(p, t);
|
||||||
const badges = eventBadges(p);
|
const badges = eventBadges(p);
|
||||||
const isAnomaly = e.type === "anomaly";
|
const isAnomaly = e.type === "anomaly" && !isRefusedWarning(e);
|
||||||
|
|
||||||
// Pretty money for any minor-unit amount in the payload.
|
// Pretty money for any minor-unit amount in the payload.
|
||||||
const money =
|
const money =
|
||||||
@@ -177,7 +201,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
||||||
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||||
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
|
||||||
{(reason || money) && (
|
{(reason || money) && (
|
||||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||||
{reason ?? money}
|
{reason ?? money}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export default defineConfig({
|
|||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
// Bind all interfaces so the dev SPA is reachable from other LAN devices
|
||||||
|
// (phone over wifi, etc.) at http://<host-lan-ip>:5173 — not just localhost.
|
||||||
|
// NB: loading from a non-localhost origin means the booth WebSocket (/api/ws)
|
||||||
|
// sends Origin: http://<host-lan-ip>:5173, which the backend's WS_ALLOWED_ORIGINS
|
||||||
|
// must include or the live feed is rejected. See apps/server/.env(.example).
|
||||||
|
host: "0.0.0.0",
|
||||||
proxy: {
|
proxy: {
|
||||||
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
|
// Use 127.0.0.1 (not "localhost") so the proxy never tries IPv6 ::1
|
||||||
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
|
// first and stall — the backend binds IPv4. Avoids slow/hung requests,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# DEV override: build the images locally from the Dockerfiles, expose both ports, run the
|
||||||
|
# stub recognizer (no model load), and verbose logging. Use with the base file:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||||
|
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/server/Dockerfile
|
||||||
|
environment:
|
||||||
|
LOG_LEVEL: debug
|
||||||
|
# Dev convenience: seed an admin on first boot (set ADMIN_PASS to enable).
|
||||||
|
SEED_ADMIN: ${SEED_ADMIN:-0}
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
ADMIN_PASS: ${ADMIN_PASS:-}
|
||||||
|
# 32+ chars and must NOT contain dev-only/insecure/change-me (auth.ts rejects those).
|
||||||
|
# This is a fixed LOCAL-DEV value only; prod injects a real `openssl rand -hex 32`.
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-localdevsecret0123456789abcdef0123}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
vision:
|
||||||
|
build:
|
||||||
|
context: apps/vision
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
VISION_RECOGNIZER: stub
|
||||||
|
ports:
|
||||||
|
- "8089:8089"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# PROD override: pull pinned registry images (no local build), restart always, real
|
||||||
|
# recognizer, and a CADDY reverse proxy in front so operators reach the booth on a clean
|
||||||
|
# port-80 URL (no :3000) — and a path to real TLS later. Server + vision stay INTERNAL
|
||||||
|
# (only Caddy publishes a port). Use with the base file and pin TAG to the branch you deploy:
|
||||||
|
# REGISTRY=git.infra.msai.al/mca/parking_solution TAG=main \
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
# See wiki/decisions/container-deployment.md.
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively).
|
||||||
|
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the
|
||||||
|
# site's real hostname later enables automatic HTTPS. The booth is reached at
|
||||||
|
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image).
|
||||||
|
proxy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile)
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
- caddy-config:/config
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
networks:
|
||||||
|
- parking
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
server:
|
||||||
|
restart: always
|
||||||
|
# No published port — only the proxy reaches the server, over the private network.
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
vision:
|
||||||
|
restart: always
|
||||||
|
# The real ANPR engine. The image baked the model weights at build (offline-first).
|
||||||
|
environment:
|
||||||
|
VISION_RECOGNIZER: fast_alpr
|
||||||
|
# No published ports — vision is reached only by the server over the private network.
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Base stack: the parking SERVER (API + SPA) + the VISION (ANPR) service. Branch-aware via
|
||||||
|
# ${REGISTRY}/${TAG} — a deploy on `dev` pulls :dev, on `main` pulls :main. Use an env
|
||||||
|
# override file for the environment: docker-compose.dev.yml (build locally, stub recognizer)
|
||||||
|
# or docker-compose.prod.yml (pull pinned images, fast_alpr). See
|
||||||
|
# wiki/decisions/container-deployment.md.
|
||||||
|
#
|
||||||
|
# local dev : docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||||
|
# prod : REGISTRY=… TAG=main docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-server:${TAG:-dev}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: /data/parking.sqlite
|
||||||
|
# Reach the vision service over the private compose network by service name.
|
||||||
|
VISION_URL: http://vision:8089
|
||||||
|
VISION_ENABLED: ${VISION_ENABLED:-1}
|
||||||
|
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
|
||||||
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
|
||||||
|
# Dedicated ledger-signing key. Falls back to JWT_SECRET (with a warning) if empty;
|
||||||
|
# set a distinct one in prod. See apps/server/.env.example + local-jwt-auth.
|
||||||
|
EVENT_SIGNING_KEY: ${EVENT_SIGNING_KEY:-}
|
||||||
|
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
|
||||||
|
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
|
||||||
|
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-0}
|
||||||
|
# The booth WS live feed checks the browser Origin — must list the address operators
|
||||||
|
# actually hit (e.g. http://<booth-ip>:3000), or the live feed is rejected.
|
||||||
|
WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-}
|
||||||
|
volumes:
|
||||||
|
- parking-data:/data
|
||||||
|
depends_on:
|
||||||
|
vision:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- parking
|
||||||
|
|
||||||
|
vision:
|
||||||
|
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-vision:${TAG:-dev}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Engine: stub (no models) by default; prod override sets fast_alpr.
|
||||||
|
VISION_RECOGNIZER: ${VISION_RECOGNIZER:-stub}
|
||||||
|
networks:
|
||||||
|
- parking
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
parking-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
parking:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Soft delete (recycle bin) for accidental hard-deletes of master data. Adds a nullable
|
||||||
|
-- `deleted_at` (ISO-8601; null = live) + `deleted_by` (the admin user id) to the mutable
|
||||||
|
-- master-data tables. A DELETE now stamps these instead of removing the row; restore
|
||||||
|
-- clears them; an admin purge (or the retention sweep) does the real DELETE. The signed
|
||||||
|
-- append-only ledger is NOT touched — it has no delete path and is out of scope here.
|
||||||
|
--
|
||||||
|
-- All additive ALTER ADD COLUMN — backward-compatible (existing rows: deleted_at null =
|
||||||
|
-- live). SQLite ADD COLUMN is in-place. Subscription PLANS are versioned (many rows per
|
||||||
|
-- plan_id); a soft-delete stamps every version row of that plan_id together.
|
||||||
|
ALTER TABLE `users` ADD `deleted_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `deleted_by` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `roles` ADD `deleted_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `roles` ADD `deleted_by` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscriptions` ADD `deleted_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscriptions` ADD `deleted_by` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscription_plans` ADD `deleted_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscription_plans` ADD `deleted_by` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `tariffs` ADD `deleted_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `tariffs` ADD `deleted_by` text;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Site master switch for the ANPR subscriber-entry bridge (anpr-entry.ts). Additive
|
||||||
|
-- ALTER ADD COLUMN — backward-compatible. Default 1 (ON) so existing installs keep the
|
||||||
|
-- now-live auto-open-for-subscriber-plates behaviour after upgrade. The toggle gates ONLY
|
||||||
|
-- the barrier-driving bridge; advisory snapshot-ANPR + lane busy/free are unaffected.
|
||||||
|
ALTER TABLE `site_config` ADD `anpr_entry_enabled` integer DEFAULT 1 NOT NULL;
|
||||||
@@ -85,6 +85,20 @@
|
|||||||
"when": 1781885400000,
|
"when": 1781885400000,
|
||||||
"tag": "0011_subscription_plan_v2",
|
"tag": "0011_subscription_plan_v2",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781885500000,
|
||||||
|
"tag": "0012_soft_delete",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781885600000,
|
||||||
|
"tag": "0013_anpr_entry_toggle",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate"
|
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate",
|
||||||
|
"db:migrate:runtime": "node scripts/migrate-runtime.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Apply Drizzle migrations against the DATABASE_URL sqlite file using the runtime
|
||||||
|
// migrator (drizzle-orm/better-sqlite3/migrator) — NOT drizzle-kit. This lets the
|
||||||
|
// container run migrations on boot with only runtime deps installed (drizzle-kit is a
|
||||||
|
// devDep, pruned out of the production image). Same migration set + folder the test
|
||||||
|
// helper uses (packages/db/src/testing.ts), so the schema matches production exactly.
|
||||||
|
//
|
||||||
|
// Usage: DATABASE_URL=/data/parking.sqlite node scripts/migrate-runtime.mjs
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error("[migrate] DATABASE_URL is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrations folder ships beside this package (packages/db/drizzle); from scripts/ that's ../drizzle.
|
||||||
|
const migrationsFolder = resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle");
|
||||||
|
|
||||||
|
// Ensure the DB's parent dir exists (a fresh mounted volume may be empty).
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(resolve(url)), { recursive: true });
|
||||||
|
} catch {
|
||||||
|
/* dir already exists (or url has no dir) — fine */
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlite = new Database(url);
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
const db = drizzle(sqlite);
|
||||||
|
|
||||||
|
console.log(`[migrate] applying migrations from ${migrationsFolder} → ${url}`);
|
||||||
|
migrate(db, { migrationsFolder });
|
||||||
|
sqlite.close();
|
||||||
|
console.log("[migrate] done");
|
||||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
|||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
// Re-export the query helpers consumers need, so they don't depend on
|
// Re-export the query helpers consumers need, so they don't depend on
|
||||||
// drizzle-orm directly (it's an implementation detail of this package).
|
// drizzle-orm directly (it's an implementation detail of this package).
|
||||||
export { eq, and, asc, desc, gte, lte, sql } from "drizzle-orm";
|
export { eq, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export const roles = sqliteTable("roles", {
|
|||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
|
// Soft delete (recycle bin): ISO instant the row was deleted, null = live; the admin
|
||||||
|
// user id who deleted it. A DELETE stamps these; restore clears them; purge/retention
|
||||||
|
// does the real row removal. See wiki/concepts/soft-delete.md.
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The role→permission grid. One row per granted `resource:action` permission.
|
/** The role→permission grid. One row per granted `resource:action` permission.
|
||||||
@@ -78,6 +83,11 @@ export const users = sqliteTable("users", {
|
|||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
|
// Soft delete (recycle bin) — see roles.deletedAt. NB: `username` stays UNIQUE across
|
||||||
|
// live AND deleted rows, so creating a new user reusing a deleted user's name is
|
||||||
|
// blocked until that row is restored or purged (the route returns a clear 409).
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- The signed business ledger (formerly `events`) ----------------------
|
// --- The signed business ledger (formerly `events`) ----------------------
|
||||||
@@ -226,6 +236,16 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
|
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
/** Site master switch for the ANPR subscriber-entry BRIDGE (anpr-entry.ts): when ON
|
||||||
|
* (default), a subscriber's plate read off a lane camera's vehicle detection opens the
|
||||||
|
* barrier through the normal gated subscription flow. When OFF, the bridge emits no read
|
||||||
|
* (subscribers fall back to their card/QR). This gates ONLY the barrier-driving bridge —
|
||||||
|
* advisory snapshot-ANPR recording and lane busy/free are unaffected. Read LIVE per event
|
||||||
|
* so toggling takes effect with no restart. Default ON because the feature is already
|
||||||
|
* live. Stored 0/1. See wiki/concepts/lane-presence-and-anpr-entry.md. */
|
||||||
|
anprEntryEnabled: integer("anpr_entry_enabled", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
|
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
|
||||||
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
|
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
|
||||||
* each published tariff version's structure.tz so the windows are frozen/immutable
|
* each published tariff version's structure.tz so the windows are frozen/immutable
|
||||||
@@ -258,6 +278,10 @@ export const tariffs = sqliteTable("tariffs", {
|
|||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
|
// Soft delete (recycle bin) — see roles.deletedAt. Stamps the rate-card row; its
|
||||||
|
// immutable tariff_versions are kept (referenced for repricing) and ride along.
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tariffVersions = sqliteTable("tariff_versions", {
|
export const tariffVersions = sqliteTable("tariff_versions", {
|
||||||
@@ -314,6 +338,12 @@ export const subscriptionPlans = sqliteTable("subscription_plans", {
|
|||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
|
// Soft delete (recycle bin) — see roles.deletedAt. A plan is VERSIONED (many rows per
|
||||||
|
// plan_id); a soft-delete stamps every version row of the plan_id together, and the bin
|
||||||
|
// shows/restores the plan as one item. Distinct from `active=0` (retire = unsellable
|
||||||
|
// but kept in the catalog); deletedAt removes it from the catalog entirely.
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subscriptions = sqliteTable("subscriptions", {
|
export const subscriptions = sqliteTable("subscriptions", {
|
||||||
@@ -348,6 +378,12 @@ export const subscriptions = sqliteTable("subscriptions", {
|
|||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
|
// Soft delete (recycle bin) — see roles.deletedAt. Distinct from `status: "revoked"`
|
||||||
|
// (a domain state that BARS the subscriber but keeps it visible); deletedAt removes it
|
||||||
|
// from the catalog entirely, recoverable from the bin. Child credential/plate rows are
|
||||||
|
// kept and restored with it.
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
|
// A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
|
||||||
|
|||||||
@@ -95,13 +95,55 @@ const channelField: ConfigField = {
|
|||||||
|
|
||||||
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
|
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
|
||||||
|
|
||||||
|
// Hikvision "Alarm Server" PUSH config. The newer firmware (Event → Smart/VCA →
|
||||||
|
// "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm Settings →
|
||||||
|
// Alarm Server) HTTP-POSTs an EventNotificationAlert to a URL we host every time the
|
||||||
|
// chosen target is detected — same shape as the Dingtian Input Link push. When enabled,
|
||||||
|
// the admin points the camera's Alarm Server at /api/devices/hikvision/:deviceId/event
|
||||||
|
// and we record what it sends. See routes/hikvision-alarm.ts, wiki/entities/lpr-camera.md.
|
||||||
|
const alarmPushFields: ConfigField[] = [
|
||||||
|
{
|
||||||
|
key: "alarmPushEnabled",
|
||||||
|
label: "Alarm Server push (Event → vehicle)",
|
||||||
|
type: "boolean",
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
help: "The camera POSTs each detected event to us (set its Alarm Settings → Alarm Server to this backend). No polling.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pushUser",
|
||||||
|
label: "Alarm push username (optional)",
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
help: "Only if the camera's Alarm Server is set to authenticate (HTTP Digest). Leave blank to accept by source-IP only.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pushPassword",
|
||||||
|
label: "Alarm push password (optional)",
|
||||||
|
type: "secret",
|
||||||
|
required: false,
|
||||||
|
help: "Paired with the username above for Digest auth on the push. Leave blank for source-IP-only.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "skipSourceIpCheck",
|
||||||
|
label: "Don't verify push source IP",
|
||||||
|
type: "boolean",
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
help: "Accept pushes regardless of the source IP. Needed when the network rewrites the inbound source address (e.g. WSL mirrored mode reports the host's own IP, not the camera's), which would otherwise reject every push. Leave OFF on a normal LAN.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const hikvisionDriver: CameraDriver = {
|
export const hikvisionDriver: CameraDriver = {
|
||||||
id: "hikvision",
|
id: "hikvision",
|
||||||
category: "camera",
|
category: "camera",
|
||||||
label: "Hikvision camera",
|
label: "Hikvision camera",
|
||||||
description: "Hikvision snapshot via ISAPI (HTTP Digest).",
|
description: "Hikvision snapshot via ISAPI (HTTP Digest) + optional Alarm Server event push.",
|
||||||
transports: ["tcp-ip"],
|
transports: ["tcp-ip"],
|
||||||
configFields: cameraConfigFields,
|
// The camera PULLS snapshots, but with Alarm Server on it ALSO pushes events to us —
|
||||||
|
// so it may need the backend push IP at assign time (like the Dingtian).
|
||||||
|
pushesToBackend: true,
|
||||||
|
configFields: [...cameraConfigFields, ...alarmPushFields],
|
||||||
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
|
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
|
||||||
create: (c) =>
|
create: (c) =>
|
||||||
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
|
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const RESOURCES = [
|
|||||||
"event", // the signed ledger feed + void
|
"event", // the signed ledger feed + void
|
||||||
"report", // events feed, occupancy, future reports
|
"report", // events feed, occupancy, future reports
|
||||||
"log", // application/diagnostic logs (app_logs) — view + retention
|
"log", // application/diagnostic logs (app_logs) — view + retention
|
||||||
|
"recyclebin", // soft-deleted master data: view / restore / purge
|
||||||
] as const;
|
] as const;
|
||||||
export type Resource = (typeof RESOURCES)[number];
|
export type Resource = (typeof RESOURCES)[number];
|
||||||
|
|
||||||
@@ -56,6 +57,9 @@ export const PERMISSIONS: readonly Permission[] = [
|
|||||||
"event:read", "event:void",
|
"event:read", "event:void",
|
||||||
"report:read",
|
"report:read",
|
||||||
"log:read",
|
"log:read",
|
||||||
|
// Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These
|
||||||
|
// are admin-grade — a restore can revive a privileged user/role, a purge is permanent.
|
||||||
|
"recyclebin:read", "recyclebin:update", "recyclebin:delete",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** The protected built-in role: non-deletable, non-editable, always = ALL
|
/** The protected built-in role: non-deletable, non-editable, always = ALL
|
||||||
@@ -343,6 +347,8 @@ export const REASON_CODES = [
|
|||||||
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
|
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
|
||||||
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
|
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
|
||||||
"sub.refused.unpaidWindow",
|
"sub.refused.unpaidWindow",
|
||||||
|
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
|
||||||
|
"void.ticketCancelled",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ReasonCode = (typeof REASON_CODES)[number];
|
export type ReasonCode = (typeof REASON_CODES)[number];
|
||||||
@@ -371,6 +377,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
|
|||||||
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
|
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
|
||||||
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
||||||
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
|
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
|
||||||
|
"void.ticketCancelled": "ticket cancelled — {reason}",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,9 @@
|
|||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
"lint": {},
|
"lint": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"dependsOn": ["^build"]
|
"dependsOn": ["^build"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ With LUKS in place, **SQLCipher becomes optional** defence-in-depth rather than
|
|||||||
layer. (The custom controller adds its own: ESP32 flash encryption + secure boot — see
|
layer. (The custom controller adds its own: ESP32 flash encryption + secure boot — see
|
||||||
[[esp32-custom-controller]].)
|
[[esp32-custom-controller]].)
|
||||||
|
|
||||||
|
> **Step-by-step OS install + TPM-seal procedure** (BIOS → encrypted install → manual PCR-7 TPM
|
||||||
|
> seal, with the Dell-7070-specific `dbt` workaround) lives in [[appliance-provisioning]] — written
|
||||||
|
> from the first real provisioning (2026-06-23) and verified on hardware. **OS hardening on the first
|
||||||
|
> unit is COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7, unattended) + Secure Boot (Deployed) + GRUB
|
||||||
|
> edit-lock** (the GRUB password is the specific countermeasure to the `init=/bin/bash` root-shell
|
||||||
|
> hole that PCR-7 sealing does NOT cover). Resolves the implementation half of [[open-questions]] #12
|
||||||
|
> for unit 1.
|
||||||
|
|
||||||
## Deploy-time server configuration (runbook)
|
## Deploy-time server configuration (runbook)
|
||||||
|
|
||||||
Env in `apps/server/.env` on the appliance (see `apps/server/.env.example`). The security-load-bearing ones:
|
Env in `apps/server/.env` on the appliance (see `apps/server/.env.example`). The security-load-bearing ones:
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, camera, anpr, subscription, lane, vision]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-22
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Lane Presence & ANPR Subscriber Entry
|
||||||
|
|
||||||
|
Two related things a camera's vehicle detection feeds, worked out over a long field session
|
||||||
|
(2026-06-22, see [[lpr-camera]] for the camera-side saga + the corrected "it was the undrawn
|
||||||
|
detection area" conclusion):
|
||||||
|
|
||||||
|
1. **Lane busy/free** — BUILT. An advisory barrier light on the booth.
|
||||||
|
2. **ANPR subscriber entry** — BUILT (2026-06-22). A subscriber's plate, read from the lane camera,
|
||||||
|
drives their entry/exit through the EXISTING [[subscription]] flow. The "bridge" below.
|
||||||
|
|
||||||
|
The camera (Hik `DS-2CD1043G2-LIU`) only emits `VMD` events with `eventState=active` and a
|
||||||
|
`targetType` of `vehicle`/`human` — a coarse **presence** signal, never an identity. Everything
|
||||||
|
here is built on that, and on the camera's hard limits.
|
||||||
|
|
||||||
|
## What the camera actually gives us (measured)
|
||||||
|
|
||||||
|
- **Push only, no poll.** No ISAPI endpoint reports "is a car in the zone now"; the only live source
|
||||||
|
is the event push. We tried to force a steadier signal by flipping `notificationRecurrence`
|
||||||
|
`beginning → recurring` via ISAPI — the firmware **accepts the PUT but silently reverts** (locked to
|
||||||
|
`beginning` on this value line). So: notify-once-at-motion-start is all we get.
|
||||||
|
- **No leave signal.** The camera never sends an `inactive`/end event. Confirmed by config
|
||||||
|
(`notificationRecurrence: beginning`) AND a controlled in/out test.
|
||||||
|
- **Re-fire is MOVEMENT-driven, not steady.** Controlled test (call out enter/leave, correlate to the
|
||||||
|
event log): while a car MOVES, `active` repeats ~1–3 s apart; while it sits MOTIONLESS, gaps stretch
|
||||||
|
to ~15–25 s. Crucially the camera has **~no dwell lag** — it goes silent within ~1 s of the car
|
||||||
|
leaving (last event 16:15:17 vs car-left ~16:15:30).
|
||||||
|
|
||||||
|
## 1. Lane busy/free (BUILT)
|
||||||
|
|
||||||
|
`apps/server/src/lane-status.ts` (`LaneStatus`) + the hik-alarm handler + the booth WS. A `vehicle`
|
||||||
|
`active` event on a camera bound to entry/exit marks THAT lane busy and arms an auto-clear timer; the
|
||||||
|
booth shows two barrier lights beside the scan input (green=free, red=busy). **Advisory only — gates
|
||||||
|
nothing** (never blocks a ticket or opens a barrier; the standing rule).
|
||||||
|
|
||||||
|
- **"Free" is timeout-driven** (no leave signal). The TTL must exceed the still-car gap (~25 s) or a
|
||||||
|
parked car flickers free — so `LANE_BUSY_TTL_MS` default is **30 s** (started at a guessed 90 s,
|
||||||
|
briefly 5 s, then set to 30 s from the measured data). The camera's lack of dwell lag means 30 s
|
||||||
|
also clears promptly after departure.
|
||||||
|
- A `both`-direction camera marks both lanes. Pushed over the existing `/api/ws` (kind `lane-status`).
|
||||||
|
|
||||||
|
## 2. ANPR subscriber entry — THE BRIDGE (BUILT 2026-06-22)
|
||||||
|
|
||||||
|
> **"Bridge" = a HANDLER CLASS in `apps/server/src/anpr-entry.ts` (`AnprBridge`). NOT a new
|
||||||
|
> service / container / app.** It is in-process glue that calls things that ALREADY exist.
|
||||||
|
|
||||||
|
**As built:** `hikvision-alarm.ts`, on a `vehicle`/non-`inactive` push from an `anpr`-opted-in
|
||||||
|
camera, hands the deviceId to `AnprBridge.onVehicleDetected()` (fire-and-forget, never awaited on the
|
||||||
|
camera's 200). The bridge: debounce (camera-level, pre-snapshot) → `captureSnapshot` (fresh pull, via
|
||||||
|
the reused `snapshot.ts buildCamera`) → `vision.analyze` → entry confidence floor
|
||||||
|
(`VISION_ENTRY_MIN_CONFIDENCE`, 0.85) → normalize plate → **`subscriptionFlow.match()` (match BEFORE
|
||||||
|
emit)** → if a subscriber, `deviceEvents.emitRead({kind:"plate"})`; if not, record an advisory
|
||||||
|
`anpr-skip` device_event and stop. The existing `onRead → ReadDispatcher → SubscriptionFlow.run()`
|
||||||
|
then does the gated entry/exit + barrier open. Constructed in `server.ts` (the flows were reordered
|
||||||
|
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
|
||||||
|
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
|
||||||
|
Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by
|
||||||
|
`anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3).
|
||||||
|
|
||||||
|
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
|
||||||
|
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
|
||||||
|
ONLY — no queue segmentation, no per-car tracking, no make/model, no ticket-button gating.
|
||||||
|
|
||||||
|
Almost everything already exists; the bridge is the one missing wire:
|
||||||
|
|
||||||
|
| Piece | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| Camera vehicle event | ✅ [[lpr-camera]] (hik-alarm) |
|
||||||
|
| Pull a snapshot | ✅ `snapshot.ts` (`captureSnapshot`) |
|
||||||
|
| Read the plate | ✅ [[opencv-anpr-service]] `/analyze` (~50 ms on the DEV PC; appliance TBD) |
|
||||||
|
| Match a plate → subscriber | ✅ `subscription-flow.ts` `match()` + `subscription_plates` (`via:"plate"`) |
|
||||||
|
| Plate read → gated entry/exit | ✅ `read-dispatch.ts` + SubscriptionFlow (active/window/blocklist/car-count) |
|
||||||
|
| **Emit the plate onto the read bus** | ✅ `anpr-entry.ts` (`AnprBridge`) — on a vehicle push from an `anpr` camera it snapshots → analyzes → matches a subscriber → `emitRead({kind:"plate"})`. (Built 2026-06-22.) |
|
||||||
|
|
||||||
|
**The bridge logic:** on a camera `vehicle`/`active` event from an **opt-in** camera (`config.anpr`),
|
||||||
|
snapshot → `vision.analyze` → if a plate clears a **HIGH** confidence floor → **debounce** → emit
|
||||||
|
`DeviceReadEvent{kind:"plate", value, deviceId}`. The existing dispatcher + flow do the rest.
|
||||||
|
|
||||||
|
### Decisions settled with the user (2026-06-22)
|
||||||
|
|
||||||
|
- **Both directions.** Entry- and exit-bound cameras both work; the dispatcher infers the verb from
|
||||||
|
the camera's bound relay direction (an entry camera → entry, exit → exit). No per-read inference.
|
||||||
|
- **High confidence required.** A barrier-driving read needs a stricter bar than the advisory-record
|
||||||
|
floor — a NEW `VISION_ENTRY_MIN_CONFIDENCE` (≈0.85) distinct from `VISION_MIN_CONFIDENCE`. (The
|
||||||
|
subscriber still holds their card/QR, so a near-miss read just falls back to that.)
|
||||||
|
- **Opt-in** per camera (`config.anpr`), so a site that didn't ask for plate-entry is unaffected.
|
||||||
|
- **Debounce is REQUIRED — for correctness, NOT CPU.** The camera re-fires ~1 Hz while a car is
|
||||||
|
present; emitting a read every second would drive REPEAT entries (a fleet sub opens a 2nd
|
||||||
|
occurrence; a single-car sub spams "already inside") or, on an exit camera, repeat exits
|
||||||
|
(`sub.refused.noSession` after the first, and FIFO could phantom-close another occurrence). So the
|
||||||
|
same plate on the same camera within ~10–15 s = ONE credential presentation.
|
||||||
|
- NB: a direction-bound camera does NOT flip entry↔exit on repeat reads (the barrier's direction
|
||||||
|
fixes the verb), so the earlier "flip-flop" fear was wrong — but repeat-same-direction is still
|
||||||
|
bad. Debounce stands.
|
||||||
|
- **Threat-model rule preserved by construction.** A plate is trivially spoofable (print it on
|
||||||
|
paper); it must NEVER be the sole reason a barrier opens. Routing through the existing
|
||||||
|
SubscriptionFlow means the plate is just another credential through the same gate (active / window
|
||||||
|
/ blocklist / car-count) — not a bypass. See [[opencv-anpr-service]], [[append-only-event-chain]].
|
||||||
|
|
||||||
|
### To validate (booth-PC test day, ~2026-06-23)
|
||||||
|
|
||||||
|
The dev-PC ANPR is ~50 ms/frame, but that says little about the hardened **booth appliance** (likely a
|
||||||
|
low-power CPU, possibly 5–15× slower). Real-hardware latency is the open number. The user is bringing
|
||||||
|
the actual booth PC to test on.
|
||||||
|
|
||||||
|
## Rejected / out of scope (and why)
|
||||||
|
|
||||||
|
- **Vision monitors the RTSP livestream continuously** for presence — rejected: rebuilds (worse) what
|
||||||
|
the camera already does (presence), pegs the appliance CPU 24/7, and the leave-detection heuristic
|
||||||
|
is no cleaner than a 30 s timeout.
|
||||||
|
- **Vision polls every ~1 s to track THE car, segment a queue, count cars, read make/model** — a
|
||||||
|
genuinely harder need (bumper-to-bumper cars the camera can't tell apart). Set ASIDE, not dismissed:
|
||||||
|
it needs a general vehicle DETECTOR ([[opencv-anpr-service]] is plate-only today) + "same-car-vs-new"
|
||||||
|
logic + make/model (a third, weak, heavy model), and its viability is **gated on appliance
|
||||||
|
inference budget we cannot measure on the dev PC**. Revisit only if real traffic + hardware justify
|
||||||
|
it. The vision feasibility was checked: `fast_alpr` live + ready; plate-only; no vehicle/presence
|
||||||
|
detector exists yet.
|
||||||
|
- **Gate the entry ticket button on lane-busy** (don't print when no car) — deferred. The lane-busy
|
||||||
|
signal supports it, but it gates a PHYSICAL action so it must FAIL-OPEN (allow when presence is
|
||||||
|
unknown). Parked pending the user's real goal (anti-spam print vs one-ticket-per-car).
|
||||||
@@ -73,6 +73,34 @@ States, as derived from events:
|
|||||||
|
|
||||||
Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
|
Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
|
||||||
|
|
||||||
|
### Cancel a wrongly-printed ticket — BUILT (2026-06-22)
|
||||||
|
|
||||||
|
A ticket printed in error (misprint, test press, wrong vehicle) is cancelled by appending a **signed
|
||||||
|
`void`** event — the `vehicle_entry` is NEVER edited or deleted (append-only; [[append-only-event-chain]]).
|
||||||
|
`apps/server/src/void-flow.ts` (`VoidFlow`) appends `{ type:"void", identity, payload:{ sessionRef,
|
||||||
|
voidedEntryRef:<entry id>, voidReason, operator, reasonCode:"void.ticketCancelled" } }`. Traceable: the
|
||||||
|
operator (from the JWT) + a **REQUIRED reason** are signed in. Route `POST /api/tickets/void` gated on
|
||||||
|
`event:void` + an open shift. **No barrier action** — a misprinted ticket's car never entered.
|
||||||
|
|
||||||
|
- **Refused** for: a subscription occurrence (closed via its own flow), an already-exited session, an
|
||||||
|
already-voided ticket, or a **paid** ticket (a refund is a separate, out-of-scope action) → 409.
|
||||||
|
- **The void folds the session CLOSED everywhere it's counted** — this is the correctness crux. A
|
||||||
|
`void` decrements like a `vehicle_exit` in `occupancy.ts` (count + reserved-spots), and reads as
|
||||||
|
closed in `pay-station.ts` (`lookup`/`activeSessions`) and `exit-flow.ts` (`#sessionFor`), and is
|
||||||
|
excluded from the `reports.ts` entries stat. So a voided car stops occupying a spot, can't be
|
||||||
|
paid/exited, and doesn't inflate "cars entered". The booth surfaces it in the pay/exit lookup modal
|
||||||
|
(transient + unpaid + open only).
|
||||||
|
|
||||||
|
### Live-feed display: refused-action WARNING vs. genuine ANOMALY
|
||||||
|
|
||||||
|
The signed ledger `type:"anomaly"` is overloaded: it carries both benign **refused-action** events
|
||||||
|
(`exitRefused` / `entryRefused` / `permitRefused` — e.g. a double card-scan, an at-capacity
|
||||||
|
subscriber, an exit on an already-closed session) AND genuine red-flags (barrier-open failure,
|
||||||
|
opened-without-ticket). The booth feed now classifies from those existing payload flags
|
||||||
|
(`event-detail.tsx isRefusedWarning`) and shows the refused ones as an amber **REFUZUAR / REFUSED**
|
||||||
|
warning, reserving red **ANOMALI** for true anomalies. **Display-only** — no ledger type/data change,
|
||||||
|
so historical events reclassify correctly too.
|
||||||
|
|
||||||
## Edge cases the model must name (not yet designed in full)
|
## Edge cases the model must name (not yet designed in full)
|
||||||
|
|
||||||
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
|
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, data, admin, safety]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-22
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Soft Delete & the Recycle Bin
|
||||||
|
|
||||||
|
A safety net for accidental admin deletes. Master-data deletes used to be **hard** and
|
||||||
|
**unrecoverable** — an admin who deleted a user, role, subscription, or plan lost it for good.
|
||||||
|
Now a delete **soft-deletes** (stamps the row) and the item waits in a **recycle bin** where an
|
||||||
|
admin can **restore** or **purge** it; unrestored items **auto-purge** after a retention window.
|
||||||
|
|
||||||
|
Built 2026-06-22 (migration `0012_soft_delete`).
|
||||||
|
|
||||||
|
## What it covers (and what it deliberately doesn't)
|
||||||
|
|
||||||
|
Soft-delete is for the **mutable master-data** tables only:
|
||||||
|
|
||||||
|
| Resource | Table(s) | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Users | `users` | A soft-deleted user **cannot log in** (the login route rejects `deleted_at != null`). |
|
||||||
|
| Roles | `roles` (+ `role_permissions` kept) | Permission rows survive, so a restore brings the role back intact. |
|
||||||
|
| Subscriptions | `subscriptions` (+ credentials/plates kept) | Distinct from `status: "revoked"` — see below. A soft-deleted sub does **not** open the barrier. |
|
||||||
|
| Plans | `subscription_plans` | **Versioned**: a soft-delete stamps **every version row** of the `plan_id`; the bin shows/restores it as ONE item. |
|
||||||
|
| Tariffs | `tariffs` | Has soft-delete for completeness; today the site runs one tariff and there's no delete button — recovery is via the bin. Immutable `tariff_versions` ride along (kept for repricing). |
|
||||||
|
|
||||||
|
**Out of scope — the signed ledger.** The append-only, hash-chained `ledger_events` has **no
|
||||||
|
delete path by design** ([[append-only-event-chain]]); soft-delete is purely for the mutable
|
||||||
|
master data. A correction to history is still a new *appended* event, never an edit/delete.
|
||||||
|
|
||||||
|
## Mechanics
|
||||||
|
|
||||||
|
- **Columns:** every covered table gets a nullable `deleted_at` (ISO instant; null = live) and
|
||||||
|
`deleted_by` (the admin user id). Additive `ALTER ADD COLUMN` — backward-compatible.
|
||||||
|
- **Delete = stamp.** Each resource's own `DELETE` route now sets the stamps instead of removing
|
||||||
|
the row. The row vanishes from every catalog because the list/lookup queries filter
|
||||||
|
`deleted_at IS NULL`.
|
||||||
|
- **Recycle bin API** (`recyclebin:*` permission): `GET /api/recycle-bin` lists everything
|
||||||
|
soft-deleted across kinds; `POST /api/recycle-bin/:kind/:id/restore` clears the stamps;
|
||||||
|
`DELETE /api/recycle-bin/:kind/:id` purges (the real `DELETE`, + children). UI: a **Recycle
|
||||||
|
bin** tab under Setup. Code: `apps/server/src/recycle-bin.ts` (+ `routes/recycle-bin.ts`),
|
||||||
|
`apps/web/src/RecycleBin.tsx`.
|
||||||
|
- **Retention sweep.** A 6-hourly (+ startup) job auto-purges items deleted longer than
|
||||||
|
`RECYCLE_BIN_RETENTION_DAYS` (default **30**) ago. `0`/negative = keep forever.
|
||||||
|
|
||||||
|
## Invariants & edge cases
|
||||||
|
|
||||||
|
- **No-lockout still holds.** The "last admin" check counts only **live** admins (a soft-deleted
|
||||||
|
admin can't log in, so they don't count) — you can't delete yourself into a locked-out box. See
|
||||||
|
[[local-jwt-auth]].
|
||||||
|
- **Soft-delete vs. domain lifecycle.** A subscription's `revoke`/`reactivate` and a plan's
|
||||||
|
`active=0` retire are **domain states** that keep the item *visible* in its catalog (barred /
|
||||||
|
unsellable). `deleted_at` is different: it removes the item from the catalog entirely,
|
||||||
|
recoverable only from the bin. Both coexist. See [[subscription]].
|
||||||
|
- **Unique-name reuse.** `username` / role `name` are `UNIQUE` across **live AND deleted** rows,
|
||||||
|
so you can't create a new user reusing a deleted user's name until that row is restored or
|
||||||
|
purged — the create route returns a clear 409 pointing at the recycle bin (rather than a raw
|
||||||
|
constraint error).
|
||||||
|
- **Dangling references on restore.** A restored user points at its `roleId`; if that role is
|
||||||
|
itself deleted, the user reappears with a deleted role. We **don't auto-cascade** (keep it
|
||||||
|
predictable) — the bin lists both; the admin restores the role too. The role guard resolves a
|
||||||
|
missing role to an **empty** permission set (safe-by-default), so a dangling role never
|
||||||
|
escalates.
|
||||||
|
- **"In use" checks count live only.** A plan blocked from deletion "while referenced" counts
|
||||||
|
only **live** subscriptions; a soft-deleted subscriber's `planId` reference doesn't block it.
|
||||||
|
|
||||||
|
## Permission
|
||||||
|
|
||||||
|
`recyclebin:read` (view), `recyclebin:update` (restore), `recyclebin:delete` (purge) — admin-grade
|
||||||
|
(a restore can revive a privileged user/role; a purge is permanent). Folded into the
|
||||||
|
code-defined PERMISSIONS grid; the built-in `admin` role holds them. See [[local-jwt-auth]].
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-23
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Appliance provisioning runbook (booth PC)
|
||||||
|
|
||||||
|
Step-by-step to take a booth PC from factory Windows to a hardened, encrypted, container-running
|
||||||
|
parking appliance. Written from the **first real provisioning, 2026-06-23**, on the actual hardware
|
||||||
|
below — every command here was run and verified on that machine, including the firmware-specific
|
||||||
|
workaround. Companion to [[disk-os-hardening]] (the *why*), [[tpm]] (TPM analysis), and
|
||||||
|
[[container-deployment]] (the images this runs).
|
||||||
|
|
||||||
|
> ⚠ This box is the [[threat-model|outsider-with-the-box]] defence. The load-bearing anti-fraud
|
||||||
|
> control is still [[reconciliation]] over the [[append-only-event-chain|signed chain]] — disk
|
||||||
|
> encryption + Secure Boot raise the cost of offline tamper, they don't replace reconciliation.
|
||||||
|
|
||||||
|
## Reference hardware (first unit, 2026-06-23)
|
||||||
|
|
||||||
|
- **Dell OptiPlex 7070**, **Intel Core i5-8500** (Coffee Lake), 238 GB SATA SSD (`/dev/sda`).
|
||||||
|
- **TPM 2.0 — discrete Nuvoton** (`Get-Tpm` → `ManufacturerIdTxt NTC`, fw 7.2.1.0). NOT Intel
|
||||||
|
PTT/fTPM. Discrete ⇒ an external LPC/SPI bus exists (bus-sniff is a theoretical physical attack on
|
||||||
|
PCR-only sealing — accepted; see [[tpm]]). Used PC — previous owner irrelevant.
|
||||||
|
- Shipped Windows 11; formatted to **Ubuntu 26.04 LTS** (the decided platform — [[desktop-shell-tauri]]).
|
||||||
|
|
||||||
|
## 1. BIOS (F2 at the Dell logo)
|
||||||
|
|
||||||
|
- **TPM**: leave **On**. Used PC → **Clear the TPM once** (Security → TPM → Clear) so the prior
|
||||||
|
owner's keys are wiped before LUKS enrollment. (PPI "Bypass for Clear" was unchecked → it asks for
|
||||||
|
physical confirmation at next boot; that's normal.)
|
||||||
|
- **Secure Boot**: **Enabled**, **Deployed Mode** (not Audit). **"Enable Custom Mode" UNCHECKED** =
|
||||||
|
Standard Mode with stock Microsoft keys — this is what Ubuntu's signed shim needs. Do NOT touch
|
||||||
|
PK/KEK/db/dbx. NB: the 7070's Expert Key Management is **edit-only** (Save/Replace/Append/Delete —
|
||||||
|
no read-only "View Key"), so you **cannot inspect db from BIOS**; verify via the live USB instead
|
||||||
|
(step 2).
|
||||||
|
- **Boot**: UEFI only (no CSM/Legacy — a Legacy install has no Secure Boot / TPM-seal path).
|
||||||
|
- Set a **BIOS admin password**.
|
||||||
|
|
||||||
|
## 2. Boot the Ubuntu 26.04 USB (Secure Boot ON)
|
||||||
|
|
||||||
|
- **Flash the ISO DIRECTLY** (Rufus GPT/UEFI, Etcher, or `dd`). **NOT Ventoy** — Ventoy's own
|
||||||
|
bootloader isn't in `db`, so Secure Boot rejects it with **`Verification failed: (0x1A) Security
|
||||||
|
Violation`** (this is Secure Boot working correctly, not a fault). A directly-flashed Ubuntu USB
|
||||||
|
boots the Microsoft-signed shim, which stock `db` trusts.
|
||||||
|
- **F12** at the Dell logo → pick the USB under **UEFI BOOT**.
|
||||||
|
- Reaching the installer with Secure Boot ON = positive proof the MS third-party UEFI CA is in `db`
|
||||||
|
(the verification the BIOS couldn't show us).
|
||||||
|
|
||||||
|
## 3. Encrypted install — the firmware workaround (IMPORTANT)
|
||||||
|
|
||||||
|
The 26.04 installer disk page offers: No Encryption / **Encrypt with a passphrase** / **Use
|
||||||
|
hardware-backed encryption** (+ advanced LVM/ZFS, both ZFS experimental).
|
||||||
|
|
||||||
|
- **"Use hardware-backed encryption" FAILS on this 7070** with:
|
||||||
|
`PCR_UNUSABLE … error with secure boot policy (PCR7) measurements: generating secure boot profiles
|
||||||
|
for systems with timestamp revocation (dbt) support is currently not supported.`
|
||||||
|
→ Ubuntu's *automated* FDE profiler can't model PCR7 on Dell firmware carrying a `dbt` (UEFI
|
||||||
|
timestamp revocation list). It is NOT a TPM or Secure-Boot fault — both are fine.
|
||||||
|
- **So: choose "Encrypt with a passphrase".** Set a strong passphrase and **SAVE IT OFF-MACHINE**
|
||||||
|
(phone / password manager). It is both the boot unlock (until TPM sealing) AND the permanent
|
||||||
|
recovery slot. Finish the install.
|
||||||
|
- Result (verify with `lsblk`): `sda1` vfat `/boot/efi`, `sda2` ext4 `/boot`, `sda3` `crypto_LUKS`
|
||||||
|
→ `dm_crypt-0` (LVM2) → `ubuntu--vg-ubuntu--lv` ext4 `/`.
|
||||||
|
|
||||||
|
## 4. Seal LUKS to the TPM (manual — PCR 7 only)
|
||||||
|
|
||||||
|
Do this AFTER first boot. Manual enrollment sidesteps the installer's dbt profiler and lets us pick
|
||||||
|
PCRs. **Bind to PCR 7 only** (Secure Boot state): it catches the attack that matters (disabling
|
||||||
|
Secure Boot to boot a tampered kernel) WITHOUT breaking on routine kernel/GRUB updates (which churn
|
||||||
|
PCRs 4/8/9 → would otherwise drop every boot to the passphrase). Firmware-only PCR 0 is the fallback
|
||||||
|
if PCR 7 ever errors.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt install -y tpm2-tools
|
||||||
|
sudo tpm2_pcrread sha256 # sanity: PCRs 0-10 populated, PCR 7 has a real value
|
||||||
|
|
||||||
|
# Enroll the TPM (prompts for the EXISTING install passphrase to authorize the new slot):
|
||||||
|
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3
|
||||||
|
|
||||||
|
# Verify TWO slots — keep BOTH (slot 0 password = recovery, slot 1 tpm2 = auto-unlock):
|
||||||
|
sudo systemd-cryptenroll /dev/sda3
|
||||||
|
# SLOT TYPE
|
||||||
|
# 0 password
|
||||||
|
# 1 tpm2
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire it into boot (back up first; the mapping is `dm_crypt-0`, the LUKS UUID is in `/etc/crypttab`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /etc/crypttab /etc/crypttab.bak
|
||||||
|
sudo sed -i 's/none luks$/none luks,tpm2-device=auto/' /etc/crypttab
|
||||||
|
cat /etc/crypttab # → dm_crypt-0 UUID=… none luks,tpm2-device=auto
|
||||||
|
sudo update-initramfs -u
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Boots straight to login, no passphrase prompt** = ✅ TPM auto-unlock works (unattended reboot
|
||||||
|
achieved — VERIFIED on this unit 2026-06-23).
|
||||||
|
- Still prompts = PCR mismatch; type the passphrase (NOT locked out), then retry with
|
||||||
|
`--tpm2-pcrs=0`. The `password` slot + `crypttab.bak` make this fully reversible.
|
||||||
|
|
||||||
|
> **Re-seal runbook:** a BIOS update / Secure Boot change alters PCR 7 → the TPM refuses → boot
|
||||||
|
> falls back to the passphrase prompt (not a brick). After such a change, re-run step 4's
|
||||||
|
> `systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` to re-bind.
|
||||||
|
|
||||||
|
## 5. GRUB password — EDIT-ONLY (VERIFIED 2026-06-23)
|
||||||
|
|
||||||
|
Closes the `init=/bin/bash` / `systemd.unit=rescue.target` local-root hole: without it, anyone at
|
||||||
|
the keyboard presses `e` at the GRUB menu, edits the kernel cmdline, and boots to a **root shell with
|
||||||
|
no login**. **The PCR-7 TPM seal does NOT cover this** — editing the GRUB cmdline doesn't change
|
||||||
|
PCR 7 (Secure Boot policy), so the TPM still releases the key and the attacker lands on the decrypted
|
||||||
|
disk. This is the specific countermeasure for the [[threat-model|operator-at-the-booth]]. Use
|
||||||
|
**edit-only** mode (`--unrestricted`) so the box still boots UNATTENDED — the password is required
|
||||||
|
only to EDIT entries, never to boot.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grub-mkpasswd-pbkdf2 # enter a password (twice) → copy the grub.pbkdf2.sha512.* hash
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the superuser (paste YOUR hash) to the end of `/etc/grub.d/40_custom`:
|
||||||
|
```
|
||||||
|
set superusers="admin"
|
||||||
|
password_pbkdf2 admin grub.pbkdf2.sha512.10000.<YOUR_HASH>
|
||||||
|
```
|
||||||
|
|
||||||
|
Make menu entries bootable WITHOUT the password (edit-only) — in `/etc/grub.d/10_linux`, set the
|
||||||
|
active `CLASS=` line to include `--unrestricted`:
|
||||||
|
```
|
||||||
|
CLASS="--class gnu-linux --class gnu --class os --unrestricted"
|
||||||
|
```
|
||||||
|
|
||||||
|
Regenerate + VERIFY BOTH HALVES landed in the real config BEFORE rebooting (a GRUB misconfig means a
|
||||||
|
rescue-USB recovery):
|
||||||
|
```bash
|
||||||
|
sudo update-grub
|
||||||
|
sudo grep -c "password_pbkdf2" /boot/grub/grub.cfg # want ≥1 (password present)
|
||||||
|
sudo grep -c "unrestricted" /boot/grub/grub.cfg # want ≥1 (entries bootable w/o password)
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ VERIFIED on this unit: boots straight to login (no GRUB prompt, TPM still auto-unlocks) AND
|
||||||
|
pressing `e` at the menu prompts for `admin` + password. Store the GRUB password off-machine
|
||||||
|
(alongside the LUKS passphrase).
|
||||||
|
|
||||||
|
> OS hardening on the first unit is now COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7) + Secure Boot
|
||||||
|
> (Deployed) + GRUB edit-lock.
|
||||||
|
|
||||||
|
## 5c. OS user model — admin vs operator (VERIFIED 2026-06-23)
|
||||||
|
|
||||||
|
The OS has TWO roles and they must be different identities ([[threat-model]]: the operator is the
|
||||||
|
adversary). Create a dedicated **admin** (real password, sudo, NO auto-login) and keep the
|
||||||
|
**operator** as an auto-login, UNPRIVILEGED account.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo adduser admin && sudo usermod -aG sudo admin
|
||||||
|
# VERIFY in a second session: log in as admin → `sudo whoami` prints root — BEFORE the next step:
|
||||||
|
sudo deluser <operator> sudo # demote the auto-login operator
|
||||||
|
groups <operator> # confirm: no 'sudo'
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ Order matters: confirm the new admin's sudo works **before** demoting the operator, or you lock
|
||||||
|
yourself out. Keep auto-login on the OPERATOR, not admin. **Leave root password disabled** (Ubuntu
|
||||||
|
default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
|
||||||
|
|
||||||
|
> Strip latent escalation groups from the operator: **`sudo deluser <operator> lxd`** (lxd group =
|
||||||
|
> launch a privileged container that mounts host `/` as root — undoes the no-sudo hardening) and
|
||||||
|
> `lpadmin` (printer admin, unneeded). And NEVER add the operator to `docker` (also root-equivalent).
|
||||||
|
|
||||||
|
## 5b. Further hardening (TODO — not yet done)
|
||||||
|
|
||||||
|
- **Key-based SSH only** (disable password auth) if SSH is enabled at all.
|
||||||
|
- **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell
|
||||||
|
([[desktop-shell-tauri]]).
|
||||||
|
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
|
||||||
|
- `sudo apt autoremove` the leftover old kernel once the new one is proven.
|
||||||
|
|
||||||
|
## 6. Runtime — Docker stack (VERIFIED 2026-06-23)
|
||||||
|
|
||||||
|
Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which
|
||||||
|
download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`.
|
||||||
|
Add only `admin` to the `docker` group (root-equivalent — NEVER the operator).
|
||||||
|
|
||||||
|
Deploy from a standalone dir (hand-copied; no repo on the appliance), e.g. `/opt/parking_solution`:
|
||||||
|
`docker-compose.yml` + `docker-compose.prod.yml` (the Caddy/prod override) + `Caddyfile` + a `.env`
|
||||||
|
(chmod 600). The `.env` (driven into the containers by the base compose):
|
||||||
|
|
||||||
|
```
|
||||||
|
JWT_SECRET=<openssl rand -hex 32> # server REFUSES to boot without (>=32, no insecure default)
|
||||||
|
EVENT_SIGNING_KEY=<a DIFFERENT openssl rand -hex 32>
|
||||||
|
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
|
||||||
|
WS_ALLOWED_ORIGINS=http://<name-or-ip> # any REMOTE origin admins use (same-origin always passes)
|
||||||
|
VISION_ENABLED=1
|
||||||
|
# REGISTRY/TAG default to git.infra.msai.al/mca/parking_solution + dev; set TAG=main to pin.
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.infra.msai.al # a read-only package token, not the account password
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # dry-run: verify the merged env
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
# Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent):
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
|
||||||
|
-e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' server node scripts/seed-admin.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Healthy startup logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked
|
||||||
|
weights), server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
|
||||||
|
`vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls
|
||||||
|
before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80).
|
||||||
|
|
||||||
|
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
|
||||||
|
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
|
||||||
|
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
|
||||||
|
`hosts`/DNS ON-SITE, never an image rebuild.
|
||||||
|
|
||||||
|
## Quick-reference: the gotchas, in order they bit us
|
||||||
|
|
||||||
|
1. Ventoy USB → `0x1A` Security Violation under Secure Boot → flash the ISO directly instead.
|
||||||
|
2. 7070 BIOS has no "View Key" → can't inspect db; the live-USB boot IS the verification.
|
||||||
|
3. Installer "hardware-backed encryption" → `PCR_UNUSABLE`/dbt → use passphrase LUKS + manual seal.
|
||||||
|
4. Bind TPM to **PCR 7 only**, not a multi-PCR set (kernel updates churn 4/8/9 → passphrase every boot).
|
||||||
|
5. Always keep the **password slot** + an off-machine copy of the passphrase (TPM is never the only key).
|
||||||
|
6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot →
|
||||||
|
breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
tags: [parking, deployment, docker, ci, offline-first]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-06-22
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Container deployment (Docker images for the non-desktop apps)
|
||||||
|
|
||||||
|
How the parking system's runtime apps are packaged as containers, tagged, and published.
|
||||||
|
Settled 2026-06-22. Companion to [[vision-service-packaging]] (which scopes the vision service
|
||||||
|
into the monorepo) and the desktop [[desktop-shell-tauri]] (a separate, tag-only bundle).
|
||||||
|
|
||||||
|
## Two images (the desktop app is NOT containerized)
|
||||||
|
|
||||||
|
- **`parking-server`** — the Fastify API **plus the built React SPA**. One container serves both:
|
||||||
|
Fastify serves `apps/web/dist` via `@fastify/static` (wired in `apps/server/src/static-spa.ts`),
|
||||||
|
with an SPA fallback to `index.html` for client routing. This matches [[offline-first]] — the
|
||||||
|
booth appliance is one box, not a web host + an API host. `@fastify/web` static serving is a
|
||||||
|
**no-op in dev** (no build dir → the Vite dev server serves the UI), so local DX is unchanged.
|
||||||
|
- **`parking-vision`** — the Python/uv ANPR service ([[opencv-anpr-service]]). Ships WITH the
|
||||||
|
`alpr` extra (real fast-alpr/onnxruntime stack); the engine is env-selected
|
||||||
|
(`VISION_RECOGNIZER=stub|fast_alpr`, default `stub` so it boots anywhere). Model weights are
|
||||||
|
**pre-warmed at build** (best-effort) so the appliance's first scan needs no network.
|
||||||
|
|
||||||
|
The **desktop** app stays on its own tag-only `release.yml` (Tauri installers), not these images.
|
||||||
|
|
||||||
|
## Branch-aware (the user's hard requirement)
|
||||||
|
|
||||||
|
- **Image tags = branch + short SHA.** A push to `dev` builds `…/parking-server:dev` +
|
||||||
|
`…/parking-server:dev-<sha>`; `main` builds `:main` + `:main-<sha>`. The moving branch tag is the
|
||||||
|
deploy pointer; the branch-SHA tag is the immutable record. Same for `parking-vision`.
|
||||||
|
- **Per-env compose.** A base `docker-compose.yml` + overrides: `docker-compose.dev.yml` (build
|
||||||
|
locally, expose ports, `stub` recognizer) and `docker-compose.prod.yml` (pull pinned images,
|
||||||
|
`restart: always`, `fast_alpr`, vision kept internal). `REGISTRY`/`TAG` come from env, so a deploy
|
||||||
|
on a branch pulls that branch's image — the branch→environment mapping IS the override file.
|
||||||
|
|
||||||
|
## Registry + CI
|
||||||
|
|
||||||
|
- Published to the house **Gitea registry** `git.infra.msai.al/mca/parking_solution/{parking-server,
|
||||||
|
parking-vision}`. Login via `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` secrets.
|
||||||
|
- New workflow **`.gitea/workflows/build-images.yml`** (separate from the checks-only `ci.yml` and the
|
||||||
|
tag-only `release.yml`): on push to `dev`/`main`, run the full `turbo build lint test` first (don't
|
||||||
|
ship a broken image), then buildx + `docker/build-push-action` for both images with branch+SHA tags
|
||||||
|
and a registry build cache. An optional Komodo redeploy webhook is guarded behind a `KOMODO_ENABLED`
|
||||||
|
var (mirrors the house `trm/processor` pattern). The vision checks need `uv` (the `astral-sh/setup-uv`
|
||||||
|
step), same as `ci.yml`.
|
||||||
|
|
||||||
|
## Build specifics that bit us (record so they don't recur)
|
||||||
|
|
||||||
|
- **`pnpm deploy --legacy --prod`, NOT `pnpm prune --prod`.** It's a pnpm/turbo monorepo; pruning at
|
||||||
|
the root leaves `packages/db/node_modules` empty, so the native **`better-sqlite3`** binding can't
|
||||||
|
resolve at runtime. `pnpm deploy` produces a self-contained, hoisted bundle (the workspace packages'
|
||||||
|
built `dist` + their native deps) — a single `COPY --from=build /deploy ./`. pnpm 10 needs `--legacy`
|
||||||
|
(or `inject-workspace-packages`).
|
||||||
|
- **Native modules**: Alpine build stage needs `python3 make g++` (node-gyp for better-sqlite3);
|
||||||
|
runtime needs `libstdc++`. `bcrypt` ships a `linux-x64/musl` prebuild, so it works on Alpine as-is.
|
||||||
|
- **`pnpm prune`/deploy refuse to run without a TTY** unless `CI=true` (or `ENV CI=true`) is set in
|
||||||
|
the build stage.
|
||||||
|
- **Migrations at boot, not at build.** The DB lives on a mounted volume (`/data`), so the entrypoint
|
||||||
|
runs them against the live file via a **drizzle-kit-free** runtime migrator
|
||||||
|
(`packages/db/scripts/migrate-runtime.mjs`, using `drizzle-orm/.../migrator` — drizzle-kit is a
|
||||||
|
devDep, pruned from the prod bundle). Idempotent: a restart re-applies nothing.
|
||||||
|
- **JWT_SECRET** must be a real value at deploy — `auth.ts` rejects anything `<32` chars or matching
|
||||||
|
`change.?me|insecure|dev-only`, so the dev compose default is a benign 32-char string, not a
|
||||||
|
"dev-only…" placeholder (which would crash boot).
|
||||||
|
- **Vision model pre-warm must run AS the runtime user.** fast-alpr's `open-image-models` caches
|
||||||
|
weights under `$HOME/.cache/open-image-models` keyed to `$HOME` — it ignores `HF_HOME`/
|
||||||
|
`XDG_CACHE_HOME`. A first attempt pre-warmed as root (`/root/.cache`), so the non-root runtime
|
||||||
|
re-downloaded at boot (offline-first BROKEN). Fix: create the `vision` user first, `USER vision`,
|
||||||
|
THEN run `python -c "from fast_alpr import ALPR; ALPR()"` so weights land in `/home/vision/.cache`
|
||||||
|
— exactly where the runtime reads. Verify the boot log shows NO "Downloading …onnx".
|
||||||
|
|
||||||
|
## Web access — relative API + Caddy proxy (2026-06-23)
|
||||||
|
|
||||||
|
- **The server-image SPA uses a RELATIVE `/api` base** (no baked origin), so the UI works loaded
|
||||||
|
from any hostname/IP. The Dockerfile empties `VITE_API_BASE` via `apps/web/.env.production.local`
|
||||||
|
before the web build — because Vite auto-loads `apps/web/.env.production`, which sets
|
||||||
|
`VITE_API_BASE=http://127.0.0.1:3000` for the **Tauri desktop** build only. Without the override
|
||||||
|
the browser bundle baked `127.0.0.1:3000` and failed Same-Origin Policy from any other host. **Do
|
||||||
|
NOT bake the domain via a build var** — relative means naming is controlled by hosts/DNS at deploy,
|
||||||
|
never a rebuild.
|
||||||
|
- **A Caddy reverse proxy** (prod override) publishes `:80` → `server:3000` (server is `expose`-only,
|
||||||
|
internal); `/api/ws` upgrades pass through. `Caddyfile` binds `:80` so it matches ANY host — booth
|
||||||
|
IP, localhost, or `parksystems.msai.al` (pointed at the booth IP via hosts/DNS on-site). TLS later:
|
||||||
|
swap `:80` for the real hostname + uncomment Caddy `:443` → auto-HTTPS.
|
||||||
|
- `WS_ALLOWED_ORIGINS` (env) must list any REMOTE origin admins use (same-origin always passes).
|
||||||
|
|
||||||
|
## Invariants (must hold)
|
||||||
|
|
||||||
|
- **Never bake the live DB.** `.dockerignore` excludes `**/parking.sqlite*` (incl. `-wal`/`-shm`/
|
||||||
|
`.bak-*`) — `pnpm deploy` copies the package dir's files ignoring `.gitignore`, so the
|
||||||
|
`.dockerignore` (which gates the build CONTEXT) is what keeps the signed ledger out of the image.
|
||||||
|
The DB is a host-volume asset ([[append-only-event-chain]], [[threat-model]]).
|
||||||
|
- **SPA serving must not shadow the API** — the fallback is GET-only and excludes `/api`, `/health`;
|
||||||
|
a missing `/api/*` still 404s as JSON, not the HTML shell.
|
||||||
|
- **Offline-first** — both images boot + serve with no network (vision default `stub`; `fast_alpr`
|
||||||
|
weights pre-warmed into the image layer).
|
||||||
|
- **Non-root runtime**, minimal final image (deploy bundle only; build toolchain dropped).
|
||||||
|
|
||||||
|
## Verified on hardware (2026-06-22)
|
||||||
|
|
||||||
|
Both images built + smoke-tested locally (Docker 29, buildx):
|
||||||
|
|
||||||
|
- **server**: build → run → entrypoint migrates `/data/parking.sqlite`, SPA static serving enabled,
|
||||||
|
server listens; `/health` 200, `/` + `/booth` serve the SPA (text/html), `/api/nope` → JSON 404;
|
||||||
|
no `parking.sqlite*` anywhere outside `/data` in the image.
|
||||||
|
- **vision** (1.8 GB, `--extra alpr`): build pre-warms the YOLOv9 + CCT weights into the image
|
||||||
|
(`/home/vision/.cache`); run as `fast_alpr` → `ready:true` with **0 downloads at boot** (offline-
|
||||||
|
first confirmed); `stub` mode also boots clean.
|
||||||
|
- **compose** (`docker-compose.yml` + `.dev.yml`): both containers come up healthy and the server
|
||||||
|
reaches the vision service over the private network (`wget http://vision:8089/health` from the
|
||||||
|
server container → 200).
|
||||||
@@ -167,3 +167,34 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
|||||||
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
||||||
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
|
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
|
||||||
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
|
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
|
||||||
|
|
||||||
|
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
|
||||||
|
|
||||||
|
The desktop bundle now runs in CI under **two distinct workflows** — keep the split clear:
|
||||||
|
|
||||||
|
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
|
||||||
|
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
|
||||||
|
and publishes a Gitea Release. This is what the auto-updater consumes. Unchanged.
|
||||||
|
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
|
||||||
|
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
|
||||||
|
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
|
||||||
|
no `TAURI_SIGNING_*`, no `latest.json` — so it must NEVER be wired to the updater (an unsigned
|
||||||
|
artifact would be rejected anyway). It exists so each branch push yields a downloadable installer
|
||||||
|
for manual testing of the native shell, and catches a broken Tauri/Rust build early. Same
|
||||||
|
system-deps + cargo cache as `release.yml`. The container images (`build-images.yml`) and the
|
||||||
|
desktop installers are deliberately separate pipelines — the desktop app is **not** containerized
|
||||||
|
([[container-deployment]]).
|
||||||
|
- **Delivery: a rolling pre-release, NOT `actions/upload-artifact`.** That action's artifact
|
||||||
|
backend isn't reliable on the Gitea runner (the *Upload installers* step failed). Instead the
|
||||||
|
workflow mirrors `release.yml`'s proven path — plain `curl` + the built-in `GITHUB_TOKEN` to the
|
||||||
|
**Releases API**. It DELETEs any existing `desktop-<branch>` release + tag, recreates it against
|
||||||
|
the new commit as a **prerelease**, and uploads the two installers (renamed space-free,
|
||||||
|
`parking-desktop-<branch>-<sha>.{deb,AppImage}`). So `desktop-dev` always holds the newest dev
|
||||||
|
build; `v*` tags remain the only *signed* releases.
|
||||||
|
- **Gotcha (the unsigned build still demands the key).** `tauri.conf.json` sets
|
||||||
|
`bundle.createUpdaterArtifacts: true` (so `release.yml` produces the `.sig` updater signatures).
|
||||||
|
With that on, `tauri build` **fails** if `TAURI_SIGNING_PRIVATE_KEY` is absent — *"A public key
|
||||||
|
has been found, but no private key"* — even though the `.deb`/`.AppImage` themselves built fine.
|
||||||
|
The unsigned CI build therefore overrides it off with
|
||||||
|
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
|
||||||
|
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
|
||||||
|
|||||||
@@ -110,3 +110,7 @@ The skeleton is **built and wired** (no recognizer models yet):
|
|||||||
weights out of the build entirely (baked into the Docker image instead).
|
weights out of the build entirely (baked into the Docker image instead).
|
||||||
- Container/runtime supervision on the appliance (systemd unit vs. compose) — deployment detail,
|
- Container/runtime supervision on the appliance (systemd unit vs. compose) — deployment detail,
|
||||||
defer to the install/hardening pass.
|
defer to the install/hardening pass.
|
||||||
|
|
||||||
|
> **Resolved 2026-06-22 → [[container-deployment]]:** the vision service now ships as the
|
||||||
|
> `parking-vision` Docker image (uv base, `--extra alpr`), model weights **pre-warmed into the image
|
||||||
|
> layer** at build (offline-first), and runs under **docker-compose** (base + per-env override).
|
||||||
|
|||||||
@@ -37,9 +37,15 @@ Authentication and authorization, kept **fully local** — a direct consequence
|
|||||||
**last user holding admin** — administration can never be locked out of the appliance.
|
**last user holding admin** — administration can never be locked out of the appliance.
|
||||||
- `event:void` is a permission, NOT a ledger delete: the append-only signed chain is untouched; the
|
- `event:void` is a permission, NOT a ledger delete: the append-only signed chain is untouched; the
|
||||||
permission only gates who may APPEND a void event (there is no void API route yet — forward seam).
|
permission only gates who may APPEND a void event (there is no void API route yet — forward seam).
|
||||||
- The grid is **extensible** — adding a feature adds its `resource:action` rows. Latest: **`log:read`**
|
- The grid is **extensible** — adding a feature adds its `resource:action` rows. Recent additions:
|
||||||
(a new `log` resource) gates the diagnostic-log viewer (`GET /api/logs`); admin holds it, and it's
|
**`log:read`** (gates the diagnostic-log viewer, `GET /api/logs`; see [[app-logs]]); **`report:read`**
|
||||||
grantable to a diagnostic role. See [[app-logs]].
|
(the admin Reports dashboard; see [[reporting-analytics]]); and **`recyclebin:read/update/delete`**
|
||||||
|
(view / restore / purge soft-deleted master data; see [[soft-delete]]). Admin holds them all; each is
|
||||||
|
grantable to a scoped role.
|
||||||
|
- **Soft-deleted users can't authenticate.** The login route rejects a user whose `deleted_at` is set
|
||||||
|
(with the same generic "invalid credentials" so a deleted account isn't enumerable). The no-lockout
|
||||||
|
"last admin" check counts only LIVE admins, so soft-deleting can't strand administration. See
|
||||||
|
[[soft-delete]].
|
||||||
- **No privilege escalation through the RBAC system itself.** `role:create`/`role:update` and
|
- **No privilege escalation through the RBAC system itself.** `role:create`/`role:update` and
|
||||||
`user:create`/`user:update` are themselves grantable, so a non-admin could otherwise self-escalate.
|
`user:create`/`user:update` are themselves grantable, so a non-admin could otherwise self-escalate.
|
||||||
Guards (`routes/roles.ts`, `routes/users.ts`): a caller may only put permissions on a role that
|
Guards (`routes/roles.ts`, `routes/users.ts`): a caller may only put permissions on a role that
|
||||||
@@ -63,6 +69,21 @@ The SPA never sees the JWT. Login (`POST /api/auth/login`) verifies bcrypt and s
|
|||||||
requires header == cookie == the signed claim (**double-submit CSRF**). Safe reads are exempt.
|
requires header == cookie == the signed claim (**double-submit CSRF**). Safe reads are exempt.
|
||||||
|
|
||||||
Routes: `login`, `logout` (clears cookies), `me` (bootstraps SPA session on load). The dev
|
Routes: `login`, `logout` (clears cookies), `me` (bootstraps SPA session on load). The dev
|
||||||
|
|
||||||
|
**Self-service profile (added 2026-06-24).** Alongside the admin user-manager (`routes/users.ts`,
|
||||||
|
gated on `user:*`), any signed-in user has two **self-only** routes (no permission needed — they
|
||||||
|
act solely on `req.user.sub`):
|
||||||
|
- `PUT /api/auth/profile` — edit own `fullName` / `email` (`""` clears → null). Returns the
|
||||||
|
refreshed session (so the SPA header updates). **Cannot** touch `username` or `role` — those stay
|
||||||
|
admin-only, so this is not a privilege-escalation surface.
|
||||||
|
- `PUT /api/auth/password` — change own password, but **must prove the current one** first
|
||||||
|
(`bcrypt.compare`) → defends a walked-up, already-logged-in booth from a silent re-key. New
|
||||||
|
password ≥ 8 chars. Distinct from the admin reset (`PUT /api/users/:id/password`), which needs no
|
||||||
|
current password but DOES need `user:update` + the no-escalation guard.
|
||||||
|
Both are still CSRF-guarded (mutations). The SPA surfaces them at `/profile` (`apps/web/src/Profile.tsx`),
|
||||||
|
reachable from the header username chip. Covered by `apps/server/src/routes/profile.test.ts`.
|
||||||
|
|
||||||
|
The dev
|
||||||
[[react-vite-spa|Vite]] proxy and the prod **nginx** reverse proxy keep the SPA and API
|
[[react-vite-spa|Vite]] proxy and the prod **nginx** reverse proxy keep the SPA and API
|
||||||
**same-origin**, so the cookies work without CORS. (This replaced an earlier dev-only
|
**same-origin**, so the cookies work without CORS. (This replaced an earlier dev-only
|
||||||
`SETUP_AUTH_BYPASS` shim, now removed.)
|
`SETUP_AUTH_BYPASS` shim, now removed.)
|
||||||
|
|||||||
@@ -58,3 +58,106 @@ A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.1
|
|||||||
correct JPEG magic. Digest handshake works through `HttpCamera`.
|
correct JPEG magic. Digest handshake works through `HttpCamera`.
|
||||||
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
|
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
|
||||||
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
|
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
|
||||||
|
|
||||||
|
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
|
||||||
|
|
||||||
|
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
|
||||||
|
us. Under **Event → Smart/VCA** (e.g. line crossing / intrusion / "Vehicle Detection") the unit
|
||||||
|
exposes **Detection Target: Human / Vehicle** — selecting **Vehicle** + **Notify Surveillance
|
||||||
|
Center**, then **Alarm Settings → Alarm Server**, makes the camera **HTTP-POST an
|
||||||
|
`EventNotificationAlert`** to a URL we host on each detection. Same machine-call shape as the
|
||||||
|
[[dingtian-relay]] Input Link push — no polling.
|
||||||
|
|
||||||
|
- **Ingress:** `POST /api/devices/hikvision/:deviceId/event` (`apps/server/src/routes/hikvision-alarm.ts`).
|
||||||
|
**Source-IP guarded** (must come from the device's configured `host`) + **optional HTTP Digest**
|
||||||
|
(some firmware can't authenticate the Alarm Server call → source-IP only). NOT behind the SPA
|
||||||
|
cookie/CSRF (it's a device call), exactly like the Dingtian push.
|
||||||
|
- **Config:** added to the `hikvision` driver — `alarmPushEnabled` (bool), `pushUser`/`pushPassword`
|
||||||
|
(optional Digest). The driver is now `pushesToBackend: true`, so first-run setup offers the backend
|
||||||
|
push IP. Point the camera's Alarm Server at `http://<backend-ip>:<port>/api/devices/hikvision/<deviceId>/event`.
|
||||||
|
- **Discovery-first:** the endpoint is **permissive** — accepts ANY content-type as raw bytes (event
|
||||||
|
XML, multipart-with-JPEG, or JSON; Hik's format varies by model/firmware), records the **verbatim
|
||||||
|
body** as a `kind:"alarm"` device_event, and best-effort extracts `eventType` / `target` / `plate`
|
||||||
|
/ `dateTime` / `channelID`. The point of this first cut is to **see exactly what a given camera
|
||||||
|
sends** (inspect via `GET /api/events` or the server log) before wiring it to the read bus.
|
||||||
|
- **Not yet a barrier trigger.** It records + breadcrumbs only; it does NOT emit a `DeviceReadEvent`
|
||||||
|
or open anything. A plate read is **advisory, never the sole reason** a barrier opens
|
||||||
|
([[append-only-event-chain]], [[opencv-anpr-service]]). Two consumers were since designed off this
|
||||||
|
same vehicle event — see **[[lane-presence-and-anpr-entry]]**: (a) BUILT — advisory lane busy/free
|
||||||
|
booth lights; (b) BUILT (2026-06-22) — the ANPR "bridge" (`anpr-entry.ts`) that snapshots → ANPR →
|
||||||
|
emits a `kind:"plate"` read for a SUBSCRIBER match through the existing gated flow (a small
|
||||||
|
`apps/server` handler class, not a service). If the camera ever emits its own `<plateNumber>` we'd
|
||||||
|
use it directly; this `DS-2CD1043G2`
|
||||||
|
does not, so the server pulls the frame and hands it to the [[opencv-anpr-service|vision service]].
|
||||||
|
|
||||||
|
### Gotchas learned the hard way (2026-06-22 field session)
|
||||||
|
|
||||||
|
Several traps surfaced trying to get a real camera to push. In order of how long each cost:
|
||||||
|
|
||||||
|
- **WSL rewrites the inbound source IP.** On the dev host (WSL mirrored mode), an inbound LAN packet
|
||||||
|
arrives at our server with its **source rewritten to the host's own IP** (`10.0.10.203`), not the
|
||||||
|
camera's. The source-IP guard then rejects every push as a mismatch. Fix: a per-device
|
||||||
|
**`skipSourceIpCheck`** config flag (a Setup checkbox) that bypasses the IP guard — the signed
|
||||||
|
ledger + optional Digest remain the real guards. Leave OFF on a normal LAN.
|
||||||
|
- **The setup checkbox saved booleans as the STRING `"true"`.** The generic config-field form had no
|
||||||
|
boolean renderer, so a `type:"boolean"` field fell through to a text input. Fixed (checkbox
|
||||||
|
renderer); the server also coerces `"true"`/`1`/`yes`/`on` defensively.
|
||||||
|
- **The camera's "Test" button proves almost nothing.** It does a TCP/connectivity probe and reports
|
||||||
|
"service available" on ANY HTTP reply (even our 404) — it does **not** POST a real event to your
|
||||||
|
URL. Only a real detection (or the ISAPI `httpHosts/<id>/test`) actually exercises the path.
|
||||||
|
- **`httpBroken` latches.** Once the camera marks the host broken (from earlier failed deliveries),
|
||||||
|
it stays `true` across reboots and won't retry. Clear it by **re-PUTting** the httpHost config
|
||||||
|
(`PUT /ISAPI/Event/notification/httpHosts/1` with `<httpBroken>false</httpBroken>`).
|
||||||
|
- **"Notify Surveillance Center" ≠ the HTTP Alarm Server** on some firmware (separate upload
|
||||||
|
channels). Always confirm the **Arming Schedule** covers the test time, too (a silent killer).
|
||||||
|
- **⭐ THE ROOT CAUSE (2026-06-22): no detection AREA drawn.** This is what actually defeated us for
|
||||||
|
most of a day. On the motion/smart-detection page there's a **Draw Area** step — if **no region is
|
||||||
|
drawn on the frame, the camera detects nothing, generates NO event, and therefore posts nothing**
|
||||||
|
anywhere (httpHost, FTP, alarm stream all stay silent because there's no event upstream). Enabling
|
||||||
|
the detection + ticking Notify Surveillance Center is **not enough** — you must draw the region.
|
||||||
|
Once an area was drawn, the very first vehicle produced a clean POST. **Check this FIRST.**
|
||||||
|
|
||||||
|
### Confirmed real payload (DS-2CD1043G2-LIU, V5.8.10, 2026-06-22)
|
||||||
|
|
||||||
|
What this camera actually POSTs on a motion event with a target — captured end-to-end:
|
||||||
|
|
||||||
|
- **`Content-Type: multipart/form-data; boundary=boundary`**, one XML part named `MoveDetection.xml`
|
||||||
|
(`Content-Type: application/xml`). A real frame/JPEG *may* be attached as a second part on other
|
||||||
|
event types — our endpoint stores the readable head; splitting an image part to `snapshots` is a
|
||||||
|
forward step (not needed for plain motion).
|
||||||
|
- The XML is an `EventNotificationAlert` with the fields we care about:
|
||||||
|
- `<eventType>VMD</eventType>` (Video Motion Detection) + `<eventState>active</eventState>`
|
||||||
|
- **`<targetType>vehicle</targetType>`** — the camera classifies **vehicle vs human ON-DEVICE**.
|
||||||
|
(Field is `targetType`, NOT `detectionTarget`.) This means simple presence + class comes for
|
||||||
|
free, no vision model needed for that part.
|
||||||
|
- `<targetInfo><targetRect>` with normalized `X/Y/width/height` (0–1) — the **bounding box**.
|
||||||
|
- `<channelID>`, `<macAddress>` (provenance), `<dateTime>` — **but the dateTime is GARBAGE**
|
||||||
|
(`2032-…`) because this unit's **RTC is dead** (see below); we use our own server receive time,
|
||||||
|
never the camera's. (No `<plateNumber>` — this is a motion event, not an ANPR camera.)
|
||||||
|
|
||||||
|
### If a camera still won't push — diagnostics (read its OWN state)
|
||||||
|
|
||||||
|
Only after confirming the **detection area is drawn** + arming schedule covers now + Notify
|
||||||
|
Surveillance Center is on. These read the camera directly (no cooperation from our server):
|
||||||
|
|
||||||
|
1. **`netstat` on the camera (via SSH) while you trigger** — watch for an OUTBOUND line
|
||||||
|
`cam:port → server:3000`. It appearing = the camera fired and is delivering (then check our
|
||||||
|
`/api/devices/hikvision/alarms`). None = no event was generated (almost always: **no area drawn**).
|
||||||
|
2. **`GET /ISAPI/Event/notification/alertStream`** (Digest, needs a clean handshake) — the live event
|
||||||
|
bus. NB: a `curl --digest` tap that fails the handshake returns empty and looks like "no events"
|
||||||
|
— don't over-read silence here (this misled us); the netstat watch above is more reliable.
|
||||||
|
3. **SSH `showStatus` / `dmesg`** expose internal state. ⚠ **Caveat learned the hard way:** these
|
||||||
|
surface scary-looking strings that are **red herrings** — `EventScribe: except`, a `diskfull`
|
||||||
|
error on `Event/triggers` (on a camera with **no disk**), and `fh rtc get time error` / a 1970
|
||||||
|
clock. On our unit ALL of these were present **and the camera worked fine** once an area was
|
||||||
|
drawn. The dead RTC is real (hence the bogus `dateTime`) but **harmless** to event push. **Do NOT
|
||||||
|
conclude "dead camera / RMA" from these** — they are not proof of a broken event engine.
|
||||||
|
|
||||||
|
> **Correction (2026-06-22):** an earlier version of this page concluded this DS-2CD1043G2-LIU was a
|
||||||
|
> **defective unit needing RMA**, based on the silent alertStream + `diskfull`/`EventScribe:except` +
|
||||||
|
> dead RTC surviving a full factory reset. **That was WRONG.** The camera was healthy; the real cause
|
||||||
|
> was simply **no detection area drawn**, so no event was ever generated. The `diskfull`/RTC findings
|
||||||
|
> were unrelated quirks (RTC genuinely dead, but it doesn't block event push). Lesson: don't
|
||||||
|
> escalate to "hardware fault" while a basic config precondition (the drawn region) is unmet — and
|
||||||
|
> treat vendor status-API error strings as unreliable. The pull + [[opencv-anpr-service|vision]] path
|
||||||
|
> remains a valid fallback, but it was not needed here.
|
||||||
|
|||||||
@@ -258,9 +258,13 @@ LPR/ANPR plate identity** (the plate binding below):
|
|||||||
number. The GEE readers are combo QR + RFID (ID/IC/NFC), so the same device captures both. A
|
number. The GEE readers are combo QR + RFID (ID/IC/NFC), so the same device captures both. A
|
||||||
Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]); the
|
Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]); the
|
||||||
[[dingtian-relay]] has no onboard card list.
|
[[dingtian-relay]] has no onboard card list.
|
||||||
- **Plate (LPR/ANPR) — NOT YET IMPLEMENTED.** When plate-bound (below), a matching plate read is an
|
- **Plate (LPR/ANPR) — matching is BUILT, the live SOURCE is the one missing wire.** When plate-bound
|
||||||
accepted identity too. The vision/ANPR service that produces plate reads is future work
|
(below), a matching plate read is an accepted identity — and `subscription-flow.ts` `match()` +
|
||||||
([[opencv-anpr-service]] / [[lpr-camera]]); until it exists, plate binding has no live source.
|
`read-dispatch.ts` already handle a `via:"plate"` read end to end (gate + entry/exit). What's
|
||||||
|
missing is the thing that EMITS a plate read from the lane camera: the **ANPR "bridge"** (a small
|
||||||
|
handler in `apps/server`, not a new service) that snapshots on a camera vehicle event, runs
|
||||||
|
[[opencv-anpr-service|ANPR]], and on a high-confidence match emits the plate onto the read bus. PLANNED,
|
||||||
|
scoped to subscribers only. See [[lane-presence-and-anpr-entry]] for the full design + decisions.
|
||||||
|
|
||||||
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
|
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
|
||||||
already in the model) and whose value is the credential id.
|
already in the model) and whose value is the credential id.
|
||||||
|
|||||||
@@ -96,8 +96,10 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
|
|||||||
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
|
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
|
||||||
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
|
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
|
||||||
- [[app-logs]] — the third stream: diagnostic logs (backend warn+ pino sink + frontend errors) → app_logs; log:read viewer; pruned by age+row cap.
|
- [[app-logs]] — the third stream: diagnostic logs (backend warn+ pino sink + frontend errors) → app_logs; log:read viewer; pruned by age+row cap.
|
||||||
|
- [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope.
|
||||||
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
|
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
|
||||||
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline.
|
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline.
|
||||||
|
- [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (BUILT) the ANPR "bridge" (`anpr-entry.ts`): a subscriber's plate read at the lane admits them via the existing gated subscription flow (match-before-emit; subscriber-only). Measured camera limits; rejected the queue-tracking/livestream ideas.
|
||||||
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
|
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
|
||||||
|
|
||||||
## Concepts — frontend / operator UI
|
## Concepts — frontend / operator UI
|
||||||
@@ -119,3 +121,5 @@ Counts: 4 sources · 19 entities · 45 concepts · 7 decision records.
|
|||||||
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
||||||
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
||||||
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
|
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
|
||||||
|
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
||||||
|
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
||||||
|
|||||||
+198
@@ -1354,3 +1354,201 @@ so the booth bundle is untouched. reports.test.ts (10) pins the sums/tz/split/du
|
|||||||
90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup
|
90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup
|
||||||
(`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR
|
(`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR
|
||||||
opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]].
|
opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]].
|
||||||
|
|
||||||
|
## [2026-06-22] feat | Soft delete + recycle bin for master data (migration 0012)
|
||||||
|
Accidental admin deletes used to be hard + unrecoverable. Now users/roles/subscriptions/plans/
|
||||||
|
tariffs soft-delete: migration 0012 adds nullable deleted_at + deleted_by; each resource's DELETE
|
||||||
|
route STAMPS instead of removing, and every catalog list filters deleted_at IS NULL. A recycle bin
|
||||||
|
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge — gated recyclebin:read/update/delete,
|
||||||
|
new resource in the PERMISSIONS grid) lists everything soft-deleted, restores, or purges; a 6-hourly
|
||||||
|
+ startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever).
|
||||||
|
Key invariants: soft-deleted users CAN'T log in (login rejects deleted_at; no-lockout counts live
|
||||||
|
admins only); a soft-deleted subscription doesn't open the barrier; PLANS are versioned so a delete
|
||||||
|
stamps all version rows of the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
|
||||||
|
rows so reuse returns a clear 409 pointing at the bin; restore doesn't auto-cascade a dangling role
|
||||||
|
(guard resolves missing role → empty perms, safe). Signed ledger is OUT of scope (no delete path).
|
||||||
|
Web: a Recycle bin tab under Setup (RecycleBin.tsx). Tests: recycle-bin.test.ts (9 unit) +
|
||||||
|
recycle-bin-routes.test.ts (4 integration: delete→can't-login→restore→login, purge, gating, 409
|
||||||
|
reuse); server 103/103, build+lint 19/19, i18n parity (sq+en). See [[soft-delete]], [[local-jwt-auth]].
|
||||||
|
|
||||||
|
## [2026-06-22] feat | Hikvision Alarm Server event-push ingress (discovery-first)
|
||||||
|
Newer Hik firmware (Event → Smart/VCA "Detection Target: Human/Vehicle" + Notify Surveillance
|
||||||
|
Center + Alarm Settings → Alarm Server) HTTP-POSTs an EventNotificationAlert on each detection.
|
||||||
|
Added POST /api/devices/hikvision/:deviceId/event (routes/hikvision-alarm.ts) — same machine-push
|
||||||
|
pattern as the Dingtian Input Link: source-IP guarded + OPTIONAL Digest, not behind SPA cookie/CSRF.
|
||||||
|
Permissive/discovery-first: a wildcard content-type parser takes ANY body as raw bytes (XML,
|
||||||
|
multipart+JPEG, JSON — Hik varies by firmware), stores it verbatim as a kind:"alarm" device_event,
|
||||||
|
and best-effort extracts eventType/target/plate/dateTime/channelID for the summary + log line. The
|
||||||
|
hikvision DRIVER gained alarmPushEnabled + pushUser/pushPassword config and pushesToBackend:true (so
|
||||||
|
setup offers the backend push IP). NOT yet a barrier trigger or DeviceReadEvent — records only; the
|
||||||
|
read-bus/ANPR wiring is the next step once the real payload is captured (advisory-only rule still
|
||||||
|
governs). Tests: hikvision-alarm.test.ts (6: vehicle XML summary, ANPR plate, raw JSON, wrong-IP
|
||||||
|
404, disabled 404, unknown-device 404); server 109/109, build+lint 14/14. See [[lpr-camera]].
|
||||||
|
|
||||||
|
## [2026-06-22] debug | Hikvision event-push field session — fixes + a verified-dead camera
|
||||||
|
Long session getting a real Hik camera to POST events. Server/integration fixes (committed): listen
|
||||||
|
on ALL methods (camera probes with GET/etc, not just POST); record rejected pushes too (kind
|
||||||
|
"alarm-rejected" + reason) so "nothing arrived" is never ambiguous; a GET /api/devices/hikvision/
|
||||||
|
alarms read endpoint; per-device skipSourceIpCheck (WSL mirrored mode REWRITES the inbound source IP
|
||||||
|
to the host's own, so the source-IP guard rejected every push); and a real checkbox renderer for
|
||||||
|
type:"boolean" config fields (they were saving the STRING "true"). Then proved — via the camera's
|
||||||
|
OWN state, not our server — that the specific DS-2CD1043G2-LIU unit has a DEAD event engine: silent
|
||||||
|
alertStream (no heartbeat), EventScribe:except, diskfull on Event/triggers, dead RTC (rtc get time
|
||||||
|
error / clock at 1970), and ZERO outbound to :3000 over a 3-min netstat watch, surviving reboot +
|
||||||
|
basic reset + FULL factory reset. Verdict: defective camera (RMA), not our code. Captured the
|
||||||
|
diagnostic method (alertStream silence / SSH showStatus / netstat) in [[lpr-camera]]. Fallback for a
|
||||||
|
dead-push camera: pull + [[opencv-anpr-service|vision]] (the same camera still serves snapshots).
|
||||||
|
|
||||||
|
## [2026-06-22] CORRECTION | Hik camera was NOT defective — the cause was an undrawn detection area
|
||||||
|
Supersedes the earlier "[2026-06-22] debug" entry's conclusion that the DS-2CD1043G2-LIU had a dead
|
||||||
|
event engine needing RMA. WRONG. The camera is healthy; it pushed a clean event the instant a
|
||||||
|
detection AREA was drawn on the frame (the "Draw Area" step). With no region drawn, the camera
|
||||||
|
detects nothing → generates no event → posts nothing anywhere — which produced all the symptoms
|
||||||
|
(silent alertStream, zero outbound to :3000). The diskfull / EventScribe:except / dead-RTC findings
|
||||||
|
were red herrings (the RTC is genuinely dead, hence a bogus 2032 dateTime in the payload, but it does
|
||||||
|
NOT block event push). Lesson: don't escalate to "hardware fault" while a basic config precondition
|
||||||
|
is unmet; vendor status-API error strings are unreliable. Confirmed real payload: multipart/form-data
|
||||||
|
(MoveDetection.xml) with EventNotificationAlert -> eventType=VMD, eventState=active,
|
||||||
|
targetType=vehicle (vehicle/human classified ON-DEVICE), targetRect bounding box. The push endpoint +
|
||||||
|
all-methods + skipSourceIpCheck + rejection-recording are all validated against the real device now.
|
||||||
|
See [[lpr-camera]] (corrected).
|
||||||
|
|
||||||
|
## [2026-06-22] design+build | Lane presence (BUILT) + ANPR subscriber-entry "bridge" (PLANNED)
|
||||||
|
Off the now-working Hik vehicle event: BUILT advisory lane busy/free booth barrier lights
|
||||||
|
(LaneStatus + WS; timeout-driven "free" since the camera sends no leave signal — TTL settled at 30s
|
||||||
|
after a controlled in/out test showed movement-driven ~1-3s re-fire but ~15-25s gaps for a still
|
||||||
|
car, and ~no dwell lag on leave). Measured the camera's hard limits: no current-state poll exists,
|
||||||
|
and flipping notificationRecurrence beginning->recurring via ISAPI is silently reverted (firmware
|
||||||
|
locked). Then narrowed the bigger ambition to a clean, high-value scope: ANPR for SUBSCRIBERS ONLY —
|
||||||
|
a plate read at the lane admits a subscriber through the EXISTING gated subscription flow. Found the
|
||||||
|
whole subscription side already supports via:"plate" (match + dispatch + gate); the one missing piece
|
||||||
|
is a small `apps/server` HANDLER ("the bridge", ~40 lines, NOT a new service/container) that on a
|
||||||
|
camera vehicle event snapshots -> ANPR -> on a HIGH-confidence match (new VISION_ENTRY_MIN_CONFIDENCE)
|
||||||
|
-> debounces (required for ledger correctness, not CPU: ~1Hz re-fire would drive repeat entries) ->
|
||||||
|
emitRead{kind:"plate"}. Both directions, opt-in per camera (config.anpr), plate never the sole
|
||||||
|
authority (routes through the gate). REJECTED: continuous livestream presence + per-car queue
|
||||||
|
tracking/make-model (needs a vehicle detector the plate-only vision lacks + appliance compute we can't
|
||||||
|
measure on the dev PC). Vision checked: fast_alpr live, ~50ms/frame on DEV PC (appliance TBD —
|
||||||
|
booth-PC test ~2026-06-23). New page [[lane-presence-and-anpr-entry]]; updated [[lpr-camera]],
|
||||||
|
[[subscription]], index.
|
||||||
|
|
||||||
|
## [2026-06-22] build | ANPR subscriber-entry "bridge" — BUILT
|
||||||
|
Built the bridge planned in the previous entry: `apps/server/src/anpr-entry.ts` (`AnprBridge`). On a
|
||||||
|
vehicle/non-`inactive` push from an `anpr`-opted-in camera, `hikvision-alarm.ts` hands the deviceId
|
||||||
|
to the bridge (fire-and-forget, never awaited on the camera's 200). The bridge debounces
|
||||||
|
(camera-level, pre-snapshot), pulls a FRESH snapshot (reused `snapshot.ts buildCamera`), runs
|
||||||
|
`vision.analyze`, applies a stricter entry floor (`VISION_ENTRY_MIN_CONFIDENCE`=0.85), then — the key
|
||||||
|
safety choice settled with the user — MATCHES the plate to a subscription BEFORE emitting: a
|
||||||
|
subscriber → `emitRead{kind:"plate"}` (→ existing `ReadDispatcher`→gated `SubscriptionFlow`); a
|
||||||
|
non-subscriber → advisory `anpr-skip` device_event, nothing emitted (so a random/printed plate never
|
||||||
|
reaches the transient plate-as-ticket exit path). Fail-soft throughout. `server.ts` reordered so the
|
||||||
|
read flows are constructed before the hik-alarm registration. New env: `VISION_ENTRY_MIN_CONFIDENCE`,
|
||||||
|
`ANPR_DEBOUNCE_MS`. Tests: `anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3); full
|
||||||
|
server suite 130 green, monorepo build+lint green. Flipped [[lane-presence-and-anpr-entry]] §2 +
|
||||||
|
table row PLANNED->BUILT; updated [[lpr-camera]]. STILL OPEN: booth-PC ANPR latency (~2026-06-23).
|
||||||
|
|
||||||
|
## [2026-06-22] build | Cancel (void) a wrongly-printed ticket + refused-vs-anomaly display split
|
||||||
|
Operator need: cancel a misprinted/test/wrong-vehicle ticket, traceably. Built it as a SIGNED `void`
|
||||||
|
(append-only — the vehicle_entry is never touched): new `apps/server/src/void-flow.ts` (`VoidFlow`)
|
||||||
|
appends void{ voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
|
||||||
|
POST /api/tickets/void gated event:void + open shift; operator from JWT, reason REQUIRED. Refuses a
|
||||||
|
subscription / already-exited / already-voided / PAID ticket (refund = out of scope). The CRUX: a
|
||||||
|
void must fold the session CLOSED everywhere it's counted — done in occupancy.ts (count +
|
||||||
|
reserved-spots, −1 like an exit), pay-station.ts (lookup/activeSessions), exit-flow.ts (#sessionFor),
|
||||||
|
and reports.ts (excluded from the entries stat). No barrier action (the car never entered). Booth UI:
|
||||||
|
"Cancel ticket" in the pay/exit lookup modal (transient + unpaid + open; gated on event:void) with a
|
||||||
|
preset-or-free reason prompt. Part 2 (display-only): the Live feed mislabeled benign refused-action
|
||||||
|
events (exitRefused/entryRefused/permitRefused — e.g. a double card-scan) as red ANOMALI; now
|
||||||
|
classified via event-detail.tsx isRefusedWarning and shown as amber REFUZUAR/REFUSED, reserving red
|
||||||
|
ANOMALI for genuine red-flags. No ledger change → historical events reclassify too. New reason code
|
||||||
|
void.ticketCancelled (shared + both web catalogs). Tests: void-flow.test.ts (8) + occupancy void fold;
|
||||||
|
141 server + 87 shared green; build+lint (TS + i18n parity) green. Updated [[parking-session]].
|
||||||
|
|
||||||
|
## [2026-06-22] build | Docker images for non-desktop apps (server+SPA, vision) + branch-aware build pipeline
|
||||||
|
Containerized the two runtime apps. parking-server = Fastify API + the bundled React SPA (wired
|
||||||
|
@fastify/static in new static-spa.ts — serves apps/web/dist with an SPA index.html fallback, GET-only
|
||||||
|
and excluding /api + /health so it never shadows the backend; a NO-OP in dev where no dist exists).
|
||||||
|
parking-vision = the Python/uv ANPR service, ships --extra alpr with model weights pre-warmed into the
|
||||||
|
image (offline-first), engine env-selected (VISION_RECOGNIZER stub|fast_alpr). Branch-aware per the
|
||||||
|
user: images tagged branch + branch-<sha>; base docker-compose.yml + docker-compose.dev.yml (build
|
||||||
|
local, stub, ports) / docker-compose.prod.yml (pull pinned, fast_alpr, vision internal, restart
|
||||||
|
always). New .gitea/workflows/build-images.yml pushes both to git.infra.msai.al/mca/parking_solution
|
||||||
|
on push to dev/main, after a full turbo build+lint+test gate (mirrors trm/processor; optional Komodo
|
||||||
|
webhook behind KOMODO_ENABLED). KEY build lessons: use `pnpm deploy --legacy --prod` NOT
|
||||||
|
`pnpm prune` (monorepo: prune leaves the native better-sqlite3 unresolved); Alpine needs
|
||||||
|
python3/make/g++ (build) + libstdc++ (runtime); set CI=true so pnpm wipes node_modules; migrate at
|
||||||
|
BOOT via a drizzle-kit-free runtime migrator (packages/db/scripts/migrate-runtime.mjs) against the
|
||||||
|
mounted /data volume; .dockerignore must exclude **/parking.sqlite* (deploy ignores .gitignore) so the
|
||||||
|
signed ledger is NEVER baked. JWT_SECRET must be a real >=32-char value (auth.ts rejects dev-only/
|
||||||
|
insecure/change-me). VERIFIED: server image builds + runs — migrates, SPA serving on, /health 200,
|
||||||
|
/ + /booth serve HTML, /api/nope JSON 404, no sqlite outside /data. Vision image build + smoke in
|
||||||
|
progress. New page [[container-deployment]]; updated [[vision-service-packaging]] (resolved its two
|
||||||
|
open Qs), index. Server tests stay 141 green (SPA serving guarded on dist existence).
|
||||||
|
|
||||||
|
## [2026-06-23] provision | First booth appliance — Dell OptiPlex 7070, Win11 → Ubuntu 26.04 LTS, encrypted + TPM-sealed
|
||||||
|
Provisioned the first real booth PC. Hardware: Dell OptiPlex 7070, i5-8500, 238GB SSD, discrete
|
||||||
|
Nuvoton TPM 2.0 (NOT Intel PTT — Get-Tpm ManufacturerIdTxt NTC). Formatted Win11 → Ubuntu 26.04 LTS
|
||||||
|
(the decided platform). Gotchas hit + resolved, in order: (1) Ventoy USB → 0x1A Security Violation
|
||||||
|
under Secure Boot (Ventoy's loader not in db) → flash the ISO directly; (2) the 7070 BIOS Expert Key
|
||||||
|
Management is edit-only, no "View Key" → can't inspect db, so the live-USB boot IS the verification
|
||||||
|
(it reached the installer with Secure Boot ON → MS 3rd-party UEFI CA confirmed present); (3) the
|
||||||
|
installer's "Use hardware-backed encryption" FAILED with PCR_UNUSABLE / "secure boot policy (PCR7) …
|
||||||
|
timestamp revocation (dbt) … not supported" — Ubuntu's automated FDE profiler can't model PCR7 on
|
||||||
|
Dell firmware with a dbt; NOT a TPM/SB fault. Workaround: "Encrypt with a passphrase" (plain LUKS) +
|
||||||
|
MANUAL TPM seal after boot via systemd-cryptenroll --tpm2-pcrs=7 /dev/sda3 (PCR 7 only — avoids
|
||||||
|
kernel-churned 4/8/9 that would drop every boot to the passphrase). Two LUKS slots kept (0 password =
|
||||||
|
recovery, 1 tpm2 = auto-unlock); crypttab gets tpm2-device=auto; update-initramfs; reboot → BOOTS
|
||||||
|
STRAIGHT TO LOGIN, no passphrase → TPM auto-unlock VERIFIED (unattended reboot achieved). New runbook
|
||||||
|
page [[appliance-provisioning]] (every command verified on hardware); cross-linked from
|
||||||
|
[[disk-os-hardening]] (resolves impl half of open-questions #12 for unit 1) + index. REMAINING on the
|
||||||
|
box: GRUB password, Docker install, run the parking-server/parking-vision stack.
|
||||||
|
|
||||||
|
## [2026-06-23] provision | First booth appliance — GRUB edit-lock added; OS hardening COMPLETE
|
||||||
|
Added the GRUB password (edit-only mode via --unrestricted) to the first booth unit. WHY it matters
|
||||||
|
specifically: the PCR-7 TPM seal does NOT cover the GRUB-cmdline attack (editing the kernel line to
|
||||||
|
init=/bin/bash doesn't change PCR 7, so the TPM still releases the LUKS key → root shell on the
|
||||||
|
decrypted disk). Edit-only mode keeps unattended boot (the box still boots password-free; the
|
||||||
|
password is required only to EDIT entries / open the GRUB shell) — the right config for an unattended
|
||||||
|
booth. Verified BOTH halves in /boot/grub/grub.cfg before rebooting (password_pbkdf2 ≥1, unrestricted
|
||||||
|
≥1) and on reboot: boots straight to login (no GRUB prompt, TPM auto-unlock intact) AND pressing `e`
|
||||||
|
prompts for admin+password. OS hardening on unit 1 is now COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7)
|
||||||
|
+ Secure Boot (Deployed) + GRUB edit-lock. Updated [[appliance-provisioning]] (§5 GRUB now a verified
|
||||||
|
step, §5b further-hardening TODO: SSH key-only, kiosk lockdown, signing key→TPM, autoremove old
|
||||||
|
kernel) + [[disk-os-hardening]]. STILL TODO on the box: Docker install + run the parking stack (needs
|
||||||
|
the images pushed — dev push + registry secrets pending).
|
||||||
|
|
||||||
|
## [2026-06-23] deploy | First booth GO-LIVE — Docker stack running + web-access fixes (CI uv, compose env, relative /api, Caddy)
|
||||||
|
Deployed the two images onto the hardened booth (Dell 7070, Ubuntu 26.04) and worked through the
|
||||||
|
real-world bring-up issues. (1) Operator/admin OS user split: created a dedicated sudo `admin` user,
|
||||||
|
removed the auto-login operator from `sudo` (and should drop `lxd`/`lpadmin` — lxd is a root-escape
|
||||||
|
path); admin is the only sudo, operator auto-logs in unprivileged. (2) Docker 29.6 installed; deploy
|
||||||
|
dir /opt/parking_solution with hand-copied compose + .env; registry login to git.infra.msai.al; the
|
||||||
|
stack came up clean — vision fast_alpr loaded from the BAKED cache (0 downloads → offline-first
|
||||||
|
confirmed on real hardware), server migrated /data, both healthy. (3) Seeded the first admin via
|
||||||
|
`docker compose exec server node scripts/seed-admin.mjs` (bcrypt, writes users table — NOT the signed
|
||||||
|
ledger). FIXES committed this session: CI `astral-sh/setup-uv` action failed on the Gitea runner →
|
||||||
|
install uv via its official curl script instead (both ci.yml + build-images.yml) [0a22eab]; the base
|
||||||
|
compose only forwarded JWT_SECRET/DATABASE_URL/VISION_URL → added COOKIE_SECURE (CRITICAL on plain-
|
||||||
|
http or login cookies never send), WS_ALLOWED_ORIGINS, EVENT_SIGNING_KEY, VISION_ENABLED [1092316];
|
||||||
|
the SPA had VITE_API_BASE=http://127.0.0.1:3000 baked in (leaked from apps/web/.env.production, which
|
||||||
|
is for the TAURI build but Vite auto-loads it for every build) → server Dockerfile now empties it via
|
||||||
|
.env.production.local so the SPA uses RELATIVE /api and works from ANY host [77b2acb]; added a CADDY
|
||||||
|
reverse proxy (prod override) so the booth is reached on a clean port-80 URL, server goes internal,
|
||||||
|
Caddyfile binds :80 to match any hostname incl. parksystems.msai.al [c637b27]. NET RESULT: no domain
|
||||||
|
baked into any image — naming controlled by hosts/DNS on-site; admin can reach it from another LAN PC.
|
||||||
|
Verified the relative-/api + Caddy fix end-to-end locally (Host: parksystems.msai.al through :80 →
|
||||||
|
SPA + /api/auth/login reach the server, no CORS). See [[container-deployment]] "Web access",
|
||||||
|
[[appliance-provisioning]]. REMAINING on the box: push dev so CI rebuilds parking-server:dev with the
|
||||||
|
relative-/api fix, then pull on the booth; kiosk autostart; operator user lxd/lpadmin cleanup.
|
||||||
|
|
||||||
|
## [2026-06-24] build | Self-service user profile + desktop installers in CI
|
||||||
|
Two app-side additions. (1) **Self-service profile** — any signed-in user can now edit their OWN
|
||||||
|
`fullName`/`email` and change their OWN password (proving the current one), without any `user:*`
|
||||||
|
permission. New routes `PUT /api/auth/profile` + `PUT /api/auth/password` (act only on `req.user.sub`;
|
||||||
|
cannot touch username/role; CSRF-guarded), SPA screen `apps/web/src/Profile.tsx` at `/profile` (header
|
||||||
|
username chip links to it), `email` added to the session view + `SessionUser`. 7 new tests
|
||||||
|
(`routes/profile.test.ts`); server 148/148 green. Distinct from the admin user-manager (`routes/users.ts`,
|
||||||
|
`user:*`-gated). See [[local-jwt-auth]]. (2) **Desktop in CI** — new `.gitea/workflows/build-desktop.yml`
|
||||||
|
builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts
|
||||||
|
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
|
||||||
|
[[desktop-shell-tauri]] "Desktop in CI".
|
||||||
|
|||||||
Reference in New Issue
Block a user