Compare commits
52 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 | |||
| 3527f48d76 | |||
| 5a5f5c554b | |||
| 742653aefb | |||
| 66c1291578 | |||
| 7629d5d7b1 | |||
| 2fb947e908 | |||
| cae900afd2 | |||
| 7e912e193b | |||
| 352c643009 | |||
| 5e9be16f65 | |||
| 0985b86fa7 | |||
| 3ed785c33e | |||
| 35c10a7310 | |||
| 2a9e6846a1 | |||
| 051b440627 | |||
| eb47016ae3 | |||
| 78d1f6808a | |||
| 31f116a068 | |||
| 663bf0e925 | |||
| 8acef0464c | |||
| df5caf8d87 | |||
| 0cbae94842 | |||
| ae5c122980 | |||
| d0536da3d7 | |||
| ae736a9e3e | |||
| 1b54775b4d |
@@ -0,0 +1,44 @@
|
|||||||
|
# Build context hygiene for the server + vision images (context = repo root).
|
||||||
|
# Keep the context small and NEVER bake build artifacts, secrets, or the live DB.
|
||||||
|
|
||||||
|
# Node / build outputs (rebuilt inside the image)
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
**/.turbo/
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# Python (vision) — rebuilt by uv inside the image
|
||||||
|
**/.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
**/.mypy_cache/
|
||||||
|
**/.pytest_cache/
|
||||||
|
**/.ruff_cache/
|
||||||
|
|
||||||
|
# Secrets + local env (the image gets config via runtime env, never baked)
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
|
||||||
|
# NEVER bake the live signed-ledger DB (or any of its WAL/SHM/backup variants) into an
|
||||||
|
# image — it lives on a mounted volume. Match the base file AND every -wal/-shm/.bak-*
|
||||||
|
# sibling (deploy copies the package dir's files, ignoring .gitignore).
|
||||||
|
**/*.sqlite
|
||||||
|
**/*.sqlite-*
|
||||||
|
**/parking.sqlite*
|
||||||
|
|
||||||
|
# Desktop app is built by its own tag-only release.yml, not these images
|
||||||
|
apps/desktop/
|
||||||
|
|
||||||
|
# VCS, logs, caches, editor cruft
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
*.log
|
||||||
|
**/.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Wiki raw sources / large docs (not needed to build)
|
||||||
|
wiki/raw/
|
||||||
|
|
||||||
|
# Plans / scratch
|
||||||
|
.planning/
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
name: Build desktop
|
||||||
|
|
||||||
|
# Build the Tauri desktop installers (.deb + .AppImage) on every push to dev/main and
|
||||||
|
# upload them as workflow ARTIFACTS — a downloadable, per-commit build for testing the
|
||||||
|
# native shell. This is NOT a release: it's unsigned (no updater key) and creates no Gitea
|
||||||
|
# Release. Signed, versioned releases stay on release.yml (tag v* → .deb/.rpm/.AppImage +
|
||||||
|
# latest.json for the auto-updater). See wiki/decisions/desktop-shell-tauri.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
paths:
|
||||||
|
- 'apps/desktop/**'
|
||||||
|
- 'apps/web/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- '.gitea/workflows/build-desktop.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
desktop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
|
||||||
|
- name: Install Tauri system deps
|
||||||
|
# Same set release.yml uses (verified): WebKitGTK 4.1 + libsoup-3 + the GTK/
|
||||||
|
# appindicator/rsvg stack + AppImage tooling (patchelf, file).
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
file \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache cargo + target
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
apps/desktop/src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build desktop bundle (.deb + .AppImage)
|
||||||
|
# Unsigned — no TAURI_SIGNING_* here (this is a test artifact, not an updater
|
||||||
|
# release). The config sets createUpdaterArtifacts:true (release.yml signs them),
|
||||||
|
# which makes tauri DEMAND the signing key and fail without it — so override it to
|
||||||
|
# false for this build via --config (a JSON patch merged over tauri.conf.json).
|
||||||
|
# --bundles restricts to the two installers we ship; tauri builds the web SPA
|
||||||
|
# first (beforeBuildCommand), so the desktop UI matches.
|
||||||
|
run: >
|
||||||
|
pnpm --filter @parking/desktop bundle
|
||||||
|
--bundles deb,appimage
|
||||||
|
--config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||||
|
|
||||||
|
- name: Collect installers
|
||||||
|
id: collect
|
||||||
|
# Copy out the two installers under SPACE-FREE names (tauri names them
|
||||||
|
# "Parking System_0.0.0_amd64.deb" — spaces break asset URLs). Short SHA in the
|
||||||
|
# name so a downloaded file is traceable to its commit.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||||
|
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
|
||||||
|
mkdir -p dist
|
||||||
|
deb=$(find "$BUNDLE/deb" -name '*.deb' | head -1)
|
||||||
|
app=$(find "$BUNDLE/appimage" -name '*.AppImage' | head -1)
|
||||||
|
cp "$deb" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.deb"
|
||||||
|
cp "$app" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.AppImage"
|
||||||
|
echo "Artifacts:"; ls -la dist/
|
||||||
|
|
||||||
|
- name: Publish to a rolling per-branch pre-release
|
||||||
|
# actions/upload-artifact's backend isn't reliable on this Gitea runner, so we
|
||||||
|
# publish to a Gitea RELEASE via the API instead (the proven pattern from
|
||||||
|
# release.yml — built-in token, plain curl). One ROLLING pre-release per branch
|
||||||
|
# (tag desktop-<branch>): delete + recreate each push so it always holds the
|
||||||
|
# latest dev/main installer. This is NOT the signed updater release (release.yml,
|
||||||
|
# tag v*) — it's a prerelease, unsigned, with no latest.json.
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.api_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TAG: desktop-${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
auth="Authorization: token ${TOKEN}"
|
||||||
|
# Drop any existing rolling release for this branch (ignore if absent) so its
|
||||||
|
# tag + stale assets don't pile up; recreate it fresh below.
|
||||||
|
OLD=$(curl -sS -H "$auth" "${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
|
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
if [ -n "$OLD" ]; then
|
||||||
|
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/releases/${OLD}" || true
|
||||||
|
# Also delete the tag itself so the recreate points at this commit.
|
||||||
|
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/git/refs/tags/${TAG}" || true
|
||||||
|
fi
|
||||||
|
REL=$(curl -sS -X POST -H "$auth" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"Desktop build (${GITHUB_REF_NAME})\",\"body\":\"Unsigned per-commit desktop installers from ${GITHUB_REF_NAME} @ ${GITHUB_SHA}. Rolling — overwritten each push. Not an updater release.\",\"draft\":false,\"prerelease\":true}" \
|
||||||
|
"${API}/repos/${REPO}/releases")
|
||||||
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
echo "release id: ${REL_ID}"
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "uploading ${name}"
|
||||||
|
curl -sS -X POST -H "$auth" -H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${f}" \
|
||||||
|
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||||
|
done
|
||||||
|
echo "done"
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
name: Build & push images
|
||||||
|
|
||||||
|
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
|
||||||
|
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, main→:main).
|
||||||
|
# Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle). Mirrors the
|
||||||
|
# house pattern (cf. trm/processor build.yml). See wiki/decisions/container-deployment.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
paths:
|
||||||
|
- 'apps/server/**'
|
||||||
|
- 'apps/web/**'
|
||||||
|
- 'apps/vision/**'
|
||||||
|
- 'packages/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'turbo.json'
|
||||||
|
- 'docker-compose*.yml'
|
||||||
|
- '.dockerignore'
|
||||||
|
- '.gitea/workflows/build-images.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.infra.msai.al/mca/parking_solution
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
images:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Set up uv (for @parking/vision checks)
|
||||||
|
# Install uv via its official standalone script rather than a third-party action —
|
||||||
|
# the Gitea runner can't reliably resolve astral-sh/setup-uv. uv provisions the
|
||||||
|
# pinned Python (apps/vision/.python-version) itself. Add it to PATH for later steps.
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Sync vision deps
|
||||||
|
working-directory: apps/vision
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
# Don't publish a broken image — run the same checks as ci.yml first.
|
||||||
|
- name: Build + lint + test (Turbo)
|
||||||
|
run: pnpm turbo run build lint test
|
||||||
|
|
||||||
|
- name: Compute tags
|
||||||
|
id: meta
|
||||||
|
# BRANCH = the pushed branch (dev|main); SHA = short commit. Two tags per image:
|
||||||
|
# the moving branch tag + an immutable branch-SHA tag.
|
||||||
|
run: |
|
||||||
|
BRANCH="${GITHUB_REF_NAME}"
|
||||||
|
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
|
||||||
|
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver: docker-container
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.infra.msai.al
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build & push SERVER (API + SPA)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/server/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
||||||
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Build & push VISION (ANPR)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: apps/vision
|
||||||
|
file: apps/vision/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}
|
||||||
|
${{ env.REGISTRY }}/parking-vision:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-vision:buildcache,mode=max
|
||||||
|
|
||||||
|
# Optional: trigger a Komodo stack redeploy (cf. trm/processor). Enable by setting the
|
||||||
|
# KOMODO_* secrets; left guarded so it no-ops until the parking stack is wired.
|
||||||
|
- name: Trigger Komodo redeploy
|
||||||
|
if: success() && vars.KOMODO_ENABLED == 'true'
|
||||||
|
env:
|
||||||
|
URL: ${{ secrets.KOMODO_STACK_WEBHOOK_URL }}
|
||||||
|
SECRET: ${{ secrets.KOMODO_WEBHOOK_SECRET }}
|
||||||
|
run: |
|
||||||
|
body="{\"ref\":\"refs/heads/${GITHUB_REF_NAME}\"}"
|
||||||
|
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H "X-Hub-Signature-256: sha256=$sig" \
|
||||||
|
-d "$body" \
|
||||||
|
"$URL"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Lint/typecheck/test the whole Turborepo on every push/PR to dev. Mirrors the
|
||||||
|
# house pattern (cf. trm/processor): setup-node + corepack pnpm + frozen install.
|
||||||
|
# No Docker, no signing — pure checks. The desktop bundle is a separate, tag-only
|
||||||
|
# pipeline (see release.yml).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
pull_request:
|
||||||
|
branches: [dev, main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
# Pin to the repo's packageManager version (pnpm 10), not latest.
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Set up uv (Python toolchain for @parking/vision)
|
||||||
|
# The vision service is a Python package wired into the Turbo graph via a
|
||||||
|
# package.json shim; its lint/typecheck/test scripts shell to `uv run …`. CI
|
||||||
|
# has no Python by default, so `uv run` would fail with "uv: not found" and
|
||||||
|
# break the whole Turbo run. Install uv via its official standalone script
|
||||||
|
# (the Gitea runner can't reliably resolve astral-sh/setup-uv); uv provisions the
|
||||||
|
# pinned Python (.python-version) itself. See wiki/decisions/vision-service-packaging.md.
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Sync vision deps
|
||||||
|
# Light deps + the dev group (ruff/mypy/pytest) only — NOT the optional `alpr`
|
||||||
|
# extra (heavy onnx/model stack), which isn't needed to lint/typecheck/test.
|
||||||
|
working-directory: apps/vision
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Build + lint (Turbo)
|
||||||
|
# Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en
|
||||||
|
# key fails the build), AND the vision service's ruff lint via uv.
|
||||||
|
run: pnpm turbo run build lint
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm turbo run test
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
name: Release desktop
|
||||||
|
|
||||||
|
# Build the signed Tauri desktop installers on a version tag and publish them as
|
||||||
|
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
|
||||||
|
# fetches these; latest.json + each installer + its .sig are what it needs.
|
||||||
|
#
|
||||||
|
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
|
||||||
|
# with the updater key (Gitea secrets), assembles latest.json, and uploads
|
||||||
|
# everything to the Release for that tag.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bundle:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Enable pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
|
||||||
|
- name: Install Tauri system deps
|
||||||
|
# ubuntu-latest runner has no GUI/webkit libs by default. These are the
|
||||||
|
# exact deps a Tauri v2 Linux build needs (verified locally): WebKitGTK
|
||||||
|
# 4.1 + libsoup-3 + the GTK/appindicator/rsvg stack + AppImage tooling.
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
file \
|
||||||
|
build-essential \
|
||||||
|
curl \
|
||||||
|
wget
|
||||||
|
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache cargo + target
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
apps/desktop/src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build + sign desktop bundle
|
||||||
|
env:
|
||||||
|
# Updater signing key (Gitea repo/org secrets). Without these the
|
||||||
|
# bundle is unsigned and the updater would reject it.
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
run: pnpm --filter @parking/desktop bundle
|
||||||
|
|
||||||
|
- name: Collect artifacts
|
||||||
|
id: collect
|
||||||
|
# Gather the installers + their .sig into a flat dist/ for upload.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||||
|
mkdir -p dist
|
||||||
|
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
||||||
|
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
||||||
|
-exec cp {} dist/ \;
|
||||||
|
echo "Artifacts:"; ls -la dist/
|
||||||
|
|
||||||
|
- name: Assemble latest.json
|
||||||
|
# The Tauri updater fetches a manifest describing the newest version, its
|
||||||
|
# notes, and per-target {signature, url}. We point the AppImage target at
|
||||||
|
# this release's asset URL. Adjust the platform keys you actually ship.
|
||||||
|
env:
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
||||||
|
SIG=$(cat "dist/${APPIMAGE}.sig")
|
||||||
|
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
|
||||||
|
cat > dist/latest.json <<JSON
|
||||||
|
{
|
||||||
|
"version": "${VERSION}",
|
||||||
|
"notes": "Parking System ${TAG}",
|
||||||
|
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||||
|
"platforms": {
|
||||||
|
"linux-x86_64": {
|
||||||
|
"signature": "${SIG}",
|
||||||
|
"url": "${ASSET_URL}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
echo "latest.json:"; cat dist/latest.json
|
||||||
|
|
||||||
|
- name: Create release + upload assets (Gitea API)
|
||||||
|
# Uses the built-in token; no marketplace release action required. Creates
|
||||||
|
# the release for this tag (idempotent-ish: ignores "already exists") and
|
||||||
|
# uploads every file in dist/ as an asset.
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.api_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# Create the release (capture id; tolerate an existing one).
|
||||||
|
REL=$(curl -sS -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
|
"${API}/repos/${REPO}/releases" || true)
|
||||||
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
# Release may already exist for this tag — look it up by tag.
|
||||||
|
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
|
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
fi
|
||||||
|
echo "release id: ${REL_ID}"
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "uploading ${name}"
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${f}" \
|
||||||
|
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||||
|
done
|
||||||
|
echo "done"
|
||||||
@@ -11,6 +11,8 @@ dist/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
# Committed (non-secret): the desktop/prod build's backend origin — see apps/web/.env.production
|
||||||
|
!.env.production
|
||||||
|
|
||||||
# Editor/OS
|
# Editor/OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Booth reverse proxy. `:80` matches ANY hostname/IP, so the booth is reachable as
|
||||||
|
# http://<booth-ip>/, http://localhost/, or http://parksystems.msai.al/ (the name pointed
|
||||||
|
# at the booth's IP via hosts/DNS on-site) — with no domain baked into any image. The SPA
|
||||||
|
# uses a relative /api base, so everything (HTTP + the /api/ws WebSocket, which Caddy
|
||||||
|
# upgrades automatically) just flows through to the server container.
|
||||||
|
#
|
||||||
|
# TLS later: replace `:80` with the real hostname (e.g. `parksystems.msai.al`), uncomment
|
||||||
|
# Caddy's :443 in docker-compose.prod.yml, and Caddy auto-provisions HTTPS. For a private
|
||||||
|
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
||||||
|
:80 {
|
||||||
|
encode gzip
|
||||||
|
reverse_proxy server:3000
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Desktop (Tauri) build — the @parking/web SPA needs to know where Fastify is.
|
||||||
|
#
|
||||||
|
# In a BROWSER (dev via the Vite proxy, or prod where Fastify serves the SPA),
|
||||||
|
# leave VITE_API_BASE UNSET — requests stay relative/same-origin.
|
||||||
|
#
|
||||||
|
# For the DESKTOP build, the bundled SPA loads from tauri://localhost and has no
|
||||||
|
# proxy, so point it at the appliance's Fastify origin. This is read at WEB build
|
||||||
|
# time, so export it before `pnpm --filter @parking/desktop build` (or put it in
|
||||||
|
# apps/web/.env.production).
|
||||||
|
VITE_API_BASE=http://127.0.0.1:3000
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Rust / Tauri build artifacts
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# @parking/desktop — Tauri v2 kiosk shell
|
||||||
|
|
||||||
|
A **thin native desktop window** over the `@parking/web` SPA. It contains **no UI and no business
|
||||||
|
logic** of its own: the window renders the *same* web app the browser does, so the desktop and the
|
||||||
|
browser stay identical and never drift. Device/auth/ledger logic stays in `@parking/server`. See
|
||||||
|
`wiki/decisions/desktop-shell-tauri.md`.
|
||||||
|
|
||||||
|
## How the "same look & functionality" guarantee works
|
||||||
|
|
||||||
|
| | Source of the UI |
|
||||||
|
| --- | --- |
|
||||||
|
| **Dev** (`tauri dev`) | the window loads `http://localhost:5173` — the **`@parking/web` Vite dev server**. Edit a component in `apps/web` → HMR updates the desktop window live. |
|
||||||
|
| **Prod** (`tauri build`) | the window bundles `apps/web`'s built `dist/`. `beforeBuildCommand` rebuilds the SPA first. |
|
||||||
|
|
||||||
|
There is only one UI codebase (`apps/web`); this package just wraps it.
|
||||||
|
|
||||||
|
## Backend connection
|
||||||
|
|
||||||
|
The SPA talks to Fastify over HTTP/WS. In a browser that's same-origin (relative `/api`). In the
|
||||||
|
desktop build the bundled assets load from `tauri://localhost`, so set **`VITE_API_BASE`** (read at
|
||||||
|
web build time — see `.env.example`) to the appliance's Fastify origin, e.g.
|
||||||
|
`http://127.0.0.1:3000`. The CSP `connect-src` in `tauri.conf.json` is already allowed for that
|
||||||
|
origin, and the backend must include the Tauri origin in `WS_ALLOWED_ORIGINS` for the live feed.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @parking/desktop dev # native window over the web dev server (HMR)
|
||||||
|
pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app (.deb/.rpm/.AppImage)
|
||||||
|
```
|
||||||
|
|
||||||
|
> `build` is a **no-op** in this package so `turbo run build` stays fast — the real desktop bundle
|
||||||
|
> (compiles Rust, minutes long) is the explicit `bundle` script above.
|
||||||
|
|
||||||
|
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
|
||||||
|
window needs a display (WSLg or an X server).
|
||||||
|
|
||||||
|
## Not here (deliberately)
|
||||||
|
|
||||||
|
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
|
||||||
|
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
|
||||||
|
this shell connects to it.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@parking/desktop",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"//": "Tauri v2 desktop shell — a THIN native window over the @parking/web SPA. No business logic lives here (device/auth/ledger stay in @parking/server); see wiki/decisions/desktop-shell-tauri.md. Dev loads the web dev server (HMR); build bundles the web app's dist/, so the desktop UI and the browser UI are the SAME codebase and never drift.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tauri dev",
|
||||||
|
"build": "echo 'no-op in the Turbo graph — the real desktop bundle is a deliberate `pnpm --filter @parking/desktop bundle` (compiles Rust + packages installers, minutes long)'",
|
||||||
|
"bundle": "tauri build",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"lint": "echo 'no JS lint (Tauri shell; Rust checked via cargo)'"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.9.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.10.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "parking-desktop"
|
||||||
|
version = "0.0.0"
|
||||||
|
description = "Parking System — desktop kiosk shell"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77"
|
||||||
|
|
||||||
|
# Thin Tauri v2 shell. Deliberately holds NO business logic — it loads the
|
||||||
|
# @parking/web SPA and lets it talk to the local Fastify server. Device/auth/
|
||||||
|
# ledger stay server-side. See wiki/decisions/desktop-shell-tauri.md.
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "parking_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde_json = "1"
|
||||||
|
# Auto-update: prompt the operator, download a signed update, relaunch.
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Minimal capability set for the kiosk shell. The window only needs to render the SPA; it is granted NOTHING that touches the filesystem, shell, or devices — those stay server-side. Add a named permission here only when a concrete need arises (deny-by-default). See wiki/decisions/desktop-shell-tauri.md.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"updater:default",
|
||||||
|
"process:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 953 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 552 B |
|
After Width: | Height: | Size: 745 B |
|
After Width: | Height: | Size: 891 B |
|
After Width: | Height: | Size: 1016 B |
|
After Width: | Height: | Size: 997 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 562 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 643 B |
|
After Width: | Height: | Size: 748 B |
|
After Width: | Height: | Size: 838 B |
|
After Width: | Height: | Size: 706 B |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
// Parking System desktop shell — entry point.
|
||||||
|
//
|
||||||
|
// Intentionally minimal: build the default Tauri app and run it. The window
|
||||||
|
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
|
||||||
|
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
|
||||||
|
// the backend over HTTP to the local Fastify server, NOT through Tauri IPC. This
|
||||||
|
// keeps the shell a thin presentation wrapper with a deny-by-default native
|
||||||
|
// surface (see wiki/decisions/desktop-shell-tauri.md).
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
// Auto-update: the JS side (apps/web) checks on launch, prompts the
|
||||||
|
// operator, and installs + relaunches on confirm. These plugins expose
|
||||||
|
// the update check/install and the relaunch to that flow. The updater
|
||||||
|
// endpoint + signing pubkey live in tauri.conf.json.
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running the Parking System desktop shell");
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents an extra console window on Windows in release.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
parking_desktop_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Parking System",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"identifier": "com.parking.desktop",
|
||||||
|
"build": {
|
||||||
|
"devUrl": "http://localhost:5173",
|
||||||
|
"frontendDist": "../../web/dist",
|
||||||
|
"beforeDevCommand": "pnpm --filter @parking/web dev",
|
||||||
|
"beforeBuildCommand": "pnpm --filter @parking/web build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "Parking System",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800,
|
||||||
|
"minWidth": 1024,
|
||||||
|
"minHeight": 640,
|
||||||
|
"resizable": true,
|
||||||
|
"maximized": true,
|
||||||
|
"fullscreen": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
||||||
|
"endpoints": [
|
||||||
|
"https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
|
||||||
|
],
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"extends": ["//"],
|
||||||
|
"//": "Tauri shell as a first-class Turbo node. build outputs [] so `turbo run build` doesn't try to cache/compile the Rust bundle on every pass (a real desktop bundle is a deliberate `pnpm --filter @parking/desktop build`).",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"outputs": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,18 @@ EVENT_SIGNING_KEY=
|
|||||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||||
# LOG_LEVEL=info
|
# LOG_LEVEL=info
|
||||||
# DATABASE_URL=./parking.sqlite
|
# DATABASE_URL=./parking.sqlite
|
||||||
# NODE_ENV=production # set in prod: makes auth cookies Secure (HTTPS-only)
|
#
|
||||||
|
# Auth-cookie Secure flag. FAIL-SAFE: cookies are Secure (HTTPS-only) BY DEFAULT —
|
||||||
|
# you only ever opt OUT, never in. Set COOKIE_SECURE=0 for a plain-HTTP deployment
|
||||||
|
# (e.g. the LAN appliance serving the SPA same-origin over http, where a Secure
|
||||||
|
# cookie would never be sent and would lock operators out). Local dev over
|
||||||
|
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
|
||||||
|
# COOKIE_SECURE=0
|
||||||
|
|
||||||
|
# Recycle bin retention: a soft-deleted user/role/subscription/plan/tariff is auto-purged
|
||||||
|
# this many days after deletion (a 6-hourly sweep). Default 30. Set 0 to keep deleted
|
||||||
|
# items forever (manual purge only). See wiki/concepts/soft-delete.md.
|
||||||
|
# RECYCLE_BIN_RETENTION_DAYS=30
|
||||||
|
|
||||||
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
||||||
# ADMIN_USER=admin
|
# ADMIN_USER=admin
|
||||||
@@ -28,7 +39,13 @@ EVENT_SIGNING_KEY=
|
|||||||
|
|
||||||
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
|
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
|
||||||
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
|
# In dev, set the Vite SPA origin. Same-origin is always allowed without this.
|
||||||
WS_ALLOWED_ORIGINS=http://localhost:5173
|
# The Tauri DESKTOP shell loads from tauri://localhost (Linux may also send
|
||||||
|
# http://tauri.localhost), which is NOT same-origin with the backend — add both
|
||||||
|
# so the desktop app's live feed connects. See apps/desktop.
|
||||||
|
# To open the dev SPA from another LAN device (phone over wifi), Vite must bind
|
||||||
|
# 0.0.0.0 (vite.config.ts) AND the host's LAN origin must be listed here, e.g.
|
||||||
|
# http://10.0.10.203:5173 — the WS handshake's Origin is that LAN address.
|
||||||
|
WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhost
|
||||||
|
|
||||||
# Vision / ANPR (optional) -------------------------------------------------
|
# Vision / ANPR (optional) -------------------------------------------------
|
||||||
# OFF by default. The Node SERVER's view of the vision microservice (apps/vision),
|
# OFF by default. The Node SERVER's view of the vision microservice (apps/vision),
|
||||||
@@ -39,4 +56,10 @@ WS_ALLOWED_ORIGINS=http://localhost:5173
|
|||||||
# 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"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Container entrypoint for the parking server. Applies DB migrations against the mounted
|
||||||
|
# volume (DATABASE_URL), optionally seeds the first admin, then execs the server. Idempotent:
|
||||||
|
# the runtime migrator (drizzle-orm migrator, no drizzle-kit) only applies pending migrations,
|
||||||
|
# so a restart is a no-op. See packages/db/scripts/migrate-runtime.mjs.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[entrypoint] DATABASE_URL=${DATABASE_URL}"
|
||||||
|
|
||||||
|
# Apply migrations against the mounted DB file (creates it + the schema on first boot).
|
||||||
|
# The migrator ships inside the @parking/db package in the deploy bundle's node_modules.
|
||||||
|
node node_modules/@parking/db/scripts/migrate-runtime.mjs
|
||||||
|
|
||||||
|
# Optional first-boot admin seed: set SEED_ADMIN=1 plus ADMIN_USER + ADMIN_PASS (the seed
|
||||||
|
# script PROMPTS when these are unset, which would hang a container — so require ADMIN_PASS).
|
||||||
|
# The seed is idempotent: it won't overwrite an existing user unless FORCE=1.
|
||||||
|
if [ "${SEED_ADMIN}" = "1" ]; then
|
||||||
|
if [ -z "${ADMIN_PASS}" ]; then
|
||||||
|
echo "[entrypoint] SEED_ADMIN=1 but ADMIN_PASS is unset — skipping seed (would hang on prompt)"
|
||||||
|
else
|
||||||
|
echo "[entrypoint] seeding admin (${ADMIN_USER:-admin})"
|
||||||
|
node scripts/seed-admin.mjs || echo "[entrypoint] seed-admin skipped/failed (non-fatal)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] starting server"
|
||||||
|
exec "$@"
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"start": "node --env-file-if-exists=.env dist/index.js",
|
"start": "node --env-file-if-exists=.env dist/index.js",
|
||||||
"seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs",
|
"seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@types/bcrypt": "6.0.0",
|
"@types/bcrypt": "6.0.0",
|
||||||
"@types/node": "25.9.3",
|
"@types/node": "25.9.3",
|
||||||
"tsx": "4.22.4",
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
import type { VisionClient, VisionResult } from "./vision-client.js";
|
||||||
|
import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js";
|
||||||
|
|
||||||
|
// The ANPR bridge: a camera vehicle detection → (opt-in) snapshot → plate → MATCH a
|
||||||
|
// subscriber → emit a plate read. We mock the camera build (buildCamera) so no real
|
||||||
|
// snapshot HTTP is made, and pass fake Vision/Subscription so the test is the bridge's
|
||||||
|
// own logic only. See anpr-entry.ts.
|
||||||
|
|
||||||
|
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
|
||||||
|
// (no registry, no network). The factory returns a fresh shot each call.
|
||||||
|
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
||||||
|
vi.mock("./snapshot.js", () => ({
|
||||||
|
buildCamera: () => ({ captureSnapshot }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import AFTER the mock is registered.
|
||||||
|
const { AnprBridge } = await import("./anpr-entry.js");
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
captureSnapshot.mockClear();
|
||||||
|
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
||||||
|
delete process.env.ANPR_DEBOUNCE_MS;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
|
||||||
|
function seedCamera(opts: { anpr?: boolean } = {}): string {
|
||||||
|
const controllerId = randomUUID();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: controllerId,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: "10.0.0.5", relays: [{ relay: 1, direction: "entry" }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const camId = randomUUID();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: camId,
|
||||||
|
category: "camera",
|
||||||
|
driverId: "hikvision",
|
||||||
|
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
return camId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fake VisionClient: enabled, returning a chosen plate/confidence (or null). */
|
||||||
|
function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: number } = {}): VisionClient {
|
||||||
|
const enabled = opts.enabled ?? true;
|
||||||
|
const result: VisionResult | null =
|
||||||
|
opts.plate == null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
analyze: vi.fn(async () => (enabled ? result : null)),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
||||||
|
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
|
||||||
|
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
||||||
|
|
||||||
|
/** Capture read events emitted during `fn` (async). */
|
||||||
|
async function captureReads(fn: () => Promise<void>): Promise<DeviceReadEvent[]> {
|
||||||
|
const got: DeviceReadEvent[] = [];
|
||||||
|
const off = deviceEvents.onRead((e) => got.push(e));
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
return got;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AnprBridge", () => {
|
||||||
|
it("does nothing for an opt-OUT camera (no anpr flag) — no analyze, no read", async () => {
|
||||||
|
const cam = seedCamera({ anpr: false });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB" });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
expect(vision.analyze).not.toHaveBeenCalled();
|
||||||
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ deviceId: cam, value: "AA111BB", kind: "plate", driverId: "hikvision" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a plate below the entry confidence floor", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.6 }); // < default 0.85
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
|
||||||
|
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
|
||||||
|
expect(skips).toHaveLength(1);
|
||||||
|
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
await bridge.onVehicleDetected(cam);
|
||||||
|
await bridge.onVehicleDetected(cam); // within the 12s window → suppressed
|
||||||
|
});
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(captureSnapshot).toHaveBeenCalledTimes(1); // 2nd was gated before the snapshot
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op (no throw) when vision is disabled or reads nothing", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const disabled = new AnprBridge(db, fakeVision({ enabled: false, plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
const noPlate = new AnprBridge(db, fakeVision({ plate: undefined }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
await disabled.onVehicleDetected(cam);
|
||||||
|
await noPlate.onVehicleDetected(cam);
|
||||||
|
});
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on an unknown device id", async () => {
|
||||||
|
const bridge = new AnprBridge(db, fakeVision({ plate: "AA111BB" }), fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
await expect(bridge.onVehicleDetected("nope")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOTHING when the admin has disabled the bridge (site_config.anprEntryEnabled = false)", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: false }).run();
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
// The flag is checked FIRST — no snapshot, no analyze, no match attempt.
|
||||||
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(vision.analyze).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still emits when the bridge is explicitly enabled (anprEntryEnabled = true)", async () => {
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
db.insert(siteConfig).values({ id: 1, anprEntryEnabled: true }).run();
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, deviceEvents as deviceEventsTable, eq, siteConfig, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { deviceEvents, type DeviceReadEvent } from "./device-events.js";
|
||||||
|
import { directionOf, type FlowDirection } from "./device-resolve.js";
|
||||||
|
import { buildCamera } from "./snapshot.js";
|
||||||
|
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
|
// The ANPR "bridge": a subscriber's plate, read from the lane camera, admits them through
|
||||||
|
// the SAME gated SubscriptionFlow a QR/card scan uses. It is the one missing wire between
|
||||||
|
// the camera's vehicle PUSH (hikvision-alarm.ts) and the read bus — NOT a new service.
|
||||||
|
//
|
||||||
|
// On a `vehicle`/`active` event from an OPT-IN camera (config.anpr === true), the bridge:
|
||||||
|
// pull a fresh snapshot → vision.analyze → entry confidence floor → debounce → MATCH the
|
||||||
|
// plate to a subscription → emit a DeviceReadEvent{kind:"plate"} ONLY if it matched.
|
||||||
|
// The existing onRead → ReadDispatcher then re-matches and runs the gated SubscriptionFlow
|
||||||
|
// (active / window / blocklist / car-count), which signs the entry/exit and opens the relay.
|
||||||
|
//
|
||||||
|
// INVARIANTS (see wiki/concepts/lane-presence-and-anpr-entry.md §2, append-only-event-chain.md):
|
||||||
|
// - Advisory, never sole authority: the bridge only emitRead()s — the signed decision +
|
||||||
|
// barrier open stay inside the existing flow. A spoofed printed plate is just another
|
||||||
|
// credential through the same gate.
|
||||||
|
// - Subscriber-ONLY: it MATCHES before emitting, so a random plate never reaches the
|
||||||
|
// transient plate-as-ticket exit flow.
|
||||||
|
// - Fail-soft + fire-and-forget: any snapshot/vision error degrades to the card/QR path;
|
||||||
|
// never throws into the push handler, never awaited on the camera's 200 response.
|
||||||
|
// - Opt-in per camera, and debounced (the camera re-fires ~1Hz while a car sits).
|
||||||
|
|
||||||
|
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
||||||
|
interface CameraConfig {
|
||||||
|
readonly anpr?: boolean;
|
||||||
|
readonly [k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stricter-than-advisory confidence floor for a BARRIER-driving plate read. A near-miss
|
||||||
|
* read falls back to the subscriber's card/QR, so we'd rather skip than wrongly admit.
|
||||||
|
* Distinct from vision-client's advisory VISION_MIN_CONFIDENCE. */
|
||||||
|
function entryMinConfidence(): number {
|
||||||
|
const raw = Number(process.env.VISION_ENTRY_MIN_CONFIDENCE ?? 0.85);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same plate/camera within this window = ONE credential presentation. The camera re-fires
|
||||||
|
* ~1Hz while a car is present; emitting every second would drive repeat entries (a fleet
|
||||||
|
* sub opens a 2nd occurrence) or exit spam. Required for correctness, not CPU. */
|
||||||
|
function debounceMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_DEBOUNCE_MS ?? 12_000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnprBridge {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #vision: VisionClient | null;
|
||||||
|
readonly #subscription: SubscriptionFlow;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #entryMinConfidence: number;
|
||||||
|
readonly #debounceMs: number;
|
||||||
|
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
||||||
|
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
||||||
|
readonly #lastFire = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#vision = vision;
|
||||||
|
this.#subscription = subscription;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#entryMinConfidence = entryMinConfidence();
|
||||||
|
this.#debounceMs = debounceMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A camera reported a vehicle. If the camera opts into ANPR, pull a snapshot, read the
|
||||||
|
* plate, and — only if it matches a subscription — emit a plate read onto the bus.
|
||||||
|
* Fire-and-forget; fail-soft. Never throws (the push handler must always 200).
|
||||||
|
*/
|
||||||
|
async onVehicleDetected(deviceId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!this.#vision?.enabled) return; // no recognizer configured
|
||||||
|
// Admin master switch (read LIVE so toggling in Site Settings takes effect with no
|
||||||
|
// restart). Gates ONLY this barrier-driving bridge — advisory snapshot-ANPR and lane
|
||||||
|
// busy/free are unaffected. Absent/unreadable config ⇒ enabled (the default).
|
||||||
|
const site = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (site && site.anprEntryEnabled === false) return;
|
||||||
|
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
|
if (!row || !row.enabled || row.category !== "camera") return;
|
||||||
|
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only
|
||||||
|
|
||||||
|
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
|
||||||
|
// snapshot + analyze every second.
|
||||||
|
if (this.#debounced(deviceId)) return;
|
||||||
|
this.#stamp(deviceId);
|
||||||
|
|
||||||
|
const camera = buildCamera(row);
|
||||||
|
if (!camera) {
|
||||||
|
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
||||||
|
// the gated flow infers the verb from the camera's bound relay direction).
|
||||||
|
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
||||||
|
const shot = await camera.captureSnapshot({ direction });
|
||||||
|
const result = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||||
|
if (!result || !result.plate) return; // nothing read
|
||||||
|
|
||||||
|
// Entry floor — stricter than the advisory floor (analyze() still returns the plate
|
||||||
|
// object with its confidence even when its own lowConfidence flag is set).
|
||||||
|
if (result.plate.confidence < this.#entryMinConfidence) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
|
||||||
|
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plate = result.plate.text.trim().toUpperCase();
|
||||||
|
if (!plate) return;
|
||||||
|
|
||||||
|
const e: DeviceReadEvent = {
|
||||||
|
driverId: row.driverId,
|
||||||
|
deviceId,
|
||||||
|
value: plate,
|
||||||
|
kind: "plate",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// MATCH BEFORE EMIT — subscriber-only. A non-subscriber plate records advisory
|
||||||
|
// telemetry and stops; it must NEVER reach the transient plate-as-ticket exit flow.
|
||||||
|
const match = this.#subscription.match(e);
|
||||||
|
if (!match) {
|
||||||
|
this.#recordSkip(deviceId, plate, result.plate.confidence);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
||||||
|
// camera-level gate re-emitting the SAME plate.
|
||||||
|
const plateKey = `${deviceId}:${plate}`;
|
||||||
|
if (this.#debounced(plateKey)) return;
|
||||||
|
this.#stamp(plateKey);
|
||||||
|
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
||||||
|
);
|
||||||
|
deviceEvents.emitRead(e); // → onRead → ReadDispatcher → gated SubscriptionFlow
|
||||||
|
} catch (err) {
|
||||||
|
// Fail-soft: an ANPR failure degrades to the subscriber's card/QR, never strands the lane.
|
||||||
|
this.#logger.warn(`anpr-bridge failed (${deviceId}): ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#debounced(key: string): boolean {
|
||||||
|
const last = this.#lastFire.get(key);
|
||||||
|
return last != null && Date.now() - last < this.#debounceMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stamp(key: string): void {
|
||||||
|
this.#lastFire.set(key, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advisory telemetry: a plate was read at the lane but matched no subscription. Not a
|
||||||
|
* read on the bus — just a breadcrumb so the operator can see ANPR is working. */
|
||||||
|
#recordSkip(deviceId: string, plate: string, confidence: number): void {
|
||||||
|
this.#logger.info(`anpr-bridge: plate '${plate}' matched no subscription — skipped`);
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.insert(deviceEventsTable)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId,
|
||||||
|
category: "camera",
|
||||||
|
kind: "anpr-skip",
|
||||||
|
detail: { plate, confidence, source: "anpr-bridge", reason: "no subscription match" },
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`anpr-bridge skip-record insert failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceRow is re-exported for the test's seed typing convenience.
|
||||||
|
export type { DeviceRow };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { secureCookies } from "./auth.js";
|
||||||
|
|
||||||
|
// The auth/CSRF cookies' Secure flag must be FAIL-SAFE: Secure by default, dropped only
|
||||||
|
// on a deliberate opt-out. The old behaviour (Secure iff NODE_ENV==="production") leaked
|
||||||
|
// cookies over plain HTTP on an appliance that forgot to set NODE_ENV — this pins the
|
||||||
|
// corrected matrix.
|
||||||
|
|
||||||
|
let savedCookieSecure: string | undefined;
|
||||||
|
let savedNodeEnv: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
savedCookieSecure = process.env.COOKIE_SECURE;
|
||||||
|
savedNodeEnv = process.env.NODE_ENV;
|
||||||
|
delete process.env.COOKIE_SECURE;
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
restore("COOKIE_SECURE", savedCookieSecure);
|
||||||
|
restore("NODE_ENV", savedNodeEnv);
|
||||||
|
});
|
||||||
|
function restore(key: string, val: string | undefined) {
|
||||||
|
if (val === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("secureCookies — fail-safe Secure flag", () => {
|
||||||
|
it("defaults to Secure when nothing is set (the appliance-forgot-NODE_ENV case)", () => {
|
||||||
|
expect(secureCookies()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays Secure in production", () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
expect(secureCookies()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops Secure only for an explicit local-dev NODE_ENV", () => {
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
expect(secureCookies()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("COOKIE_SECURE override wins: falsey values opt OUT", () => {
|
||||||
|
for (const v of ["0", "false", "no", "off", "FALSE", " Off "]) {
|
||||||
|
process.env.COOKIE_SECURE = v;
|
||||||
|
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("COOKIE_SECURE override wins: any other value opts IN (even in dev)", () => {
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
for (const v of ["1", "true", "yes", "on", ""]) {
|
||||||
|
process.env.COOKIE_SECURE = v;
|
||||||
|
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,9 +50,28 @@ export function requireJwtSecret(): string {
|
|||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cookies are secure in production; relaxed for local http dev. */
|
/**
|
||||||
function secureCookies(): boolean {
|
* Whether to set the `Secure` flag on the auth/CSRF cookies. FAIL-SAFE: default is
|
||||||
return process.env.NODE_ENV === "production";
|
* `true` (Secure) — a misconfigured/forgotten env can only ever make cookies MORE
|
||||||
|
* restrictive, never silently drop the flag.
|
||||||
|
*
|
||||||
|
* The previous gate keyed off `NODE_ENV === "production"`, which meant an appliance
|
||||||
|
* deployed without that var leaked cookies over plain HTTP. Now `Secure` is the
|
||||||
|
* default and is dropped ONLY for an explicit, deliberate opt-out — `COOKIE_SECURE`
|
||||||
|
* set to a falsey value (`0/false/no/off`), or the legacy `NODE_ENV !== production`
|
||||||
|
* signal kept as a fallback so existing dev setups still work over http://localhost.
|
||||||
|
*
|
||||||
|
* The parking appliance often serves the SPA same-origin over the LAN with no TLS;
|
||||||
|
* THAT box sets `COOKIE_SECURE=0` on purpose (a Secure cookie would never be sent
|
||||||
|
* over its http origin and would lock operators out). Everything else stays secure.
|
||||||
|
*/
|
||||||
|
export function secureCookies(): boolean {
|
||||||
|
const override = process.env.COOKIE_SECURE;
|
||||||
|
if (override !== undefined) {
|
||||||
|
return !/^(0|false|no|off)$/i.test(override.trim());
|
||||||
|
}
|
||||||
|
// No explicit override: secure unless this is an obvious local-dev run.
|
||||||
|
return process.env.NODE_ENV !== "development";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newCsrfToken(): string {
|
export function newCsrfToken(): string {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
formatStampSq,
|
|
||||||
printWithFailover,
|
printWithFailover,
|
||||||
registry,
|
registry,
|
||||||
type PrinterDevice,
|
type PrinterDevice,
|
||||||
@@ -166,26 +165,19 @@ export async function printSubscriptionCard(
|
|||||||
*/
|
*/
|
||||||
export async function printWindowChargeNotice(
|
export async function printWindowChargeNotice(
|
||||||
db: Db,
|
db: Db,
|
||||||
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number; edge: "entry" | "exit" },
|
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number | null; edge: "entry" | "exit" },
|
||||||
logger: FastifyBaseLogger,
|
logger: FastifyBaseLogger,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const printers = loadPrinters(db);
|
const printers = loadPrinters(db);
|
||||||
const hhmm = (m?: number) =>
|
|
||||||
m == null ? "" : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
|
||||||
const lines = [
|
|
||||||
`Abonent: ${notice.holderName || "-"}`,
|
|
||||||
`${notice.edge === "entry" ? "Hyrje" : "Dalje"}: ${formatStampSq(notice.at)}`,
|
|
||||||
notice.edge === "entry"
|
|
||||||
? `Ka hyrë jashtë orarit${notice.windowOpensMin != null ? ` (orari hap ${hhmm(notice.windowOpensMin)})` : ""}`
|
|
||||||
: "Ka dalë jashtë orarit",
|
|
||||||
"",
|
|
||||||
"⚠ Detyrim do të llogaritet në dalje",
|
|
||||||
" (paguhet në kabinë para se të dilni)",
|
|
||||||
"",
|
|
||||||
`Nr: ${notice.occurrenceId}`,
|
|
||||||
];
|
|
||||||
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
||||||
d.printReport({ title: "PARKIM — JASHTË ORARIT", lines }),
|
d.printWindowChargeNotice({
|
||||||
|
occurrenceId: notice.occurrenceId,
|
||||||
|
holderName: notice.holderName ?? null,
|
||||||
|
at: notice.at,
|
||||||
|
edge: notice.edge,
|
||||||
|
windowOpensMin: notice.windowOpensMin ?? null,
|
||||||
|
header: ticketHeader(db),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
|
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
|
||||||
return printedBy;
|
return printedBy;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateTicketCode } from "./entry-flow.js";
|
||||||
|
|
||||||
|
// validateTicketCode is the manual-entry typo guard: an all-digit code whose last digit
|
||||||
|
// is the Luhn check of the rest. The booth uses it to reject a mistyped ticket up front
|
||||||
|
// (instead of a confusing "session not found"). The capacity-gate / print-hold / sign-
|
||||||
|
// before-open paths of EntryFlow need device fakes and are exercised in the device +
|
||||||
|
// route phases; here we pin the pure, exported checksum contract.
|
||||||
|
|
||||||
|
describe("validateTicketCode (Luhn)", () => {
|
||||||
|
it("accepts a well-formed 11-digit id", () => {
|
||||||
|
// 10-digit body + its Luhn check digit. 0000000000 → check digit 0.
|
||||||
|
expect(validateTicketCode("00000000000")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a single-digit typo", () => {
|
||||||
|
expect(validateTicketCode("00000000000")).toBe(true);
|
||||||
|
expect(validateTicketCode("00000000010")).toBe(false); // flipped a digit, checksum now wrong
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-digit and out-of-length strings", () => {
|
||||||
|
expect(validateTicketCode("abc")).toBe(false);
|
||||||
|
expect(validateTicketCode("123")).toBe(false); // too short
|
||||||
|
expect(validateTicketCode("123456789012345")).toBe(false); // too long
|
||||||
|
expect(validateTicketCode("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a generated body+check (Luhn is self-consistent)", () => {
|
||||||
|
// Construct a valid code: pick a body, compute its check the same way the issuer does.
|
||||||
|
const body = "4992739871";
|
||||||
|
// brute the check digit 0..9 — exactly one makes a valid code.
|
||||||
|
const valid = Array.from({ length: 10 }, (_, d) => body + d).filter(validateTicketCode);
|
||||||
|
expect(valid).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a legacy 13-digit id shape", () => {
|
||||||
|
// 12-digit body 000000000000 → check 0; the validator is length-agnostic in 10..14.
|
||||||
|
expect(validateTicketCode("0000000000000")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||||
|
import { EventLog, canonicalize, hashEvent } from "./event-log.js";
|
||||||
|
import { SoftwareSigner, buildVerifier } from "./signer.js";
|
||||||
|
|
||||||
|
// The append-only, hash-chained, signed event log is THE anti-fraud primitive
|
||||||
|
// (threat model: the operator at the booth). These tests pin every integrity rule:
|
||||||
|
// monotonic index, prevHash linkage, payload-in-signature, and that verifyChain()
|
||||||
|
// catches each class of tamper (content edit, reorder, deletion gap, forged sig,
|
||||||
|
// missing key). No live DB is touched — a fresh in-memory SQLite per test.
|
||||||
|
|
||||||
|
const SECRET = "test-event-signing-key-0123456789";
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = new EventLog(db, new SoftwareSigner(SECRET), buildVerifier);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
describe("EventLog.append — chain construction", () => {
|
||||||
|
it("assigns a monotonic index starting at 1", async () => {
|
||||||
|
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
|
||||||
|
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
|
||||||
|
expect(a.index).toBe(1);
|
||||||
|
expect(b.index).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("genesis event has a null prevHash; the next chains to it", async () => {
|
||||||
|
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
|
||||||
|
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
|
||||||
|
expect(a.prevHash).toBeNull();
|
||||||
|
expect(b.prevHash).toBe(hashEvent(canonicalize(a)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs each row under the active keyId", async () => {
|
||||||
|
const row = await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 100 } });
|
||||||
|
expect(row.keyId).toBe("sw-hmac-v2");
|
||||||
|
expect(new SoftwareSigner(SECRET).verify(canonicalize(row), row.signature)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent appends without index collisions", async () => {
|
||||||
|
const rows = await Promise.all(
|
||||||
|
Array.from({ length: 25 }, (_, i) => log.append({ type: "vehicle_entry", identity: `T${i}` })),
|
||||||
|
);
|
||||||
|
const indices = rows.map((r) => r.index).sort((a, b) => a - b);
|
||||||
|
expect(indices).toEqual(Array.from({ length: 25 }, (_, i) => i + 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EventLog.verifyChain — integrity", () => {
|
||||||
|
async function seed() {
|
||||||
|
await log.append({ type: "vehicle_entry", identity: "T1", direction: "entry" });
|
||||||
|
await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 200, tariffVersionId: "tv1" } });
|
||||||
|
await log.append({ type: "vehicle_exit", identity: "T1", direction: "exit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts an untampered chain", async () => {
|
||||||
|
await seed();
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an empty chain", () => {
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a tampered payload (the money amount)", async () => {
|
||||||
|
await seed();
|
||||||
|
// Rewrite the payment amount directly in the DB — exactly the booth-operator
|
||||||
|
// fraud the signed payload defends against.
|
||||||
|
db.update(ledgerEvents).set({ payload: { amountMinor: 1, tariffVersionId: "tv1" } }).where(eq(ledgerEvents.index, 2)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.index).toBe(2);
|
||||||
|
expect(r.reason).toMatch(/signature invalid/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a deleted row as an index gap", async () => {
|
||||||
|
await seed();
|
||||||
|
db.delete(ledgerEvents).where(eq(ledgerEvents.index, 2)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/index gap/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a broken prevHash link (reordering / re-chaining)", async () => {
|
||||||
|
await seed();
|
||||||
|
db.update(ledgerEvents).set({ prevHash: "0".repeat(64) }).where(eq(ledgerEvents.index, 3)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.index).toBe(3);
|
||||||
|
expect(r.reason).toMatch(/prevHash/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects an event signed under a key that is no longer configured", async () => {
|
||||||
|
await seed();
|
||||||
|
// Re-sign row 2 under an unknown keyId — buildVerifier can't resolve it.
|
||||||
|
db.update(ledgerEvents).set({ keyId: "atecc608-slot9" }).where(eq(ledgerEvents.index, 2)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/no signer for keyId/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canonicalize — byte-stability", () => {
|
||||||
|
it("is independent of payload key order (sorted recursively)", () => {
|
||||||
|
const base = { index: 1, type: "payment", direction: null, source: null, identity: "T1", occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
|
||||||
|
const a = canonicalize({ ...base, payload: { amountMinor: 100, tariffVersionId: "tv1" } });
|
||||||
|
const b = canonicalize({ ...base, payload: { tariffVersionId: "tv1", amountMinor: 100 } });
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when any signed field changes", () => {
|
||||||
|
const base = { index: 1, type: "payment" as const, direction: null, source: null, identity: "T1", payload: { amountMinor: 100 }, occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
|
||||||
|
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, payload: { amountMinor: 101 } }));
|
||||||
|
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, identity: "T2" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||||
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { PayStation } from "./pay-station.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The exit flow is the anti-fraud GATE: no car leaves without a covering payment within
|
||||||
|
// the walk-back grace (the no-unpaid-bypass + no-free-overstay rules), and the booth has
|
||||||
|
// no bypass. With no relay configured a clean exit returns { opened:false } — we assert
|
||||||
|
// the DECISION (refuse vs. sign the exit), not the hardware open.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
let exit: ExitFlow;
|
||||||
|
let pay: PayStation;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = makeLog(db);
|
||||||
|
exit = new ExitFlow(db, log, silentLogger());
|
||||||
|
pay = new PayStation(db, log, silentLogger());
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
|
||||||
|
}
|
||||||
|
function exitsSigned(identity: string) {
|
||||||
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("exitForBooth — refusal gates", () => {
|
||||||
|
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
||||||
|
const r = await exit.exitForBooth("ghost");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "no_session" });
|
||||||
|
const anomalies = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all();
|
||||||
|
expect(anomalies).toHaveLength(1);
|
||||||
|
expect(exitsSigned("ghost")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an UNPAID open session — no exit signed (no-unpaid-bypass)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const r = await exit.exitForBooth("T1");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "unpaid" });
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(0); // the car did NOT leave
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a paid session whose walk-back grace has EXPIRED (no free overstay)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(200));
|
||||||
|
// A payment made 60 min ago → its 15-min walk-back grace lapsed long ago.
|
||||||
|
await log.append({
|
||||||
|
type: "payment", source: "manual", identity: "T1", occurredAt: minutesAgo(60),
|
||||||
|
payload: { sessionRef: "T1", amountMinor: 10000, currency: "ALL", tender: "cash", graceExitMin: 15 },
|
||||||
|
});
|
||||||
|
const r = await exit.exitForBooth("T1");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "grace_expired" });
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exitForBooth — valid exit signs the vehicle_exit", () => {
|
||||||
|
it("a paid session within grace signs an exit (opened:false — no relay in tests)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
await pay.pay("T1", "cash"); // fresh payment → within grace
|
||||||
|
const r = await exit.exitForBooth("T1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.opened).toBe(false); // signed, but no barrier resolves in tests
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(1); // the exit IS on the chain
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// NB: a subscriber's normal exit runs through SubscriptionFlow (the reader/credential
|
||||||
|
// path), not exitForBooth — the booth's transient exit has no subscription bypass and
|
||||||
|
// applies the same paid/grace gate to any identity it's handed. Asserting that here so
|
||||||
|
// the boundary is explicit: handing a bare occurrence to exitForBooth is refused, and a
|
||||||
|
// subscriber leaves via reopenBarrier (assist) or the subscription reader flow instead.
|
||||||
|
it("does NOT give the booth transient-exit path a subscription bypass", async () => {
|
||||||
|
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
|
||||||
|
const r = await exit.exitForBooth("SUBSESS-1");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "unpaid" });
|
||||||
|
expect(exitsSigned("SUBSESS-1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a prepaid subscriber out via the assist (reopenBarrier) path", async () => {
|
||||||
|
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
|
||||||
|
const r = await exit.reopenBarrier("SUBSESS-1", "op1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(exitsSigned("SUBSESS-1")).toHaveLength(1); // assist closes the open occurrence
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reopenBarrier — no unpaid re-open", () => {
|
||||||
|
it("refuses to re-open an unpaid transient session", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const r = await exit.reopenBarrier("T1", "op1");
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-opening a paid OPEN session also closes it (signs the exit)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
await pay.pay("T1", "cash");
|
||||||
|
const r = await exit.reopenBarrier("T1", "op1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
// The open session is closed by the human-intervention exit so it leaves the list.
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||||
|
import { getOccupancy, occupancyCount, reservedSubscriberSpots } from "./occupancy.js";
|
||||||
|
|
||||||
|
// Occupancy is a FOLD over the signed ledger, never a stored counter. These tests
|
||||||
|
// pin: the entries-minus-exits count, the capacity/full gate, and the reserved-
|
||||||
|
// subscriber-spots model (its trickiest invariant — never double-count a parked
|
||||||
|
// subscriber, and never gate the subscriber's own entry).
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
// Insert a ledger row directly (these fns read raw rows; signing is event-log's job).
|
||||||
|
let idx = 0;
|
||||||
|
function entry(identity: string, payload?: Record<string, unknown>) {
|
||||||
|
idx += 1;
|
||||||
|
db.insert(ledgerEvents).values({
|
||||||
|
id: `e${idx}`, index: idx, type: "vehicle_entry", direction: "entry",
|
||||||
|
identity, payload: payload ?? null, occurredAt: new Date().toISOString(),
|
||||||
|
signature: "x", keyId: "test",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
function exit(identity: string) {
|
||||||
|
idx += 1;
|
||||||
|
db.insert(ledgerEvents).values({
|
||||||
|
id: `e${idx}`, index: idx, type: "vehicle_exit", direction: "exit",
|
||||||
|
identity, payload: null, occurredAt: new Date().toISOString(),
|
||||||
|
signature: "x", keyId: "test",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
function voidEvt(identity: string) {
|
||||||
|
idx += 1;
|
||||||
|
db.insert(ledgerEvents).values({
|
||||||
|
id: `e${idx}`, index: idx, type: "void",
|
||||||
|
identity, payload: { sessionRef: identity, voidReason: "misprint" }, occurredAt: new Date().toISOString(),
|
||||||
|
signature: "x", keyId: "test",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
|
||||||
|
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("occupancyCount", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("is 0 with no events", () => {
|
||||||
|
expect(occupancyCount(db)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts open sessions (entries minus matching exits)", () => {
|
||||||
|
entry("A"); entry("B"); entry("C");
|
||||||
|
exit("B");
|
||||||
|
expect(occupancyCount(db)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a re-entry after exit counts again", () => {
|
||||||
|
entry("A"); exit("A"); entry("A");
|
||||||
|
expect(occupancyCount(db)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a voided (cancelled) entry does NOT count inside", () => {
|
||||||
|
entry("A"); entry("B");
|
||||||
|
voidEvt("B"); // B's ticket was a misprint — cancelled
|
||||||
|
expect(occupancyCount(db)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOccupancy — capacity + full gate", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("uncapped: never full, free/effectiveFree null", () => {
|
||||||
|
setSite({ capacity: null });
|
||||||
|
entry("A");
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.full).toBe(false);
|
||||||
|
expect(o.free).toBeNull();
|
||||||
|
expect(o.effectiveFree).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("capped: full when count reaches capacity", () => {
|
||||||
|
setSite({ capacity: 2 });
|
||||||
|
entry("A");
|
||||||
|
expect(getOccupancy(db).full).toBe(false);
|
||||||
|
entry("B");
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.full).toBe(true);
|
||||||
|
expect(o.free).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reservedSubscriberSpots", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
function addSub(id: string, opts: Partial<typeof subscriptions.$inferInsert> = {}) {
|
||||||
|
db.insert(subscriptions).values({ id, status: "active", quantity: 1, period: "month", ...opts }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("is 0 when the toggle is off (default)", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: false });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds quantity spots for an active, not-parked subscription", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT double-count a subscriber already parked (holds only the rest)", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
// One of the family's two cars is inside (occurrence entry carries permitId = sub id).
|
||||||
|
entry("SUBSESS-1", { permitId: "s1" });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(1); // 2 quantity − 1 inside
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores suspended/revoked and out-of-window subscriptions", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("active", { quantity: 1 });
|
||||||
|
addSub("suspended", { quantity: 5, status: "suspended" });
|
||||||
|
addSub("expired", { quantity: 5, validTo: "2000-01-01T00:00:00.000Z" });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOccupancy — reserved tightens the transient gate", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("transient sees full once count + reserved ≥ capacity", () => {
|
||||||
|
setSite({ capacity: 3, reserveSubscriberSpots: true });
|
||||||
|
db.insert(subscriptions).values({ id: "s1", status: "active", quantity: 2, period: "month" }).run();
|
||||||
|
entry("A"); // 1 inside + 2 reserved = 3 ≥ capacity 3
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.reserved).toBe(2);
|
||||||
|
expect(o.effectiveFree).toBe(0);
|
||||||
|
expect(o.full).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||||
|
import { PayStation, NoOpenSessionError, NoTariffError } from "./pay-station.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The pay station prices an open session against the tariff frozen at entry and writes
|
||||||
|
// a SIGNED payment event (never a mutable "paid" flag). These tests pin the quote math,
|
||||||
|
// the signed-payment side effect, the no-session / no-tariff errors, and the lookup
|
||||||
|
// view the booth modal reads.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
let pay: PayStation;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = makeLog(db);
|
||||||
|
pay = new PayStation(db, log, silentLogger());
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PayStation.quote", () => {
|
||||||
|
it("throws NoOpenSessionError for an unknown ticket", () => {
|
||||||
|
seedTariff(db);
|
||||||
|
expect(() => pay.quote("nope")).toThrow(NoOpenSessionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NoTariffError when no site tariff is configured", async () => {
|
||||||
|
await enter("T1", minutesAgo(120));
|
||||||
|
expect(() => pay.quote("T1")).toThrow(NoTariffError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices a stay against the frozen tariff (90min → 2 increments at 100/h = 200)", async () => {
|
||||||
|
// 90 min rounds UP to a 2nd 60-min increment; well clear of the boundary so a few
|
||||||
|
// ms of test runtime can't tip it into a 3rd increment.
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const q = pay.quote("T1");
|
||||||
|
expect(q.amountMinor).toBe(20000);
|
||||||
|
expect(q.currency).toBe("ALL");
|
||||||
|
expect(q.overstay).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices 0 within the entry grace (quick in-and-out)", async () => {
|
||||||
|
seedTariff(db, { gracePeriodEntryMin: 10 });
|
||||||
|
await enter("T1", minutesAgo(5));
|
||||||
|
expect(pay.quote("T1").amountMinor).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayStation.pay — signed payment side effect", () => {
|
||||||
|
it("appends a signed payment event carrying amount, currency, tender, grace", async () => {
|
||||||
|
const { currency } = seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
|
||||||
|
const res = await pay.pay("T1", "cash");
|
||||||
|
expect(res.amountMinor).toBe(20000);
|
||||||
|
expect(res.currency).toBe(currency);
|
||||||
|
|
||||||
|
const payments = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all();
|
||||||
|
expect(payments).toHaveLength(1);
|
||||||
|
const pl = payments[0].payload as Record<string, unknown>;
|
||||||
|
expect(pl.amountMinor).toBe(20000);
|
||||||
|
expect(pl.tender).toBe("cash");
|
||||||
|
expect(pl.graceExitMin).toBe(15);
|
||||||
|
// It must be a real signed chain event.
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours an operator override amount (lost ticket / dispute)", async () => {
|
||||||
|
seedTariff(db);
|
||||||
|
await enter("T1", minutesAgo(120));
|
||||||
|
const res = await pay.pay("T1", "card", 99900);
|
||||||
|
expect(res.amountMinor).toBe(99900);
|
||||||
|
const pl = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all()[0].payload as Record<string, unknown>;
|
||||||
|
expect(pl.amountMinor).toBe(99900);
|
||||||
|
expect(pl.reason).toBe("operator-set amount");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayStation.lookup — booth modal view", () => {
|
||||||
|
it("reports not-found for an unknown ticket", () => {
|
||||||
|
const v = pay.lookup("ghost");
|
||||||
|
expect(v.found).toBe(false);
|
||||||
|
expect(v.open).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an open unpaid transient with the amount owed", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const v = pay.lookup("T1");
|
||||||
|
expect(v.found).toBe(true);
|
||||||
|
expect(v.open).toBe(true);
|
||||||
|
expect(v.paidAt).toBeNull();
|
||||||
|
expect(v.amountMinor).toBe(20000);
|
||||||
|
expect(v.subscription).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after payment shows paid + within grace, amount cleared", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(120));
|
||||||
|
await pay.pay("T1", "cash");
|
||||||
|
const v = pay.lookup("T1");
|
||||||
|
expect(v.paidAt).not.toBeNull();
|
||||||
|
expect(v.withinGrace).toBe(true);
|
||||||
|
expect(v.overstay).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a subscription occurrence (prepaid — never a transient charge)", async () => {
|
||||||
|
seedTariff(db);
|
||||||
|
await enter("SUBSESS-1", minutesAgo(120), { permit: true, permitId: "sub-1" });
|
||||||
|
const v = pay.lookup("SUBSESS-1");
|
||||||
|
expect(v.subscription).toBe(true);
|
||||||
|
expect(v.subscriptionId).toBe("sub-1");
|
||||||
|
expect(v.amountMinor).toBeNull(); // no timeframes → nothing owed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayStation.activeSessions", () => {
|
||||||
|
it("lists open sessions newest-first and omits exited-past-grace", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("OLD", minutesAgo(200));
|
||||||
|
await enter("NEW", minutesAgo(30));
|
||||||
|
const list = pay.activeSessions();
|
||||||
|
expect(list.map((s) => s.identity)).toEqual(["NEW", "OLD"]);
|
||||||
|
expect(list.every((s) => s.open)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { sessions, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { makeLog } from "./test-helpers.js";
|
||||||
|
import { reportSummary } from "./reports.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Reports aggregation — LEDGER-FIRST. These pin that the numbers an admin sees are
|
||||||
|
// summed straight from the signed ledger (entry/exit counts + payment money, split the
|
||||||
|
// same way the shift Z-report splits it), bucketed in the SITE TIMEZONE, with duration
|
||||||
|
// stats from the closed-sessions cache and subscription counts as of the range end.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let log: EventLog;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
log = makeLog(db);
|
||||||
|
// Fix the site timezone so bucket labels are deterministic regardless of the test host.
|
||||||
|
db.insert(siteConfig).values({ id: 1, timezone: "Europe/Tirane" }).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** ISO at a UTC instant, for deterministic bucket assertions. */
|
||||||
|
function at(iso: string): string {
|
||||||
|
return new Date(iso).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entry(occurredAt: string): Promise<void> {
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity: randomUUID(), occurredAt });
|
||||||
|
}
|
||||||
|
async function exit(occurredAt: string): Promise<void> {
|
||||||
|
await log.append({ type: "vehicle_exit", direction: "exit", identity: randomUUID(), occurredAt });
|
||||||
|
}
|
||||||
|
async function payment(
|
||||||
|
occurredAt: string,
|
||||||
|
amountMinor: number,
|
||||||
|
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await log.append({
|
||||||
|
type: "payment",
|
||||||
|
occurredAt,
|
||||||
|
payload: {
|
||||||
|
amountMinor,
|
||||||
|
currency: "ALL",
|
||||||
|
tender: opts.tender ?? "cash",
|
||||||
|
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
|
||||||
|
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE = { from: at("2026-06-01T00:00:00Z"), to: at("2026-06-30T23:59:59Z") };
|
||||||
|
|
||||||
|
describe("reportSummary — ledger-first totals", () => {
|
||||||
|
it("counts entries and exits from the signed ledger", async () => {
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
await entry(at("2026-06-10T09:00:00Z"));
|
||||||
|
await exit(at("2026-06-10T18:00:00Z"));
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.entries).toBe(2);
|
||||||
|
expect(r.totals.exits).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes events outside [from, to)", async () => {
|
||||||
|
await entry(at("2026-05-31T23:00:00Z")); // before
|
||||||
|
await entry(at("2026-06-15T10:00:00Z")); // inside
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.entries).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums payment money and splits cash vs card", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 20000, { tender: "cash" });
|
||||||
|
await payment(at("2026-06-12T11:00:00Z"), 5000, { tender: "card" });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.payments).toBe(2);
|
||||||
|
expect(r.totals.revenueMinor).toBe(25000);
|
||||||
|
expect(r.totals.cashMinor).toBe(20000);
|
||||||
|
expect(r.totals.cardMinor).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits revenue into ticket / subscription-sale / out-of-window, mirroring the Z-report", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 10000); // transient ticket
|
||||||
|
await payment(at("2026-06-12T10:05:00Z"), 30000, { subscriptionSale: true });
|
||||||
|
await payment(at("2026-06-12T10:06:00Z"), 1500, { subscriptionWindowCharge: true });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.ticketMinor).toBe(10000);
|
||||||
|
expect(r.totals.subscriptionSalesMinor).toBe(30000);
|
||||||
|
expect(r.totals.subscriptionWindowMinor).toBe(1500);
|
||||||
|
// The three add up to the gross revenue.
|
||||||
|
expect(r.totals.revenueMinor).toBe(41500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up the currency from a payment in range", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 10000);
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.currency).toBe("ALL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reportSummary — time bucketing (site timezone)", () => {
|
||||||
|
it("buckets by local day; a 23:30 UTC event lands on the NEXT local day in Tirane (UTC+2/3)", async () => {
|
||||||
|
// 2026-06-15T23:30Z is 2026-06-16 01:30 local (summer, UTC+2) → the 16th bucket.
|
||||||
|
await entry(at("2026-06-15T23:30:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
const point = r.series.find((p) => p.entries > 0);
|
||||||
|
expect(point?.bucket).toBe("2026-06-16");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("series points are sorted and carry per-bucket entries/exits/revenue", async () => {
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
await payment(at("2026-06-10T09:00:00Z"), 7000);
|
||||||
|
await entry(at("2026-06-12T08:00:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
const labels = r.series.map((p) => p.bucket);
|
||||||
|
expect(labels).toEqual([...labels].sort());
|
||||||
|
const d10 = r.series.find((p) => p.bucket === "2026-06-10");
|
||||||
|
expect(d10?.entries).toBe(1);
|
||||||
|
expect(d10?.revenueMinor).toBe(7000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entriesByHour is a 24-slot local-hour histogram", async () => {
|
||||||
|
// 06:00Z = 08:00 local (summer) → hour slot 8.
|
||||||
|
await entry(at("2026-06-10T06:00:00Z"));
|
||||||
|
await entry(at("2026-06-11T06:00:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.entriesByHour).toHaveLength(24);
|
||||||
|
expect(r.entriesByHour[8]).toBe(2);
|
||||||
|
expect(r.entriesByHour.reduce((a, b) => a + b, 0)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
||||||
|
it("computes parked-minute stats from closed sessions whose exit fell in range", async () => {
|
||||||
|
// 60-min and 120-min stays → avg 90, median 90.
|
||||||
|
db.insert(sessions).values({
|
||||||
|
id: "s1",
|
||||||
|
identity: "t1",
|
||||||
|
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||||
|
exitedAt: at("2026-06-10T09:00:00Z"),
|
||||||
|
state: "closed",
|
||||||
|
}).run();
|
||||||
|
db.insert(sessions).values({
|
||||||
|
id: "s2",
|
||||||
|
identity: "t2",
|
||||||
|
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||||
|
exitedAt: at("2026-06-10T10:00:00Z"),
|
||||||
|
state: "closed",
|
||||||
|
}).run();
|
||||||
|
// An OPEN session (no exit) must not count.
|
||||||
|
db.insert(sessions).values({ id: "s3", identity: "t3", enteredAt: at("2026-06-10T08:00:00Z"), state: "open" }).run();
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.closedSessions).toBe(2);
|
||||||
|
expect(r.totals.totalParkedMinutes).toBe(180);
|
||||||
|
expect(r.totals.avgParkedMinutes).toBe(90);
|
||||||
|
expect(r.totals.medianParkedMinutes).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts subscriptions by status and currently-valid coverage as of `to`", async () => {
|
||||||
|
const base = { holderName: "x", period: "month" as const, createdAt: at("2026-06-01T00:00:00Z") };
|
||||||
|
// active + valid window covering `to`, quantity 2.
|
||||||
|
db.insert(subscriptions).values({
|
||||||
|
id: "a", status: "active", quantity: 2,
|
||||||
|
validFrom: at("2026-06-01T00:00:00Z"), validTo: at("2026-07-01T00:00:00Z"), ...base,
|
||||||
|
}).run();
|
||||||
|
// active but EXPIRED before `to` → not currently valid.
|
||||||
|
db.insert(subscriptions).values({
|
||||||
|
id: "b", status: "active", quantity: 1,
|
||||||
|
validFrom: at("2026-05-01T00:00:00Z"), validTo: at("2026-06-05T00:00:00Z"), ...base,
|
||||||
|
}).run();
|
||||||
|
// suspended.
|
||||||
|
db.insert(subscriptions).values({ id: "c", status: "suspended", quantity: 1, ...base }).run();
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.subscriptions.active).toBe(2);
|
||||||
|
expect(r.subscriptions.suspended).toBe(1);
|
||||||
|
expect(r.subscriptions.revoked).toBe(0);
|
||||||
|
expect(r.subscriptions.currentlyValid).toBe(1);
|
||||||
|
expect(r.subscriptions.coveredCars).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
lte,
|
||||||
|
ledgerEvents,
|
||||||
|
sessions,
|
||||||
|
subscriptions,
|
||||||
|
tariffVersions,
|
||||||
|
tariffs,
|
||||||
|
type Db,
|
||||||
|
} from "@parking/db";
|
||||||
|
import { siteTz } from "./subscription-window.js";
|
||||||
|
|
||||||
|
// Admin reporting — LEDGER-FIRST aggregation (decision 2026-06-22). The numbers an
|
||||||
|
// admin sees on the Reports page are summed from the SIGNED, hash-chained
|
||||||
|
// ledger_events (vehicle_entry/exit + payment), the same source the shift Z-report
|
||||||
|
// reconciles against — so a chart total always ties out to the drawer. Only the
|
||||||
|
// duration/occupancy view leans on the derived `sessions` cache, where the ledger is
|
||||||
|
// awkward (you'd have to pair every entry with its exit by hand); that's flagged as a
|
||||||
|
// cache, not the financial truth. See wiki/concepts/reports.md, event-streams-split.md.
|
||||||
|
//
|
||||||
|
// All bucketing is in the SITE TIMEZONE (siteConfig.timezone) — a "day" is a local
|
||||||
|
// calendar day, not a UTC one, so a 01:00-local payment lands on the right date and the
|
||||||
|
// peak-hour chart reads in wall-clock. Pure date math on the stored ISO strings; no
|
||||||
|
// floats (money is integer minor units throughout).
|
||||||
|
|
||||||
|
export type Bucket = "hour" | "day" | "month";
|
||||||
|
|
||||||
|
export interface ReportQuery {
|
||||||
|
/** Inclusive lower bound (ISO instant). */
|
||||||
|
readonly from: string;
|
||||||
|
/** Exclusive upper bound (ISO instant). */
|
||||||
|
readonly to: string;
|
||||||
|
/** Time grain for the series. Default "day". */
|
||||||
|
readonly bucket: Bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One point in a time series, keyed by its local-time bucket label (e.g. "2026-06-22"
|
||||||
|
* for a day, "2026-06-22 14" for an hour). */
|
||||||
|
export interface SeriesPoint {
|
||||||
|
readonly bucket: string;
|
||||||
|
readonly entries: number;
|
||||||
|
readonly exits: number;
|
||||||
|
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
||||||
|
readonly revenueMinor: number;
|
||||||
|
/** Payment COUNT in the bucket (transactions, not amount). */
|
||||||
|
readonly payments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTotals {
|
||||||
|
readonly entries: number;
|
||||||
|
readonly exits: number;
|
||||||
|
readonly payments: number;
|
||||||
|
readonly revenueMinor: number;
|
||||||
|
readonly cashMinor: number;
|
||||||
|
readonly cardMinor: number;
|
||||||
|
/** Revenue split by what was sold. ticket = transient parking; subscriptionSales =
|
||||||
|
* new/renewed subscriptions; subscriptionWindow = out-of-window tariff-bridge charges. */
|
||||||
|
readonly ticketMinor: number;
|
||||||
|
readonly subscriptionSalesMinor: number;
|
||||||
|
readonly subscriptionWindowMinor: number;
|
||||||
|
/** Closed transient sessions in range + their parked-minutes stats (from the cache). */
|
||||||
|
readonly closedSessions: number;
|
||||||
|
readonly totalParkedMinutes: number;
|
||||||
|
readonly avgParkedMinutes: number;
|
||||||
|
readonly medianParkedMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionStats {
|
||||||
|
readonly active: number;
|
||||||
|
readonly suspended: number;
|
||||||
|
readonly revoked: number;
|
||||||
|
/** Active subscriptions whose window covers `to` (the report's "now"). */
|
||||||
|
readonly currentlyValid: number;
|
||||||
|
/** Cars covered by currently-valid subscriptions (Σ quantity). */
|
||||||
|
readonly coveredCars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSummary {
|
||||||
|
readonly from: string;
|
||||||
|
readonly to: string;
|
||||||
|
readonly bucket: Bucket;
|
||||||
|
readonly tz: string;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly totals: ReportTotals;
|
||||||
|
readonly series: SeriesPoint[];
|
||||||
|
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
||||||
|
readonly entriesByHour: number[];
|
||||||
|
readonly subscriptions: SubscriptionStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
||||||
|
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
|
||||||
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
||||||
|
return {
|
||||||
|
y: Number(parts.year),
|
||||||
|
mo: Number(parts.month),
|
||||||
|
d: Number(parts.day),
|
||||||
|
h: Number(parts.hour),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket label for an instant at the chosen grain, in local time. Sorts lexically. */
|
||||||
|
function bucketLabel(iso: string, tz: string, bucket: Bucket): string {
|
||||||
|
const p = localParts(iso, tz);
|
||||||
|
const mo = String(p.mo).padStart(2, "0");
|
||||||
|
const d = String(p.d).padStart(2, "0");
|
||||||
|
const h = String(p.h).padStart(2, "0");
|
||||||
|
if (bucket === "month") return `${p.y}-${mo}`;
|
||||||
|
if (bucket === "hour") return `${p.y}-${mo}-${d} ${h}`;
|
||||||
|
return `${p.y}-${mo}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentPayload {
|
||||||
|
amountMinor?: number;
|
||||||
|
currency?: string;
|
||||||
|
tender?: "cash" | "card";
|
||||||
|
subscriptionSale?: boolean;
|
||||||
|
subscriptionWindowCharge?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(sorted: number[]): number {
|
||||||
|
if (sorted.length === 0) return 0;
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
const hi = sorted[mid] ?? 0;
|
||||||
|
if (sorted.length % 2) return hi;
|
||||||
|
const lo = sorted[mid - 1] ?? 0;
|
||||||
|
return Math.round((lo + hi) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the admin report summary for [from, to) at the chosen grain. Entry/exit counts
|
||||||
|
* and money are summed from the signed ledger; duration stats from the closed sessions
|
||||||
|
* in range; subscription counts from the subscriptions table as of `to`.
|
||||||
|
*/
|
||||||
|
export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||||
|
const tz = siteTz(db);
|
||||||
|
|
||||||
|
// --- Ledger: entry/exit/payment in range, oldest-first so the series builds in order.
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(and(gte(ledgerEvents.occurredAt, q.from), lte(ledgerEvents.occurredAt, q.to)))
|
||||||
|
.orderBy(asc(ledgerEvents.index))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Currency for display: money everywhere is { minorUnits, currency }; payments carry
|
||||||
|
// the currency they were taken in, so take it from a payment in range (then fall back
|
||||||
|
// to the active tariff version). Reports never mix currencies (single-currency site).
|
||||||
|
let currency: string | null = null;
|
||||||
|
|
||||||
|
const seriesMap = new Map<string, SeriesPoint>();
|
||||||
|
const entriesByHour = new Array<number>(24).fill(0);
|
||||||
|
const totals = {
|
||||||
|
entries: 0,
|
||||||
|
exits: 0,
|
||||||
|
payments: 0,
|
||||||
|
revenueMinor: 0,
|
||||||
|
cashMinor: 0,
|
||||||
|
cardMinor: 0,
|
||||||
|
ticketMinor: 0,
|
||||||
|
subscriptionSalesMinor: 0,
|
||||||
|
subscriptionWindowMinor: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function point(label: string): SeriesPoint {
|
||||||
|
let p = seriesMap.get(label);
|
||||||
|
if (!p) {
|
||||||
|
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
|
||||||
|
seriesMap.set(label, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-pass: identities cancelled by a `void` in range. A voided entry was a wrongly-
|
||||||
|
// printed ticket (no car entered), so it must NOT inflate the "entries" stat. (The void's
|
||||||
|
// entry is normally in the same window; this skips it when both are in range.)
|
||||||
|
const voided = new Set<string>();
|
||||||
|
for (const row of rows) if (row.type === "void" && row.identity) voided.add(row.identity);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
||||||
|
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
||||||
|
if (row.type === "vehicle_entry") {
|
||||||
|
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||||
|
totals.entries++;
|
||||||
|
p.entries++;
|
||||||
|
const h = localParts(row.occurredAt, tz).h;
|
||||||
|
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
|
||||||
|
} else if (row.type === "vehicle_exit") {
|
||||||
|
totals.exits++;
|
||||||
|
p.exits++;
|
||||||
|
} else if (row.type === "payment") {
|
||||||
|
const pl = (row.payload ?? {}) as PaymentPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (!currency && typeof pl.currency === "string") currency = pl.currency;
|
||||||
|
totals.payments++;
|
||||||
|
totals.revenueMinor += amt;
|
||||||
|
p.payments++;
|
||||||
|
p.revenueMinor += amt;
|
||||||
|
if (pl.tender === "card") totals.cardMinor += amt;
|
||||||
|
else totals.cashMinor += amt;
|
||||||
|
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
||||||
|
// (the rest is) transient ticket revenue.
|
||||||
|
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
||||||
|
else if (pl.subscriptionWindowCharge === true) totals.subscriptionWindowMinor += amt;
|
||||||
|
else totals.ticketMinor += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
||||||
|
|
||||||
|
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
||||||
|
// zero-revenue range still labels its money column.
|
||||||
|
if (!currency) {
|
||||||
|
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||||
|
if (tariff) {
|
||||||
|
const tv = db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.get();
|
||||||
|
currency = tv?.currency ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Duration: closed transient sessions whose EXIT fell in range (the cache; flagged).
|
||||||
|
const closed = db
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(and(gte(sessions.exitedAt, q.from), lte(sessions.exitedAt, q.to)))
|
||||||
|
.all();
|
||||||
|
const durations: number[] = [];
|
||||||
|
for (const s of closed) {
|
||||||
|
if (!s.enteredAt || !s.exitedAt) continue;
|
||||||
|
const mins = Math.max(0, Math.round((Date.parse(s.exitedAt) - Date.parse(s.enteredAt)) / 60000));
|
||||||
|
durations.push(mins);
|
||||||
|
}
|
||||||
|
durations.sort((a, b) => a - b);
|
||||||
|
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
||||||
|
const subs = db.select().from(subscriptions).all();
|
||||||
|
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
||||||
|
for (const s of subs) {
|
||||||
|
if (s.status === "active") subStats.active++;
|
||||||
|
else if (s.status === "suspended") subStats.suspended++;
|
||||||
|
else if (s.status === "revoked") subStats.revoked++;
|
||||||
|
const validNow =
|
||||||
|
s.status === "active" &&
|
||||||
|
(!s.validFrom || s.validFrom <= q.to) &&
|
||||||
|
(!s.validTo || s.validTo >= q.to);
|
||||||
|
if (validNow) {
|
||||||
|
subStats.currentlyValid++;
|
||||||
|
subStats.coveredCars += s.quantity ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: q.from,
|
||||||
|
to: q.to,
|
||||||
|
bucket: q.bucket,
|
||||||
|
tz,
|
||||||
|
currency,
|
||||||
|
totals: {
|
||||||
|
...totals,
|
||||||
|
closedSessions: durations.length,
|
||||||
|
totalParkedMinutes,
|
||||||
|
avgParkedMinutes: durations.length ? Math.round(totalParkedMinutes / durations.length) : 0,
|
||||||
|
medianParkedMinutes: median(durations),
|
||||||
|
},
|
||||||
|
series,
|
||||||
|
entriesByHour,
|
||||||
|
subscriptions: subStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { desc, gte, ledgerEvents, type Db } from "@parking/db";
|
import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { enrichEvents } from "../event-enrich.js";
|
import { enrichEvents } from "../event-enrich.js";
|
||||||
@@ -19,19 +19,26 @@ export async function eventRoutes(
|
|||||||
const guard = requirePermission("event:read");
|
const guard = requirePermission("event:read");
|
||||||
|
|
||||||
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
||||||
// Optional `since` (ISO) scopes the page to events at/after that instant — the
|
// Optional `since` (ISO) scopes to events at/after that instant — the booth passes
|
||||||
// booth passes the current shift's start so the live feed shows ONLY this shift's
|
// the current shift's start so the live feed shows ONLY this shift's activity. An
|
||||||
// activity (logs are per-shift, not all history). See wiki/concepts/shift.md.
|
// optional `until` (ISO) closes the upper bound — the shift-history screen passes a
|
||||||
app.get<{ Querystring: { limit?: string; since?: string } }>(
|
// selected shift's [start, end] to show just that shift's signed activity log.
|
||||||
|
// (logs are per-shift, not all history). See wiki/concepts/shift.md.
|
||||||
|
app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>(
|
||||||
"/api/events",
|
"/api/events",
|
||||||
{ preHandler: guard },
|
{ preHandler: guard },
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||||
const since = (req.query.since ?? "").trim();
|
const since = (req.query.since ?? "").trim();
|
||||||
|
const until = (req.query.until ?? "").trim();
|
||||||
|
const bounds = [
|
||||||
|
since ? gte(ledgerEvents.occurredAt, since) : undefined,
|
||||||
|
until ? lte(ledgerEvents.occurredAt, until) : undefined,
|
||||||
|
].filter(Boolean);
|
||||||
const rows = db
|
const rows = db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(since ? gte(ledgerEvents.occurredAt, since) : undefined)
|
.where(bounds.length ? and(...bounds) : undefined)
|
||||||
.orderBy(desc(ledgerEvents.index))
|
.orderBy(desc(ledgerEvents.index))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all();
|
.all();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { reportSummary, type Bucket } from "../reports.js";
|
||||||
|
|
||||||
|
// Admin reporting API. Read-only aggregation over the signed ledger (+ the sessions
|
||||||
|
// cache for durations); no writes, no new event types. Gated on `report:read` — the
|
||||||
|
// same permission the events feed/occupancy use. See reports.ts, wiki/concepts/reports.md.
|
||||||
|
|
||||||
|
const BUCKETS: Bucket[] = ["hour", "day", "month"];
|
||||||
|
|
||||||
|
/** Clamp a query into a valid [from, to) + bucket. Defaults: last 30 days, daily. */
|
||||||
|
function parseQuery(q: { from?: string; to?: string; bucket?: string }): {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
bucket: Bucket;
|
||||||
|
} {
|
||||||
|
const now = Date.now();
|
||||||
|
const to = isFiniteIso(q.to) ? q.to! : new Date(now).toISOString();
|
||||||
|
const from = isFiniteIso(q.from) ? q.from! : new Date(now - 30 * 86_400_000).toISOString();
|
||||||
|
const bucket = BUCKETS.includes(q.bucket as Bucket) ? (q.bucket as Bucket) : "day";
|
||||||
|
// Guard the inversion (from after to) — swap rather than return an empty report.
|
||||||
|
return from <= to ? { from, to, bucket } : { from: to, to: from, bucket };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteIso(s: string | undefined): boolean {
|
||||||
|
return !!s && Number.isFinite(Date.parse(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const guard = requirePermission("report:read");
|
||||||
|
|
||||||
|
// The whole dashboard in one call: totals, the time series, peak-hour histogram, and
|
||||||
|
// subscription stats — aggregated server-side so the SPA just renders. Bucketed in the
|
||||||
|
// site timezone. See reports.ts.
|
||||||
|
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
|
||||||
|
"/api/reports/summary",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req) => reportSummary(db, parseQuery(req.query)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same series as CSV (one row per bucket) for spreadsheet / accountant export.
|
||||||
|
// Amounts are in MAJOR units with 2 decimals here (a CSV is for humans/Excel), unlike
|
||||||
|
// the JSON which stays in minor units. text/csv with a download filename.
|
||||||
|
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
|
||||||
|
"/api/reports/summary.csv",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const summary = reportSummary(db, parseQuery(req.query));
|
||||||
|
const lines = [
|
||||||
|
"bucket,entries,exits,payments,revenue",
|
||||||
|
...summary.series.map((p) =>
|
||||||
|
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
reply
|
||||||
|
.header("content-type", "text/csv; charset=utf-8")
|
||||||
|
.header(
|
||||||
|
"content-disposition",
|
||||||
|
`attachment; filename="parking-report-${summary.from.slice(0, 10)}_${summary.to.slice(0, 10)}.csv"`,
|
||||||
|
)
|
||||||
|
.send(lines.join("\n") + "\n");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// HTTP integration: boot the REAL Fastify app over a fresh in-memory DB (no listen —
|
||||||
|
// app.inject drives it) and exercise the auth + RBAC guards end to end. The point is the
|
||||||
|
// security seam: no token → 401, wrong permission → 403, CSRF required on mutations, and
|
||||||
|
// a correctly-scoped user passes. (vitest.config sets JWT_SECRET/EVENT_SIGNING_KEY.)
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("health + login", () => {
|
||||||
|
it("GET /health is open", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/health" });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login with bad credentials is rejected", async () => {
|
||||||
|
await seedUser(db, { username: "alice", password: "right-password" });
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "wrong" } });
|
||||||
|
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login with good credentials sets auth + csrf cookies", async () => {
|
||||||
|
await seedUser(db, { username: "alice", password: "right-password" });
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "right-password" } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const names = res.cookies.map((c) => c.name);
|
||||||
|
expect(names).toContain("parking_token");
|
||||||
|
expect(names).toContain("parking_csrf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth guard — no token", () => {
|
||||||
|
it("GET /api/occupancy without a session is 401", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/occupancy" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RBAC permission gate", () => {
|
||||||
|
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer", roleId: "viewer", permissions: ["site:read"],
|
||||||
|
});
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
// GET allowed (site:read).
|
||||||
|
const get = await app.inject({ method: "GET", url: "/api/occupancy", headers: { cookie } });
|
||||||
|
expect(get.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// PUT requires site:update — which this role lacks → 403 (with valid CSRF, so the
|
||||||
|
// 403 is the PERMISSION check, not CSRF).
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { capacity: 50 },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin user passes the same PUT", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { capacity: 50 },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBeLessThan(300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSRF double-submit on mutations", () => {
|
||||||
|
it("a mutation with the auth cookie but NO csrf header is 403", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie }, // csrf header deliberately omitted
|
||||||
|
payload: { capacity: 50 },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,16 +4,19 @@ import { eq, devices, setupState, type Db } from "@parking/db";
|
|||||||
import {
|
import {
|
||||||
hasPreconditions,
|
hasPreconditions,
|
||||||
hasPushConfig,
|
hasPushConfig,
|
||||||
|
isCamera,
|
||||||
isDiscoverable,
|
isDiscoverable,
|
||||||
isHardenable,
|
isHardenable,
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
|
type CameraDevice,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
type DeviceConfig,
|
type DeviceConfig,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||||
|
import type { VisionClient } from "../vision-client.js";
|
||||||
|
|
||||||
// First-run setup API. The admin reads the driver catalog and assigns devices
|
// First-run setup API. The admin reads the driver catalog and assigns devices
|
||||||
// per lane. See wiki/concepts/first-run-setup.md.
|
// per lane. See wiki/concepts/first-run-setup.md.
|
||||||
@@ -172,7 +175,11 @@ async function configureDevice(
|
|||||||
return { config: fullConfig, warnings: hardenWarnings };
|
return { config: fullConfig, warnings: hardenWarnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
export async function setupRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
vision?: VisionClient | null,
|
||||||
|
): Promise<void> {
|
||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
|
|
||||||
@@ -261,6 +268,72 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Test ANPR end-to-end on a camera config WITHOUT saving: capture a live snapshot
|
||||||
|
// off the camera and run it through the vision (ANPR) service, reporting whether a
|
||||||
|
// plate was extracted, the read, and how long it took. Lets the admin verify the
|
||||||
|
// camera→vision pipeline before committing the camera's `anpr` opt-in. Advisory +
|
||||||
|
// fail-soft, exactly like the runtime path (snapshot.ts): a vision failure is a
|
||||||
|
// reported "no plate", never a 500. See wiki/entities/opencv-anpr-service.md.
|
||||||
|
app.post<{ Body: TestBody }>(
|
||||||
|
"/api/setup/test-anpr",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { driverId, config } = req.body;
|
||||||
|
const driver = registry.get(driverId);
|
||||||
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
if (driver.category !== "camera") {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} is not a camera` });
|
||||||
|
}
|
||||||
|
if (!vision?.enabled) {
|
||||||
|
// The vision service is off (VISION_ENABLED unset) — there's nothing to test
|
||||||
|
// against. Report it cleanly so the UI can say "enable vision first".
|
||||||
|
return reply.send({ ok: false, reason: "vision-disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(driverId, config);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!isCamera(device)) {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} cannot capture snapshots` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Grab a frame off the camera. A camera/network failure here is the failure
|
||||||
|
// we're testing for — report it, don't 500.
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let shot: Awaited<ReturnType<CameraDevice["captureSnapshot"]>>;
|
||||||
|
try {
|
||||||
|
shot = await device.captureSnapshot({ direction: "entry" });
|
||||||
|
} catch (err) {
|
||||||
|
return reply.send({
|
||||||
|
ok: false,
|
||||||
|
reason: "snapshot-failed",
|
||||||
|
detail: (err as Error).message,
|
||||||
|
tookMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Run the same advisory analyze the runtime path uses. `analyze` is fail-soft
|
||||||
|
// (null on any error/timeout) and applies the confidence floor.
|
||||||
|
const result = await vision.analyze(shot.bytes, shot.contentType);
|
||||||
|
const tookMs = Date.now() - startedAt;
|
||||||
|
if (!result || !result.plate) {
|
||||||
|
return reply.send({ ok: false, reason: "no-plate", tookMs });
|
||||||
|
}
|
||||||
|
return reply.send({
|
||||||
|
ok: true,
|
||||||
|
plate: result.plate.text.trim().toUpperCase(),
|
||||||
|
confidence: result.plate.confidence,
|
||||||
|
region: result.plate.region ?? null,
|
||||||
|
lowConfidence: result.lowConfidence,
|
||||||
|
modelVersion: result.modelVersion,
|
||||||
|
tookMs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Candidate backend IPs the device can push to, for a given device host. The
|
// Candidate backend IPs the device can push to, for a given device host. The
|
||||||
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||||
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||||
|
|||||||
@@ -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, 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 } 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";
|
||||||
@@ -57,6 +58,13 @@ interface SubscriptionBody {
|
|||||||
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
||||||
* plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */
|
* plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */
|
||||||
tender?: Tender;
|
tender?: Tender;
|
||||||
|
/** UPDATE-only CORRECTION: move this sub to a different VERSION of its SAME plan (e.g.
|
||||||
|
* an admin published v2 with different timeframes and wants an existing subscriber on
|
||||||
|
* it, or back on v1). Must be a version of the sub's existing planId; price/currency/
|
||||||
|
* period stay FROZEN (not a re-sale — only the access rules change going forward).
|
||||||
|
* Gated on `subscription:plan` (plan-management, stronger than subscription:update);
|
||||||
|
* ignored from a non-privileged caller. See wiki/entities/subscription.md. */
|
||||||
|
planVersionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */
|
/** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */
|
||||||
@@ -219,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)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -433,10 +442,41 @@ export async function subscriptionRoutes(
|
|||||||
const b = req.body ?? {};
|
const b = req.body ?? {};
|
||||||
const problems = validate(b);
|
const problems = validate(b);
|
||||||
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
||||||
// An update is a MASTER-DATA edit — it never re-sells or re-prices. The price,
|
|
||||||
// plan, version and currency are FROZEN as the original sale recorded them (a new
|
// PLAN-VERSION CORRECTION (opt-in, privileged). Move the sub to a different VERSION
|
||||||
// price means a new sale = a new subscription). Editable here: holder/contact,
|
// of its SAME plan — e.g. an admin published v2 (different timeframes) and wants this
|
||||||
// car-count, the validity window, status, and credentials/plates.
|
// subscriber on it, or back on v1. Price/currency/period stay frozen (not a re-sale).
|
||||||
|
// Guarded HERE on `subscription:plan` (stronger than the route's subscription:update),
|
||||||
|
// so a plain operator's edit can't move a version; a non-privileged caller sending it
|
||||||
|
// is rejected rather than silently ignored.
|
||||||
|
let planVersionId = existing.planVersionId;
|
||||||
|
if (b.planVersionId !== undefined && b.planVersionId !== existing.planVersionId) {
|
||||||
|
if (!req.user || !roleHasPermissions(req.user.roleId, ["subscription:plan"])) {
|
||||||
|
return reply.code(403).send({ error: "changing the plan version requires the subscription:plan permission" });
|
||||||
|
}
|
||||||
|
const target = db
|
||||||
|
.select()
|
||||||
|
.from(subscriptionPlans)
|
||||||
|
.where(eq(subscriptionPlans.id, b.planVersionId))
|
||||||
|
.get();
|
||||||
|
if (!target) return reply.code(404).send({ error: "plan version not found" });
|
||||||
|
// Must be a version of the SAME plan — this field corrects the version, never the
|
||||||
|
// plan itself (a different plan = a different price basis = a re-sale).
|
||||||
|
if (target.planId !== existing.planId) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: `plan version belongs to "${target.planId}", not this subscription's plan "${existing.planId}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
planVersionId = b.planVersionId;
|
||||||
|
req.log.info(
|
||||||
|
`subscription ${req.params.id} plan version ${existing.planVersionId} → ${b.planVersionId} (plan ${existing.planId}) by ${req.user.username ?? "?"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An update is otherwise a MASTER-DATA edit — it never re-sells or re-prices. Price,
|
||||||
|
// plan and currency are FROZEN as the original sale recorded them (a new price means a
|
||||||
|
// new sale = a new subscription). Editable here: holder/contact, car-count, the
|
||||||
|
// validity window, status, credentials/plates, and (privileged) the plan version.
|
||||||
db.update(subscriptions)
|
db.update(subscriptions)
|
||||||
.set({
|
.set({
|
||||||
holderName: b.holderName ?? null,
|
holderName: b.holderName ?? null,
|
||||||
@@ -445,6 +485,7 @@ export async function subscriptionRoutes(
|
|||||||
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
|
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
|
||||||
validTo: resolveValidTo(b, existing.validTo),
|
validTo: resolveValidTo(b, existing.validTo),
|
||||||
status: b.status ?? existing.status,
|
status: b.status ?? existing.status,
|
||||||
|
planVersionId,
|
||||||
})
|
})
|
||||||
.where(eq(subscriptions.id, req.params.id))
|
.where(eq(subscriptions.id, req.params.id))
|
||||||
.run();
|
.run();
|
||||||
@@ -493,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,7 +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 { 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";
|
||||||
@@ -37,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
|
||||||
@@ -93,17 +101,33 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
await userRoutes(app, db);
|
await userRoutes(app, db);
|
||||||
await roleRoutes(app, db);
|
await roleRoutes(app, db);
|
||||||
|
|
||||||
|
// Vision (ANPR) client — built early so the device monitor can include the vision
|
||||||
|
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
|
||||||
|
// snapshot→analyze probe on an ANPR-enabled camera. Opt-in (VISION_ENABLED) +
|
||||||
|
// fail-soft; advisory only. See wiki/entities/opencv-anpr-service.md.
|
||||||
|
const visionClient = new VisionClient(app.log);
|
||||||
|
if (visionClient.enabled) app.log.info("vision client enabled");
|
||||||
|
|
||||||
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||||
// button) and binds readers/cameras to a controller relay at first-run. There is
|
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||||
await setupRoutes(app, db);
|
await setupRoutes(app, db, visionClient);
|
||||||
|
|
||||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||||
// the device's lane_devices config (written on assign).
|
// the device's lane_devices config (written on assign).
|
||||||
await deviceRoutes(app, db);
|
await deviceRoutes(app, db);
|
||||||
|
|
||||||
|
// Lane busy/free tracker: a camera's vehicle detection marks its bound lane busy
|
||||||
|
// (advisory barrier lights on the booth); auto-clears on a timeout. See lane-status.ts.
|
||||||
|
const laneStatus = new LaneStatus(db, app.log);
|
||||||
|
app.addHook("onClose", async () => laneStatus.stop());
|
||||||
|
|
||||||
|
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
||||||
|
// flows are constructed — because the ANPR bridge they carry depends on the
|
||||||
|
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
||||||
|
|
||||||
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
|
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
|
||||||
// pushes changes to the booth UI. setupRoutes() has already registered the
|
// pushes changes to the booth UI. setupRoutes() has already registered the
|
||||||
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
|
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
|
||||||
@@ -112,12 +136,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
app.addHook("onReady", async () => printerMonitor.start());
|
app.addHook("onReady", async () => printerMonitor.start());
|
||||||
app.addHook("onClose", async () => printerMonitor.stop());
|
app.addHook("onClose", async () => printerMonitor.stop());
|
||||||
|
|
||||||
// Vision (ANPR) client — built early so the device monitor can include the vision
|
|
||||||
// service's health in the footer. Opt-in (VISION_ENABLED) + fail-soft; advisory only.
|
|
||||||
// See wiki/entities/opencv-anpr-service.md.
|
|
||||||
const visionClient = new VisionClient(app.log);
|
|
||||||
if (visionClient.enabled) app.log.info("vision client enabled");
|
|
||||||
|
|
||||||
// Unified device-status monitor: polls EVERY configured device (relays/readers/
|
// Unified device-status monitor: polls EVERY configured device (relays/readers/
|
||||||
// cameras via healthCheck, printers via rich readStatus) PLUS the vision service's
|
// cameras via healthCheck, printers via rich readStatus) PLUS the vision service's
|
||||||
// /health, and feeds the booth's device-status footer over the WS. Read-only.
|
// /health, and feeds the booth's device-status footer over the WS. Read-only.
|
||||||
@@ -140,9 +158,17 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
);
|
);
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
|
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
||||||
|
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
|
||||||
|
await reportRoutes(app, db);
|
||||||
|
|
||||||
|
// Recycle bin: view / restore / purge soft-deleted master data (users/roles/subs/
|
||||||
|
// plans/tariffs). Gated on recyclebin:*. See routes/recycle-bin.ts, recycle-bin.ts.
|
||||||
|
await recycleBinRoutes(app, db);
|
||||||
|
|
||||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
// 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);
|
||||||
@@ -174,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.
|
||||||
@@ -194,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.
|
||||||
@@ -226,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
|
||||||
@@ -247,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
ShiftService,
|
||||||
|
ShiftAlreadyOpenError,
|
||||||
|
NoOpenShiftError,
|
||||||
|
NoShiftOpenError,
|
||||||
|
InvalidCashMovementError,
|
||||||
|
} from "./shift-service.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The shift is an operator's accountability period — signed shift_open … shift_z_report,
|
||||||
|
// no mutable table. These tests pin: the site-wide single-open invariant, the takings
|
||||||
|
// SPLIT by source (subscription sales vs out-of-window charges vs transient tickets — the
|
||||||
|
// 2026-06-21 work), the drawer carry-forward, and that close signs a Z-report with the
|
||||||
|
// right figures.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
let shift: ShiftService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = makeLog(db);
|
||||||
|
shift = new ShiftService(db, log, silentLogger());
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
/** Append a signed payment with source-split flags, as the booth/pay paths do. */
|
||||||
|
async function payment(
|
||||||
|
amountMinor: number,
|
||||||
|
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
|
||||||
|
) {
|
||||||
|
await log.append({
|
||||||
|
type: "payment", source: "manual", identity: "T",
|
||||||
|
payload: {
|
||||||
|
sessionRef: "T", amountMinor, currency: "ALL", tender: opts.tender ?? "cash",
|
||||||
|
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
|
||||||
|
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("single-open invariant", () => {
|
||||||
|
it("opens a shift and reports it as the current open one", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
const cur = shift.currentOpenShift();
|
||||||
|
expect(cur?.identity).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second open while one is already open (even another operator)", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await expect(shift.open("alice")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||||
|
await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a new shift after the prior one closes", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.close("alice");
|
||||||
|
await expect(shift.open("bob")).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close without an open shift throws", async () => {
|
||||||
|
await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requireOpenShift throws when none is open", () => {
|
||||||
|
expect(() => shift.requireOpenShift()).toThrow(NoShiftOpenError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("takings split by source", () => {
|
||||||
|
it("separates subscription sales, out-of-window charges, and transient tickets", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(50000, { subscriptionSale: true }); // monthly fee
|
||||||
|
await payment(20000, { subscriptionWindowCharge: true }); // out-of-window
|
||||||
|
await payment(10000); // transient ticket
|
||||||
|
await payment(30000, { tender: "card" }); // transient ticket, card
|
||||||
|
|
||||||
|
const r = shift.currentReport()!;
|
||||||
|
expect(r.subscriptionSalesMinor).toBe(50000);
|
||||||
|
expect(r.subscriptionWindowMinor).toBe(20000);
|
||||||
|
expect(r.subscriptionTotalMinor).toBe(70000);
|
||||||
|
expect(r.ticketTotalMinor).toBe(40000); // 10000 cash + 30000 card
|
||||||
|
// The split must reconcile to the cash+card grand total.
|
||||||
|
expect(r.cashTotalMinor + r.cardTotalMinor).toBe(
|
||||||
|
r.ticketTotalMinor + r.subscriptionTotalMinor,
|
||||||
|
);
|
||||||
|
expect(r.cashTotalMinor).toBe(80000); // 50000 + 20000 + 10000
|
||||||
|
expect(r.cardTotalMinor).toBe(30000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("drawer carry-forward", () => {
|
||||||
|
it("cash payments enter the drawer; card does not", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(10000, { tender: "cash" });
|
||||||
|
await payment(50000, { tender: "card" });
|
||||||
|
const r = shift.currentReport()!;
|
||||||
|
expect(r.cashTotalMinor).toBe(10000);
|
||||||
|
// Expected drawer = opening(0) + cash(10000) + added(0) − removed(0).
|
||||||
|
expect(r.expectedDrawerMinor).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a closed shift's expected drawer becomes the next shift's opening float", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(25000, { tender: "cash" });
|
||||||
|
const closed = await shift.close("alice");
|
||||||
|
expect(closed.expectedDrawerMinor).toBe(25000);
|
||||||
|
|
||||||
|
const next = await shift.open("bob");
|
||||||
|
expect(next.openingFloatMinor).toBe(25000); // inherited
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cash_in / cash_out vouchers adjust the drawer", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 100000, reason: "float load" });
|
||||||
|
await shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: 30000, reason: "bank drop" });
|
||||||
|
const r = shift.currentReport()!;
|
||||||
|
expect(r.cashAddedMinor).toBe(100000);
|
||||||
|
expect(r.cashRemovedMinor).toBe(30000);
|
||||||
|
expect(r.expectedDrawerMinor).toBe(70000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive voucher amount", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await expect(
|
||||||
|
shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 0, reason: "x" }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
|
await expect(
|
||||||
|
shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: -5, reason: "x" }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("close signs a Z-report; listShifts reads it back", () => {
|
||||||
|
it("a closed shift appears in history with its split figures", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(50000, { subscriptionSale: true });
|
||||||
|
await payment(10000); // ticket
|
||||||
|
await shift.close("alice");
|
||||||
|
|
||||||
|
const history = shift.listShifts();
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
const s = history[0];
|
||||||
|
expect(s.operator).toBe("alice");
|
||||||
|
expect(s.subscriptionSalesMinor).toBe(50000);
|
||||||
|
expect(s.ticketTotalMinor).toBe(10000);
|
||||||
|
expect(s.cashTotalMinor).toBe(60000);
|
||||||
|
// The Z-report is a signed chain event.
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters history by operator", async () => {
|
||||||
|
await shift.open("alice"); await shift.close("alice");
|
||||||
|
await shift.open("bob"); await shift.close("bob");
|
||||||
|
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,10 @@ export interface ShiftSummary {
|
|||||||
readonly cardTotalMinor: number;
|
readonly cardTotalMinor: number;
|
||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
readonly paymentCount: number;
|
readonly paymentCount: number;
|
||||||
|
readonly ticketTotalMinor: number;
|
||||||
|
readonly subscriptionTotalMinor: number;
|
||||||
|
readonly subscriptionSalesMinor: number;
|
||||||
|
readonly subscriptionWindowMinor: number;
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
readonly cashAddedMinor: number;
|
readonly cashAddedMinor: number;
|
||||||
readonly cashRemovedMinor: number;
|
readonly cashRemovedMinor: number;
|
||||||
@@ -65,6 +69,15 @@ export interface ShiftReport {
|
|||||||
readonly cardTotalMinor: number;
|
readonly cardTotalMinor: number;
|
||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
readonly paymentCount: number;
|
readonly paymentCount: number;
|
||||||
|
// --- Takings split by SOURCE (cash+card combined; the drawer cash/card stay above) ---
|
||||||
|
/** Transient TICKET money (the default — any payment not flagged subscription). */
|
||||||
|
readonly ticketTotalMinor: number;
|
||||||
|
/** All SUBSCRIBER money = monthly sales + out-of-window charges. */
|
||||||
|
readonly subscriptionTotalMinor: number;
|
||||||
|
/** Subscription SALES only (the prepaid monthly/period fee). */
|
||||||
|
readonly subscriptionSalesMinor: number;
|
||||||
|
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
||||||
|
readonly subscriptionWindowMinor: number;
|
||||||
// --- Drawer (physical cash till; carries across shifts) ---
|
// --- Drawer (physical cash till; carries across shifts) ---
|
||||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
@@ -160,6 +173,10 @@ export class ShiftService {
|
|||||||
cashTotalMinor?: number;
|
cashTotalMinor?: number;
|
||||||
cardTotalMinor?: number;
|
cardTotalMinor?: number;
|
||||||
paymentCount?: number;
|
paymentCount?: number;
|
||||||
|
ticketTotalMinor?: number;
|
||||||
|
subscriptionTotalMinor?: number;
|
||||||
|
subscriptionSalesMinor?: number;
|
||||||
|
subscriptionWindowMinor?: number;
|
||||||
openingFloatMinor?: number;
|
openingFloatMinor?: number;
|
||||||
cashAddedMinor?: number;
|
cashAddedMinor?: number;
|
||||||
cashRemovedMinor?: number;
|
cashRemovedMinor?: number;
|
||||||
@@ -180,6 +197,16 @@ export class ShiftService {
|
|||||||
cardTotalMinor: pl.cardTotalMinor ?? 0,
|
cardTotalMinor: pl.cardTotalMinor ?? 0,
|
||||||
currency: pl.currency ?? null,
|
currency: pl.currency ?? null,
|
||||||
paymentCount: pl.paymentCount ?? 0,
|
paymentCount: pl.paymentCount ?? 0,
|
||||||
|
// Split-by-source fields (added 2026-06-21). Old reports lack them → default the
|
||||||
|
// subscription buckets to 0 and let ticket absorb the whole take, so the buckets
|
||||||
|
// still reconcile to cash+card for a pre-split shift.
|
||||||
|
subscriptionSalesMinor: pl.subscriptionSalesMinor ?? 0,
|
||||||
|
subscriptionWindowMinor: pl.subscriptionWindowMinor ?? 0,
|
||||||
|
subscriptionTotalMinor:
|
||||||
|
pl.subscriptionTotalMinor ?? (pl.subscriptionSalesMinor ?? 0) + (pl.subscriptionWindowMinor ?? 0),
|
||||||
|
ticketTotalMinor:
|
||||||
|
pl.ticketTotalMinor ??
|
||||||
|
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
||||||
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||||
@@ -348,14 +375,29 @@ export class ShiftService {
|
|||||||
|
|
||||||
let cashTotalMinor = 0;
|
let cashTotalMinor = 0;
|
||||||
let cardTotalMinor = 0;
|
let cardTotalMinor = 0;
|
||||||
|
// Split by SOURCE: subscription SALES (the prepaid fee), subscriber OUT-OF-WINDOW
|
||||||
|
// charges, and everything else = transient TICKET money. Both subscriber kinds roll
|
||||||
|
// up into subscriptionTotal; the rest is ticketTotal. The flags ride the signed
|
||||||
|
// payment payload (subscriptionSale / subscriptionWindowCharge — see pay-station +
|
||||||
|
// the subscription sale path).
|
||||||
|
let subscriptionSalesMinor = 0;
|
||||||
|
let subscriptionWindowMinor = 0;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
for (const p of payments) {
|
for (const p of payments) {
|
||||||
const pl = (p.payload ?? {}) as LedgerPayload;
|
const pl = (p.payload ?? {}) as LedgerPayload & {
|
||||||
|
subscriptionSale?: boolean;
|
||||||
|
subscriptionWindowCharge?: boolean;
|
||||||
|
};
|
||||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
if (pl.tender === "card") cardTotalMinor += amt;
|
if (pl.tender === "card") cardTotalMinor += amt;
|
||||||
else cashTotalMinor += amt;
|
else cashTotalMinor += amt;
|
||||||
|
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
||||||
|
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||||||
|
// (else → transient ticket; derived below as total − subscription)
|
||||||
if (pl.currency) currency = pl.currency;
|
if (pl.currency) currency = pl.currency;
|
||||||
}
|
}
|
||||||
|
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||||
|
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
|
||||||
|
|
||||||
// --- Drawer figures ---
|
// --- Drawer figures ---
|
||||||
// Opening float was fixed on shift_open (inherited from the chain at start);
|
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||||||
@@ -403,6 +445,10 @@ export class ShiftService {
|
|||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency,
|
currency,
|
||||||
paymentCount: payments.length,
|
paymentCount: payments.length,
|
||||||
|
ticketTotalMinor,
|
||||||
|
subscriptionTotalMinor,
|
||||||
|
subscriptionSalesMinor,
|
||||||
|
subscriptionWindowMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -437,6 +483,10 @@ export class ShiftService {
|
|||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency,
|
currency,
|
||||||
paymentCount,
|
paymentCount,
|
||||||
|
ticketTotalMinor,
|
||||||
|
subscriptionTotalMinor,
|
||||||
|
subscriptionSalesMinor,
|
||||||
|
subscriptionWindowMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -455,6 +505,10 @@ export class ShiftService {
|
|||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency: currency ?? undefined,
|
currency: currency ?? undefined,
|
||||||
paymentCount,
|
paymentCount,
|
||||||
|
ticketTotalMinor,
|
||||||
|
subscriptionTotalMinor,
|
||||||
|
subscriptionSalesMinor,
|
||||||
|
subscriptionWindowMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -492,6 +546,12 @@ export class ShiftService {
|
|||||||
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
|
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
||||||
"",
|
"",
|
||||||
|
"-- Arkëtime sipas burimit --",
|
||||||
|
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
||||||
|
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||||
|
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
|
||||||
|
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||||
|
"",
|
||||||
"-- Arka --",
|
"-- Arka --",
|
||||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SoftwareSigner, buildSigner, buildVerifier } from "./signer.js";
|
||||||
|
|
||||||
|
// The signer is half of the anti-fraud chain (the other half is event-log's hashing).
|
||||||
|
// These tests pin: a sign/verify round-trip, rejection of any tamper, constant-time
|
||||||
|
// length handling, and the keyId rotation contract that lets one chain span keys.
|
||||||
|
|
||||||
|
describe("SoftwareSigner", () => {
|
||||||
|
it("verifies its own signature (round-trip)", () => {
|
||||||
|
const s = new SoftwareSigner("a-test-secret-key");
|
||||||
|
const sig = s.sign("hello world");
|
||||||
|
expect(s.verify("hello world", sig)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a signature over different content (tamper-evidence)", () => {
|
||||||
|
const s = new SoftwareSigner("a-test-secret-key");
|
||||||
|
const sig = s.sign("amount=100");
|
||||||
|
// Flip the signed content — the whole point of signing the payload.
|
||||||
|
expect(s.verify("amount=9999", sig)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a signature made under a different key (forgery)", () => {
|
||||||
|
const real = new SoftwareSigner("the-real-host-key");
|
||||||
|
const forger = new SoftwareSigner("an-attacker-guess");
|
||||||
|
const forged = forger.sign("amount=100");
|
||||||
|
expect(real.verify("amount=100", forged)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed / wrong-length signature without throwing", () => {
|
||||||
|
const s = new SoftwareSigner("a-test-secret-key");
|
||||||
|
// timingSafeEqual throws on length mismatch; verify() must guard it.
|
||||||
|
expect(() => s.verify("x", "deadbeef")).not.toThrow();
|
||||||
|
expect(s.verify("x", "deadbeef")).toBe(false);
|
||||||
|
expect(s.verify("x", "")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic — same key + payload yields the same signature", () => {
|
||||||
|
const a = new SoftwareSigner("k").sign("p");
|
||||||
|
const b = new SoftwareSigner("k").sign("p");
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to the v2 keyId", () => {
|
||||||
|
expect(new SoftwareSigner("k").keyId).toBe("sw-hmac-v2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildSigner", () => {
|
||||||
|
// vitest.config.ts sets EVENT_SIGNING_KEY + JWT_SECRET for the whole run.
|
||||||
|
it("prefers EVENT_SIGNING_KEY (keyId sw-hmac-v2)", () => {
|
||||||
|
const s = buildSigner();
|
||||||
|
expect(s.keyId).toBe("sw-hmac-v2");
|
||||||
|
const sig = s.sign("x");
|
||||||
|
expect(s.verify("x", sig)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildVerifier (key rotation)", () => {
|
||||||
|
it("returns a working verifier for the configured v2 key", () => {
|
||||||
|
const v = buildVerifier("sw-hmac-v2");
|
||||||
|
expect(v).toBeDefined();
|
||||||
|
const signer = new SoftwareSigner(process.env.EVENT_SIGNING_KEY!, "sw-hmac-v2");
|
||||||
|
expect(v!.verify("x", signer.sign("x"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the jwtfallback key when present", () => {
|
||||||
|
const v = buildVerifier("sw-hmac-jwtfallback");
|
||||||
|
expect(v).toBeDefined();
|
||||||
|
const signer = new SoftwareSigner(process.env.JWT_SECRET!, "sw-hmac-jwtfallback");
|
||||||
|
expect(v!.verify("x", signer.sign("x"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an unknown keyId (key gone, not a false tamper)", () => {
|
||||||
|
expect(buildVerifier("atecc608-slot0")).toBeUndefined();
|
||||||
|
expect(buildVerifier("nonsense")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
@@ -189,13 +192,16 @@ export class SubscriptionFlow {
|
|||||||
return { accepted: false, direction: "entry", reason };
|
return { accepted: false, direction: "entry", reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TARIFF BRIDGE — early entry. If the plan has time windows and this scan is before
|
// TARIFF BRIDGE — out-of-window entry. If the plan has time windows and this scan is
|
||||||
// the window opens, the subscriber owes the transient tariff for arrival→window-open.
|
// OUTSIDE the allowed window, the subscriber will owe the transient tariff for the time
|
||||||
// We DEFER it (open now, collect at exit): stamp the owed amount on the SIGNED entry
|
// they actually park out-of-window. The AMOUNT is NOT knowable now — it depends on when
|
||||||
// payload (the source of truth — `windowOwedMinor`), so the exit gate reads it back
|
// they leave (a subscriber who enters early and leaves before the window opens owes only
|
||||||
// from the chain. Plans without timeframes return null → nothing owed. See
|
// their parked minutes, NOT the whole gap-to-window-open). So we stamp only a MARKER
|
||||||
// wiki/entities/subscription.md.
|
// (`outOfWindow`) + the tariff version, and price it live at settlement from
|
||||||
const entryCharge = windowCharge(this.#db, sub.planVersionId, now, "entry");
|
// minutesOutsideWindow(entry → pay-time), which caps at the window edges. Open now
|
||||||
|
// (never trap); the charge is gated at exit. Plans without timeframes → null → no
|
||||||
|
// marker. See wiki/entities/subscription.md ("tariff bridge").
|
||||||
|
const outOfWindow = windowCharge(this.#db, sub.planVersionId, now, "entry");
|
||||||
|
|
||||||
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in
|
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in
|
||||||
// the payload's `permitId` (which every fold matches on), so the key stays compact.
|
// the payload's `permitId` (which every fold matches on), so the key stays compact.
|
||||||
@@ -205,29 +211,27 @@ export class SubscriptionFlow {
|
|||||||
direction: "entry",
|
direction: "entry",
|
||||||
source,
|
source,
|
||||||
identity: occurrenceId,
|
identity: occurrenceId,
|
||||||
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
|
// The subscription IS the authorization (no fee for in-window use). `permitId`/`permit`
|
||||||
// `permitId`/`permit` are the on-chain field names (immutable). A deferred early-
|
// are the on-chain field names (immutable). An out-of-window entry is MARKED here
|
||||||
// entry charge is signed here (windowOwedMinor + the priced gap) so it's owed at exit.
|
// (`outOfWindow` + the tariff version for reproducible pricing) so the booth/exit gate
|
||||||
|
// know to charge the parked-out-of-window minutes — priced live, not a fixed amount.
|
||||||
payload: {
|
payload: {
|
||||||
sessionRef: occurrenceId,
|
sessionRef: occurrenceId,
|
||||||
permitId: m.subscriptionId,
|
permitId: m.subscriptionId,
|
||||||
permit: true,
|
permit: true,
|
||||||
via: m.via,
|
via: m.via,
|
||||||
...(entryCharge
|
...(outOfWindow
|
||||||
? {
|
? {
|
||||||
windowOwedMinor: entryCharge.amountMinor,
|
outOfWindow: true,
|
||||||
windowCurrency: entryCharge.currency,
|
windowTariffVersionId: outOfWindow.tariffVersionId,
|
||||||
windowTariffVersionId: entryCharge.tariffVersionId,
|
|
||||||
windowGapStart: entryCharge.gapStart,
|
|
||||||
windowGapEnd: entryCharge.gapEnd,
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
});
|
});
|
||||||
if (entryCharge) {
|
if (outOfWindow) {
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`subscription early-entry charge ${entryCharge.amountMinor} ${entryCharge.currency} (${entryCharge.minutes}min) deferred on ${occurrenceId}`,
|
`subscription out-of-window entry marked on ${occurrenceId} (charge priced from parked minutes at exit)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
|
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
|
||||||
@@ -246,11 +250,12 @@ export class SubscriptionFlow {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
// BEST-EFFORT: print an advisory "out-of-window" slip so the subscriber has paper
|
// BEST-EFFORT: print the out-of-window TICKET so the subscriber has the paper the
|
||||||
// proof a fee is pending (the final amount is computed at the booth on settlement,
|
// operator scans to settle at the booth. It carries the occurrence id as a scannable
|
||||||
// combining early-entry + any late-exit time). AFTER the open + cache, and fully
|
// code; the amount is computed at settlement from the minutes actually parked
|
||||||
|
// out-of-window (capped at the window edges). AFTER the open + cache, and fully
|
||||||
// swallowed — a missing/failed printer must NEVER block or delay the barrier.
|
// swallowed — a missing/failed printer must NEVER block or delay the barrier.
|
||||||
if (entryCharge) {
|
if (outOfWindow) {
|
||||||
const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null;
|
const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null;
|
||||||
void printWindowChargeNotice(
|
void printWindowChargeNotice(
|
||||||
this.#db,
|
this.#db,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { roles, rolePermissions, tariffs, tariffVersions, users, type Db } from "@parking/db";
|
||||||
|
import type { Permission, TariffStructure } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger, FastifyInstance } from "fastify";
|
||||||
|
import { EventLog } from "./event-log.js";
|
||||||
|
import { SoftwareSigner, buildVerifier } from "./signer.js";
|
||||||
|
|
||||||
|
// Shared scaffolding for server tests (NOT a *.test file, so it is not collected as a
|
||||||
|
// suite and stays out of shipped dist via the tsconfig test-exclude). Builds the real
|
||||||
|
// EventLog over a fresh test DB, a silent logger, and a minimal active tariff so the
|
||||||
|
// pay/exit flows have something to price against.
|
||||||
|
|
||||||
|
const SECRET = "test-event-signing-key-0123456789";
|
||||||
|
|
||||||
|
/** Real EventLog (real signer + per-keyId verifier) over a test DB. */
|
||||||
|
export function makeLog(db: Db): EventLog {
|
||||||
|
return new EventLog(db, new SoftwareSigner(SECRET), buildVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A logger that swallows everything — flows log liberally; tests don't care. */
|
||||||
|
export function silentLogger(): FastifyBaseLogger {
|
||||||
|
const noop = () => {};
|
||||||
|
const l: Record<string, unknown> = {
|
||||||
|
info: noop, warn: noop, error: noop, debug: noop, fatal: noop, trace: noop,
|
||||||
|
silent: noop, level: "silent",
|
||||||
|
};
|
||||||
|
l.child = () => l;
|
||||||
|
return l as unknown as FastifyBaseLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A simple flat-rate V1 tariff: free under the entry grace, then a fixed price per
|
||||||
|
* increment, with a walk-back exit grace. Returns the tariffVersionId + currency. */
|
||||||
|
export function seedTariff(
|
||||||
|
db: Db,
|
||||||
|
opts: { pricePerIncrementMinor?: number; incrementMin?: number; gracePeriodEntryMin?: number; gracePeriodExitMin?: number; currency?: string; effectiveFrom?: string } = {},
|
||||||
|
): { tariffVersionId: string; currency: string } {
|
||||||
|
const tariffId = randomUUID();
|
||||||
|
const versionId = randomUUID();
|
||||||
|
const currency = opts.currency ?? "ALL";
|
||||||
|
const structure: TariffStructure = {
|
||||||
|
gracePeriodEntryMin: opts.gracePeriodEntryMin ?? 10,
|
||||||
|
incrementMin: opts.incrementMin ?? 60,
|
||||||
|
blocks: [{ uptoMin: null, priceMinorPerIncrement: opts.pricePerIncrementMinor ?? 10000 }],
|
||||||
|
dailyCapMinor: null,
|
||||||
|
lostTicketMinor: 50000,
|
||||||
|
gracePeriodExitMin: opts.gracePeriodExitMin ?? 15,
|
||||||
|
overstay: "reprice",
|
||||||
|
};
|
||||||
|
db.insert(tariffs).values({ id: tariffId, scope: "site", name: "Test" }).run();
|
||||||
|
db.insert(tariffVersions).values({
|
||||||
|
id: versionId,
|
||||||
|
tariffId,
|
||||||
|
effectiveFrom: opts.effectiveFrom ?? "2000-01-01T00:00:00.000Z",
|
||||||
|
currency,
|
||||||
|
structure: structure as unknown as Record<string, unknown>,
|
||||||
|
}).run();
|
||||||
|
return { tariffVersionId: versionId, currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO string `minutes` ago from now (for entries that should already owe a fee). */
|
||||||
|
export function minutesAgo(minutes: number): string {
|
||||||
|
return new Date(Date.now() - minutes * 60_000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP integration scaffolding (route tests via app.inject) -----------------
|
||||||
|
|
||||||
|
/** Seed a user with a role. `admin` role grants every permission (ADMIN_PERMS);
|
||||||
|
* any other role gets exactly the `permissions` listed. Returns the credentials. */
|
||||||
|
export async function seedUser(
|
||||||
|
db: Db,
|
||||||
|
opts: { username?: string; password?: string; roleId?: string; permissions?: Permission[] } = {},
|
||||||
|
): Promise<{ username: string; password: string; roleId: string }> {
|
||||||
|
const username = opts.username ?? "tester";
|
||||||
|
const password = opts.password ?? "test-password-123";
|
||||||
|
const roleId = opts.roleId ?? "admin";
|
||||||
|
if (roleId !== "admin") {
|
||||||
|
db.insert(roles).values({ id: roleId, name: roleId, builtin: 0 }).onConflictDoNothing().run();
|
||||||
|
for (const p of opts.permissions ?? []) {
|
||||||
|
db.insert(rolePermissions).values({ roleId, permission: p }).onConflictDoNothing().run();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The admin role row must exist for the FK; ADMIN_PERMS is resolved in code.
|
||||||
|
db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run();
|
||||||
|
}
|
||||||
|
db.insert(users).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
username,
|
||||||
|
passwordHash: await bcrypt.hash(password, 10),
|
||||||
|
roleId,
|
||||||
|
}).run();
|
||||||
|
return { username, password, roleId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log in via the real auth route and return the cookie header + CSRF token to
|
||||||
|
* replay on subsequent requests (mutations need both the cookie and the header). */
|
||||||
|
export async function login(
|
||||||
|
app: FastifyInstance,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ cookie: string; csrf: string }> {
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
|
||||||
|
if (res.statusCode !== 200) throw new Error(`login failed: ${res.statusCode} ${res.body}`);
|
||||||
|
const setCookies = res.cookies;
|
||||||
|
const cookie = setCookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
||||||
|
const csrf = setCookies.find((c) => c.name === "parking_csrf")?.value ?? "";
|
||||||
|
return { cookie, csrf };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,5 +9,6 @@
|
|||||||
{ "path": "../../packages/db" },
|
{ "path": "../../packages/db" },
|
||||||
{ "path": "../../packages/devices" }
|
{ "path": "../../packages/devices" }
|
||||||
],
|
],
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
// Server tests live next to the code under test (src/**/*.test.ts). They run against
|
||||||
|
// a fresh in-memory SQLite from @parking/db/testing — never the live parking.sqlite.
|
||||||
|
// A test signing key is set here so the SoftwareSigner/buildSigner path works without
|
||||||
|
// a real .env (the value is irrelevant — tests assert self-consistency, not secrecy).
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
env: {
|
||||||
|
EVENT_SIGNING_KEY: "test-event-signing-key-0123456789",
|
||||||
|
JWT_SECRET: "test-jwt-secret-0123456789abcdef",
|
||||||
|
// Silence the Fastify request logger — route tests assert 401/403 responses,
|
||||||
|
// whose error logs would otherwise flood the test output.
|
||||||
|
LOG_LEVEL: "silent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Shared test fixtures.
|
||||||
|
|
||||||
|
The stub-mode smoke tests must be deterministic regardless of the developer's local
|
||||||
|
apps/vision/.env (which may set VISION_RECOGNIZER=fast_alpr for real-model work). An OS
|
||||||
|
environment variable takes precedence over the .env file in pydantic-settings, so we
|
||||||
|
force stub mode for the whole test session before the app's lifespan builds the
|
||||||
|
recognizer. Tests that exercise the real recognizer set their own override explicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _force_stub_recognizer() -> None:
|
||||||
|
"""Pin the recognizer to the model-free stub for every test (overrides .env)."""
|
||||||
|
prev = os.environ.get("VISION_RECOGNIZER")
|
||||||
|
os.environ["VISION_RECOGNIZER"] = "stub"
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if prev is None:
|
||||||
|
os.environ.pop("VISION_RECOGNIZER", None)
|
||||||
|
else:
|
||||||
|
os.environ["VISION_RECOGNIZER"] = prev
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Production build env for the SPA (auto-loaded by `vite build`, which the Tauri
|
||||||
|
# desktop bundle runs via beforeBuildCommand). NOT loaded by `vite` dev.
|
||||||
|
#
|
||||||
|
# The desktop shell serves the bundled SPA from tauri://localhost (no proxy, not
|
||||||
|
# same-origin), so the SPA must reach Fastify by absolute origin. This is the
|
||||||
|
# appliance's local Fastify address. Not a secret — committed for reproducible
|
||||||
|
# desktop builds. Override per-deployment if Fastify binds elsewhere.
|
||||||
|
#
|
||||||
|
# NOTE: a plain browser prod build (Fastify serving dist/ same-origin) does NOT
|
||||||
|
# want this set. If you build the SPA for that, override VITE_API_BASE="" .
|
||||||
|
VITE_API_BASE=http://127.0.0.1:3000
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
@@ -17,10 +18,13 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.15",
|
"@radix-ui/react-tabs": "^1.1.15",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
|
"recharts": "^3.2.1",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -28,9 +32,12 @@
|
|||||||
"@tanstack/react-router-devtools": "^1.167.0",
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@types/react": "19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.16"
|
"vite": "8.0.16",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
|||||||
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
||||||
// possibly-present until grace runs out). Lets the operator find a stuck car —
|
// possibly-present until grace runs out). Lets the operator find a stuck car —
|
||||||
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
|
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
|
||||||
// - click a row → the pay/exit modal (pay an unpaid car, or review),
|
// - click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
|
||||||
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
|
// out-of-window charge, assist-open a prepaid subscriber, or review),
|
||||||
// No payment → no Open barrier button (the no-unpaid-bypass rule).
|
// - "Open barrier" (PAID transient sessions only) → an audited human-intervention
|
||||||
|
// re-pulse for a car that paid but whose barrier didn't confirm.
|
||||||
|
// No payment → no Open barrier button (the no-unpaid-bypass rule). Subscriptions get
|
||||||
|
// NO inline open here — their assist-open / window-charge payment is modal-only, so
|
||||||
|
// the list can't one-click past an unpaid out-of-window charge.
|
||||||
//
|
//
|
||||||
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
|
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
|
||||||
// stay listed with a distinct badge. A new period has begun (the car re-parked or is
|
// stay listed with a distinct badge. A new period has begun (the car re-parked or is
|
||||||
@@ -173,12 +177,14 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Open barrier — PAID-and-still-in-grace transient OR a SUBSCRIPTION
|
{/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
|
||||||
(prepaid). NOT an OVERSTAY session: its grace has expired, so the car
|
re-pulse for a car that paid but the barrier didn't confirm. NOT an
|
||||||
owes a top-up — the row routes to the pay/exit modal instead (no
|
OVERSTAY (grace expired → owes a top-up; routes to the pay/exit modal)
|
||||||
free overstay exit). An unpaid transient also has no button
|
and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
|
||||||
(no-unpaid-bypass). Mirrors reopenBarrier's server-side guard. */}
|
live in the pay/exit modal — the list must not offer a one-click open,
|
||||||
{(s.paidAt && !s.overstay) || s.subscription ? (
|
which would bypass an unpaid window charge). An unpaid transient has no
|
||||||
|
button either (no-unpaid-bypass). Mirrors reopenBarrier's server guard. */}
|
||||||
|
{s.paidAt && !s.overstay && !s.subscription ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={reopen.isPending || !shiftReady}
|
disabled={reopen.isPending || !shiftReady}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -45,6 +48,18 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
const [result, setResult] = useState<string | null>(null);
|
const [result, setResult] = useState<string | null>(null);
|
||||||
const [openingShift, setOpeningShift] = useState(false);
|
const [openingShift, setOpeningShift] = useState(false);
|
||||||
const [reprinting, setReprinting] = useState(false);
|
const [reprinting, setReprinting] = useState(false);
|
||||||
|
// For a PREPAID subscriber with nothing owed, the audited manual barrier open
|
||||||
|
// (assist a faulty reader / lost card) is no longer the default action — the
|
||||||
|
// operator reveals it explicitly so the modal isn't an always-on "open" button.
|
||||||
|
const [assistRevealed, setAssistRevealed] = useState(false);
|
||||||
|
// 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.
|
||||||
|
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.
|
||||||
@@ -87,6 +102,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscriber out-of-window charge: take the payment, but DON'T exit yet. The
|
||||||
|
// barrier open is the operator's explicit second step (so the flow reads:
|
||||||
|
// pay → then Open barrier), mirroring the two-step the operator asked for.
|
||||||
|
async function handlePaySubscriptionWindow() {
|
||||||
|
if (!s) return;
|
||||||
|
setError(null);
|
||||||
|
setPhase("paying");
|
||||||
|
try {
|
||||||
|
await paySession(identity, tender);
|
||||||
|
setWindowPaid(true);
|
||||||
|
setPhase("review");
|
||||||
|
void qc.invalidateQueries({ queryKey: ["session", identity] });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleOpenShift() {
|
async function handleOpenShift() {
|
||||||
setOpeningShift(true);
|
setOpeningShift(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -101,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);
|
||||||
@@ -280,17 +337,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* For a subscription with a window charge, explain why it's payable. For a
|
{/* Subscription guidance: an unpaid window charge explains the pay-first
|
||||||
plain prepaid subscription, explain the assist-open is the only action. */}
|
gate; once paid, prompt the operator to open the barrier; a prepaid
|
||||||
{subWindowDue ? (
|
subscriber sees the assist explanation only after revealing it. */}
|
||||||
|
{subWindowDue && !windowPaid ? (
|
||||||
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
|
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
|
||||||
{t("pay.windowChargeHint")}
|
{t("pay.windowChargeHint")}
|
||||||
</div>
|
</div>
|
||||||
) : isSubscription && (
|
) : isSubscription && windowPaid ? (
|
||||||
|
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[12px] text-term-text">
|
||||||
|
{t("pay.windowPaidHint")}
|
||||||
|
</div>
|
||||||
|
) : isSubscription && assistRevealed ? (
|
||||||
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
|
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
|
||||||
{t("pay.subAssistHint")}
|
{t("pay.subAssistHint")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* For an overstay, explain why a top-up is required (no free exit). */}
|
{/* For an overstay, explain why a top-up is required (no free exit). */}
|
||||||
{isOverstay && (
|
{isOverstay && (
|
||||||
@@ -302,10 +364,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
{/* Snapshots */}
|
{/* Snapshots */}
|
||||||
<SnapshotStrip identity={identity} />
|
<SnapshotStrip identity={identity} />
|
||||||
|
|
||||||
{phase !== "done" && !isSubscription && (
|
{/* Tender — shown for any payable case (transient, overstay, OR a
|
||||||
<>
|
subscriber window charge that's still unpaid). */}
|
||||||
{/* Tender */}
|
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
||||||
{canPay && (
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||||
{(["cash", "card"] as const).map((tn) => (
|
{(["cash", "card"] as const).map((tn) => (
|
||||||
@@ -321,7 +382,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Voucher checkbox (default from site config) */}
|
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
||||||
|
{phase !== "done" && !isSubscription && (
|
||||||
<label className="flex items-center gap-2 text-[12px]">
|
<label className="flex items-center gap-2 text-[12px]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -332,7 +394,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
{t("pay.printExitVoucher")}
|
{t("pay.printExitVoucher")}
|
||||||
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
|
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
|
||||||
</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>}
|
||||||
@@ -374,8 +465,21 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
{isSubscription ? (
|
{isSubscription ? (
|
||||||
// Prepaid — the only action is the audited barrier open (assist
|
subWindowDue && !windowPaid ? (
|
||||||
// a faulty exit reader / missing card). Gated on an open shift.
|
// Step 1 — a window charge is owed: take payment first. The
|
||||||
|
// barrier open is the explicit next step (revealed once paid).
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePaySubscriptionWindow}
|
||||||
|
disabled={!shiftReady || phase === "paying"}
|
||||||
|
className="btn btn-go btn-lg"
|
||||||
|
>
|
||||||
|
{phase === "paying" ? t("pay.takingPayment") : t("pay.payWindowCharge")}
|
||||||
|
</button>
|
||||||
|
) : windowPaid || assistRevealed ? (
|
||||||
|
// The audited barrier open. Shown only AFTER a window charge is
|
||||||
|
// settled, or after the operator explicitly reveals the assist —
|
||||||
|
// never as the default action for a prepaid subscriber.
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenBarrier}
|
onClick={handleOpenBarrier}
|
||||||
@@ -385,6 +489,40 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
// Prepaid, nothing owed: no default open. A small reveal exposes
|
||||||
|
// the audited manual open for a faulty reader / lost card.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAssistRevealed(true)}
|
||||||
|
disabled={!shiftReady}
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
{t("pay.assistOpenReveal")}
|
||||||
|
</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}
|
||||||
@@ -405,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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { useRef, useState, type ReactNode } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||||
import { formatMoney } from "./lib/format.js";
|
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useLiveStore } from "./lib/live-store.js";
|
import { useLiveStore } from "./lib/live-store.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
|
import { useScanner } from "./lib/use-scanner.js";
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
import { StatusDot } from "./ui/StatusDot.js";
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
import { BoothPayModal } from "./BoothPayModal.js";
|
import { BoothPayModal } from "./BoothPayModal.js";
|
||||||
import { ActiveSessions } from "./ActiveSessions.js";
|
import { ActiveSessions } from "./ActiveSessions.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
|
||||||
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||||
import { renderReason } from "./lib/reason.js";
|
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||||
|
|
||||||
// The live operator booth view — the real-time heart of the console. Occupancy
|
// The live operator booth view — the real-time heart of the console. Occupancy
|
||||||
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
||||||
@@ -21,21 +19,6 @@ import { renderReason } from "./lib/reason.js";
|
|||||||
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
|
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
|
||||||
|
|
||||||
/** Per-event-type display: i18n label key + accent colour for the ticker. */
|
/** Per-event-type display: i18n label key + accent colour for the ticker. */
|
||||||
const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
|
|
||||||
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
|
|
||||||
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
|
|
||||||
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
|
|
||||||
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
|
|
||||||
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
|
|
||||||
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
|
|
||||||
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
|
|
||||||
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
|
|
||||||
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
|
||||||
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
|
||||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
|
||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Live-feed filter category for an event type. Several ledger types collapse into a
|
// Live-feed filter category for an event type. Several ledger types collapse into a
|
||||||
// few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the
|
// few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the
|
||||||
// filter and only show under "all".
|
// filter and only show under "all".
|
||||||
@@ -57,12 +40,6 @@ function feedCat(type: string): FeedCat | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hhmmss(iso: string): string {
|
|
||||||
// Local time-of-day, terminal style. Defensive against a bad timestamp.
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
|
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
|
||||||
@@ -98,260 +75,6 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Translated classification badges derived from a payload's boolean flags. Unlike
|
|
||||||
* `reason` (an immutable English sentence baked into the signed ledger, shown
|
|
||||||
* verbatim), these are computed client-side so they CAN be localized. They give a
|
|
||||||
* glanceable "what kind of anomaly" tag without parsing the free-text reason. */
|
|
||||||
function eventBadges(p: LedgerEvent["payload"]): string[] {
|
|
||||||
if (!p) return [];
|
|
||||||
const keys: string[] = [];
|
|
||||||
if (p.entryRefused) keys.push("booth.badgeEntryRefused");
|
|
||||||
if (p.exitRefused) keys.push("booth.badgeExitRefused");
|
|
||||||
if (p.full) keys.push("booth.badgeLotFull");
|
|
||||||
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
|
|
||||||
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
|
||||||
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
|
|
||||||
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
|
|
||||||
// Subscriber entered/exited outside their plan's allowed window → owes a deferred
|
|
||||||
// transient charge, collected (gated) at exit. Flag it so the operator KNOWS now.
|
|
||||||
if (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0) keys.push("booth.badgeWindowCharge");
|
|
||||||
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The i18n key for a subscriber's access medium (`via`), or null. Lets the activity
|
|
||||||
* log show HOW a subscriber entered/left — QR code, RFID card/chip, or plate. */
|
|
||||||
function viaKey(p: LedgerEvent["payload"]): string | null {
|
|
||||||
if (!p) return null;
|
|
||||||
if (p.via === "qr") return "booth.viaQr";
|
|
||||||
if (p.via === "card") return "booth.viaCard";
|
|
||||||
if (p.via === "plate") return "booth.viaPlate";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A short money summary for payment events (e.g. "350.00 ALL"). */
|
|
||||||
function paymentSummary(p: LedgerEvent["payload"]): string | null {
|
|
||||||
if (!p || typeof p.amountMinor !== "number" || !p.currency) return null;
|
|
||||||
return formatMoney(p.amountMinor, p.currency);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What to SHOW for an event's actor. A subscription occurrence has an opaque
|
|
||||||
* `SUBSESS-…` identity; the server resolves the holder's name into `subscriberLabel`,
|
|
||||||
* so we show that (e.g. "Aqif Kopertoni") instead. Otherwise the identity itself. */
|
|
||||||
function displayIdentity(e: LedgerEvent): string {
|
|
||||||
return e.subscriberLabel ?? e.identity ?? "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const style = EVENT_STYLE[e.type];
|
|
||||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
|
||||||
const isAnomaly = e.type === "anomaly";
|
|
||||||
const p = e.payload;
|
|
||||||
// Localize the reason from the signed reasonCode (falls back to the English text on
|
|
||||||
// legacy events). Anomalies ALWAYS get a detail line so a red flag is never silent.
|
|
||||||
const reason = renderReason(p, t);
|
|
||||||
const amount = paymentSummary(p);
|
|
||||||
const badges = eventBadges(p);
|
|
||||||
const via = viaKey(p);
|
|
||||||
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
|
||||||
const showDetail = detail != null || badges.length > 0 || via != null;
|
|
||||||
|
|
||||||
// The whole row is a button → opens the event-detail modal (full payload + the
|
|
||||||
// session's entry/exit snapshots). A grid keeps the time/label/identity/index
|
|
||||||
// columns aligned across rows; the detail line lives in its own row, indented to
|
|
||||||
// start under the identity column so it never collides with the ticket code.
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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 ${
|
|
||||||
isAnomaly ? "bg-term-red/5" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
|
||||||
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
|
||||||
{e.plate && (
|
|
||||||
<span
|
|
||||||
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
|
|
||||||
title={t("booth.plateTitle")}
|
|
||||||
>
|
|
||||||
{e.plate}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="text-term-muted">#{e.index}</span>
|
|
||||||
{showDetail && (
|
|
||||||
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
{badges.map((k) => (
|
|
||||||
<span
|
|
||||||
key={k}
|
|
||||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
|
||||||
>
|
|
||||||
{t(k)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{via && (
|
|
||||||
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
|
||||||
{t(via)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{detail && (
|
|
||||||
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One label/value line in the event-detail modal. */
|
|
||||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
|
||||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
|
||||||
<span className="min-w-0 break-words text-term-text">{children}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full read-only detail for one ledger event: business fields + the human-readable
|
|
||||||
* reason + the session's entry/exit snapshots, then the signed-chain provenance
|
|
||||||
* (signature/prev-hash/key) for an audit trail. Read-only — the ledger is immutable;
|
|
||||||
* this only DISPLAYS the signed record. */
|
|
||||||
function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const style = EVENT_STYLE[e.type];
|
|
||||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
|
||||||
const p = e.payload;
|
|
||||||
const reason = renderReason(p, t);
|
|
||||||
const amount = paymentSummary(p);
|
|
||||||
const badges = eventBadges(p);
|
|
||||||
const isAnomaly = e.type === "anomaly";
|
|
||||||
|
|
||||||
// Pretty money for any minor-unit amount in the payload.
|
|
||||||
const money =
|
|
||||||
p && typeof p.amountMinor === "number" && typeof p.currency === "string"
|
|
||||||
? formatMoney(p.amountMinor, p.currency)
|
|
||||||
: null;
|
|
||||||
// Pull out the business fields worth a labelled row. Everything else (and the raw
|
|
||||||
// bytes) lives behind the audit disclosure — the operator sees a clean summary.
|
|
||||||
const sessionRef = typeof p?.sessionRef === "string" ? p.sessionRef : null;
|
|
||||||
const plate = typeof p?.plate === "string" ? p.plate : null;
|
|
||||||
const category = typeof p?.category === "string" ? p.category : null;
|
|
||||||
const operator = typeof p?.operator === "string" ? p.operator : null;
|
|
||||||
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{/* 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={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
|
||||||
{(reason || money) && (
|
|
||||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
|
||||||
{reason ?? money}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!reason && !money && isAnomaly && (
|
|
||||||
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
|
|
||||||
)}
|
|
||||||
{badges.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
||||||
{badges.map((k) => (
|
|
||||||
<span
|
|
||||||
key={k}
|
|
||||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
|
||||||
>
|
|
||||||
{t(k)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
|
||||||
<div>
|
|
||||||
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
|
||||||
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
|
||||||
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
|
||||||
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
|
||||||
<DetailRow label={t("booth.edIdentity")}>{displayIdentity(e)}</DetailRow>
|
|
||||||
{/* When we showed a subscriber NAME above, also expose the raw occurrence id
|
|
||||||
(the SUBSESS-… session key) for traceability against the ledger. */}
|
|
||||||
{e.subscriberLabel && e.identity && (
|
|
||||||
<DetailRow label={t("booth.edOccurrence")}>
|
|
||||||
<code className="text-[11px] text-term-muted">{e.identity}</code>
|
|
||||||
</DetailRow>
|
|
||||||
)}
|
|
||||||
{money && (
|
|
||||||
<DetailRow label={t("booth.edAmount")}>
|
|
||||||
<span className="text-term-cyan">{money}</span>
|
|
||||||
</DetailRow>
|
|
||||||
)}
|
|
||||||
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
|
|
||||||
{viaKey(p) && (
|
|
||||||
<DetailRow label={t("booth.edVia")}>
|
|
||||||
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
|
|
||||||
</DetailRow>
|
|
||||||
)}
|
|
||||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
|
||||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
|
||||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
|
||||||
{sessionRef && sessionRef !== e.identity && (
|
|
||||||
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
|
||||||
)}
|
|
||||||
{tariffVersionId && (
|
|
||||||
<DetailRow label={t("booth.edTariffVersion")}>
|
|
||||||
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
|
|
||||||
</DetailRow>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The entry/exit evidence images for this session's identity. */}
|
|
||||||
{e.identity && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
|
||||||
<SnapshotStrip identity={e.identity} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audit data — collapsed by default. The signed-chain provenance (signature,
|
|
||||||
key, prev-hash) and the raw payload are an auditor's concern, not the
|
|
||||||
operator's; tucking them behind a disclosure keeps the common view clean
|
|
||||||
while preserving the tamper-evidence trail on demand. */}
|
|
||||||
<details className="rounded-term border border-term-border bg-term-panel-2">
|
|
||||||
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
|
||||||
{t("booth.edAuditData")}
|
|
||||||
</summary>
|
|
||||||
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
|
||||||
<DetailRow label={t("booth.edSignature")}>
|
|
||||||
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
|
|
||||||
</DetailRow>
|
|
||||||
<DetailRow label={t("booth.edKeyId")}>
|
|
||||||
<code className="text-[11px] text-term-muted">{e.keyId}</code>
|
|
||||||
</DetailRow>
|
|
||||||
<DetailRow label={t("booth.edPrevHash")}>
|
|
||||||
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
|
|
||||||
</DetailRow>
|
|
||||||
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
|
||||||
{t("booth.edRawPayload")}
|
|
||||||
</div>
|
|
||||||
{p && Object.keys(p).length > 0 ? (
|
|
||||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
|
|
||||||
{JSON.stringify(p, null, 2)}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual
|
/** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual
|
||||||
* operator types it. Either way, submit opens the pay/exit modal for that id. The
|
* operator types it. Either way, submit opens the pay/exit modal for that id. The
|
||||||
@@ -389,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
|
||||||
@@ -410,6 +171,11 @@ export function BoothScreen() {
|
|||||||
// The ledger event open in the read-only detail modal (null = closed).
|
// The ledger event open in the read-only detail modal (null = closed).
|
||||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||||
|
|
||||||
|
// A hardware scan opens the pay/exit modal regardless of focus (the operator needn't
|
||||||
|
// click the ticket field first). Paused while a modal is already up — a scan must not
|
||||||
|
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
|
||||||
|
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
|
||||||
|
|
||||||
// Live-feed filters: free-text search, event category, and direction/source.
|
// Live-feed filters: free-text search, event category, and direction/source.
|
||||||
const [feedSearch, setFeedSearch] = useState("");
|
const [feedSearch, setFeedSearch] = useState("");
|
||||||
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
||||||
@@ -470,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>
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function LogsViewer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl">
|
<div className="">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("logs.title")}</h1>
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("logs.title")}</h1>
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => q.refetch()}>
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => q.refetch()}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||