Compare commits
148 Commits
desktop-dev
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 21bfdce27a | |||
| d3288e29eb | |||
| baf7a4a99d | |||
| 885b410e48 | |||
| a1f3103a76 | |||
| 0fd66b261a | |||
| dfc5a07c10 | |||
| 5aabd7a791 | |||
| 0e9b9f5d82 | |||
| 642c5f4f70 | |||
| cb9f4d4979 | |||
| ea8fe22969 | |||
| 2910672b5a | |||
| 3a176c5cc8 | |||
| 19dff97c74 | |||
| 0ed43239c3 | |||
| 28bd838696 | |||
| 692dff5f89 | |||
| ba7538aeb5 | |||
| bb365b5d6e | |||
| c52a42dad2 | |||
| 22544ecf63 | |||
| ba5b4b1f4e | |||
| 51b160bfc9 | |||
| e2d5105da2 | |||
| 5287be5278 | |||
| 3a85483e6c | |||
| 6ceaadfbf2 | |||
| 7f42805e8d | |||
| cd3b534e51 | |||
| 011fe5a4c4 | |||
| 6f3f6ca596 | |||
| 5443b910c6 | |||
| a02957034d | |||
| ee61c24bb9 | |||
| 3a186d29df | |||
| 827445d514 | |||
| f9887c2a76 | |||
| 7649b897c4 | |||
| ab968eb25e | |||
| 5e1a885dcb | |||
| 6cf3492bff | |||
| ffe8c13a1c | |||
| fcea992e1e | |||
| 81bc2e357c | |||
| 7ef332999e | |||
| a2e102f3dd | |||
| 14638c2e13 | |||
| 4f902d869e | |||
| a5e54a8b93 | |||
| 0180394c45 | |||
| d905dd19b4 | |||
| c5ed3f1308 | |||
| 0b7eb28dfa | |||
| d5ff2097bd | |||
| 1de209be48 | |||
| dc2cdc0a91 | |||
| fd9885e9ec | |||
| 52a89bfa56 | |||
| d9e6c13831 | |||
| 493210bbb0 | |||
| a9f18be700 | |||
| 72ad504b8d | |||
| 5cdf8f227b | |||
| 365b648282 | |||
| c03ef2a34b | |||
| d9829eb61f | |||
| bafa3282c7 | |||
| 93f9ebea05 | |||
| c21babf293 | |||
| 44f34d68c4 | |||
| 43c1f45e29 | |||
| 35e593ab63 | |||
| b4f1418858 | |||
| 9d73561855 | |||
| 094e963e5e | |||
| 6505a4a73b | |||
| 8b65e199a3 | |||
| f486dcbbfc | |||
| c142166972 | |||
| 306d136a08 | |||
| 61b9955160 | |||
| b7e4037fbe | |||
| d2ab2e022e | |||
| 33c4ea1e91 | |||
| 114a32e6f2 | |||
| 018328a877 | |||
| 266e9b0027 | |||
| d92b8d1e6a | |||
| 61de1fe772 | |||
| cfac14e09e | |||
| 1b86750b0d | |||
| 9c6741a485 | |||
| d0b609e375 | |||
| 8f32d90d28 | |||
| 07295f8063 | |||
| 652d6599d3 | |||
| 39c778fbac | |||
| e16bccc2f5 | |||
| 2ab001054d | |||
| 381046190b | |||
| 84f00db48b | |||
| d5e41500a8 | |||
| 0c218179c4 | |||
| 9e442586af | |||
| 11567a417f | |||
| f6e35bbebf | |||
| 96acd6b662 | |||
| cce99aadfd | |||
| f706726eeb | |||
| 6734e9815e | |||
| 38481f105f | |||
| 4418594af0 | |||
| 25a72ff20a | |||
| c2a861208f | |||
| a888125eca | |||
| 96fd97efa9 | |||
| 2a13b95da6 | |||
| 513566c89e | |||
| f77ed11782 | |||
| e4a17efd97 | |||
| 6d32e0fc0f | |||
| 3a60367232 | |||
| 045892bc94 | |||
| 916c147b4d | |||
| 1ea1aa4189 | |||
| 9c20faf8de | |||
| a68dc23393 | |||
| c87dcb2253 | |||
| 7eadf71a0b | |||
| 9918f278b2 | |||
| 83298bc0c5 | |||
| 898cf1953a | |||
| dd0f6e483a | |||
| 40de8a7467 | |||
| f0fd15bb88 | |||
| 40ffa90dac | |||
| b3cb67188e | |||
| b1c4109045 | |||
| 50dd554b43 | |||
| 6d7682ab4a | |||
| 793b8d83ee | |||
| 7366ad19cb | |||
| 5a5fedf4f4 | |||
| 830993bcb8 | |||
| fd15988a73 | |||
| 420542ce10 | |||
| 2915d141aa |
@@ -0,0 +1,37 @@
|
|||||||
|
# Booth deploy env — copy to `.env` and fill in, then run ./scripts/booth.sh up
|
||||||
|
# (prod). Consumed by docker-compose.yml + the prod override via --env-file.
|
||||||
|
# See wiki/decisions/container-deployment.md. Do NOT commit the filled-in .env.
|
||||||
|
|
||||||
|
# --- image source (prod pulls from the house Gitea registry) ------------------
|
||||||
|
# The registry namespace; combined with the image name + TAG below.
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# Image tag to deploy. CI publishes TWO tags per build: a MOVING branch tag
|
||||||
|
# (`dev`, and `main` once that branch is built) republished on every push, and an
|
||||||
|
# IMMUTABLE per-commit `dev-<sha>` (e.g. dev-830993b). Use the moving tag for a
|
||||||
|
# self-updating booth (`booth.sh update` pulls the latest); pin the `<branch>-<sha>`
|
||||||
|
# form for a reproducible, deterministic deploy. NOTE: `main` images only exist once
|
||||||
|
# something is built on main — until then deploy from `dev`.
|
||||||
|
TAG=dev
|
||||||
|
|
||||||
|
# --- secrets (NO safe defaults — the server refuses to boot without a real one) -
|
||||||
|
# JWT signing secret. Generate yourself, never share it: openssl rand -hex 32
|
||||||
|
# Must be 32+ chars and must NOT contain change-me / insecure / dev-only.
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Ledger-signing key for the append-only signed event chain. Set a DISTINCT value
|
||||||
|
# in prod (don't reuse JWT_SECRET). openssl rand -hex 32
|
||||||
|
EVENT_SIGNING_KEY=
|
||||||
|
|
||||||
|
# --- booth LAN specifics ------------------------------------------------------
|
||||||
|
# Auth cookie is HTTPS-only by default; the booth is plain HTTP behind Caddy on
|
||||||
|
# :80, so this MUST stay 0 or operators cannot log in. Set to 1 only behind TLS.
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
|
||||||
|
# Remote origins the live WS feed must accept (same-origin always passes). Add any
|
||||||
|
# address admins hit the UI from beyond the booth itself, comma-separated, e.g.
|
||||||
|
# http://parksystems.msai.al (leave blank if only the local booth URL is used).
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# Vision/ANPR. Prod override already forces the fast_alpr engine; leave VISION_ENABLED=1
|
||||||
|
# unless you are running without the camera. (Set 0 to disable the vision call entirely.)
|
||||||
|
VISION_ENABLED=1
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
name: Build & push images
|
name: Build & push images
|
||||||
|
|
||||||
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
|
# 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).
|
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage,
|
||||||
# Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle). Mirrors the
|
# main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle).
|
||||||
# house pattern (cf. trm/processor build.yml). See wiki/decisions/container-deployment.md.
|
# Mirrors the house pattern (cf. trm/processor build.yml). See
|
||||||
|
# wiki/decisions/container-deployment.md and fleet-deployment-komodo.md (dev→stage→main tiers).
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev, main]
|
branches: [dev, stage, main]
|
||||||
paths:
|
paths:
|
||||||
- 'apps/server/**'
|
- 'apps/server/**'
|
||||||
- 'apps/web/**'
|
- 'apps/web/**'
|
||||||
@@ -20,6 +21,10 @@ on:
|
|||||||
- 'docker-compose*.yml'
|
- 'docker-compose*.yml'
|
||||||
- '.dockerignore'
|
- '.dockerignore'
|
||||||
- '.gitea/workflows/build-images.yml'
|
- '.gitea/workflows/build-images.yml'
|
||||||
|
# Deploy/IaC changes (compose above, plus the Komodo Stack defs) also rebuild — so a
|
||||||
|
# promotion or a Stack tweak gets the same build+checks sanity pass before it reaches a
|
||||||
|
# booth, and a komodo-only push to `stage` still produces a :stage image.
|
||||||
|
- 'komodo/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -87,6 +92,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: apps/server/Dockerfile
|
file: apps/server/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
||||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
|||||||
+114
-12
@@ -1,12 +1,24 @@
|
|||||||
name: Release desktop
|
name: Release desktop
|
||||||
|
|
||||||
# Build the signed Tauri desktop installers on a version tag and publish them as
|
# 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)
|
# a Gitea Release — TWICE: once on this (private, source) repo for our own
|
||||||
# fetches these; latest.json + each installer + its .sig are what it needs.
|
# records/history, and once mirrored to mca/public_releases, which is what the
|
||||||
|
# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at.
|
||||||
|
#
|
||||||
|
# WHY a separate public repo: the updater runs on offline-first field appliances
|
||||||
|
# with no Gitea credentials, so its endpoint + installer downloads must be
|
||||||
|
# reachable unauthenticated. Mirroring compiled installers to a public
|
||||||
|
# releases-only repo avoids embedding any read token in the shipped app (which
|
||||||
|
# would leak the moment a booth PC is compromised — this box's threat model
|
||||||
|
# names the operator/booth as the primary adversary, see CLAUDE.md). Source
|
||||||
|
# stays private; only signed installers become public, same as most desktop
|
||||||
|
# software. mca/public_releases is shared across apps in the org, not
|
||||||
|
# parking-specific — namespace release tags/asset names accordingly if another
|
||||||
|
# app starts publishing there too.
|
||||||
#
|
#
|
||||||
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
|
# 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
|
# with the updater key (Gitea secrets), assembles latest.json pointing at the
|
||||||
# everything to the Release for that tag.
|
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -73,30 +85,41 @@ jobs:
|
|||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
id: collect
|
id: collect
|
||||||
# Gather the installers + their .sig into a flat dist/ for upload.
|
# Gather the installers + their .sig into a flat dist/ for upload, spaces
|
||||||
|
# stripped from filenames. productName is "Parking System" (a space), so
|
||||||
|
# Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an
|
||||||
|
# unescaped space in a filename breaks the later curl asset-upload URL
|
||||||
|
# ("URL rejected: Malformed input to a URL function", hit on the very
|
||||||
|
# first v0.1.0 release) AND would land in latest.json's asset url, which
|
||||||
|
# the updater's plain HTTP GET can't handle either. Rename on copy.
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
||||||
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
||||||
-exec cp {} dist/ \;
|
-print0 | while IFS= read -r -d '' f; do
|
||||||
|
name=$(basename "$f" | tr ' ' '-')
|
||||||
|
cp "$f" "dist/${name}"
|
||||||
|
done
|
||||||
echo "Artifacts:"; ls -la dist/
|
echo "Artifacts:"; ls -la dist/
|
||||||
|
|
||||||
- name: Assemble latest.json
|
- name: Assemble latest.json
|
||||||
# The Tauri updater fetches a manifest describing the newest version, its
|
# The Tauri updater fetches a manifest describing the newest version, its
|
||||||
# notes, and per-target {signature, url}. We point the AppImage target at
|
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||||
# this release's asset URL. Adjust the platform keys you actually ship.
|
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
||||||
|
# appliances actually reach; see the workflow header for why. Adjust the
|
||||||
|
# platform keys you actually ship.
|
||||||
env:
|
env:
|
||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
REPO: ${{ github.repository }}
|
MIRROR_REPO: mca/public_releases
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
||||||
SIG=$(cat "dist/${APPIMAGE}.sig")
|
SIG=$(cat "dist/${APPIMAGE}.sig")
|
||||||
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
|
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}"
|
||||||
cat > dist/latest.json <<JSON
|
cat > dist/latest.json <<JSON
|
||||||
{
|
{
|
||||||
"version": "${VERSION}",
|
"version": "${VERSION}",
|
||||||
@@ -129,12 +152,12 @@ jobs:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
"${API}/repos/${REPO}/releases" || true)
|
"${API}/repos/${REPO}/releases" || true)
|
||||||
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
if [ -z "$REL_ID" ]; then
|
if [ -z "$REL_ID" ]; then
|
||||||
# Release may already exist for this tag — look it up by tag.
|
# Release may already exist for this tag — look it up by tag.
|
||||||
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||||
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
fi
|
fi
|
||||||
echo "release id: ${REL_ID}"
|
echo "release id: ${REL_ID}"
|
||||||
for f in dist/*; do
|
for f in dist/*; do
|
||||||
@@ -147,3 +170,82 @@ jobs:
|
|||||||
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||||
done
|
done
|
||||||
echo "done"
|
echo "done"
|
||||||
|
|
||||||
|
- name: Mirror release to mca/public_releases (Gitea API)
|
||||||
|
# This is the release the updater and any human downloader actually use —
|
||||||
|
# public_releases has no source, only installers, so it can be public
|
||||||
|
# without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository
|
||||||
|
# token scoped for pushing releases into that repo (Gitea's org secrets,
|
||||||
|
# not exposed to any deployed client).
|
||||||
|
#
|
||||||
|
# Publishes to TWO tags there, since public_releases is shared across
|
||||||
|
# apps in the org and Gitea's "latest release" redirect resolves by
|
||||||
|
# newest tag on the WHOLE repo (would break the moment another app
|
||||||
|
# publishes something newer):
|
||||||
|
# - desktop-<TAG> versioned, permanent — audit trail / rollback.
|
||||||
|
# - desktop-latest moving — assets deleted + re-uploaded each release.
|
||||||
|
# This is the fixed URL tauri.conf.json's updater endpoint points at
|
||||||
|
# (a stable name every appliance can always resolve, regardless of
|
||||||
|
# what else gets released in this repo meanwhile).
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }}
|
||||||
|
API: ${{ github.api_url }}
|
||||||
|
MIRROR_REPO: mca/public_releases
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
create_or_get_release() {
|
||||||
|
local mirror_tag="$1" prerelease="$2"
|
||||||
|
REL=$(curl -sS -w '\n%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases" || true)
|
||||||
|
echo "create response (${mirror_tag}): ${REL}"
|
||||||
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}")
|
||||||
|
echo "tag lookup response (${mirror_tag}): ${LOOKUP}"
|
||||||
|
REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
fi
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
upload_assets() {
|
||||||
|
local rel_id="$1"
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "mirroring ${name} -> release ${rel_id}"
|
||||||
|
HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${f}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}")
|
||||||
|
if [ "$HTTP_CODE" -ge 300 ]; then
|
||||||
|
echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Versioned, permanent.
|
||||||
|
create_or_get_release "desktop-${TAG}" false
|
||||||
|
echo "versioned mirror release id: ${REL_ID}"
|
||||||
|
upload_assets "${REL_ID}"
|
||||||
|
|
||||||
|
# 2. Moving desktop-latest — delete existing assets first (re-upload
|
||||||
|
# with the same name 409s otherwise), then re-upload.
|
||||||
|
create_or_get_release "desktop-latest" false
|
||||||
|
LATEST_REL_ID="${REL_ID}"
|
||||||
|
echo "latest mirror release id: ${LATEST_REL_ID}"
|
||||||
|
EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets")
|
||||||
|
printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null
|
||||||
|
done || true
|
||||||
|
upload_assets "${LATEST_REL_ID}"
|
||||||
|
echo "done"
|
||||||
|
|||||||
+2
-1
@@ -26,4 +26,5 @@ dist/
|
|||||||
|
|
||||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||||
graphify-out/
|
graphify-out/
|
||||||
parking.sqlite*.bak-*
|
parking.sqlite*.bak-*
|
||||||
|
questions.txt
|
||||||
|
|||||||
@@ -9,5 +9,7 @@
|
|||||||
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
|
||||||
:80 {
|
:80 {
|
||||||
encode gzip
|
encode gzip
|
||||||
reverse_proxy server:3000
|
# Host network (prod): the server runs on the host's net namespace (to reach the booth LAN /
|
||||||
|
# device VLAN), so reach it over loopback, not the compose service name `server`.
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -35,8 +35,16 @@ pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app
|
|||||||
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
|
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).
|
window needs a display (WSLg or an X server).
|
||||||
|
|
||||||
|
## Auto-update
|
||||||
|
|
||||||
|
Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored
|
||||||
|
to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first
|
||||||
|
field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated —
|
||||||
|
see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and
|
||||||
|
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
|
||||||
|
committed.
|
||||||
|
|
||||||
## Not here (deliberately)
|
## Not here (deliberately)
|
||||||
|
|
||||||
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
|
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
|
||||||
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
|
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
|
||||||
this shell connects to it.
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Parking System",
|
"productName": "Parking System",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.parking.desktop",
|
"identifier": "com.parking.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"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.",
|
"//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
|
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
|
||||||
],
|
],
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ JWT_SECRET=
|
|||||||
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
||||||
EVENT_SIGNING_KEY=
|
EVENT_SIGNING_KEY=
|
||||||
|
|
||||||
|
# On-site encrypted DB backup (durability for the signed ledger). A daily timer + an admin
|
||||||
|
# "back up now" button write a consistent, AES-256-GCM-encrypted copy to the target. The
|
||||||
|
# TARGET DIRECTORY is chosen by the admin in the UI (Setup → Backup) and stored in the DB —
|
||||||
|
# NOT here. Only the encryption KEY is an env secret. RESTORE is an out-of-band runbook action,
|
||||||
|
# not a console call. See wiki/concepts/backup-recovery.md.
|
||||||
|
#
|
||||||
|
# Dedicated backup-encryption key (>=16 chars), SEPARATE from EVENT_SIGNING_KEY so it can
|
||||||
|
# rotate without fracturing the signed chain. Generate with: openssl rand -hex 32
|
||||||
|
# Escrow it offsite (alongside EVENT_SIGNING_KEY) — recovery needs both, and neither is ever
|
||||||
|
# stored inside the backup it unlocks. Backups stay a no-op until BOTH this key and an in-UI
|
||||||
|
# target directory are set. The target directory AND retention (keep-last / keep-daily) are
|
||||||
|
# admin-chosen in the UI (Setup → Backup), NOT env — only this key is an env secret.
|
||||||
|
# BACKUP_KEY=
|
||||||
|
|
||||||
# Optional ----------------------------------------------------------------
|
# Optional ----------------------------------------------------------------
|
||||||
# PORT=3000
|
# PORT=3000
|
||||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
|||||||
# ---- runtime: slim, non-root ----
|
# ---- runtime: slim, non-root ----
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Set by CI to "<branch>-<short-sha>" (e.g. "stage-28bd838"), matching the same string used
|
||||||
|
# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the
|
||||||
|
# same string an admin would look up there. Empty/absent on a local `docker build` (dev only).
|
||||||
|
ARG BUILD_VERSION=""
|
||||||
|
ENV BUILD_VERSION=$BUILD_VERSION
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
||||||
RUN addgroup -S app && adduser -S -G app app
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"fastify": "5.8.5",
|
"fastify": "5.8.5",
|
||||||
"fastify-plugin": "6.0.0"
|
"fastify-plugin": "6.0.0",
|
||||||
|
"sharp": "^0.35.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "6.0.0",
|
"@types/bcrypt": "6.0.0",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
|
|||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const bcrypt = require("bcrypt");
|
const bcrypt = require("bcrypt");
|
||||||
const { createDb, users, eq } = require("@parking/db");
|
const { createDb, users, roles, eq } = require("@parking/db");
|
||||||
|
|
||||||
const DEFAULT_USERNAME = "admin";
|
const DEFAULT_USERNAME = "admin";
|
||||||
|
|
||||||
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
|
||||||
|
// Self-heal the built-in `admin` ROLE row. Migration 0007 seeds it once, but the
|
||||||
|
// training reset (reset-db.mjs --users/--all) wipes the roles table and points here
|
||||||
|
// to re-seed — without this, the user insert dies on the role_id FOREIGN KEY (field
|
||||||
|
// failure 2026-07-06). The admin permission SET is resolved in code (auth.ts), so
|
||||||
|
// the row alone is all the FK needs.
|
||||||
|
await db.insert(roles).values({ id: "admin", name: "Admin", builtin: 1 }).onConflictDoNothing();
|
||||||
|
|
||||||
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
||||||
if (existing && process.env.FORCE !== "1") {
|
if (existing && process.env.FORCE !== "1") {
|
||||||
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
||||||
@@ -73,4 +81,30 @@ if (existing) {
|
|||||||
});
|
});
|
||||||
console.log(`created admin "${username}"`);
|
console.log(`created admin "${username}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record the action into the SIGNED ledger (config_change). A console seed/reset is
|
||||||
|
// a Linux-admin action the app can't gate — but it must stay ATTRIBUTABLE after the
|
||||||
|
// fact (the chain is the audit record; whoever holds root can reset a password, they
|
||||||
|
// can't do it silently). Uses the server's own compiled EventLog + signer from dist/
|
||||||
|
// (present in the container; in a dev checkout run `pnpm build` first). Best-effort:
|
||||||
|
// a missing build or signing key WARNS loudly but never blocks the seed — locking an
|
||||||
|
// admin out to protect an audit line would invert the priority.
|
||||||
|
try {
|
||||||
|
const { EventLog } = await import("../dist/event-log.js");
|
||||||
|
const { buildSigner } = await import("../dist/signer.js");
|
||||||
|
const log = new EventLog(db, buildSigner());
|
||||||
|
await log.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: `user:${username}`,
|
||||||
|
payload: {
|
||||||
|
setting: existing ? "admin.passwordReset" : "admin.seeded",
|
||||||
|
username,
|
||||||
|
operator: "console:seed-admin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("recorded to the signed ledger (config_change)");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`WARNING: NOT recorded to the signed ledger: ${err.message}`);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import type { SubscriptionFlow, SubscriptionMatch } from "./subscription-flow.js
|
|||||||
// Mock buildCamera so the bridge gets a fake camera whose captureSnapshot is a stub
|
// 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.
|
// (no registry, no network). The factory returns a fresh shot each call.
|
||||||
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
const captureSnapshot = vi.fn(async () => ({ bytes: Buffer.from("jpg"), contentType: "image/jpeg" }));
|
||||||
|
// The bridge now goes through captureSnapshotShared (the dedup wrapper, exercised in
|
||||||
|
// snapshot.test.ts); here it just delegates to the fake camera's captureSnapshot so this
|
||||||
|
// suite stays focused on the bridge's own match/debounce/emit logic.
|
||||||
vi.mock("./snapshot.js", () => ({
|
vi.mock("./snapshot.js", () => ({
|
||||||
buildCamera: () => ({ captureSnapshot }),
|
buildCamera: () => ({ captureSnapshot }),
|
||||||
|
captureSnapshotShared: (_id: string, camera: { captureSnapshot: typeof captureSnapshot }, ctx: unknown) =>
|
||||||
|
camera.captureSnapshot(ctx as never),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Import AFTER the mock is registered.
|
// Import AFTER the mock is registered.
|
||||||
@@ -28,13 +33,22 @@ beforeEach(() => {
|
|||||||
captureSnapshot.mockClear();
|
captureSnapshot.mockClear();
|
||||||
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
||||||
delete process.env.ANPR_DEBOUNCE_MS;
|
delete process.env.ANPR_DEBOUNCE_MS;
|
||||||
|
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
|
||||||
|
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
|
||||||
|
// bridge reads these in its constructor, so set them before `new AnprBridge`.
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "5";
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
delete process.env.ANPR_POLL_MS;
|
||||||
|
delete process.env.ANPR_POLL_WINDOW_MS;
|
||||||
|
delete process.env.ANPR_POLL_MAX_MS;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
|
/** A camera bound to an entry relay; `anpr` toggles recognition, `anprAutoTrigger` the
|
||||||
function seedCamera(opts: { anpr?: boolean } = {}): string {
|
* per-camera auto-open gate (absent ⇒ defaults on). */
|
||||||
|
function seedCamera(opts: { anpr?: boolean; anprAutoTrigger?: boolean } = {}): string {
|
||||||
const controllerId = randomUUID();
|
const controllerId = randomUUID();
|
||||||
db.insert(devices).values({
|
db.insert(devices).values({
|
||||||
id: controllerId,
|
id: controllerId,
|
||||||
@@ -48,7 +62,13 @@ function seedCamera(opts: { anpr?: boolean } = {}): string {
|
|||||||
id: camId,
|
id: camId,
|
||||||
category: "camera",
|
category: "camera",
|
||||||
driverId: "hikvision",
|
driverId: "hikvision",
|
||||||
config: { host: "10.0.0.9", controllerId, relay: 1, ...(opts.anpr ? { anpr: true } : {}) },
|
config: {
|
||||||
|
host: "10.0.0.9",
|
||||||
|
controllerId,
|
||||||
|
relay: 1,
|
||||||
|
...(opts.anpr ? { anpr: true } : {}),
|
||||||
|
...(opts.anprAutoTrigger === false ? { anprAutoTrigger: false } : {}),
|
||||||
|
},
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}).run();
|
}).run();
|
||||||
return camId;
|
return camId;
|
||||||
@@ -74,8 +94,17 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
/** A fake SubscriptionFlow: only `match()` is called by the bridge. */
|
||||||
function fakeSubFlow(match: SubscriptionMatch | null): SubscriptionFlow {
|
function fakeSubFlow(
|
||||||
return { match: vi.fn(() => match) } as unknown as SubscriptionFlow;
|
match: SubscriptionMatch | null,
|
||||||
|
// openOccurrenceCount: a constant, or a sequence consumed per call (to simulate a
|
||||||
|
// credential closing an occurrence mid-poll → count changes).
|
||||||
|
openCounts: number | number[] = 1,
|
||||||
|
): SubscriptionFlow {
|
||||||
|
const seq = Array.isArray(openCounts) ? [...openCounts] : null;
|
||||||
|
return {
|
||||||
|
match: vi.fn(() => match),
|
||||||
|
openOccurrenceCount: vi.fn(() => (seq ? (seq.length > 1 ? seq.shift()! : seq[0]) : (openCounts as number))),
|
||||||
|
} as unknown as SubscriptionFlow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
const SUB_MATCH: SubscriptionMatch = { subscriptionId: "sub-1", carKey: "AA111BB", via: "plate" };
|
||||||
@@ -104,6 +133,18 @@ describe("AnprBridge", () => {
|
|||||||
expect(captureSnapshot).not.toHaveBeenCalled();
|
expect(captureSnapshot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does NOT auto-trigger when anprAutoTrigger=false (recognition on, auto-open off)", async () => {
|
||||||
|
// Shared entry/exit lane: the exit cam keeps anpr (recognition) but auto-trigger off, so a
|
||||||
|
// car driving IN isn't phantom-EXITed by its back plate. The bridge bails before snapshot.
|
||||||
|
const cam = seedCamera({ anpr: true, anprAutoTrigger: false });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.99 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]);
|
||||||
|
expect(captureSnapshot).not.toHaveBeenCalled(); // gated before the poll loop
|
||||||
|
});
|
||||||
|
|
||||||
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
it("emits a plate read (upper-cased) for a high-confidence SUBSCRIBER plate", async () => {
|
||||||
const cam = seedCamera({ anpr: true });
|
const cam = seedCamera({ anpr: true });
|
||||||
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
const vision = fakeVision({ plate: " aa111bb ", confidence: 0.97 });
|
||||||
@@ -123,6 +164,85 @@ describe("AnprBridge", () => {
|
|||||||
expect(reads).toEqual([]);
|
expect(reads).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
|
||||||
|
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
|
||||||
|
// must re-pull until one frame clears the floor, not give up on the first bad frame.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
|
||||||
|
const confs = [0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
// Generous window so all 3 escalation attempts run deterministically under suite load
|
||||||
|
// (the global beforeEach sets a tiny 5ms window for the give-up cases).
|
||||||
|
process.env.ANPR_POLL_MS = "1";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "2000";
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
|
||||||
|
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SLIDES the window: a push mid-poll keeps the loop alive past the initial deadline", async () => {
|
||||||
|
// A loop started by an early/far car would expire — but a NEW push (another car arriving)
|
||||||
|
// extends the deadline, so the loop keeps polling and reads the car that settles at the
|
||||||
|
// barrier. Here: a SHORT base window, vision stays low until attempt 5; a second push at
|
||||||
|
// the start bumps the deadline so attempt 5's confident read still lands.
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const confs = [0.2, 0.2, 0.2, 0.2, 0.97];
|
||||||
|
let i = 0;
|
||||||
|
const vision = {
|
||||||
|
enabled: true,
|
||||||
|
analyze: vi.fn(async () => ({
|
||||||
|
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||||
|
plates: [],
|
||||||
|
lowConfidence: false,
|
||||||
|
modelVersion: "test",
|
||||||
|
tookMs: 1,
|
||||||
|
})),
|
||||||
|
} as unknown as VisionClient;
|
||||||
|
process.env.ANPR_POLL_MS = "5";
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "12"; // tiny — would expire ~attempt 2 WITHOUT a slide
|
||||||
|
process.env.ANPR_POLL_MAX_MS = "5000"; // ceiling far above, so the slide is what matters
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(async () => {
|
||||||
|
const loop = bridge.onVehicleDetected(cam); // starts the loop
|
||||||
|
// Joining pushes keep sliding the deadline forward so the slow-to-confident read lands.
|
||||||
|
for (let k = 0; k < 5; k++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
void bridge.onVehicleDetected(cam); // each bumps the deadline (loop already running)
|
||||||
|
}
|
||||||
|
await loop;
|
||||||
|
});
|
||||||
|
expect(reads).toHaveLength(1);
|
||||||
|
expect(reads[0]).toMatchObject({ value: "AA111BB" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ABORTS if the subscriber transacts by another credential mid-poll (no double-act)", async () => {
|
||||||
|
// The car's plate is read (identity known) but stays below the floor; meanwhile the
|
||||||
|
// subscriber scans their card → openOccurrenceCount drops. The bridge must abort and NOT
|
||||||
|
// emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. fleet).
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.5 }); // never clears the floor
|
||||||
|
// openOccurrenceCount: 1 at baseline, then 0 (the card exit closed it) on the next check.
|
||||||
|
const sub = fakeSubFlow(SUB_MATCH, [1, 0]);
|
||||||
|
const bridge = new AnprBridge(db, vision, sub, silentLogger());
|
||||||
|
|
||||||
|
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(reads).toEqual([]); // aborted — the credential already handled it
|
||||||
|
});
|
||||||
|
|
||||||
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
||||||
const cam = seedCamera({ anpr: true });
|
const cam = seedCamera({ anpr: true });
|
||||||
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||||
@@ -136,6 +256,22 @@ describe("AnprBridge", () => {
|
|||||||
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
|
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("analyzes AT LEAST ONE frame even if the poll window already elapsed (loaded host)", async () => {
|
||||||
|
// Regression for a CI flake (2026-07-04): with a plain `while`, a window that lapsed
|
||||||
|
// between deadline-set and loop-entry (slow runner; here forced with a 0ms window)
|
||||||
|
// meant ZERO analyze attempts — the detection was silently dropped ("gave up") and no
|
||||||
|
// skip was recorded. The do-while guarantees one frame per detection regardless of load.
|
||||||
|
process.env.ANPR_POLL_WINDOW_MS = "0";
|
||||||
|
const cam = seedCamera({ anpr: true });
|
||||||
|
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||||
|
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
|
||||||
|
|
||||||
|
await captureReads(() => bridge.onVehicleDetected(cam));
|
||||||
|
expect(captureSnapshot).toHaveBeenCalledTimes(1); // the guaranteed first attempt
|
||||||
|
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
|
||||||
|
expect(skips).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
|
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
|
||||||
const cam = seedCamera({ anpr: true });
|
const cam = seedCamera({ anpr: true });
|
||||||
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||||
|
|||||||
+156
-12
@@ -30,6 +30,10 @@ import type { VisionClient } from "./vision-client.js";
|
|||||||
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
/** Camera config flag opting it into the ANPR bridge (same flag advisory ANPR uses). */
|
||||||
interface CameraConfig {
|
interface CameraConfig {
|
||||||
readonly anpr?: boolean;
|
readonly anpr?: boolean;
|
||||||
|
/** Whether this camera may AUTO-OPEN the barrier (entry/exit). Absent ⇒ true (when anpr is
|
||||||
|
* on). Set false to keep recognition but suppress auto-trigger — e.g. the exit camera on a
|
||||||
|
* shared entry/exit lane. */
|
||||||
|
readonly anprAutoTrigger?: boolean;
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +53,40 @@ function debounceMs(): number {
|
|||||||
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
return Number.isFinite(raw) && raw > 0 ? raw : 12_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single alarm fires the INSTANT motion starts — the car is still approaching, so the
|
||||||
|
* first frame often has a small/blurry/absent plate (a low-confidence misread). But the car
|
||||||
|
* then STOPS at the barrier (waiting for it to open) — the same stationary, well-framed
|
||||||
|
* moment the manual test reads at ~100%. So instead of one shot, we POLL fresh frames and
|
||||||
|
* re-run ANPR until one clears the confidence floor, or the window elapses. Poll interval: */
|
||||||
|
function pollMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MS ?? 1000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long to keep polling AFTER THE LAST vehicle push before giving up. SLIDING: each new
|
||||||
|
* push for the camera extends the deadline by this much from now — so a loop started by a
|
||||||
|
* far/early car keeps pulling fresh frames as the REAL car arrives and settles at the
|
||||||
|
* barrier (the loop tracks "whoever is here now", not the car that started it). */
|
||||||
|
function pollWindowMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_WINDOW_MS ?? 8000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hard ceiling on a single loop from its START, so a continuously-busy lane (pushes never
|
||||||
|
* stop) can't slide the window forever. The loop ends at min(lastPush + window, start + max). */
|
||||||
|
function pollMaxMs(): number {
|
||||||
|
const raw = Number(process.env.ANPR_POLL_MAX_MS ?? 30_000);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** A plate DeviceReadEvent skeleton (value filled by the caller) — for matching the
|
||||||
|
* subscriber by plate during the poll loop without re-building the whole event. */
|
||||||
|
function baseRead(row: { driverId: string }, deviceId: string): Omit<DeviceReadEvent, "value"> {
|
||||||
|
return { driverId: row.driverId, deviceId, kind: "plate", at: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
export class AnprBridge {
|
export class AnprBridge {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #vision: VisionClient | null;
|
readonly #vision: VisionClient | null;
|
||||||
@@ -56,9 +94,19 @@ export class AnprBridge {
|
|||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
readonly #entryMinConfidence: number;
|
readonly #entryMinConfidence: number;
|
||||||
readonly #debounceMs: number;
|
readonly #debounceMs: number;
|
||||||
|
readonly #pollMs: number;
|
||||||
|
readonly #pollWindowMs: number;
|
||||||
|
readonly #pollMaxMs: number;
|
||||||
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
/** Last-fire timestamps, keyed by deviceId (camera-level, pre-snapshot) AND by
|
||||||
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
* `deviceId:plate` (post-match) — both gated against #debounceMs. */
|
||||||
readonly #lastFire = new Map<string, number>();
|
readonly #lastFire = new Map<string, number>();
|
||||||
|
/** Cameras with a poll loop already in flight — a re-fired alarm (the camera pushes ~1Hz
|
||||||
|
* while the car sits) must NOT start a second concurrent loop on the same camera. */
|
||||||
|
readonly #polling = new Set<string>();
|
||||||
|
/** Per-camera SLIDING deadline for the running poll loop. A push that joins a running loop
|
||||||
|
* bumps this forward (lastPush + window, capped at start + max), so the loop keeps pulling
|
||||||
|
* fresh frames while cars keep arriving — tracking whoever settles at the barrier. */
|
||||||
|
readonly #pollDeadline = new Map<string, number>();
|
||||||
|
|
||||||
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
constructor(db: Db, vision: VisionClient | null, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -67,6 +115,9 @@ export class AnprBridge {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#entryMinConfidence = entryMinConfidence();
|
this.#entryMinConfidence = entryMinConfidence();
|
||||||
this.#debounceMs = debounceMs();
|
this.#debounceMs = debounceMs();
|
||||||
|
this.#pollMs = pollMs();
|
||||||
|
this.#pollWindowMs = pollWindowMs();
|
||||||
|
this.#pollMaxMs = pollMaxMs();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,15 +135,35 @@ export class AnprBridge {
|
|||||||
if (site && site.anprEntryEnabled === false) return;
|
if (site && site.anprEntryEnabled === false) return;
|
||||||
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
const row = this.#db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||||||
if (!row || !row.enabled || row.category !== "camera") return;
|
if (!row || !row.enabled || row.category !== "camera") return;
|
||||||
if ((row.config as CameraConfig)?.anpr !== true) return; // opt-in only
|
const cfg = row.config as CameraConfig;
|
||||||
|
if (cfg?.anpr !== true) return; // recognition opt-in (also gates the evidence/advisory path)
|
||||||
|
// Per-camera AUTO-TRIGGER gate. `anpr` keeps recognition (snapshots + plate record) on;
|
||||||
|
// this controls whether THIS camera may auto-open the barrier. A shared entry/exit lane
|
||||||
|
// sets it false on (e.g.) the exit camera so its back-plate read doesn't phantom-exit the
|
||||||
|
// car that just entered. Absent ⇒ true (back-compat: existing anpr cameras still trigger).
|
||||||
|
if (cfg.anprAutoTrigger === false) return;
|
||||||
|
|
||||||
// Camera-level debounce (pre-snapshot): a car re-firing ~1Hz must not pull a
|
// Post-success debounce: once we've emitted a read for this camera, ignore the
|
||||||
// snapshot + analyze every second.
|
// ~1Hz re-fires for #debounceMs (set on success below). A fresh alarm AFTER the
|
||||||
|
// window is a new presentation and may start a new poll loop.
|
||||||
if (this.#debounced(deviceId)) return;
|
if (this.#debounced(deviceId)) return;
|
||||||
this.#stamp(deviceId);
|
// One poll loop per camera. A push that arrives while a loop runs JOINs it — and
|
||||||
|
// SLIDES the deadline forward (a different car arriving mid-loop keeps the loop alive
|
||||||
|
// so it tracks whoever's at the barrier now, instead of giving up on the early car).
|
||||||
|
const now = Date.now();
|
||||||
|
if (this.#polling.has(deviceId)) {
|
||||||
|
const cur = this.#pollDeadline.get(deviceId) ?? now;
|
||||||
|
// Slide to lastPush + window, but never past the per-loop hard ceiling (set at start).
|
||||||
|
this.#pollDeadline.set(deviceId, Math.max(cur, now + this.#pollWindowMs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#polling.add(deviceId);
|
||||||
|
// Initial deadline; the hard ceiling (start + max) is enforced in the loop below.
|
||||||
|
this.#pollDeadline.set(deviceId, now + this.#pollWindowMs);
|
||||||
|
|
||||||
const camera = buildCamera(row);
|
const camera = buildCamera(row);
|
||||||
if (!camera) {
|
if (!camera) {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
this.#logger.warn(`anpr-bridge: camera ${deviceId} config won't build`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,16 +171,79 @@ export class AnprBridge {
|
|||||||
// "both" collapses to entry purely for the capture hint (it doesn't pick the lane —
|
// "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).
|
// the gated flow infers the verb from the camera's bound relay direction).
|
||||||
const direction: FlowDirection = directionOf(this.#db, row) === "exit" ? "exit" : "entry";
|
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
|
// POLL-UNTIL-CONFIDENT. The alarm fires as the car APPROACHES (small/blurry/absent
|
||||||
// object with its confidence even when its own lowConfidence flag is set).
|
// plate → low-confidence misread, e.g. '111'@0.20). But the car then STOPS at the
|
||||||
if (result.plate.confidence < this.#entryMinConfidence) {
|
// barrier — the stationary, well-framed moment the manual test reads at ~100%. So we
|
||||||
|
// pull a FRESH frame every #pollMs and re-run ANPR until one clears the floor, or the
|
||||||
|
// #pollWindowMs window elapses (car drove off / non-subscriber). NB: a fresh pull each
|
||||||
|
// tick — NOT captureSnapshotShared, whose TTL would re-serve the same bad frame.
|
||||||
|
// While polling, watch whether THIS subscriber transacts by another credential
|
||||||
|
// (card/QR at the reader). If their open-occurrence count drops mid-poll, the
|
||||||
|
// subscriber already exited/entered — the bridge must NOT also emit (it would act on
|
||||||
|
// the NEXT open occurrence: a phantom double-exit, worst for a fleet sub). We learn the
|
||||||
|
// subscription as soon as a frame reads the bound plate (identity needs no confidence),
|
||||||
|
// snapshot the count, then keep polling for a CONFIDENT read; abort if the count moved.
|
||||||
|
let result: Awaited<ReturnType<VisionClient["analyze"]>> = null;
|
||||||
|
let watchedSubId: string | null = null;
|
||||||
|
let baselineOpen = 0;
|
||||||
|
// Hard ceiling for THIS loop (start + max); the sliding deadline (bumped by joining
|
||||||
|
// pushes) is read from #pollDeadline each tick but never allowed past this cap.
|
||||||
|
const hardCap = Date.now() + this.#pollMaxMs;
|
||||||
|
let attempts = 0;
|
||||||
|
try {
|
||||||
|
// DO-while: a detection always analyzes AT LEAST ONE frame, however loaded the
|
||||||
|
// host — a plain while could zero-iterate if the window elapsed between setting
|
||||||
|
// the deadline and reaching the loop (seen as a CI flake with the tests' 5ms
|
||||||
|
// window; on a busy booth it would silently drop a real car's detection). Exit
|
||||||
|
// is via the breaks below (confident read, or next tick would pass the deadline).
|
||||||
|
do {
|
||||||
|
attempts++;
|
||||||
|
const shot = await camera.captureSnapshot({ direction });
|
||||||
|
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||||
|
|
||||||
|
// Identify the subscriber from ANY readable plate (even below the barrier floor),
|
||||||
|
// and baseline their open count once — so we can detect a credential beating us.
|
||||||
|
if (r?.plate?.text) {
|
||||||
|
const m0 = this.#subscription.match({ ...baseRead(row, deviceId), value: r.plate.text.trim().toUpperCase() });
|
||||||
|
if (m0 && watchedSubId == null) {
|
||||||
|
watchedSubId = m0.subscriptionId;
|
||||||
|
baselineOpen = this.#subscription.openOccurrenceCount(watchedSubId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A credential (card/QR) closed/opened an occurrence for this subscriber mid-poll →
|
||||||
|
// they already transacted; stop polling and do NOT emit.
|
||||||
|
if (watchedSubId && this.#subscription.openOccurrenceCount(watchedSubId) !== baselineOpen) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: subscriber ${watchedSubId} transacted by another credential mid-poll — aborting ANPR`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r?.plate && r.plate.confidence >= this.#entryMinConfidence) {
|
||||||
|
result = r;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (r?.plate) {
|
||||||
|
this.#logger.info(
|
||||||
|
`anpr-bridge: '${r.plate.text}' (${r.plate.confidence.toFixed(3)}) below floor ` +
|
||||||
|
`${this.#entryMinConfidence} — re-pulling (attempt ${attempts})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Stop if the next tick would land past the (possibly slid) deadline or the cap.
|
||||||
|
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
|
||||||
|
if (Date.now() + this.#pollMs >= effDeadline) break;
|
||||||
|
await sleep(this.#pollMs);
|
||||||
|
} while (true);
|
||||||
|
} finally {
|
||||||
|
this.#polling.delete(deviceId);
|
||||||
|
this.#pollDeadline.delete(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result || !result.plate) {
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`anpr-bridge: plate '${result.plate.text}' below entry floor ` +
|
`anpr-bridge: no confident plate from ${deviceId} after ${attempts} attempt(s) ` +
|
||||||
`(${result.plate.confidence.toFixed(3)} < ${this.#entryMinConfidence}) — ignored`,
|
`in ${this.#pollWindowMs}ms — gave up`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,11 +267,21 @@ export class AnprBridge {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final guard against the credential-mid-poll race: if the subscriber transacted between
|
||||||
|
// our baseline and now (e.g. a card scan in the last tick), don't double-act.
|
||||||
|
if (watchedSubId === match.subscriptionId && this.#subscription.openOccurrenceCount(match.subscriptionId) !== baselineOpen) {
|
||||||
|
this.#logger.info(`anpr-bridge: ${match.subscriptionId} already transacted — skipping ANPR emit`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
// Plate-level debounce — belt-and-suspenders against a gap that slips the
|
||||||
// camera-level gate re-emitting the SAME plate.
|
// camera-level gate re-emitting the SAME plate.
|
||||||
const plateKey = `${deviceId}:${plate}`;
|
const plateKey = `${deviceId}:${plate}`;
|
||||||
if (this.#debounced(plateKey)) return;
|
if (this.#debounced(plateKey)) return;
|
||||||
this.#stamp(plateKey);
|
this.#stamp(plateKey);
|
||||||
|
// Camera-level debounce stamp — now that we've emitted, suppress the camera's ~1Hz
|
||||||
|
// re-fires (and any new poll loop) for #debounceMs.
|
||||||
|
this.#stamp(deviceId);
|
||||||
|
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
`anpr-bridge: subscriber plate '${plate}' (${result.plate.confidence.toFixed(3)}) → read bus`,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { eq, siteConfig } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { BackupService } from "./backup-service.js";
|
||||||
|
|
||||||
|
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
|
||||||
|
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
|
||||||
|
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
|
||||||
|
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
|
||||||
|
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
|
||||||
|
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
|
||||||
|
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
|
||||||
|
// See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||||
|
|
||||||
|
let workDir: string;
|
||||||
|
let target: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
|
||||||
|
target = join(workDir, "target");
|
||||||
|
process.env.BACKUP_KEY = KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(workDir, { recursive: true, force: true });
|
||||||
|
delete process.env.BACKUP_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
|
||||||
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (existing) {
|
||||||
|
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BackupService — persisted status survives a restart", () => {
|
||||||
|
it("a fresh instance sees the previous instance's last success", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
expect(first.status().lastSuccessAt).toBeNull();
|
||||||
|
const result = await first.run("manual");
|
||||||
|
|
||||||
|
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
|
||||||
|
// production this would be a fresh process re-opening the same sqlite file).
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
const status = second.status();
|
||||||
|
expect(status.lastSuccessAt).not.toBeNull();
|
||||||
|
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
|
||||||
|
expect(status.lastError).toBeNull();
|
||||||
|
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
|
||||||
|
// throw, giving us a real, deterministic failure without needing to mock anything.
|
||||||
|
const badTarget = join(workDir, "not-a-dir");
|
||||||
|
writeFileSync(badTarget, "x");
|
||||||
|
setTargetDir(t.db, badTarget);
|
||||||
|
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
await expect(first.run("manual")).rejects.toThrow();
|
||||||
|
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
const status = second.status();
|
||||||
|
expect(status.lastError).not.toBeNull();
|
||||||
|
expect(status.lastErrorAt).not.toBeNull();
|
||||||
|
expect(status.lastSuccessAt).toBeNull();
|
||||||
|
|
||||||
|
// Now point at a real directory and succeed — the persisted error must clear.
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
await second.run("manual");
|
||||||
|
const third = new BackupService(t.db);
|
||||||
|
const finalStatus = third.status();
|
||||||
|
expect(finalStatus.lastSuccessAt).not.toBeNull();
|
||||||
|
expect(finalStatus.lastError).toBeNull();
|
||||||
|
expect(finalStatus.lastErrorAt).toBeNull();
|
||||||
|
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
|
||||||
|
it("is due immediately when no success has ever been recorded", () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
expect(svc.isDue()).toBe(true);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
await first.run("manual");
|
||||||
|
|
||||||
|
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
|
||||||
|
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
expect(second.isDue()).toBe(false);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is due once the persisted last-success timestamp is old enough", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
await svc.run("manual");
|
||||||
|
|
||||||
|
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
|
||||||
|
expect(svc.isDue(almostADayLater)).toBe(false);
|
||||||
|
|
||||||
|
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
|
||||||
|
expect(svc.isDue(overADayLater)).toBe(true);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
await svc.run("manual");
|
||||||
|
const afterFirst = svc.status().lastSuccessAt;
|
||||||
|
|
||||||
|
await svc.runScheduled(); // not due yet — must not run again
|
||||||
|
expect(svc.status().lastSuccessAt).toBe(afterFirst);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { constants } from "node:fs";
|
||||||
|
import { access, stat } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRetention } from "./backup.js";
|
||||||
|
|
||||||
|
// Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen
|
||||||
|
// and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI
|
||||||
|
// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) —
|
||||||
|
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
||||||
|
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||||
|
// wiki/concepts/backup-recovery.md.
|
||||||
|
//
|
||||||
|
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
|
||||||
|
// memory — an earlier version tracked these as plain in-process fields only, so every server
|
||||||
|
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
|
||||||
|
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
|
||||||
|
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||||
|
export function backupKeyFromEnv(): string {
|
||||||
|
return process.env.BACKUP_KEY ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TargetCheck {
|
||||||
|
readonly ok: boolean;
|
||||||
|
/** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */
|
||||||
|
readonly reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupStatus {
|
||||||
|
/** True once a target dir is set AND a usable key is present (else backups are a no-op). */
|
||||||
|
readonly configured: boolean;
|
||||||
|
/** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */
|
||||||
|
readonly targetDir: string | null;
|
||||||
|
/** Admin-tuned retention (resolved: DB value or code default) — surfaced for the UI form. */
|
||||||
|
readonly keepLast: number;
|
||||||
|
readonly keepDailyDays: number;
|
||||||
|
/** Whether the env key is present + long enough (the UI flags a missing key distinctly). */
|
||||||
|
readonly keyPresent: boolean;
|
||||||
|
readonly running: boolean;
|
||||||
|
readonly lastSuccessAt: string | null;
|
||||||
|
readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null;
|
||||||
|
readonly lastErrorAt: string | null;
|
||||||
|
readonly lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
|
||||||
|
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
|
||||||
|
const trimmed = dir.trim();
|
||||||
|
if (!trimmed) return { ok: false, reason: "empty" };
|
||||||
|
const path = resolve(trimmed);
|
||||||
|
let st: Awaited<ReturnType<typeof stat>>;
|
||||||
|
try {
|
||||||
|
st = await stat(path);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "missing" };
|
||||||
|
}
|
||||||
|
if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" };
|
||||||
|
try {
|
||||||
|
await access(path, constants.W_OK);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "not_writable" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BackupService {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger?: FastifyBaseLogger;
|
||||||
|
|
||||||
|
#running = false;
|
||||||
|
|
||||||
|
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
|
||||||
|
* or reset on restart). */
|
||||||
|
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
|
||||||
|
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#persist(patch: {
|
||||||
|
backupLastSuccessAt?: string | null;
|
||||||
|
backupLastResultJson?: string | null;
|
||||||
|
backupLastErrorAt?: string | null;
|
||||||
|
backupLastError?: string | null;
|
||||||
|
}): void {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const existing = this.#row();
|
||||||
|
if (existing) {
|
||||||
|
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||||
|
targetDir(): string | null {
|
||||||
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const dir = row?.backupTargetDir?.trim();
|
||||||
|
return dir ? dir : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved retention from site_config, falling back to the code default per field. Read fresh. */
|
||||||
|
retention(): BackupRetention {
|
||||||
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const keepLast = row?.backupKeepLast;
|
||||||
|
const keepDailyDays = row?.backupKeepDailyDays;
|
||||||
|
return {
|
||||||
|
keepLast: keepLast != null && keepLast >= 0 ? keepLast : DEFAULT_BACKUP_RETENTION.keepLast,
|
||||||
|
keepDailyDays:
|
||||||
|
keepDailyDays != null && keepDailyDays >= 0 ? keepDailyDays : DEFAULT_BACKUP_RETENTION.keepDailyDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get keyPresent(): boolean {
|
||||||
|
return backupKeyFromEnv().length >= 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
get configured(): boolean {
|
||||||
|
return this.targetDir() !== null && this.keyPresent;
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): BackupStatus {
|
||||||
|
const r = this.retention();
|
||||||
|
const row = this.#row();
|
||||||
|
let lastResult: BackupStatus["lastResult"] = null;
|
||||||
|
if (row?.backupLastResultJson) {
|
||||||
|
try {
|
||||||
|
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
|
||||||
|
} catch {
|
||||||
|
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configured: this.configured,
|
||||||
|
targetDir: this.targetDir(),
|
||||||
|
keepLast: r.keepLast,
|
||||||
|
keepDailyDays: r.keepDailyDays,
|
||||||
|
keyPresent: this.keyPresent,
|
||||||
|
running: this.#running,
|
||||||
|
lastSuccessAt: row?.backupLastSuccessAt ?? null,
|
||||||
|
lastResult,
|
||||||
|
lastErrorAt: row?.backupLastErrorAt ?? null,
|
||||||
|
lastError: row?.backupLastError ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one backup. `trigger` is just for the log line. Serialized: if one is already in
|
||||||
|
* flight, resolves to that same promise. Reads the target dir + key at run time. Records
|
||||||
|
* last-success/last-error. Re-throws on failure so a manual caller (the route) can surface
|
||||||
|
* it; the scheduled timer wraps + swallows.
|
||||||
|
*/
|
||||||
|
#inflight: Promise<BackupResult> | null = null;
|
||||||
|
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
|
||||||
|
if (this.#inflight) return this.#inflight;
|
||||||
|
const targetDir = this.targetDir();
|
||||||
|
const key = backupKeyFromEnv();
|
||||||
|
if (!targetDir) throw new Error("backup: no target directory configured");
|
||||||
|
if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
|
||||||
|
|
||||||
|
this.#running = true;
|
||||||
|
this.#inflight = (async () => {
|
||||||
|
try {
|
||||||
|
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||||
|
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||||
|
this.#persist({
|
||||||
|
backupLastSuccessAt: new Date().toISOString(),
|
||||||
|
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
|
||||||
|
backupLastErrorAt: null,
|
||||||
|
backupLastError: null,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
const message = (err as Error).message;
|
||||||
|
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
|
||||||
|
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.#running = false;
|
||||||
|
this.#inflight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return this.#inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
|
||||||
|
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
|
||||||
|
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
|
||||||
|
* frequent backups.
|
||||||
|
*/
|
||||||
|
async runScheduled(): Promise<void> {
|
||||||
|
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||||
|
if (!this.isDue()) return;
|
||||||
|
try {
|
||||||
|
await this.run("scheduled");
|
||||||
|
} catch {
|
||||||
|
/* recorded in last-error; already logged */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
|
||||||
|
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
|
||||||
|
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
|
||||||
|
* (or skips a whole day) across every restart, since the countdown restarts from zero each
|
||||||
|
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
|
||||||
|
*/
|
||||||
|
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
|
||||||
|
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
|
||||||
|
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
|
||||||
|
const last = new Date(lastSuccessAt).getTime();
|
||||||
|
if (Number.isNaN(last)) return true;
|
||||||
|
return now.getTime() - last >= intervalMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||||
|
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createTestDb, openRawDb } from "@parking/db/testing";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_BACKUP_RETENTION,
|
||||||
|
parseBackupStamp,
|
||||||
|
pruneOldBackups,
|
||||||
|
runBackup,
|
||||||
|
} from "./backup.js";
|
||||||
|
|
||||||
|
// Mirror of the engine's header layout, so the test decrypts independently (a real restore
|
||||||
|
// tool would do exactly this) rather than trusting the engine to also decrypt.
|
||||||
|
const MAGIC = Buffer.from("PKBK", "ascii");
|
||||||
|
const SALT_LEN = 16;
|
||||||
|
const IV_LEN = 12;
|
||||||
|
const TAG_LEN = 16;
|
||||||
|
|
||||||
|
function decryptBackup(enc: Buffer, key: string): Buffer {
|
||||||
|
expect(enc.subarray(0, 4)).toEqual(MAGIC);
|
||||||
|
expect(enc[4]).toBe(1); // format version
|
||||||
|
let off = 5;
|
||||||
|
const salt = enc.subarray(off, (off += SALT_LEN));
|
||||||
|
const iv = enc.subarray(off, (off += IV_LEN));
|
||||||
|
const tag = enc.subarray(enc.length - TAG_LEN);
|
||||||
|
const ciphertext = enc.subarray(off, enc.length - TAG_LEN);
|
||||||
|
const derived = scryptSync(key, salt, 32);
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", derived, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let workDir: string;
|
||||||
|
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workDir = mkdtempSync(join(tmpdir(), "pk-backup-test-"));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(workDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runBackup — round-trip", () => {
|
||||||
|
it("produces an encrypted backup that decrypts to a byte-identical, queryable DB", async () => {
|
||||||
|
// A real on-disk DB so the engine's better-sqlite3 .backup() runs for real.
|
||||||
|
const dbPath = join(workDir, "source.sqlite");
|
||||||
|
const t = createTestDb(dbPath);
|
||||||
|
// Put some recognizable data in.
|
||||||
|
t.sqlite.exec("CREATE TABLE marker (k TEXT PRIMARY KEY, v TEXT)");
|
||||||
|
t.sqlite.prepare("INSERT INTO marker (k, v) VALUES (?, ?)").run("hello", "world");
|
||||||
|
|
||||||
|
const targetDir = join(workDir, "target");
|
||||||
|
const res = await runBackup(t.db, { targetDir, key: KEY });
|
||||||
|
t.close();
|
||||||
|
|
||||||
|
expect(res.bytes).toBeGreaterThan(0);
|
||||||
|
expect(res.path).toMatch(/parking-backup-\d{8}T\d{6}Z\.sqlite\.enc$/);
|
||||||
|
|
||||||
|
// Decrypt independently and open the recovered DB raw (no migrations — verify as-written).
|
||||||
|
const plain = decryptBackup(readFileSync(res.path), KEY);
|
||||||
|
const restoredPath = join(workDir, "restored.sqlite");
|
||||||
|
writeFileSync(restoredPath, plain);
|
||||||
|
const restored = openRawDb(restoredPath);
|
||||||
|
const row = restored.prepare("SELECT v FROM marker WHERE k = ?").get("hello") as { v: string };
|
||||||
|
expect(row.v).toBe("world");
|
||||||
|
restored.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing/short key before touching the filesystem", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
await expect(runBackup(t.db, { targetDir: join(workDir, "t"), key: "short" })).rejects.toThrow(
|
||||||
|
/BACKUP_KEY/,
|
||||||
|
);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the plaintext scratch copy after a successful run", async () => {
|
||||||
|
const scratchDir = join(workDir, "scratch");
|
||||||
|
const t = createTestDb();
|
||||||
|
await runBackup(t.db, {
|
||||||
|
targetDir: join(workDir, "target"),
|
||||||
|
key: KEY,
|
||||||
|
scratchDir,
|
||||||
|
// Stub the copy so we don't need a file-backed handle here.
|
||||||
|
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "PRAGMA;"),
|
||||||
|
});
|
||||||
|
t.close();
|
||||||
|
// The only thing left in scratch must NOT be a .sqlite plaintext.
|
||||||
|
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
|
||||||
|
expect(left).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wipes the plaintext scratch copy even when the copy step fails", async () => {
|
||||||
|
const scratchDir = join(workDir, "scratch");
|
||||||
|
mkdirSync(scratchDir, { recursive: true });
|
||||||
|
const t = createTestDb();
|
||||||
|
// Force a failure: the copy step writes the plaintext, then throws (mid-pipeline). The
|
||||||
|
// finally{} must still remove the plaintext it left behind.
|
||||||
|
await expect(
|
||||||
|
runBackup(t.db, {
|
||||||
|
targetDir: join(workDir, "target"),
|
||||||
|
key: KEY,
|
||||||
|
scratchDir,
|
||||||
|
makeConsistentCopy: async (_db, dest) => {
|
||||||
|
writeFileSync(dest, "PRAGMA;"); // leave a plaintext intermediate…
|
||||||
|
throw new Error("simulated copy failure"); // …then fail
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/simulated copy failure/);
|
||||||
|
t.close();
|
||||||
|
const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite"));
|
||||||
|
expect(left).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("backup encryption — tamper evidence (AES-256-GCM)", () => {
|
||||||
|
it("a flipped ciphertext byte fails authentication on decrypt", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
const targetDir = join(workDir, "target");
|
||||||
|
const res = await runBackup(t.db, {
|
||||||
|
targetDir,
|
||||||
|
key: KEY,
|
||||||
|
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "the quick brown fox".repeat(100)),
|
||||||
|
});
|
||||||
|
t.close();
|
||||||
|
|
||||||
|
const enc = readFileSync(res.path);
|
||||||
|
// Flip a byte in the ciphertext region (after the header, before the tag).
|
||||||
|
enc[5 + SALT_LEN + IV_LEN + 3] ^= 0xff;
|
||||||
|
expect(() => decryptBackup(enc, KEY)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the wrong key fails authentication", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
const res = await runBackup(t.db, {
|
||||||
|
targetDir: join(workDir, "target"),
|
||||||
|
key: KEY,
|
||||||
|
makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "payload".repeat(50)),
|
||||||
|
});
|
||||||
|
t.close();
|
||||||
|
expect(() => decryptBackup(readFileSync(res.path), "a-different-but-also-long-key-xx")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseBackupStamp", () => {
|
||||||
|
it("round-trips a stamped name and rejects non-backups", () => {
|
||||||
|
const d = parseBackupStamp("parking-backup-20260629T141503Z.sqlite.enc");
|
||||||
|
expect(d?.toISOString()).toBe("2026-06-29T14:15:03.000Z");
|
||||||
|
expect(parseBackupStamp("random.txt")).toBeNull();
|
||||||
|
expect(parseBackupStamp("parking-backup-not-a-date.sqlite.enc")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pruneOldBackups — keep-last-N + dailies", () => {
|
||||||
|
const day = 24 * 60 * 60 * 1000;
|
||||||
|
const now = new Date("2026-06-29T12:00:00Z");
|
||||||
|
|
||||||
|
function seed(stamps: string[]) {
|
||||||
|
const dir = join(workDir, "retain");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
for (const s of stamps) writeFileSync(join(dir, `parking-backup-${s}.sqlite.enc`), "x");
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
const stamp = (ms: number) =>
|
||||||
|
new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||||
|
|
||||||
|
it("keeps the keepLast newest regardless of age", async () => {
|
||||||
|
// 5 backups within the last hour; keepLast=3 → 2 pruned, even though all are recent.
|
||||||
|
const t = now.getTime();
|
||||||
|
const dir = seed([0, 1, 2, 3, 4].map((i) => stamp(t - i * 60 * 1000)));
|
||||||
|
const pruned = await pruneOldBackups(dir, { keepLast: 3, keepDailyDays: 0 }, now);
|
||||||
|
expect(pruned).toBe(2);
|
||||||
|
expect(readdirSync(dir).length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one-per-day within the daily window and drops older", async () => {
|
||||||
|
const t = now.getTime();
|
||||||
|
// Two backups today, one 5 days ago, one 40 days ago. keepLast=1, keepDailyDays=30.
|
||||||
|
const dir = seed([
|
||||||
|
stamp(t), // today A (newest → kept by keepLast)
|
||||||
|
stamp(t - 60 * 1000), // today B (same day as the kept one → pruned)
|
||||||
|
stamp(t - 5 * day), // 5 days ago (kept: within window, unique day)
|
||||||
|
stamp(t - 40 * day), // 40 days ago (pruned: outside the window)
|
||||||
|
]);
|
||||||
|
const pruned = await pruneOldBackups(dir, { keepLast: 1, keepDailyDays: 30 }, now);
|
||||||
|
expect(pruned).toBe(2);
|
||||||
|
const left = readdirSync(dir);
|
||||||
|
expect(left.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op on a missing target dir", async () => {
|
||||||
|
const pruned = await pruneOldBackups(join(workDir, "does-not-exist"), DEFAULT_BACKUP_RETENTION, now);
|
||||||
|
expect(pruned).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { createCipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||||
|
import { createReadStream, createWriteStream } from "node:fs";
|
||||||
|
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { basename, join, resolve } from "node:path";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
|
||||||
|
// On-site encrypted DB backup — the durability half of the anti-fraud design. The SQLite
|
||||||
|
// DB *is* the signed append-only ledger, so a disk failure / stolen-or-destroyed PC means
|
||||||
|
// total revenue-history loss. This produces a consistent, encrypted, restore-to-a-fresh-
|
||||||
|
// appliance copy. See wiki/concepts/backup-recovery.md.
|
||||||
|
//
|
||||||
|
// Two load-bearing properties:
|
||||||
|
// 1. CONSISTENT copy of a LIVE WAL-mode DB — via better-sqlite3's online .backup() (NOT a
|
||||||
|
// raw file copy, which can capture a torn WAL). The result must still verifyChain.
|
||||||
|
// 2. Encrypted with a DEDICATED key (BACKUP_KEY / park_buzi_backup_key), SEPARATE from
|
||||||
|
// EVENT_SIGNING_KEY — so the backup key can rotate without fracturing the signed chain,
|
||||||
|
// and a backup target never exposes the signing key. The key is NEVER written into the
|
||||||
|
// backup it unlocks.
|
||||||
|
//
|
||||||
|
// This module is the engine (consistent copy → encrypt → retention). Targets beyond a local/
|
||||||
|
// mounted path (SMB/NFS are just mount paths; SFTP) and the manual button/route are layered on
|
||||||
|
// top. RESTORE is intentionally NOT here — it's an out-of-band runbook action on a fresh box.
|
||||||
|
|
||||||
|
/** AES-256-GCM with a scrypt-derived key. Self-describing header so a restore tool needs only
|
||||||
|
* the key + the file. Layout: magic | version | salt(16) | iv(12) | ciphertext… | authTag(16). */
|
||||||
|
const MAGIC = Buffer.from("PKBK", "ascii"); // ParKing BacKup
|
||||||
|
const FORMAT_VERSION = 1;
|
||||||
|
const SALT_LEN = 16;
|
||||||
|
const IV_LEN = 12;
|
||||||
|
const TAG_LEN = 16;
|
||||||
|
const SCRYPT_KEYLEN = 32; // AES-256
|
||||||
|
|
||||||
|
export interface BackupRetention {
|
||||||
|
/** Keep at least this many most-recent backups regardless of age. */
|
||||||
|
readonly keepLast: number;
|
||||||
|
/** Beyond keepLast, keep one backup per day for this many days; older ones are pruned. */
|
||||||
|
readonly keepDailyDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code defaults — the fallback when the admin hasn't set a value in site_config (the source of
|
||||||
|
// truth). NOT env-driven: retention is operational policy tuned from the Backup screen.
|
||||||
|
export const DEFAULT_BACKUP_RETENTION: BackupRetention = {
|
||||||
|
keepLast: 7,
|
||||||
|
keepDailyDays: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BackupOptions {
|
||||||
|
/** Directory the encrypted backup is written to (a mounted local/USB/SATA/SMB/NFS path). */
|
||||||
|
readonly targetDir: string;
|
||||||
|
/** Encryption key (BACKUP_KEY / park_buzi_backup_key). ≥16 chars enforced. */
|
||||||
|
readonly key: string;
|
||||||
|
readonly retention?: BackupRetention;
|
||||||
|
/** Override the consistent-copy step (tests inject a fake to avoid a real sqlite handle). */
|
||||||
|
readonly makeConsistentCopy?: (db: Db, destPath: string) => Promise<void>;
|
||||||
|
/** Override "now" for deterministic filenames/retention in tests. */
|
||||||
|
readonly now?: () => Date;
|
||||||
|
/** Scratch dir for the intermediate plaintext copy (default os.tmpdir()). */
|
||||||
|
readonly scratchDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupResult {
|
||||||
|
/** Absolute path of the encrypted backup written. */
|
||||||
|
readonly path: string;
|
||||||
|
/** Size of the encrypted file in bytes. */
|
||||||
|
readonly bytes: number;
|
||||||
|
/** Backups pruned by the retention policy this run. */
|
||||||
|
readonly prunedFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filename convention: parking-backup-YYYYMMDDTHHMMSSZ.sqlite.enc — sortable, UTC, parseable. */
|
||||||
|
const FILE_PREFIX = "parking-backup-";
|
||||||
|
const FILE_SUFFIX = ".sqlite.enc";
|
||||||
|
|
||||||
|
function stampFor(d: Date): string {
|
||||||
|
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the UTC instant back out of a backup filename, or null if it doesn't match. */
|
||||||
|
export function parseBackupStamp(name: string): Date | null {
|
||||||
|
const base = basename(name);
|
||||||
|
if (!base.startsWith(FILE_PREFIX) || !base.endsWith(FILE_SUFFIX)) return null;
|
||||||
|
const stamp = base.slice(FILE_PREFIX.length, -FILE_SUFFIX.length);
|
||||||
|
// 20260629T141503Z → 2026-06-29T14:15:03Z
|
||||||
|
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
|
||||||
|
if (!m) return null;
|
||||||
|
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
||||||
|
const dt = new Date(iso);
|
||||||
|
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistent online copy of the live WAL-mode DB via better-sqlite3's native backup(). */
|
||||||
|
async function defaultConsistentCopy(db: Db, destPath: string): Promise<void> {
|
||||||
|
// db.$client is the raw better-sqlite3 Database; .backup() returns a promise and copies a
|
||||||
|
// transactionally-consistent snapshot even while the source is being written.
|
||||||
|
const client = db.$client as { backup: (dest: string) => Promise<unknown> };
|
||||||
|
await client.backup(destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt `srcPath` → `destPath` streaming, with the self-describing header. */
|
||||||
|
async function encryptFile(srcPath: string, destPath: string, key: string): Promise<void> {
|
||||||
|
const salt = randomBytes(SALT_LEN);
|
||||||
|
const iv = randomBytes(IV_LEN);
|
||||||
|
const derived = scryptSync(key, salt, SCRYPT_KEYLEN);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", derived, iv);
|
||||||
|
|
||||||
|
const out = createWriteStream(destPath);
|
||||||
|
const header = Buffer.concat([MAGIC, Buffer.from([FORMAT_VERSION]), salt, iv]);
|
||||||
|
out.write(header);
|
||||||
|
|
||||||
|
await pipeline(createReadStream(srcPath), cipher, out, { end: false });
|
||||||
|
// GCM auth tag is available only after the cipher has flushed; append it, then close.
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
await new Promise<void>((res, rej) => {
|
||||||
|
out.end(tag, () => res());
|
||||||
|
out.on("error", rej);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one backup: consistent copy → encrypt → prune old backups by retention.
|
||||||
|
* Best-effort caller-facing: throws on real failure (so a manual run surfaces the error),
|
||||||
|
* but the scheduled timer wraps it and logs.
|
||||||
|
*/
|
||||||
|
export async function runBackup(
|
||||||
|
db: Db,
|
||||||
|
opts: BackupOptions,
|
||||||
|
logger?: FastifyBaseLogger,
|
||||||
|
): Promise<BackupResult> {
|
||||||
|
if (!opts.key || opts.key.length < 16) {
|
||||||
|
throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
|
||||||
|
}
|
||||||
|
const now = opts.now ?? (() => new Date());
|
||||||
|
const retention = opts.retention ?? DEFAULT_BACKUP_RETENTION;
|
||||||
|
const targetDir = resolve(opts.targetDir);
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const stamp = stampFor(now());
|
||||||
|
const finalPath = join(targetDir, `${FILE_PREFIX}${stamp}${FILE_SUFFIX}`);
|
||||||
|
|
||||||
|
// Intermediate plaintext copy in scratch (NOT the target dir — the target may be a network
|
||||||
|
// share / removable disk; keep the plaintext local and short-lived, then wipe it).
|
||||||
|
const scratch = opts.scratchDir ?? tmpdir();
|
||||||
|
await mkdir(scratch, { recursive: true });
|
||||||
|
const plainPath = join(scratch, `${FILE_PREFIX}${stamp}.sqlite`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copy = opts.makeConsistentCopy ?? defaultConsistentCopy;
|
||||||
|
await copy(db, plainPath);
|
||||||
|
await encryptFile(plainPath, finalPath, opts.key);
|
||||||
|
} finally {
|
||||||
|
// Always wipe the plaintext intermediate, success or fail — it's the unencrypted ledger.
|
||||||
|
await rm(plainPath, { force: true }).catch((err) =>
|
||||||
|
logger?.warn(`backup: failed to remove plaintext scratch copy: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { size } = await stat(finalPath);
|
||||||
|
const prunedFiles = await pruneOldBackups(targetDir, retention, now());
|
||||||
|
logger?.info(
|
||||||
|
`backup: wrote ${basename(finalPath)} (${(size / 1048576).toFixed(1)} MB)` +
|
||||||
|
(prunedFiles > 0 ? `, pruned ${prunedFiles} old` : ""),
|
||||||
|
);
|
||||||
|
return { path: finalPath, bytes: size, prunedFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retention: keep the `keepLast` most-recent backups always; beyond those, keep at most one
|
||||||
|
* backup per UTC day for `keepDailyDays` days; delete anything older or any extra same-day
|
||||||
|
* duplicates outside the keepLast window. Returns the count deleted.
|
||||||
|
*/
|
||||||
|
export async function pruneOldBackups(
|
||||||
|
targetDir: string,
|
||||||
|
retention: BackupRetention,
|
||||||
|
now: Date,
|
||||||
|
): Promise<number> {
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = await readdir(targetDir);
|
||||||
|
} catch {
|
||||||
|
return 0; // target gone/unmounted — nothing to prune (the write would have failed first)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = names
|
||||||
|
.map((n) => ({ name: n, at: parseBackupStamp(n) }))
|
||||||
|
.filter((b): b is { name: string; at: Date } => b.at !== null)
|
||||||
|
.sort((a, b) => b.at.getTime() - a.at.getTime()); // newest first
|
||||||
|
|
||||||
|
const keep = new Set<string>();
|
||||||
|
// 1. Always keep the keepLast newest.
|
||||||
|
for (const b of backups.slice(0, Math.max(0, retention.keepLast))) keep.add(b.name);
|
||||||
|
|
||||||
|
// 2. Beyond that, keep the newest per UTC day within the keepDailyDays window.
|
||||||
|
const cutoff = now.getTime() - retention.keepDailyDays * 24 * 60 * 60 * 1000;
|
||||||
|
const seenDays = new Set<string>();
|
||||||
|
for (const b of backups) {
|
||||||
|
if (keep.has(b.name)) {
|
||||||
|
seenDays.add(b.at.toISOString().slice(0, 10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (b.at.getTime() < cutoff) continue; // too old → not kept
|
||||||
|
const day = b.at.toISOString().slice(0, 10);
|
||||||
|
if (seenDays.has(day)) continue; // already have a backup for this day → prune the extra
|
||||||
|
seenDays.add(day);
|
||||||
|
keep.add(b.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pruned = 0;
|
||||||
|
for (const b of backups) {
|
||||||
|
if (keep.has(b.name)) continue;
|
||||||
|
await rm(join(targetDir, b.name), { force: true });
|
||||||
|
pruned += 1;
|
||||||
|
}
|
||||||
|
return pruned;
|
||||||
|
}
|
||||||
@@ -80,6 +80,8 @@ function receiptFigures(
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
tender?: "cash" | "card";
|
tender?: "cash" | "card";
|
||||||
graceExitMin?: number;
|
graceExitMin?: number;
|
||||||
|
grossMinor?: number;
|
||||||
|
validationLines?: { label: string; discountMinor: number }[];
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
ticketId,
|
ticketId,
|
||||||
@@ -89,6 +91,9 @@ function receiptFigures(
|
|||||||
currency: p.currency ?? "ALL",
|
currency: p.currency ?? "ALL",
|
||||||
tender: p.tender === "card" ? "card" : "cash",
|
tender: p.tender === "card" ? "card" : "cash",
|
||||||
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
||||||
|
// Merchant validations, as settled on the signed payment (gross → lines → net).
|
||||||
|
grossMinor: typeof p.grossMinor === "number" ? p.grossMinor : null,
|
||||||
|
validationLines: Array.isArray(p.validationLines) ? p.validationLines : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { ButtonLightController } from "./button-light.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// ButtonLightController: alert (radarAlert) relays — the entry-button lamp on a spare
|
||||||
|
// relay, driven by the lamp's trigger input vs. the camera lane status. Truth table:
|
||||||
|
// trigger active + lane busy -> SOLID on
|
||||||
|
// trigger active + lane free -> BLINK (~1 Hz)
|
||||||
|
// otherwise -> OFF
|
||||||
|
// Lamp is a non-barrier aux output; fails OFF; de-dupes redundant writes. A controller may
|
||||||
|
// carry several alert relays (each its own row + trigger input), keyed independently.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CONTROLLER = "ctl-1";
|
||||||
|
const RADAR_INPUT = 2; // I2
|
||||||
|
const LAMP_RELAY = 3; // spare relay R3
|
||||||
|
|
||||||
|
/** A fake aux device recording setAux calls (channel,on). Optionally throws. */
|
||||||
|
function fakeAux(record: Array<{ ch: number; on: boolean }>, throwOnce = { v: false }): AuxOutputDevice {
|
||||||
|
return {
|
||||||
|
async setAux(channel: number, on: boolean): Promise<void> {
|
||||||
|
if (throwOnce.v) {
|
||||||
|
throwOnce.v = false;
|
||||||
|
throw new Error("UDP down");
|
||||||
|
}
|
||||||
|
record.push({ ch: channel, on });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// One controller: entry relay 1 with radar on I2; lamp on spare relay 3.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CONTROLLER,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emit a radar (presence input) edge for the controller. */
|
||||||
|
function radar(present: boolean): void {
|
||||||
|
deviceEvents.emitInput({
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CONTROLLER,
|
||||||
|
input: RADAR_INPUT,
|
||||||
|
edge: present ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit a lane status (entry busy/free). */
|
||||||
|
function lane(entryBusy: boolean): void {
|
||||||
|
deviceEvents.emitLaneStatus({ entry: entryBusy, exit: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush the microtask queue so serialized setAux promises (and their re-pump on
|
||||||
|
* completion) settle. The lamp worker sends ONE UDP at a time and re-pumps on resolve;
|
||||||
|
* a few turns drain a burst. Needed because sends are now async (was synchronous). */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ButtonLightController truth table", () => {
|
||||||
|
it("OFF at start (no radar, no car)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
// confirmedOn starts null; OFF de-dupes (null !== false → one off write), so the
|
||||||
|
// device is confirmed OFF and at most one call was made.
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane busy -> SOLID on", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // device latched ON
|
||||||
|
// Solid = no blinking: advancing time produces no further sends.
|
||||||
|
const n = calls.length;
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar present + lane free -> BLINK (toggles the device over time)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // lane still free
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // on now
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // toggled off
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // toggled on
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blink -> solid when the camera confirms a car (lane busy)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
radar(true); // blink
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
lane(true); // camera confirms
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("solid");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
// No more toggles (blink torn down) — the device stays ON over time.
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar clears -> OFF", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid
|
||||||
|
await flush();
|
||||||
|
radar(false); // car gone
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("off");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // device latched OFF
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes redundant writes (no spam on repeat events)", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
lane(true);
|
||||||
|
radar(true); // solid, on
|
||||||
|
await flush();
|
||||||
|
const n = calls.length;
|
||||||
|
radar(true); // same state — no new edge (present unchanged)
|
||||||
|
lane(true); // same lane — no change
|
||||||
|
await flush();
|
||||||
|
expect(calls.length).toBe(n);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails OFF: a setAux error does not throw or escalate", async () => {
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const throwOnce = { v: true };
|
||||||
|
const aux = fakeAux(calls, throwOnce);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// First write (initial off) throws — must be swallowed.
|
||||||
|
expect(() => ctl.start()).not.toThrow();
|
||||||
|
await flush();
|
||||||
|
// The failure arms a backoff (1s) rather than retrying inline; desired-state
|
||||||
|
// changes during the window just update the target the retry will assert.
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBeNull(); // still backing off
|
||||||
|
await vi.advanceTimersByTimeAsync(1000); // retry fires; aux is healthy again
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // converged to solid ON
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unreachable controller backs off (1s→30s), not a hot retry loop", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const aux: AuxOutputDevice = {
|
||||||
|
async setAux() {
|
||||||
|
attempts += 1;
|
||||||
|
throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const errors: string[] = [];
|
||||||
|
const logger = silentLogger();
|
||||||
|
(logger as { error: (msg: string) => void }).error = (msg) => errors.push(msg);
|
||||||
|
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||||
|
ctl.start(); // initial OFF write → attempt 1 fails at t=0
|
||||||
|
await flush();
|
||||||
|
expect(attempts).toBe(1); // the old code hot-looped here
|
||||||
|
|
||||||
|
// Failures at t≈0,1,3,7,15,31 (doubling, capped 30s) → 6 attempts in the first
|
||||||
|
// minute instead of thousands.
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(attempts).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(attempts).toBeLessThanOrEqual(7);
|
||||||
|
|
||||||
|
// Only the FIRST failure was logged so far; the next log is a ≥60s summary.
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(35_000); // t≈95s → the t=61s attempt logged a summary
|
||||||
|
expect(errors.length).toBe(2);
|
||||||
|
expect(errors[1]).toContain("still failing");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a single recovery line and resets the backoff after success", async () => {
|
||||||
|
let failing = true;
|
||||||
|
let attempts = 0;
|
||||||
|
const aux: AuxOutputDevice = {
|
||||||
|
async setAux() {
|
||||||
|
attempts += 1;
|
||||||
|
if (failing) throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const infos: string[] = [];
|
||||||
|
const logger = silentLogger();
|
||||||
|
(logger as { info: (msg: string) => void }).info = (msg) => infos.push(msg);
|
||||||
|
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
await vi.advanceTimersByTimeAsync(3_000); // attempts at t=0,1,3 all fail
|
||||||
|
const failed = attempts;
|
||||||
|
expect(failed).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
failing = false; // controller reachable again
|
||||||
|
await vi.advanceTimersByTimeAsync(8_000); // next armed retry succeeds
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // OFF asserted on the device
|
||||||
|
expect(infos.filter((m) => m.includes("recovered"))).toHaveLength(1);
|
||||||
|
|
||||||
|
// Backoff reset: a fresh state change sends immediately (no lingering retryAt).
|
||||||
|
const before = attempts;
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
expect(attempts).toBe(before + 1);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores controllers without an alert relay", () => {
|
||||||
|
// A second controller, no alert relay.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "ctl-2",
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: "10.0.0.6", relays: [{ relay: 1, direction: "entry", presenceInput: 2 }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => fakeAux(calls));
|
||||||
|
ctl.start();
|
||||||
|
expect(ctl.stateOf("ctl-2")).toBeNull();
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up an alert relay ADDED after start() (no restart needed)", async () => {
|
||||||
|
// Fresh controller with a radar input but NO alert relay yet.
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
// Replace the seeded controller with one that has the radar but no lamp.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
// No lamp configured → an input does nothing.
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBeNull();
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
radar(false);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Admin saves an alert relay (relay 3, trigger I2) — without restarting the server.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", presenceInput: RADAR_INPUT, presenceKind: "radar" },
|
||||||
|
{ relay: LAMP_RELAY, direction: "radarAlert", triggerInput: RADAR_INPUT, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// The very next radar edge reconciles + blinks (lane still free).
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER)).toBe("blink");
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drives two alert relays on one controller independently", async () => {
|
||||||
|
const R3 = 3;
|
||||||
|
const R4 = 4;
|
||||||
|
const I2 = 2;
|
||||||
|
const I3 = 3;
|
||||||
|
// Controller with two alert lamps, each on its own trigger input.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", presenceInput: I2, presenceKind: "radar" },
|
||||||
|
{ relay: R3, direction: "radarAlert", triggerInput: I2, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
{ relay: R4, direction: "radarAlert", triggerInput: I3, blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
const calls: Array<{ ch: number; on: boolean }> = [];
|
||||||
|
const aux = fakeAux(calls);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||||
|
|
||||||
|
// I2 active → only R3 blinks; R4 stays off (different trigger).
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("off");
|
||||||
|
|
||||||
|
// I3 active → R4 blinks too, independently.
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I3, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("blink");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// Camera confirms a car → BOTH lock solid (lane-busy is site-wide).
|
||||||
|
lane(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("solid");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
|
||||||
|
// I2 clears → R3 off, R4 still solid (its trigger still active).
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I2, edge: "off", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R3)).toBe("off");
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an EXIT alert lamp locks on the EXIT camera, not entry", async () => {
|
||||||
|
const R4 = 4;
|
||||||
|
const I5 = 5; // exit radar
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
// Exit alert lamp: triggers on the exit radar, locks on the EXIT camera.
|
||||||
|
{ relay: R4, direction: "radarAlert", triggerInput: I5, lockLane: "exit", blinkOnMs: 500, blinkOffMs: 500 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CONTROLLER))
|
||||||
|
.run();
|
||||||
|
const aux = fakeAux([]);
|
||||||
|
const ctl = new ButtonLightController(db, silentLogger(), () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Exit radar active → blink.
|
||||||
|
deviceEvents.emitInput({ driverId: "dingtian", deviceId: CONTROLLER, input: I5, edge: "on", at: new Date().toISOString(), source: "poll" });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// ENTRY camera busy must NOT lock this exit lamp — it still blinks.
|
||||||
|
deviceEvents.emitLaneStatus({ entry: true, exit: false });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("blink");
|
||||||
|
|
||||||
|
// EXIT camera busy → SOLID.
|
||||||
|
deviceEvents.emitLaneStatus({ entry: true, exit: true });
|
||||||
|
await flush();
|
||||||
|
expect(ctl.stateOf(CONTROLLER, R4)).toBe("solid");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { eq, devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { hasAuxOutput, registry, type AuxOutputDevice } from "@parking/devices";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LaneStatusEvent } from "./device-events.js";
|
||||||
|
import { alertRelaysOf, relayForPresence, type RelaySpec } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Alert (radarAlert) relays — non-barrier indicator lamps, e.g. the entry button's 12 V
|
||||||
|
// light. Each lamp is a `relays[]` row with event `radarAlert`, driven by ITS trigger
|
||||||
|
// input vs. the camera "car in zone" signal (the advisory lane-status). A disagreement
|
||||||
|
// indicator:
|
||||||
|
// trigger active + lane busy (camera confirms a car) → SOLID on
|
||||||
|
// trigger active + lane free (radar sees something, no car) → BLINK (~1 Hz)
|
||||||
|
// otherwise → OFF
|
||||||
|
// The lamp is a NON-barrier aux output (setAux latch), so holding/blinking it is fine
|
||||||
|
// — barrier-not-a-door applies only to barriers, which still only pulseOpen. The lamp
|
||||||
|
// FAILS OFF: any error / shutdown leaves it off, so a dead lamp is "no hint", never a
|
||||||
|
// misleading solid "go". A controller may have several alert relays (each its own row +
|
||||||
|
// trigger input), keyed independently. See wiki/concepts/button-light-indicator.md.
|
||||||
|
|
||||||
|
type LightState = "off" | "solid" | "blink";
|
||||||
|
|
||||||
|
const DEFAULT_BLINK_MS = 500;
|
||||||
|
|
||||||
|
// Failed-send retry backoff: 1s doubling to 30s, reset on success. Without this an
|
||||||
|
// unreachable controller (ENETUNREACH) became a hot loop — the failure re-pump retried
|
||||||
|
// instantly, thousands of sends + error lines per minute (field incident 2026-07-07).
|
||||||
|
const RETRY_BASE_MS = 1_000;
|
||||||
|
const RETRY_MAX_MS = 30_000;
|
||||||
|
/** After the first failure of a streak, log at most one summary line per this window. */
|
||||||
|
const FAIL_LOG_EVERY_MS = 60_000;
|
||||||
|
|
||||||
|
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
||||||
|
interface LampState {
|
||||||
|
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
||||||
|
readonly controllerId: string;
|
||||||
|
/** Alert relay row (relay #, triggerInput, blink ms). Mutable: #reconcile updates it in
|
||||||
|
* place when the admin changes the alert config without a restart. */
|
||||||
|
spec: RelaySpec;
|
||||||
|
/** Is the lamp's trigger input (the radar) currently active? */
|
||||||
|
present: boolean;
|
||||||
|
/** The high-level state we're rendering (to avoid restarting a running blink). */
|
||||||
|
rendered: LightState | null;
|
||||||
|
/** Active blink timer, if blinking. */
|
||||||
|
blink: ReturnType<typeof setInterval> | null;
|
||||||
|
/** Blink phase (true = currently on). */
|
||||||
|
blinkOn: boolean;
|
||||||
|
/** The output we WANT the relay to be in. The serialized worker drives the device
|
||||||
|
* toward this. The blink timer only flips this flag — it never sends directly. */
|
||||||
|
desiredOn: boolean;
|
||||||
|
/** The output we last CONFIRMED on the device (after a successful send). null = unknown. */
|
||||||
|
confirmedOn: boolean | null;
|
||||||
|
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||||
|
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||||
|
sending: boolean;
|
||||||
|
/** Consecutive failed sends (0 = healthy). Drives the backoff delay + log summaries. */
|
||||||
|
failCount: number;
|
||||||
|
/** Epoch ms before which #pump must not send (0 = no backoff). The armed retry
|
||||||
|
* timer re-pumps when it elapses; desired-state changes in between just update
|
||||||
|
* `desiredOn` and are picked up by that same retry. */
|
||||||
|
retryAt: number;
|
||||||
|
/** The armed backoff retry, if any. */
|
||||||
|
retryTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
/** Epoch ms of the last failure line we actually logged (rate-limits the flood). */
|
||||||
|
lastFailLogAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||||
|
* driver registry; tests inject a spy. Returns null when the controller has no
|
||||||
|
* aux-output capability (or won't build). */
|
||||||
|
export type AuxResolver = (controllerId: string) => AuxOutputDevice | null;
|
||||||
|
|
||||||
|
export class ButtonLightController {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #resolveAux: AuxResolver;
|
||||||
|
/** Per-lamp state, keyed by `${controllerId}:${relay}` (a controller may have several). */
|
||||||
|
readonly #lamps = new Map<string, LampState>();
|
||||||
|
/** Latest lane status — a camera-confirmed car in the entry / exit zone. A lamp locks
|
||||||
|
* SOLID off its OWN lane's camera (`spec.lockLane`), so an exit radar's lamp tracks the
|
||||||
|
* exit camera, not the entry one. */
|
||||||
|
#entryBusy = false;
|
||||||
|
#exitBusy = false;
|
||||||
|
/** Controllers we've already warned lack the aux-output capability (warn once). */
|
||||||
|
readonly #warned = new Set<string>();
|
||||||
|
#unsubInput: (() => void) | null = null;
|
||||||
|
#unsubLane: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, resolveAux?: AuxResolver) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#resolveAux = resolveAux ?? ((id) => this.#auxFromRegistry(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to radar input edges + lane status, and initialise every lamp OFF. */
|
||||||
|
start(): void {
|
||||||
|
this.#reconcile();
|
||||||
|
// All lamps start OFF (known-safe baseline) regardless of prior device state.
|
||||||
|
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||||
|
|
||||||
|
this.#unsubInput = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
this.#unsubLane = deviceEvents.onLaneStatus((s) => this.#onLane(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile the lamp map with the CURRENT device config (the booth can add/change a
|
||||||
|
* button light without a server restart). Mirrors DeviceMonitor, which re-reads the
|
||||||
|
* device set each tick. Adds lamps for newly-configured controllers, updates the spec
|
||||||
|
* (relay #, blink ms) in place — preserving live `present`/blink state — and drops
|
||||||
|
* lamps whose controller lost its buttonLight or was disabled. Called at start() and
|
||||||
|
* before handling each event, so a just-saved lamp takes effect immediately. */
|
||||||
|
#reconcile(): void {
|
||||||
|
const rows = this.#db.select().from(devices).where(eq(devices.category, "access")).all();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.enabled) continue;
|
||||||
|
for (const spec of alertRelaysOf(row)) {
|
||||||
|
const key = lampKey(row.id, spec.relay);
|
||||||
|
seen.add(key);
|
||||||
|
const existing = this.#lamps.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.spec = spec; // pick up a changed trigger input / blink cadence
|
||||||
|
} else {
|
||||||
|
this.#lamps.set(key, {
|
||||||
|
controllerId: row.id,
|
||||||
|
spec,
|
||||||
|
present: false,
|
||||||
|
rendered: null,
|
||||||
|
blink: null,
|
||||||
|
blinkOn: false,
|
||||||
|
desiredOn: false,
|
||||||
|
confirmedOn: null,
|
||||||
|
sending: false,
|
||||||
|
failCount: 0,
|
||||||
|
retryAt: 0,
|
||||||
|
retryTimer: null,
|
||||||
|
lastFailLogAt: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||||
|
for (const [key, lamp] of this.#lamps) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
this.#disarm(lamp);
|
||||||
|
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
||||||
|
this.#lamps.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A radar (presence) edge updates that controller's `present` flag. We resolve the
|
||||||
|
* edge the SAME way the entry flow does (relayForPresence on an entry/both relay),
|
||||||
|
* so the lamp and the one-car-one-ticket gate always agree on "a car is here". */
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
// Reconcile first so a lamp added/changed since boot (no restart) is picked up.
|
||||||
|
this.#reconcile();
|
||||||
|
const present = e.edge === "on";
|
||||||
|
for (const lamp of this.#lamps.values()) {
|
||||||
|
if (lamp.controllerId !== e.deviceId) continue;
|
||||||
|
// A lamp's trigger is its own `triggerInput`; if unset, fall back to the controller's
|
||||||
|
// entry-relay presence terminal (resolved the SAME way the entry flow does) so the
|
||||||
|
// lamp and the one-car-one-ticket gate always agree on "a car is here".
|
||||||
|
const trigger =
|
||||||
|
lamp.spec.triggerInput ?? relayForPresence(this.#db, e.deviceId, e.input)?.presenceInput;
|
||||||
|
if (trigger !== e.input) continue; // not this lamp's trigger terminal
|
||||||
|
if (present === lamp.present) continue;
|
||||||
|
lamp.present = present;
|
||||||
|
this.#apply(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lane status changed: a camera-confirmed car in the entry and/or exit zone. */
|
||||||
|
#onLane(s: LaneStatusEvent): void {
|
||||||
|
if (s.entry === this.#entryBusy && s.exit === this.#exitBusy) return;
|
||||||
|
this.#entryBusy = s.entry;
|
||||||
|
this.#exitBusy = s.exit;
|
||||||
|
// Re-render every lamp (each picks its own lane's camera in #apply).
|
||||||
|
for (const lamp of this.#lamps.values()) this.#apply(lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute + render the target state for one lamp. Drives are fire-and-forget (the
|
||||||
|
* timer/state machine is synchronous; the UDP write resolves on its own). */
|
||||||
|
#apply(lamp: LampState): void {
|
||||||
|
// SOLID only once THIS lamp's lane camera confirms a car (default entry).
|
||||||
|
const laneBusy = lamp.spec.lockLane === "exit" ? this.#exitBusy : this.#entryBusy;
|
||||||
|
const target: LightState = !lamp.present ? "off" : laneBusy ? "solid" : "blink";
|
||||||
|
if (target === lamp.rendered) return; // already rendering this state
|
||||||
|
|
||||||
|
// Tear down any running blink before switching states.
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
lamp.rendered = target;
|
||||||
|
|
||||||
|
if (target === "off") {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
this.#pump(lamp);
|
||||||
|
} else if (target === "solid") {
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
this.#pump(lamp);
|
||||||
|
} else {
|
||||||
|
// BLINK: a wall-clock timer flips ONLY the desired flag; #pump does the actual
|
||||||
|
// (serialized) UDP send. A symmetric cadence uses one interval; an asymmetric one
|
||||||
|
// re-arms each phase with its own duration. Sends never overlap or reorder, so the
|
||||||
|
// relay can't get stuck on a stale packet.
|
||||||
|
const onMs = lamp.spec.blinkOnMs && lamp.spec.blinkOnMs > 0 ? lamp.spec.blinkOnMs : DEFAULT_BLINK_MS;
|
||||||
|
const offMs = lamp.spec.blinkOffMs && lamp.spec.blinkOffMs > 0 ? lamp.spec.blinkOffMs : DEFAULT_BLINK_MS;
|
||||||
|
lamp.blinkOn = true;
|
||||||
|
lamp.desiredOn = true;
|
||||||
|
const tick = () => {
|
||||||
|
lamp.blinkOn = !lamp.blinkOn;
|
||||||
|
lamp.desiredOn = lamp.blinkOn;
|
||||||
|
this.#pump(lamp);
|
||||||
|
if (onMs !== offMs && lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = setInterval(tick, lamp.blinkOn ? onMs : offMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lamp.blink = setInterval(tick, onMs);
|
||||||
|
lamp.blink.unref?.();
|
||||||
|
this.#pump(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialized per-lamp worker: drive the relay toward `desiredOn`, one UDP send at a
|
||||||
|
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||||
|
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||||
|
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
||||||
|
* so the LAST desired state is always the one finally asserted on the device.
|
||||||
|
*
|
||||||
|
* Failures back off (1s → 30s, reset on success) instead of retrying inline: an
|
||||||
|
* unreachable controller rejects instantly, and an immediate re-pump was a hot loop.
|
||||||
|
* During backoff `desiredOn` keeps tracking the truth table; the armed retry timer
|
||||||
|
* converges to whatever it says when it fires. Only the FIRST failure of a streak is
|
||||||
|
* logged, then one summary per minute, and an info line on recovery. */
|
||||||
|
#pump(lamp: LampState): void {
|
||||||
|
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
||||||
|
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
||||||
|
if (Date.now() < lamp.retryAt) return; // backing off — the retry timer will re-pump
|
||||||
|
const aux = this.#resolveAux(lamp.controllerId);
|
||||||
|
if (!aux) return;
|
||||||
|
const target = lamp.desiredOn;
|
||||||
|
lamp.sending = true;
|
||||||
|
void aux
|
||||||
|
.setAux(lamp.spec.relay, target)
|
||||||
|
.then(() => {
|
||||||
|
lamp.confirmedOn = target;
|
||||||
|
if (lamp.failCount > 0) {
|
||||||
|
this.#logger.info(
|
||||||
|
`button-light setAux recovered (${lamp.controllerId} R${lamp.spec.relay}) after ${lamp.failCount} failed attempts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lamp.failCount = 0;
|
||||||
|
lamp.retryAt = 0;
|
||||||
|
lamp.lastFailLogAt = 0;
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// Leave confirmedOn unchanged so the armed retry re-asserts the (then-current)
|
||||||
|
// desired state. Never escalates — a dead lamp is "no hint", never a fault.
|
||||||
|
lamp.failCount += 1;
|
||||||
|
const delay = Math.min(RETRY_BASE_MS * 2 ** (lamp.failCount - 1), RETRY_MAX_MS);
|
||||||
|
lamp.retryAt = Date.now() + delay;
|
||||||
|
const now = Date.now();
|
||||||
|
if (lamp.failCount === 1 || now - lamp.lastFailLogAt >= FAIL_LOG_EVERY_MS) {
|
||||||
|
lamp.lastFailLogAt = now;
|
||||||
|
const streak =
|
||||||
|
lamp.failCount > 1 ? ` — still failing (attempt ${lamp.failCount}, retrying ≤${RETRY_MAX_MS / 1000}s)` : "";
|
||||||
|
this.#logger.error(
|
||||||
|
`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}${streak}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lamp.retryTimer) clearTimeout(lamp.retryTimer);
|
||||||
|
lamp.retryTimer = setTimeout(() => {
|
||||||
|
lamp.retryTimer = null;
|
||||||
|
this.#pump(lamp);
|
||||||
|
}, delay);
|
||||||
|
lamp.retryTimer.unref?.();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
lamp.sending = false;
|
||||||
|
// Desired state may have changed while we were busy — re-pump to converge (the
|
||||||
|
// backoff gate above makes this a no-op right after a failure). This is what
|
||||||
|
// makes the final state authoritative.
|
||||||
|
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the live aux-output adapter for a controller, or null (logged once). */
|
||||||
|
#auxFromRegistry(controllerId: string): AuxOutputDevice | null {
|
||||||
|
const row = this.#db.select().from(devices).where(eq(devices.id, controllerId)).get();
|
||||||
|
if (!row) return null;
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
let device: unknown;
|
||||||
|
try {
|
||||||
|
device = driver.create(row.config as never);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!hasAuxOutput(device)) {
|
||||||
|
if (!this.#warned.has(controllerId)) {
|
||||||
|
this.#warned.add(controllerId);
|
||||||
|
this.#logger.warn(`button-light: controller ${controllerId} (${row.driverId}) has no aux-output — lamp ignored`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe, stop all blink timers, and best-effort drive every lamp OFF. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsubInput?.();
|
||||||
|
this.#unsubLane?.();
|
||||||
|
this.#unsubInput = null;
|
||||||
|
this.#unsubLane = null;
|
||||||
|
for (const lamp of this.#lamps.values()) {
|
||||||
|
this.#disarm(lamp);
|
||||||
|
// Best-effort fail-OFF on shutdown.
|
||||||
|
this.#finalOff(lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop a lamp's timers (blink + backoff retry) without touching the device. */
|
||||||
|
#disarm(lamp: LampState): void {
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
if (lamp.retryTimer) {
|
||||||
|
clearTimeout(lamp.retryTimer);
|
||||||
|
lamp.retryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
||||||
|
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
||||||
|
* in-flight send — it converges to OFF. Any backoff is waived so the last-gasp OFF
|
||||||
|
* gets one immediate try (a lamp mid-backoff may just have recovered). */
|
||||||
|
#finalOff(lamp: LampState): void {
|
||||||
|
lamp.desiredOn = false;
|
||||||
|
lamp.retryAt = 0;
|
||||||
|
this.#pump(lamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: current high-level state being rendered for a lamp (controller + relay).
|
||||||
|
* `relay` defaults to the controller's only/first alert relay for single-lamp tests. */
|
||||||
|
stateOf(controllerId: string, relay?: number): LightState | null {
|
||||||
|
return this.#lamp(controllerId, relay)?.rendered ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: the state last CONFIRMED on the device for a lamp (after a successful
|
||||||
|
* send). null = unknown / nothing sent yet. `relay` defaults to the only alert relay. */
|
||||||
|
confirmedOf(controllerId: string, relay?: number): boolean | null {
|
||||||
|
return this.#lamp(controllerId, relay)?.confirmedOn ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a lamp by controller + relay. When `relay` is omitted, returns the
|
||||||
|
* controller's single lamp (the common single-alert case); ambiguous if several. */
|
||||||
|
#lamp(controllerId: string, relay?: number): LampState | undefined {
|
||||||
|
if (relay != null) return this.#lamps.get(lampKey(controllerId, relay));
|
||||||
|
for (const lamp of this.#lamps.values()) if (lamp.controllerId === controllerId) return lamp;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composite key for the lamp map (a controller may carry several alert relays). */
|
||||||
|
function lampKey(controllerId: string, relay: number): string {
|
||||||
|
return `${controllerId}:${relay}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a controller row's live aux device (exported for reuse/tests). */
|
||||||
|
export function buildAux(db: Db, row: DeviceRow): AuxOutputDevice | null {
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
try {
|
||||||
|
const device = driver.create(row.config as never);
|
||||||
|
return hasAuxOutput(device) ? device : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,13 +24,20 @@ export interface DeviceReadEvent {
|
|||||||
readonly deviceId: string; // devices id of the reader/scanner/camera
|
readonly deviceId: string; // devices id of the reader/scanner/camera
|
||||||
readonly value: string; // the ticket id / plate / card number
|
readonly value: string; // the ticket id / plate / card number
|
||||||
readonly kind: "ticket" | "plate" | "qr" | "card";
|
readonly kind: "ticket" | "plate" | "qr" | "card";
|
||||||
|
/** The CONFIRMED physical channel the value arrived on, when the reader tags it
|
||||||
|
* (the DT-008 output prefixes — see routes/qr-reader.ts). `optical` = decoded by
|
||||||
|
* the barcode/QR engine; `rf` = read from a card/chip. Undefined = legacy reader
|
||||||
|
* with no prefixes configured (channel unknown — flows must not assume). Lets the
|
||||||
|
* subscription match refuse an OPTICAL decode claiming an RF credential (a printed
|
||||||
|
* copy of a card's UID must not clone the card). */
|
||||||
|
readonly channel?: "optical" | "rf";
|
||||||
readonly at: string; // ISO-8601
|
readonly at: string; // ISO-8601
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
* The decision a read produced. Returned by the read flows so a SYNCHRONOUS reader
|
||||||
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
|
* (e.g. the QR reader, whose HTTP reply drives its beep + output) can answer the
|
||||||
* device. A fire-and-forget reader simply ignores it. See wiki/entities/gee-qr-er80.md.
|
* device. A fire-and-forget reader simply ignores it. See wiki/entities/dingtian-dt008-reader.md.
|
||||||
*/
|
*/
|
||||||
export interface ReadOutcome {
|
export interface ReadOutcome {
|
||||||
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
/** Was the vehicle admitted/exited (barrier opened)? Drives the reader's beep. */
|
||||||
@@ -86,6 +93,28 @@ export interface LaneStatusEvent {
|
|||||||
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
readonly exit: boolean; // true = busy (a vehicle is at the exit vicinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A plate was RECOGNIZED for a session AFTER its entry/exit event already shipped. Plate
|
||||||
|
* recognition is async/advisory (a vision round-trip off the snapshot), so it lands a
|
||||||
|
* moment after the signed event — too late for the event's own WS push to carry it. This
|
||||||
|
* notifies the booth so it can fill in the plate badge on the already-rendered feed row /
|
||||||
|
* active session in place, no refresh. Advisory; never touches the signed ledger. See
|
||||||
|
* snapshot.ts (recognizePlate) + event-enrich.ts. */
|
||||||
|
export interface PlateRecognizedEvent {
|
||||||
|
readonly identity: string; // the session identity the plate is tied to
|
||||||
|
readonly plate: string; // normalized plate text (trimmed, upper)
|
||||||
|
readonly direction: "entry" | "exit";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
||||||
|
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
||||||
|
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
||||||
|
* radar-present + camera-not-busy. Drives the booth's barrier light blink. Advisory only —
|
||||||
|
* it gates nothing. See wiki/concepts/button-light-indicator.md. */
|
||||||
|
export interface LanePresenceEvent {
|
||||||
|
readonly entry: boolean; // true = a presence input on an entry barrier is active
|
||||||
|
readonly exit: boolean; // true = a presence input on an exit barrier is active
|
||||||
|
}
|
||||||
|
|
||||||
class DeviceEventBus extends EventEmitter {
|
class DeviceEventBus extends EventEmitter {
|
||||||
emitInput(event: DeviceInputEvent): void {
|
emitInput(event: DeviceInputEvent): void {
|
||||||
this.emit("input", event);
|
this.emit("input", event);
|
||||||
@@ -148,6 +177,26 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("lane-status", cb);
|
this.on("lane-status", cb);
|
||||||
return () => this.off("lane-status", cb);
|
return () => this.off("lane-status", cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emitted whenever a lane's RADAR presence CHANGES (a presence input shorted/cleared
|
||||||
|
* at an entry/exit barrier). Drives the booth barrier light's blink. Advisory only. */
|
||||||
|
emitLanePresence(event: LanePresenceEvent): void {
|
||||||
|
this.emit("lane-presence", event);
|
||||||
|
}
|
||||||
|
onLanePresence(cb: (event: LanePresenceEvent) => void): () => void {
|
||||||
|
this.on("lane-presence", cb);
|
||||||
|
return () => this.off("lane-presence", cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emitted when an async plate recognition completes for a session (after its event
|
||||||
|
* already shipped). Lets the booth backfill the plate badge in place. Advisory only. */
|
||||||
|
emitPlateRecognized(event: PlateRecognizedEvent): void {
|
||||||
|
this.emit("plate-recognized", event);
|
||||||
|
}
|
||||||
|
onPlateRecognized(cb: (event: PlateRecognizedEvent) => void): () => void {
|
||||||
|
this.on("plate-recognized", cb);
|
||||||
|
return () => this.off("plate-recognized", cb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process-wide device event bus. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { localIsoWithOffset } from "./device-monitor.js";
|
||||||
|
|
||||||
|
// The camera clock-sync sends the SITE's wall-clock now with an explicit UTC offset
|
||||||
|
// (ISAPI localTime) — the offset is what makes the instant unambiguous regardless of
|
||||||
|
// the camera's own tz/DST config. Pin the DST both-sides behaviour for the site tz.
|
||||||
|
|
||||||
|
describe("localIsoWithOffset (camera clock sync payload)", () => {
|
||||||
|
it("Tirane summer = +02:00 (CEST)", () => {
|
||||||
|
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||||
|
"2026-07-07T12:00:00+02:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("Tirane winter = +01:00 (CET)", () => {
|
||||||
|
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-01-15T10:00:00Z"))).toBe(
|
||||||
|
"2026-01-15T11:00:00+01:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("UTC = +00:00", () => {
|
||||||
|
expect(localIsoWithOffset("UTC", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||||
|
"2026-07-07T10:00:00+00:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { devices, type Db, type DeviceRow } from "@parking/db";
|
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
import { isMonitorable, registry } from "@parking/devices";
|
import { isClockSyncable, isMonitorable, registry, type Device } from "@parking/devices";
|
||||||
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
||||||
import { directionOf, relaysOf } from "./device-resolve.js";
|
import { directionOf, relaysOf } from "./device-resolve.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
import { siteTz } from "./subscription-window.js";
|
||||||
|
|
||||||
/** Synthetic device id for the vision service in the status footer (it's a service,
|
/** Synthetic device id for the vision service in the status footer (it's a service,
|
||||||
* not a device row, but shares the footer's traffic-light + WS plumbing). */
|
* not a device row, but shares the footer's traffic-light + WS plumbing). */
|
||||||
@@ -23,6 +24,40 @@ const VISION_STATUS_ID = "vision-service";
|
|||||||
|
|
||||||
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
||||||
|
|
||||||
|
// Camera clock sync (Hikvision loses its clock on power cuts — reboots at the 1970
|
||||||
|
// epoch until a human logs into its web UI). The monitor re-syncs from the HOST
|
||||||
|
// clock (the site's offline time authority) at the offline→ready edge — exactly the
|
||||||
|
// power-restored moment — plus a daily backstop; drift under the threshold is left
|
||||||
|
// alone. See wiki/entities/lpr-camera.md (clock sync).
|
||||||
|
const CLOCK_SYNC_BACKSTOP_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const CLOCK_MAX_DRIFT_SEC = 60;
|
||||||
|
|
||||||
|
/** The site's wall-clock now as ISO WITH utc offset (e.g. 2026-07-07T15:30:22+02:00)
|
||||||
|
* — what ISAPI's localTime wants. Derived via Intl for the site tz (no dep). */
|
||||||
|
export function localIsoWithOffset(tz: string, at = new Date()): string {
|
||||||
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
|
||||||
|
const wallAsUtcMs = Date.UTC(
|
||||||
|
Number(p.year), Number(p.month) - 1, Number(p.day),
|
||||||
|
Number(p.hour), Number(p.minute), Number(p.second),
|
||||||
|
);
|
||||||
|
const offMin = Math.round((wallAsUtcMs - at.getTime()) / 60_000);
|
||||||
|
const sign = offMin < 0 ? "-" : "+";
|
||||||
|
const abs = Math.abs(offMin);
|
||||||
|
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||||
|
const mm = String(abs % 60).padStart(2, "0");
|
||||||
|
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}${sign}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
||||||
* tokens the client localises next to the category:
|
* tokens the client localises next to the category:
|
||||||
@@ -39,7 +74,12 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
case "access": {
|
case "access": {
|
||||||
const dirs = new Set(relaysOf(row).map((r) => r.direction));
|
// Only barrier relays carry a role direction; alert (radarAlert) relays don't.
|
||||||
|
const dirs = new Set(
|
||||||
|
relaysOf(row)
|
||||||
|
.map((r) => r.direction)
|
||||||
|
.filter((d): d is "entry" | "exit" | "both" => d !== "radarAlert"),
|
||||||
|
);
|
||||||
if (dirs.size === 0) return null;
|
if (dirs.size === 0) return null;
|
||||||
if (dirs.size > 1) return "mixed";
|
if (dirs.size > 1) return "mixed";
|
||||||
const only = [...dirs][0]; // entry | exit | both
|
const only = [...dirs][0]; // entry | exit | both
|
||||||
@@ -134,6 +174,7 @@ export class DeviceMonitor {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let next: DeviceStatusEvent;
|
let next: DeviceStatusEvent;
|
||||||
|
let device: Device | null = null;
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
// Configured against a driver that's no longer registered — surface it,
|
// Configured against a driver that's no longer registered — surface it,
|
||||||
@@ -141,7 +182,7 @@ export class DeviceMonitor {
|
|||||||
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const device = driver.create(cfg as never);
|
device = driver.create(cfg as never);
|
||||||
// Printers expose richer paper/cover/cutter status; everything else uses
|
// Printers expose richer paper/cover/cutter status; everything else uses
|
||||||
// the generic reachability probe. Both flatten to the same traffic-light.
|
// the generic reachability probe. Both flatten to the same traffic-light.
|
||||||
if (isMonitorable(device)) {
|
if (isMonitorable(device)) {
|
||||||
@@ -158,6 +199,33 @@ export class DeviceMonitor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Camera clock re-sync at the power-restored edge (prev offline/unknown →
|
||||||
|
// ready) + a daily backstop. Stamped BEFORE the async attempt so a failing
|
||||||
|
// camera is retried at backstop cadence, never every poll.
|
||||||
|
if (row.category === "camera" && next.state === "ready" && device && isClockSyncable(device)) {
|
||||||
|
const prev = this.#latest.get(row.id);
|
||||||
|
const cameBack = !prev || prev.state === "offline";
|
||||||
|
const last = this.#clockSyncedAt.get(row.id) ?? 0;
|
||||||
|
if (cameBack || Date.now() - last > CLOCK_SYNC_BACKSTOP_MS) {
|
||||||
|
this.#clockSyncedAt.set(row.id, Date.now());
|
||||||
|
const cam = device;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const r = await cam.syncClock(localIsoWithOffset(siteTz(this.#db)), CLOCK_MAX_DRIFT_SEC);
|
||||||
|
if (r.synced) {
|
||||||
|
// A large jump is the 1970 power-cut signature — warn (persisted) so
|
||||||
|
// the reboot stays visible; a small correction is routine info.
|
||||||
|
const msg = `device-monitor: camera ${row.id} clock synced (was ${r.driftSeconds ?? "unparseable"}s off)`;
|
||||||
|
if (r.driftSeconds == null || r.driftSeconds > 3600) this.#log.warn(msg);
|
||||||
|
else this.#log.info(msg);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.#log.warn(`device-monitor: camera ${row.id} clock sync failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.#publish(row.id, next);
|
this.#publish(row.id, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +246,9 @@ export class DeviceMonitor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-camera timestamp of the last clock-sync ATTEMPT (backstop pacing). */
|
||||||
|
readonly #clockSyncedAt = new Map<string, number>();
|
||||||
|
|
||||||
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
||||||
#publish(id: string, next: DeviceStatusEvent): void {
|
#publish(id: string, next: DeviceStatusEvent): void {
|
||||||
const prev = this.#latest.get(id);
|
const prev = this.#latest.get(id);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { inputsOf, relayForButton, relayForPresence } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// device-resolve: the input resolution layer. Inputs live in config.inputs[] (the first-class
|
||||||
|
// model); a pre-inputs[] controller is back-compat-synthesized from the legacy per-relay
|
||||||
|
// button/presenceInput fields. relayForButton/relayForPresence must resolve IDENTICALLY from
|
||||||
|
// either shape, so an exit radar = just another presence row.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CTL = "ctl-1";
|
||||||
|
|
||||||
|
function seed(config: Record<string, unknown>): void {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({ id: CTL, category: "access", driverId: "dingtian", config, enabled: true }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("inputsOf back-compat synth", () => {
|
||||||
|
it("synthesizes inputs[] from legacy relay button/presence fields", () => {
|
||||||
|
seed({
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "radar", presenceActiveLow: true },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const row = db.select().from(devices).get()!;
|
||||||
|
const inputs = inputsOf(row);
|
||||||
|
expect(inputs).toEqual([
|
||||||
|
{ input: 1, role: "button", relay: 1, cooldownSec: undefined },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar", activeLow: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an explicit inputs[] over the legacy fields", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 1, direction: "entry", button: 9 /* legacy ignored */ }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||||
|
});
|
||||||
|
const row = db.select().from(devices).get()!;
|
||||||
|
expect(inputsOf(row)).toEqual([{ input: 1, role: "button", relay: 1 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("relayForButton / relayForPresence", () => {
|
||||||
|
it("resolves a button + presence from inputs[]", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 1, role: "button", relay: 1 },
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const byBtn = relayForButton(db, CTL, 1);
|
||||||
|
expect(byBtn).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2, presenceKind: "radar" });
|
||||||
|
const byPres = relayForPresence(db, CTL, 2);
|
||||||
|
expect(byPres).toMatchObject({ relay: 1, direction: "entry", presenceInput: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves IDENTICALLY from the legacy shape (no inputs[])", () => {
|
||||||
|
seed({ relays: [{ relay: 1, direction: "entry", button: 1, presenceInput: 2, presenceKind: "loop" }] });
|
||||||
|
expect(relayForButton(db, CTL, 1)).toMatchObject({ relay: 1, presenceInput: 2, presenceKind: "loop" });
|
||||||
|
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1, presenceInput: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an EXIT presence row to the exit relay (the exit radar)", () => {
|
||||||
|
seed({
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" }, // entry radar
|
||||||
|
{ input: 5, role: "presence", relay: 2, kind: "radar" }, // exit radar
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// NOTE: relayForPresence only gates entry/both relays (transient entry). The exit radar
|
||||||
|
// resolves to null HERE (the exit barrier has no entry gate) — but it's still a valid
|
||||||
|
// inputs[] row the lamp can trigger on. The entry radar resolves to relay 1.
|
||||||
|
expect(relayForPresence(db, CTL, 2)).toMatchObject({ relay: 1 });
|
||||||
|
expect(relayForPresence(db, CTL, 5)).toBeNull(); // exit relay isn't a transient-entry gate
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a button on an exit-only relay is not a transient-entry trigger", () => {
|
||||||
|
seed({
|
||||||
|
relays: [{ relay: 2, direction: "exit" }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 2 }],
|
||||||
|
});
|
||||||
|
expect(relayForButton(db, CTL, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,35 +10,74 @@ export type Direction = "entry" | "exit" | "both";
|
|||||||
/** A concrete flow a credential/button drives (never "both"). */
|
/** A concrete flow a credential/button drives (never "both"). */
|
||||||
export type FlowDirection = "entry" | "exit";
|
export type FlowDirection = "entry" | "exit";
|
||||||
|
|
||||||
/** One relay on an access controller: which barrier it opens, in which direction,
|
/** The EVENT a relay reacts to. The barrier events (entry/exit/both) `pulseOpen`; the
|
||||||
* and (optionally) the input terminals its entry button + presence loop are wired to. */
|
* `radarAlert` event drives a non-barrier alert lamp (blink while the trigger input is
|
||||||
|
* active, locked SOLID by the camera). A relay is "when EVENT X happens, do its action" —
|
||||||
|
* the action is implied by the event. See wiki/concepts/button-light-indicator.md. */
|
||||||
|
export type RelayEvent = Direction | "radarAlert";
|
||||||
|
|
||||||
|
/** What a controller input terminal MEANS. `button` = a transient-entry button; `presence`
|
||||||
|
* = a one-car-one-ticket sensor (induction loop or radar); `alertTrigger` = the edge that
|
||||||
|
* starts a `radarAlert` lamp blinking. See wiki/concepts/entry-double-press.md. */
|
||||||
|
export type InputRole = "button" | "presence" | "alertTrigger";
|
||||||
|
|
||||||
|
/** One INPUT terminal the host reads, as a first-class citizen (the twin of RelaySpec).
|
||||||
|
* An exit radar is just another `presence` row serving the exit relay. */
|
||||||
|
export interface InputSpec {
|
||||||
|
/** 1-based input terminal the host reads. */
|
||||||
|
readonly input: number;
|
||||||
|
readonly role: InputRole;
|
||||||
|
/** The barrier relay this input serves. Required for `button`/`presence` (the gate is
|
||||||
|
* keyed per relay); optional for `alertTrigger` (a standalone lamp trigger). */
|
||||||
|
readonly relay?: number;
|
||||||
|
/** `presence` only — induction LOOP or RADAR. Label only (gate is identical). Default loop. */
|
||||||
|
readonly kind?: "loop" | "radar";
|
||||||
|
/** This terminal is ACTIVE-LOW (idles HIGH) — e.g. a radar wired opposite the button.
|
||||||
|
* Maps to the driver's per-input `inputActiveLow`. See wiki/entities/hikvision-radar.md. */
|
||||||
|
readonly activeLow?: boolean;
|
||||||
|
/** `button` only — presence-less fallback: suppress repeat presses for N seconds after a
|
||||||
|
* ticket. A timer (mitigation, not a guarantee); used when no `presence` row serves this relay. */
|
||||||
|
readonly cooldownSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One relay on an access controller: the event it reacts to. Input wiring (button,
|
||||||
|
* presence) lives in `config.inputs[]`; the LEGACY per-relay fields below are still read
|
||||||
|
* (back-compat) but no longer written by the UI. */
|
||||||
export interface RelaySpec {
|
export interface RelaySpec {
|
||||||
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
/** 1-based relay channel on the board (the driver's pulseOpen(doorId)). */
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
/** The event this relay reacts to. entry/exit/both → pulse a barrier; `radarAlert` →
|
||||||
/** 1-based input terminal of the entry button that fires this relay (transient
|
* drive an alert lamp (blink + camera-lock) via `setAux`, NEVER pulseOpen. */
|
||||||
* entry). Absent = no button at this barrier (subscriber/reader-driven only). */
|
readonly direction: RelayEvent;
|
||||||
|
|
||||||
|
// ── LEGACY input fields (read-only back-compat; superseded by config.inputs[]) ──
|
||||||
|
// Pre-inputs[] configs wired the entry button + presence sensor here. `inputsOf()`
|
||||||
|
// synthesizes InputSpec rows from these when a controller has no `inputs[]` yet.
|
||||||
readonly button?: number;
|
readonly button?: number;
|
||||||
/**
|
|
||||||
* Anti-double-press for the transient entry button (one car must yield ONE ticket).
|
|
||||||
* Two modes, chosen by what barrier feedback exists at this lane:
|
|
||||||
* - PRESENCE (preferred, when a vehicle loop is wired): `presenceInput` = the
|
|
||||||
* 1-based input terminal of an induction loop / barrier presence signal on THIS
|
|
||||||
* controller. A press prints only while a car is present, and no second ticket
|
|
||||||
* issues until the loop CLEARS (car drove in) and a new car re-occupies it. This
|
|
||||||
* makes one-car-one-ticket physical.
|
|
||||||
* - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses
|
|
||||||
* on this relay for N seconds after a ticket prints. A pure timer — mitigation,
|
|
||||||
* not a guarantee. Used when `presenceInput` is unset (or as a secondary guard).
|
|
||||||
* Both absent = no guard (legacy behaviour). See wiki/concepts/entry-double-press.md.
|
|
||||||
*/
|
|
||||||
readonly presenceInput?: number;
|
readonly presenceInput?: number;
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
readonly presenceActiveLow?: boolean;
|
||||||
readonly entryCooldownSec?: number;
|
readonly entryCooldownSec?: number;
|
||||||
|
|
||||||
|
// ── radarAlert-only (direction === "radarAlert") ──
|
||||||
|
// A non-barrier indicator lamp wired to this (spare) relay — e.g. the entry button's
|
||||||
|
// 12 V light. Driven by the server ButtonLightController off its trigger input vs. the
|
||||||
|
// camera lane status: blink while the trigger is active + lane free, SOLID once the
|
||||||
|
// camera confirms a car, OFF otherwise. NOT a barrier (uses setAux, never pulseOpen).
|
||||||
|
/** 1-based input terminal whose active edge starts the blink (the radar). */
|
||||||
|
readonly triggerInput?: number;
|
||||||
|
/** Which lane's camera locks this lamp SOLID — the entry or the exit camera. Default
|
||||||
|
* "entry". An exit radar's lamp must lock on the EXIT camera. */
|
||||||
|
readonly lockLane?: FlowDirection;
|
||||||
|
/** Blink cadence (ms on / ms off) for the radar-only state. Default 500/500. */
|
||||||
|
readonly blinkOnMs?: number;
|
||||||
|
readonly blinkOffMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Access controller config (the `relays[]` map + connection fields). */
|
/** Access controller config (the `relays[]` + `inputs[]` maps + connection fields). */
|
||||||
interface AccessConfig {
|
interface AccessConfig {
|
||||||
readonly relays?: RelaySpec[];
|
readonly relays?: RelaySpec[];
|
||||||
|
readonly inputs?: InputSpec[];
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +99,11 @@ export interface ResolvedRelay {
|
|||||||
readonly controller: DeviceRow;
|
readonly controller: DeviceRow;
|
||||||
readonly relay: number;
|
readonly relay: number;
|
||||||
readonly direction: Direction;
|
readonly direction: Direction;
|
||||||
/** 1-based presence-loop input gating this relay's entry (when wired). */
|
/** 1-based presence input gating this relay's entry (loop or radar, when wired). */
|
||||||
readonly presenceInput?: number;
|
readonly presenceInput?: number;
|
||||||
/** Cooldown seconds suppressing repeat presses (fallback when no presence loop). */
|
/** Sensor kind on the presence input (loop|radar) — telemetry/label only. */
|
||||||
|
readonly presenceKind?: "loop" | "radar";
|
||||||
|
/** Cooldown seconds suppressing repeat presses (fallback when no presence input). */
|
||||||
readonly entryCooldownSec?: number;
|
readonly entryCooldownSec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +124,49 @@ export function relaysOf(row: DeviceRow): RelaySpec[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a button press to the relay it fires: the access controller with this
|
* The INPUT terminals declared on an access controller — the back-compat keystone. Returns
|
||||||
* deviceId, and the relay whose `button` terminal matches the pressed input. Only
|
* `config.inputs[]` when present; otherwise SYNTHESIZES InputSpec rows from the LEGACY
|
||||||
* an ENTRY (or both) relay is a transient-entry trigger. Returns null otherwise.
|
* per-relay fields (`relays[].button` → a `button` row; `relays[].presenceInput` → a
|
||||||
|
* `presence` row) so a pre-inputs[] controller resolves identically. Everything that reads
|
||||||
|
* inputs goes through here, so the legacy fold lives in exactly one place.
|
||||||
|
*/
|
||||||
|
export function inputsOf(row: DeviceRow): InputSpec[] {
|
||||||
|
const cfg = row.config as AccessConfig;
|
||||||
|
if (Array.isArray(cfg.inputs) && cfg.inputs.length > 0) return cfg.inputs;
|
||||||
|
const synth: InputSpec[] = [];
|
||||||
|
for (const r of relaysOf(row)) {
|
||||||
|
if (typeof r.button === "number") {
|
||||||
|
synth.push({ input: r.button, role: "button", relay: r.relay, cooldownSec: r.entryCooldownSec });
|
||||||
|
}
|
||||||
|
if (typeof r.presenceInput === "number") {
|
||||||
|
synth.push({
|
||||||
|
input: r.presenceInput,
|
||||||
|
role: "presence",
|
||||||
|
relay: r.relay,
|
||||||
|
kind: r.presenceKind ?? "loop",
|
||||||
|
activeLow: r.presenceActiveLow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return synth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The barrier RelaySpec a `button`/`presence` input row serves (its `relay`), or null —
|
||||||
|
* only entry/both relays gate transient entry. Narrows `direction` to a barrier Direction. */
|
||||||
|
function barrierForInput(row: DeviceRow, spec: InputSpec): (RelaySpec & { direction: Direction }) | null {
|
||||||
|
if (typeof spec.relay !== "number") return null;
|
||||||
|
const relay = relaysOf(row).find((r) => r.relay === spec.relay);
|
||||||
|
if (!relay) return null;
|
||||||
|
if (relay.direction !== "entry" && relay.direction !== "both") return null;
|
||||||
|
return { ...relay, direction: relay.direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a button press to the relay it fires: the access controller with this deviceId,
|
||||||
|
* and the relay served by the `button` input on this terminal (via inputsOf). Only an
|
||||||
|
* ENTRY (or both) relay is a transient-entry trigger. Carries the one-car-one-ticket
|
||||||
|
* config (presence input + cooldown) for that relay so the entry flow can enforce it.
|
||||||
|
* Returns null otherwise.
|
||||||
*/
|
*/
|
||||||
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
export function relayForButton(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||||
const row = db
|
const row = db
|
||||||
@@ -94,23 +175,28 @@ export function relayForButton(db: Db, controllerId: string, terminal: number):
|
|||||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
.get();
|
.get();
|
||||||
if (!row || !row.enabled) return null;
|
if (!row || !row.enabled) return null;
|
||||||
const spec = relaysOf(row).find((r) => r.button === terminal);
|
const inputs = inputsOf(row);
|
||||||
if (!spec) return null;
|
const btn = inputs.find((i) => i.role === "button" && i.input === terminal);
|
||||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
if (!btn) return null;
|
||||||
|
const relay = barrierForInput(row, btn);
|
||||||
|
if (!relay) return null;
|
||||||
|
// The presence sensor (if any) serving the SAME relay supplies the gate.
|
||||||
|
const presence = inputs.find((i) => i.role === "presence" && i.relay === relay.relay);
|
||||||
return {
|
return {
|
||||||
controller: row,
|
controller: row,
|
||||||
relay: spec.relay,
|
relay: relay.relay,
|
||||||
direction: spec.direction,
|
direction: relay.direction,
|
||||||
presenceInput: spec.presenceInput,
|
presenceInput: presence?.input,
|
||||||
entryCooldownSec: spec.entryCooldownSec,
|
presenceKind: presence?.kind ?? "loop",
|
||||||
|
entryCooldownSec: btn.cooldownSec,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a PRESENCE-LOOP input edge to the entry relay it gates: the controller with
|
* Resolve a PRESENCE input edge to the entry relay it gates: the controller with this
|
||||||
* this deviceId, and the relay whose `presenceInput` terminal matches the fired input.
|
* deviceId, and the relay served by the `presence` input on this terminal. Lets the entry
|
||||||
* Lets the entry flow track "a car is physically at this entry barrier" so it issues
|
* flow track "a car is physically at this entry barrier" so it issues exactly one ticket
|
||||||
* exactly one ticket per car. Only entry/both relays gate transient entry. Null otherwise.
|
* per car. Only entry/both relays gate transient entry. Null otherwise.
|
||||||
*/
|
*/
|
||||||
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
export function relayForPresence(db: Db, controllerId: string, terminal: number): ResolvedRelay | null {
|
||||||
const row = db
|
const row = db
|
||||||
@@ -119,10 +205,43 @@ export function relayForPresence(db: Db, controllerId: string, terminal: number)
|
|||||||
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
.get();
|
.get();
|
||||||
if (!row || !row.enabled) return null;
|
if (!row || !row.enabled) return null;
|
||||||
const spec = relaysOf(row).find((r) => r.presenceInput === terminal);
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||||
if (!spec) return null;
|
if (!presence) return null;
|
||||||
if (spec.direction !== "entry" && spec.direction !== "both") return null;
|
const relay = barrierForInput(row, presence);
|
||||||
return { controller: row, relay: spec.relay, direction: spec.direction };
|
if (!relay) return null;
|
||||||
|
return {
|
||||||
|
controller: row,
|
||||||
|
relay: relay.relay,
|
||||||
|
direction: relay.direction,
|
||||||
|
presenceInput: presence.input,
|
||||||
|
presenceKind: presence.kind ?? "loop",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The alert (radarAlert) relay rows declared on an access controller — the lamps the
|
||||||
|
* ButtonLightController drives. Each is a `relays[]` row whose event is `radarAlert`. */
|
||||||
|
export function alertRelaysOf(row: DeviceRow): RelaySpec[] {
|
||||||
|
return relaysOf(row).filter((r) => r.direction === "radarAlert" && typeof r.relay === "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which LANE a presence input belongs to — for the booth's barrier-light blink (advisory).
|
||||||
|
* Unlike `relayForPresence` (entry-gated, for the one-car-one-ticket gate), this resolves a
|
||||||
|
* presence input on ANY barrier: entry/both → "entry", exit → "exit". Returns null if the
|
||||||
|
* terminal isn't a presence input on a barrier relay. See lane-presence.ts.
|
||||||
|
*/
|
||||||
|
export function presenceLaneOf(db: Db, controllerId: string, terminal: number): FlowDirection | null {
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(devices)
|
||||||
|
.where(and(eq(devices.id, controllerId), eq(devices.category, "access")))
|
||||||
|
.get();
|
||||||
|
if (!row || !row.enabled) return null;
|
||||||
|
const presence = inputsOf(row).find((i) => i.role === "presence" && i.input === terminal);
|
||||||
|
if (!presence || typeof presence.relay !== "number") return null;
|
||||||
|
const relay = relaysOf(row).find((r) => r.relay === presence.relay);
|
||||||
|
if (!relay) return null;
|
||||||
|
return relay.direction === "exit" ? "exit" : relay.direction === "radarAlert" ? null : "entry";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,7 +263,10 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
|||||||
.get();
|
.get();
|
||||||
if (controller && controller.enabled) {
|
if (controller && controller.enabled) {
|
||||||
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
const spec = relaysOf(controller).find((r) => r.relay === cfg.relay);
|
||||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
// Only a barrier relay opens; an alert (radarAlert) relay is never a barrier.
|
||||||
|
if (spec && spec.direction !== "radarAlert") {
|
||||||
|
return { controller, relay: spec.relay, direction: spec.direction };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -164,9 +286,22 @@ export function relayForDevice(db: Db, deviceRow: DeviceRow): ResolvedRelay | nu
|
|||||||
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
export function firstRelayByDirection(db: Db, direction: FlowDirection): ResolvedRelay | null {
|
||||||
for (const controller of accessRows(db)) {
|
for (const controller of accessRows(db)) {
|
||||||
const spec = relaysOf(controller).find(
|
const spec = relaysOf(controller).find(
|
||||||
(r) => r.direction === direction || r.direction === "both",
|
(r): r is RelaySpec & { direction: Direction } =>
|
||||||
|
r.direction === direction || r.direction === "both",
|
||||||
);
|
);
|
||||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
if (spec) {
|
||||||
|
// Attach the presence sensor (if any) serving the SAME relay, so callers that gate on
|
||||||
|
// presence (the operator-issued entry) see it. Without this the ResolvedRelay carried
|
||||||
|
// no presenceInput and the presence gate read as "unavailable". Mirrors relayForButton.
|
||||||
|
const presence = inputsOf(controller).find((i) => i.role === "presence" && i.relay === spec.relay);
|
||||||
|
return {
|
||||||
|
controller,
|
||||||
|
relay: spec.relay,
|
||||||
|
direction: spec.direction,
|
||||||
|
presenceInput: presence?.input,
|
||||||
|
presenceKind: presence?.kind ?? "loop",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { deviceEvents as deviceEventsTable, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { flagDuplicateEntryPlate } from "./snapshot.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Entry-side duplicate-plate reconciliation (2026-07-04): when ANPR recognizes a plate on
|
||||||
|
// a fresh transient entry and that plate is already OPEN under another RECENT session,
|
||||||
|
// the same car most likely minted a second ticket (a motion radar dropped the stationary
|
||||||
|
// car → the button re-armed). We sign ONE entry.duplicatePlate anomaly for the operator
|
||||||
|
// to void. Post-hoc + advisory: recognition never gates the (already-open) barrier —
|
||||||
|
// exactly the non-blocking role the plate can play here.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let log: EventLog;
|
||||||
|
|
||||||
|
const PLATE = "AA111BB";
|
||||||
|
const OLD = "11111111111";
|
||||||
|
const NEW = "22222222222";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
log = makeLog(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Seed the prior entry's unsigned plate-read telemetry (what recognizePlate records). */
|
||||||
|
function seedPriorRead(opts: { identity?: string; plate?: string; direction?: string; agoMs?: number } = {}) {
|
||||||
|
db.insert(deviceEventsTable).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId: "cam-entry",
|
||||||
|
category: "camera",
|
||||||
|
kind: "read",
|
||||||
|
detail: {
|
||||||
|
identity: opts.identity ?? OLD,
|
||||||
|
direction: opts.direction ?? "entry",
|
||||||
|
plate: opts.plate ?? PLATE,
|
||||||
|
snapshotId: "snap-old",
|
||||||
|
source: "entry-exit-snapshot",
|
||||||
|
},
|
||||||
|
occurredAt: new Date(Date.now() - (opts.agoMs ?? 60_000)).toISOString(),
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSession(id: string, state: "open" | "closed") {
|
||||||
|
db.insert(sessions).values({
|
||||||
|
id,
|
||||||
|
identity: id,
|
||||||
|
source: "ticket",
|
||||||
|
enteredAt: new Date(Date.now() - 60_000).toISOString(),
|
||||||
|
state,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const flag = () =>
|
||||||
|
flagDuplicateEntryPlate({ db, log, identity: NEW, plate: PLATE, snapshotId: "snap-new", logger: silentLogger() });
|
||||||
|
|
||||||
|
const anomalies = () =>
|
||||||
|
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||||
|
|
||||||
|
describe("flagDuplicateEntryPlate", () => {
|
||||||
|
it("same plate OPEN under another recent session → signs ONE entry.duplicatePlate anomaly", async () => {
|
||||||
|
seedPriorRead();
|
||||||
|
seedSession(OLD, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(1);
|
||||||
|
const a = anomalies()[0];
|
||||||
|
expect(a.identity).toBe(NEW); // keyed to the NEW (suspect) ticket
|
||||||
|
expect(a.payload).toMatchObject({
|
||||||
|
reasonCode: "entry.duplicatePlate",
|
||||||
|
duplicateEntrySuspected: true,
|
||||||
|
plate: PLATE,
|
||||||
|
otherIdentity: OLD,
|
||||||
|
snapshotId: "snap-new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prior session already CLOSED → no anomaly (that car drove off; a re-visit is legit)", async () => {
|
||||||
|
seedPriorRead();
|
||||||
|
seedSession(OLD, "closed");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prior read outside the window → no anomaly (stale coincidence, not a double press)", async () => {
|
||||||
|
seedPriorRead({ agoMs: 30 * 60_000 }); // beyond the 15-min default window
|
||||||
|
seedSession(OLD, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("own read (same identity) never flags itself", async () => {
|
||||||
|
seedPriorRead({ identity: NEW });
|
||||||
|
seedSession(NEW, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("different plate / exit-side reads are ignored", async () => {
|
||||||
|
seedPriorRead({ plate: "ZZ999ZZ" });
|
||||||
|
seedPriorRead({ direction: "exit" });
|
||||||
|
seedSession(OLD, "open");
|
||||||
|
await flag();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+210
-23
@@ -12,10 +12,10 @@ import {
|
|||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceInputEvent } from "./device-events.js";
|
import type { DeviceInputEvent, LaneStatusEvent } from "./device-events.js";
|
||||||
import { getOccupancy } from "./occupancy.js";
|
import { getOccupancy } from "./occupancy.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||||
import { snapshotAsync } from "./snapshot.js";
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
@@ -48,9 +48,21 @@ import type { VisionClient } from "./vision-client.js";
|
|||||||
// input edges to track presence + "armed" per relay.
|
// input edges to track presence + "armed" per relay.
|
||||||
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
|
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
|
||||||
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
|
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
|
||||||
|
// When a loop IS wired the cooldown still runs as a BACKSTOP behind it: a motion
|
||||||
|
// radar can drop a STATIONARY car (no doppler return) and spuriously re-arm, and the
|
||||||
|
// cooldown bounds how fast that re-armed press can mint a second ticket.
|
||||||
|
// - CAMERA (when an entry camera is configured): a press is live only while the entry
|
||||||
|
// lane camera confirms a vehicle — the button lamp's SOLID state (button-light.ts).
|
||||||
|
// A radar false-positive (rain, a pedestrian) blinks the lamp but prints nothing.
|
||||||
|
// Camera-less sites keep the radar-only gate; a faulty camera is dropped via the
|
||||||
|
// admin bypass (wiki/concepts/entry-presence-bypass.md).
|
||||||
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
|
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
|
||||||
// See wiki/concepts/entry-double-press.md.
|
// See wiki/concepts/entry-double-press.md.
|
||||||
|
|
||||||
|
/** A presence signal the entry gate can require (or, when a device is faulty, the admin
|
||||||
|
* can bypass): the radar/loop presence input, or the camera vehicle-detection. */
|
||||||
|
export type PresenceSignal = "radar" | "camera";
|
||||||
|
|
||||||
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
|
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
|
||||||
interface RelayGuardState {
|
interface RelayGuardState {
|
||||||
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
|
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
|
||||||
@@ -72,6 +84,10 @@ export class EntryFlow {
|
|||||||
readonly #guard = new Map<string, RelayGuardState>();
|
readonly #guard = new Map<string, RelayGuardState>();
|
||||||
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
|
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
|
||||||
readonly #vision: VisionClient | null;
|
readonly #vision: VisionClient | null;
|
||||||
|
/** Live entry-lane camera state (LaneStatus mirror, fed by onLaneStatus). Gates the
|
||||||
|
* physical press when an entry camera is configured — advisory sensor, but here it
|
||||||
|
* only ever SUPPRESSES a reprint; it never opens a barrier or traps a car. */
|
||||||
|
#entryBusy = false;
|
||||||
|
|
||||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -121,6 +137,12 @@ export class EntryFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Track the entry lane's camera state (wired to deviceEvents.onLaneStatus in
|
||||||
|
* server.ts). LaneStatus emits on every flip, so this mirror stays current. */
|
||||||
|
onLaneStatus(s: LaneStatusEvent): void {
|
||||||
|
this.#entryBusy = s.entry;
|
||||||
|
}
|
||||||
|
|
||||||
/** Stable per-relay key for the guard map. */
|
/** Stable per-relay key for the guard map. */
|
||||||
#relayKey(r: ResolvedRelay): string {
|
#relayKey(r: ResolvedRelay): string {
|
||||||
return `${r.controller.id}:${r.relay}`;
|
return `${r.controller.id}:${r.relay}`;
|
||||||
@@ -153,17 +175,35 @@ export class EntryFlow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
|
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
|
||||||
* PRESENCE mode is authoritative when a loop is wired; otherwise COOLDOWN; else no
|
* Three layered gates: CAMERA (when an entry camera is configured), PRESENCE
|
||||||
* guard (legacy). The two can coexist — presence first, cooldown as a backstop. */
|
* (when a loop is wired), and COOLDOWN — no longer alternatives: the cooldown
|
||||||
|
* runs as a backstop BEHIND presence, because a motion radar can drop a
|
||||||
|
* stationary car and spuriously re-arm one-car-one-ticket. */
|
||||||
#suppressReason(r: ResolvedRelay): string | null {
|
#suppressReason(r: ResolvedRelay): string | null {
|
||||||
const s = this.#guardState(r);
|
const s = this.#guardState(r);
|
||||||
|
const bypass = this.#presenceBypass();
|
||||||
|
|
||||||
if (typeof r.presenceInput === "number") {
|
// CAMERA GATE — the lamp's blink-vs-solid rule, enforced at the press: with an entry
|
||||||
|
// camera configured, a press is live only once the camera confirms a vehicle in the
|
||||||
|
// entry zone (SOLID). Blink (radar-only — rain, a pedestrian, a reflection) prints
|
||||||
|
// nothing. Only ever suppresses a ticket; never opens or traps (advisory rule kept).
|
||||||
|
// A camera-less site skips this; a faulty camera is dropped via the admin bypass.
|
||||||
|
if (!bypass.camera && !this.#entryBusy && this.#entryCameraConfigured()) {
|
||||||
|
return "no camera-confirmed vehicle in the entry zone";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin bypass for a FAULTY radar/loop: skip the presence-loop check so a press prints.
|
||||||
|
// A dead loop can't re-arm one-car-one-ticket, so the cooldown below is what stops a
|
||||||
|
// held button minting a burst. If no cooldown is configured there's no anti-double-press
|
||||||
|
// left — that's the admin's accepted tradeoff while bypassed. See
|
||||||
|
// wiki/concepts/entry-presence-bypass.md.
|
||||||
|
if (typeof r.presenceInput === "number" && !bypass.radar) {
|
||||||
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
|
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
|
||||||
// ticket already issued for this still-present car).
|
// ticket already issued for this still-present car).
|
||||||
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
|
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
|
||||||
if (!s.armed) return "ticket already issued for the car at the barrier";
|
if (!s.armed) return "ticket already issued for the car at the barrier";
|
||||||
return null;
|
// Fall THROUGH to the cooldown backstop: a presence-approved press can still be the
|
||||||
|
// SAME stationary car after a radar dropout re-armed the guard.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
|
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
|
||||||
@@ -176,6 +216,13 @@ export class EntryFlow {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Is at least one enabled camera bound to the entry lane? The camera gate applies only
|
||||||
|
* then — a site with no entry camera keeps the radar-only press gate. Read live (like
|
||||||
|
* the bypass flags) so adding/removing a camera needs no restart. */
|
||||||
|
#entryCameraConfigured(): boolean {
|
||||||
|
return devicesByDirection(this.#db, "camera", "entry").length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
|
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
|
||||||
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
|
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
|
||||||
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
|
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
|
||||||
@@ -229,9 +276,36 @@ export class EntryFlow {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.#issueTicket(resolved, { source: "ticket" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared "issue a transient ticket" sequence used by BOTH the physical button
|
||||||
|
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
|
||||||
|
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
|
||||||
|
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
|
||||||
|
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
|
||||||
|
* signed entry AND append a companion `anomaly` (the operator-adversary path always
|
||||||
|
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
|
||||||
|
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
|
||||||
|
*/
|
||||||
|
async #issueTicket(
|
||||||
|
resolved: ResolvedRelay,
|
||||||
|
opts: {
|
||||||
|
source: "ticket" | "manual";
|
||||||
|
operator?: string;
|
||||||
|
overCapacity?: { count: number; capacity: number | null };
|
||||||
|
/** Presence signals that were BYPASSED (admin dropped them due to faulty hardware).
|
||||||
|
* Recorded on the signed entry so a ticket issued under a weakened gate is auditable. */
|
||||||
|
presenceBypassed?: PresenceSignal[];
|
||||||
|
},
|
||||||
|
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
|
||||||
const ticketId = newTicketId();
|
const ticketId = newTicketId();
|
||||||
const issuedAt = new Date().toISOString();
|
const issuedAt = new Date().toISOString();
|
||||||
const printers = this.#loadPrinters();
|
const printers = this.#loadPrinters();
|
||||||
|
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
|
||||||
|
// + operatorInitiated:true in the payload. The button path is source "ticket".
|
||||||
|
const operatorInitiated = opts.source === "manual";
|
||||||
|
|
||||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||||
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||||
@@ -260,17 +334,14 @@ export class EntryFlow {
|
|||||||
// Capture who is held at the barrier (evidence for the operator handling the car).
|
// Capture who is held at the barrier (evidence for the operator handling the car).
|
||||||
this.#fireSnapshot("entry", ticketId);
|
this.#fireSnapshot("entry", ticketId);
|
||||||
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||||
return;
|
return { ok: false, reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||||
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
||||||
// later reprices the same way at exit. Today every transient takes the SITE
|
// later reprices the same way at exit. Today every transient takes the SITE
|
||||||
// default category (operator policy, site_config.default_vehicle_category;
|
// default category (operator policy, site_config.default_vehicle_category;
|
||||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
|
// falls back to the shared DEFAULT_VEHICLE_CATEGORY).
|
||||||
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
|
|
||||||
// is the future seam — source it from `resolved` then. A V1/no-category tariff
|
|
||||||
// ignores it; only V2 category cards consult it.
|
|
||||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
const category =
|
const category =
|
||||||
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
||||||
@@ -279,44 +350,160 @@ export class EntryFlow {
|
|||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "vehicle_entry",
|
type: "vehicle_entry",
|
||||||
direction: "entry",
|
direction: "entry",
|
||||||
source: "ticket",
|
source: opts.source,
|
||||||
identity: ticketId,
|
identity: ticketId,
|
||||||
payload: { sessionRef: ticketId, ticketPrinted: true, category },
|
payload: {
|
||||||
|
sessionRef: ticketId,
|
||||||
|
ticketPrinted: true,
|
||||||
|
category,
|
||||||
|
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
|
||||||
|
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
|
||||||
|
...(opts.presenceBypassed && opts.presenceBypassed.length > 0
|
||||||
|
? { presenceBypassed: opts.presenceBypassed }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
occurredAt: issuedAt,
|
occurredAt: issuedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
|
||||||
|
// always leaves a red-flag row in the tamper-evident record for reconciliation.
|
||||||
|
if (operatorInitiated) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: ticketId,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
|
||||||
|
source: "booth",
|
||||||
|
operatorInitiated: true,
|
||||||
|
...(opts.operator ? { operator: opts.operator } : {}),
|
||||||
|
...(opts.overCapacity ? { lotFull: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
||||||
const access = this.#buildAccess(resolved.controller);
|
const access = this.#buildAccess(resolved.controller);
|
||||||
if (access) await access.pulseOpen(resolved.relay);
|
let opened = false;
|
||||||
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
if (access) {
|
||||||
|
await access.pulseOpen(resolved.relay);
|
||||||
|
opened = true;
|
||||||
|
} else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||||
|
|
||||||
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
|
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; a
|
||||||
// a camera failure must not delay or block the already-open barrier).
|
// camera failure must not delay or block the already-open barrier). This is ALSO
|
||||||
|
// what records the plate that plate-reconciliation reads at exit.
|
||||||
this.#fireSnapshot("entry", ticketId);
|
this.#fireSnapshot("entry", ticketId);
|
||||||
|
|
||||||
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
|
||||||
// just a fast read-model, never the source of truth).
|
|
||||||
try {
|
try {
|
||||||
this.#db
|
this.#db
|
||||||
.insert(sessions)
|
.insert(sessions)
|
||||||
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
|
||||||
.run();
|
.run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
|
||||||
// can be rebuilt. Log it; don't fail the (already-open) entry.
|
|
||||||
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
return { ok: true, ticketId, opened };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
|
||||||
|
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
|
||||||
|
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
|
||||||
|
* presence is this flow's own per-relay guard state. If a site has no presence loop the
|
||||||
|
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
|
||||||
|
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
|
||||||
|
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
|
||||||
|
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
|
||||||
|
* See wiki/concepts/operator-issued-entry.md.
|
||||||
|
*/
|
||||||
|
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
|
||||||
|
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
|
||||||
|
> {
|
||||||
|
const resolved = firstRelayByDirection(this.#db, "entry");
|
||||||
|
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
|
||||||
|
|
||||||
|
// PRESENCE GATE — normally require BOTH radar/loop presence AND camera detection. An
|
||||||
|
// admin may BYPASS a signal when its device is faulty (site_config, signed config_change);
|
||||||
|
// the bypassed signal is dropped as a requirement and RECORDED on the issued ticket.
|
||||||
|
const bypass = this.#presenceBypass();
|
||||||
|
const bypassed: PresenceSignal[] = [];
|
||||||
|
|
||||||
|
// Radar/loop side. A configured loop is only mandatory while radar is still REQUIRED;
|
||||||
|
// if radar is bypassed we skip the loop entirely (a dead loop is exactly why they bypass).
|
||||||
|
const radarRequired = !bypass.radar;
|
||||||
|
let radarPresent: boolean | null = null;
|
||||||
|
if (radarRequired) {
|
||||||
|
if (typeof resolved.presenceInput !== "number") {
|
||||||
|
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable (or bypass radar)" };
|
||||||
|
}
|
||||||
|
radarPresent = this.#guardState(resolved).present;
|
||||||
|
} else {
|
||||||
|
bypassed.push("radar");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera side.
|
||||||
|
const cameraRequired = !bypass.camera;
|
||||||
|
if (!cameraRequired) bypassed.push("camera");
|
||||||
|
|
||||||
|
// Refuse only when a STILL-REQUIRED signal fails to confirm a vehicle.
|
||||||
|
const radarOk = !radarRequired || radarPresent === true;
|
||||||
|
const cameraOk = !cameraRequired || cameraBusy;
|
||||||
|
if (!radarOk || !cameraOk) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("entry.issue.noPresence", { operator }),
|
||||||
|
source: "booth",
|
||||||
|
operator,
|
||||||
|
radarPresent,
|
||||||
|
cameraBusy,
|
||||||
|
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(
|
||||||
|
`operator entry refused by ${operator}: no vehicle present (radar=${radarPresent}, camera=${cameraBusy}, bypassed=[${bypassed.join(",")}])`,
|
||||||
|
);
|
||||||
|
return { ok: false, reason: "no vehicle detected at the entry" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `operator-issue:${this.#relayKey(resolved)}`;
|
||||||
|
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
const occ = getOccupancy(this.#db);
|
||||||
|
const res = await this.#issueTicket(resolved, {
|
||||||
|
source: "manual",
|
||||||
|
operator,
|
||||||
|
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
|
||||||
|
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) return res;
|
||||||
|
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
||||||
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
|
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
|
||||||
* held car is exactly when the operator wants the photo. */
|
* held car is exactly when the operator wants the photo. */
|
||||||
#fireSnapshot(direction: "entry", identity: string): void {
|
#fireSnapshot(direction: "entry", identity: string): void {
|
||||||
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision }).catch(
|
// `log` lets the ANPR ride-along flag a duplicate-plate entry (a signed anomaly) —
|
||||||
|
// still fire-and-forget; recognition never gates the open. See snapshot.ts.
|
||||||
|
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision, log: this.#log }).catch(
|
||||||
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
|
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current admin presence-gate bypass (site_config), read LIVE so a toggle takes effect
|
||||||
|
* with no restart. Default: nothing bypassed (the normal both-required gate). */
|
||||||
|
#presenceBypass(): { radar: boolean; camera: boolean } {
|
||||||
|
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return { radar: cfg?.bypassPresenceRadar ?? false, camera: cfg?.bypassPresenceCamera ?? false };
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a live access adapter from a resolved controller row, or null. */
|
/** Build a live access adapter from a resolved controller row, or null. */
|
||||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, siteConfig, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The entry presence gate normally requires BOTH radar/loop presence AND camera detection.
|
||||||
|
// An admin may BYPASS a signal when its device is faulty (site_config, set via a signed
|
||||||
|
// endpoint). These tests pin the GATE decision in EntryFlow.issueForOperator under each
|
||||||
|
// bypass combination: a still-required-but-absent signal refuses (+ signs an anomaly); a
|
||||||
|
// bypassed signal is dropped and recorded. We assert the gate outcome via the refuse path
|
||||||
|
// (deterministic, no printer needed); the allow path is proven by getting PAST the gate
|
||||||
|
// (it then fails at printing — a different reason — which is exactly "the gate opened").
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let flow: EntryFlow;
|
||||||
|
|
||||||
|
const CTL = "ctl-entry";
|
||||||
|
const PRESENCE_INPUT = 2;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
// A controller with an entry barrier (R1), a presence loop on input 2, and an entry button
|
||||||
|
// on input 1 — the shape device-resolve expects (relays[] + inputs[]).
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: {
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 1, role: "button", relay: 1 },
|
||||||
|
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "loop" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
flow = new EntryFlow(db, makeLog(db), silentLogger());
|
||||||
|
});
|
||||||
|
|
||||||
|
function setBypass(patch: { radar?: boolean; camera?: boolean }) {
|
||||||
|
db.insert(siteConfig)
|
||||||
|
.values({ id: 1, bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: siteConfig.id,
|
||||||
|
set: { bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive a presence loop edge so the flow's per-relay guard marks a car present/clear. */
|
||||||
|
async function setRadarPresent(present: boolean) {
|
||||||
|
await flow.onInput({
|
||||||
|
driverId: "stub-access",
|
||||||
|
deviceId: CTL,
|
||||||
|
input: PRESENCE_INPUT,
|
||||||
|
edge: present ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const anomalies = () =>
|
||||||
|
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||||
|
|
||||||
|
describe("entry presence-gate bypass", () => {
|
||||||
|
it("no bypass + no vehicle → refuses and signs a noPresence anomaly", async () => {
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(anomalies()).toHaveLength(1);
|
||||||
|
expect(anomalies()[0].payload).toMatchObject({ reasonCode: "entry.issue.noPresence" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera bypassed + radar present → gate OPENS (no refuse anomaly)", async () => {
|
||||||
|
setBypass({ camera: true });
|
||||||
|
await setRadarPresent(true);
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false); // camera absent but bypassed
|
||||||
|
// Gate passed: no noPresence refusal. (It then proceeds to print — no printer configured,
|
||||||
|
// so it HOLDS with a print reason, not a presence reason. Either way the gate opened.)
|
||||||
|
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusals).toHaveLength(0);
|
||||||
|
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("radar bypassed + camera busy → gate OPENS even with NO presence loop reading", async () => {
|
||||||
|
setBypass({ radar: true });
|
||||||
|
// radar NOT set present; camera busy=true → radar dropped, camera satisfies.
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||||
|
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusals).toHaveLength(0);
|
||||||
|
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera bypassed but radar STILL required and absent → refuses (only the faulty signal is dropped)", async () => {
|
||||||
|
setBypass({ camera: true });
|
||||||
|
await setRadarPresent(false); // radar required (not bypassed) and clear
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
const refusal = anomalies().find((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusal, "the still-required radar gates the button").toBeTruthy();
|
||||||
|
// The refusal records which signal was bypassed (audit).
|
||||||
|
expect(refusal!.payload).toMatchObject({ presenceBypassed: ["camera"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("both bypassed → gate OPENS with no radar and no camera (press-to-print)", async () => {
|
||||||
|
setBypass({ radar: true, camera: true });
|
||||||
|
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||||
|
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||||
|
expect(refusals).toHaveLength(0);
|
||||||
|
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, siteConfig, ledgerEvents, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { registry, type PrinterDevice } from "@parking/devices";
|
||||||
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The PHYSICAL entry button's press gate (#suppressReason), layered (2026-07-04):
|
||||||
|
// CAMERA — with an entry camera configured, a press is live only while the entry lane
|
||||||
|
// camera confirms a vehicle (the button lamp's SOLID state). Blink (radar-only) prints
|
||||||
|
// nothing. Camera-less sites skip this; the admin camera bypass drops it.
|
||||||
|
// PRESENCE — one-car-one-ticket off the loop (unchanged).
|
||||||
|
// COOLDOWN — now a BACKSTOP behind presence, not an alternative: a motion radar drops a
|
||||||
|
// stationary car (no doppler return), spuriously re-arming the guard; the cooldown bounds
|
||||||
|
// how fast that re-armed press can mint a second ticket for the same car.
|
||||||
|
// A suppressed press is unsigned telemetry (entrySuppressed), never a ledger anomaly.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let flow: EntryFlow;
|
||||||
|
|
||||||
|
const CTL = "ctl-entry";
|
||||||
|
const BUTTON_INPUT = 1;
|
||||||
|
const PRESENCE_INPUT = 2;
|
||||||
|
|
||||||
|
// A no-op printer that always succeeds, so the happy path reaches the signed
|
||||||
|
// vehicle_entry (the real drivers need hardware). Registered once (registry is global).
|
||||||
|
const noopPrinter: PrinterDevice = {
|
||||||
|
driverId: "test-printer-ok",
|
||||||
|
connect: async () => {},
|
||||||
|
disconnect: async () => {},
|
||||||
|
healthCheck: async () => ({ status: "ready" as const }),
|
||||||
|
printTicket: async () => {},
|
||||||
|
printReport: async () => {},
|
||||||
|
printSubscriptionCard: async () => {},
|
||||||
|
printReceipt: async () => {},
|
||||||
|
printWindowChargeNotice: async () => {},
|
||||||
|
};
|
||||||
|
if (!registry.get("test-printer-ok")) {
|
||||||
|
registry.register({
|
||||||
|
id: "test-printer-ok",
|
||||||
|
category: "printer",
|
||||||
|
label: "Test printer",
|
||||||
|
description: "always-succeeds stub for tests",
|
||||||
|
transports: [],
|
||||||
|
configFields: [],
|
||||||
|
create: () => noopPrinter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: {
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: BUTTON_INPUT, role: "button", relay: 1 },
|
||||||
|
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "printer-entry",
|
||||||
|
category: "printer",
|
||||||
|
driverId: "test-printer-ok",
|
||||||
|
config: { direction: "entry" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
flow = new EntryFlow(db, makeLog(db), silentLogger());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Add an entry camera row. The driver never builds (unknown id) — only its EXISTENCE
|
||||||
|
* matters to the press gate; snapshot capture failing is the normal fire-and-forget path. */
|
||||||
|
function addEntryCamera() {
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "cam-entry",
|
||||||
|
category: "camera",
|
||||||
|
driverId: "no-such-camera-driver",
|
||||||
|
config: { direction: "entry" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCameraBypass(on: boolean) {
|
||||||
|
db.insert(siteConfig)
|
||||||
|
.values({ id: 1, bypassPresenceCamera: on })
|
||||||
|
.onConflictDoUpdate({ target: siteConfig.id, set: { bypassPresenceCamera: on } })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function edge(input: number, edge: "on" | "off") {
|
||||||
|
await flow.onInput({
|
||||||
|
driverId: "stub-access",
|
||||||
|
deviceId: CTL,
|
||||||
|
input,
|
||||||
|
edge,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const press = () => edge(BUTTON_INPUT, "on");
|
||||||
|
const radar = (present: boolean) => edge(PRESENCE_INPUT, present ? "on" : "off");
|
||||||
|
|
||||||
|
const entries = () =>
|
||||||
|
db.select().from(ledgerEvents).all().filter((r) => r.type === "vehicle_entry");
|
||||||
|
const suppressed = () =>
|
||||||
|
db.select().from(deviceEventsTable).all()
|
||||||
|
.map((r) => r.detail as { entrySuppressed?: boolean; reason?: string })
|
||||||
|
.filter((d) => d.entrySuppressed === true);
|
||||||
|
|
||||||
|
describe("entry press gate — camera (blink vs solid)", () => {
|
||||||
|
it("BLINK state (radar present, no camera confirmation) → press suppressed, nothing signed", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
await radar(true); // lamp would blink: radar sees something, camera does not
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(0);
|
||||||
|
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // no anomaly either — telemetry only
|
||||||
|
expect(suppressed()).toHaveLength(1);
|
||||||
|
expect(suppressed()[0].reason).toMatch(/camera/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SOLID state (radar present + camera busy) → press prints and signs a vehicle_entry", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
await radar(true);
|
||||||
|
flow.onLaneStatus({ entry: true, exit: false }); // camera confirms → SOLID
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
expect(suppressed()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera-less site → the camera gate does not apply (radar-only, as before)", async () => {
|
||||||
|
await radar(true); // no camera row; lane state irrelevant
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("camera bypassed (faulty camera) → press prints without camera confirmation", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
setCameraBypass(true);
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no car at all (radar clear too) → suppressed even with the camera bypassed", async () => {
|
||||||
|
addEntryCamera();
|
||||||
|
setCameraBypass(true);
|
||||||
|
await press(); // radar never went on
|
||||||
|
expect(entries()).toHaveLength(0);
|
||||||
|
expect(suppressed()[0].reason).toMatch(/presence loop clear/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("entry press gate — cooldown backstop behind presence", () => {
|
||||||
|
/** Same lane but the button carries a cooldown, making it a backstop behind the loop. */
|
||||||
|
function setButtonCooldown(sec: number) {
|
||||||
|
db.delete(devices).run();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: {
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: BUTTON_INPUT, role: "button", relay: 1, cooldownSec: sec },
|
||||||
|
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "printer-entry",
|
||||||
|
category: "printer",
|
||||||
|
driverId: "test-printer-ok",
|
||||||
|
config: { direction: "entry" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("radar dropout re-arm + quick re-press → caught by the cooldown (one ticket)", async () => {
|
||||||
|
setButtonCooldown(60);
|
||||||
|
await radar(true);
|
||||||
|
await press(); // ticket 1 (no camera configured — radar-only site)
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
// The motion radar loses the STATIONARY car and re-fires: off (re-arms!) then on.
|
||||||
|
await radar(false);
|
||||||
|
await radar(true);
|
||||||
|
await press(); // presence gate says yes (present + re-armed) — the backstop must catch it
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
expect(suppressed().some((d) => /cooldown/.test(d.reason ?? ""))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without a cooldown the dropout re-press mints a second ticket (the documented residual risk)", async () => {
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
await radar(false);
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
expect(entries()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still-present car re-pressing (no dropout) stays suppressed by one-car-one-ticket", async () => {
|
||||||
|
await radar(true);
|
||||||
|
await press();
|
||||||
|
await press(); // car never left the loop → not re-armed
|
||||||
|
expect(entries()).toHaveLength(1);
|
||||||
|
expect(suppressed().some((d) => /already issued/.test(d.reason ?? ""))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { createTestDb } from "@parking/db/testing";
|
import { createTestDb } from "@parking/db/testing";
|
||||||
import { ledgerEvents, eq, type Db } from "@parking/db";
|
import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
import { PayStation } from "./pay-station.js";
|
import { PayStation } from "./pay-station.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
@@ -33,6 +34,19 @@ async function enter(identity: string, enteredAt: string, payload?: Record<strin
|
|||||||
function exitsSigned(identity: string) {
|
function exitsSigned(identity: string) {
|
||||||
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
||||||
}
|
}
|
||||||
|
function anomalies(reason?: string) {
|
||||||
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all()
|
||||||
|
.filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason));
|
||||||
|
}
|
||||||
|
/** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the
|
||||||
|
* plate-reconciliation check can see this identity's plate against open sessions. */
|
||||||
|
function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) {
|
||||||
|
db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run();
|
||||||
|
db.insert(deviceEventsTable).values({
|
||||||
|
id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt,
|
||||||
|
detail: { identity, direction: "entry", plate, confidence },
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
describe("exitForBooth — refusal gates", () => {
|
describe("exitForBooth — refusal gates", () => {
|
||||||
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
||||||
@@ -116,3 +130,63 @@ describe("reopenBarrier — no unpaid re-open", () => {
|
|||||||
expect(exitsSigned("T1")).toHaveLength(1);
|
expect(exitsSigned("T1")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => {
|
||||||
|
// The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside".
|
||||||
|
// The plate is the invariant — the exiting car's plate is already open under the old ticket.
|
||||||
|
it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
// Original car entered on 1234, plate AA123BB, still open (never paid/exited).
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||||
|
// A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit.
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" });
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(0); // NOT let out
|
||||||
|
expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237", { override: true, operator: "op1" });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1); // released
|
||||||
|
const ov = anomalies("released a suspected ticket-swap");
|
||||||
|
expect(ov.length).toBe(1);
|
||||||
|
expect((ov[0].payload as { operator?: string }).operator).toBe("op1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r.ok).toBe(true); // no warning — exits normally
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1237", minutesAgo(90));
|
||||||
|
seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
||||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
import { snapshotAsync } from "./snapshot.js";
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||||
@@ -47,6 +48,11 @@ interface SessionView {
|
|||||||
* the barrier didn't open (payment stands; operator opens manually). */
|
* the barrier didn't open (payment stands; operator opens manually). */
|
||||||
export type BoothExitResult =
|
export type BoothExitResult =
|
||||||
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||||
|
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
|
||||||
|
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
|
||||||
|
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
|
||||||
|
// See wiki/concepts/plate-reconciliation.md.
|
||||||
|
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
|
||||||
| { ok: true; opened: true }
|
| { ok: true; opened: true }
|
||||||
| { ok: true; opened: false; reason: string };
|
| { ok: true; opened: false; reason: string };
|
||||||
|
|
||||||
@@ -57,6 +63,11 @@ export type BoothReopenResult =
|
|||||||
| { ok: false; reason: string }
|
| { ok: false; reason: string }
|
||||||
| { ok: true; opened: boolean; reason?: string };
|
| { ok: true; opened: boolean; reason?: string };
|
||||||
|
|
||||||
|
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
|
||||||
|
* exiting read and the matched open session's entry read. Below this, the read is advisory-
|
||||||
|
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
|
||||||
|
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
|
||||||
|
|
||||||
export class ExitFlow {
|
export class ExitFlow {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -88,7 +99,7 @@ export class ExitFlow {
|
|||||||
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
||||||
* operator opens manually. Payment is never rolled back.
|
* operator opens manually. Payment is never rolled back.
|
||||||
*/
|
*/
|
||||||
async exitForBooth(identity: string): Promise<BoothExitResult> {
|
async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise<BoothExitResult> {
|
||||||
const id = identity.trim();
|
const id = identity.trim();
|
||||||
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
||||||
|
|
||||||
@@ -122,6 +133,40 @@ export class ExitFlow {
|
|||||||
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
|
||||||
|
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
|
||||||
|
// (unless they consciously override). A denial here never traps the car — exit fails
|
||||||
|
// open and the operator can override; the anomaly is the control either way.
|
||||||
|
const swap = this.#reconcilePlateAtExit(id);
|
||||||
|
if (swap) {
|
||||||
|
if (!opts?.override) {
|
||||||
|
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
|
||||||
|
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: id,
|
||||||
|
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
|
||||||
|
});
|
||||||
|
this.#fireExitSnapshot(id);
|
||||||
|
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
|
||||||
|
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
|
||||||
|
}
|
||||||
|
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: id,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||||
|
source: "booth",
|
||||||
|
plateSwapOverride: true,
|
||||||
|
plate: swap.plate,
|
||||||
|
otherIdentity: swap.otherIdentity,
|
||||||
|
...(opts.operator ? { operator: opts.operator } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
|
||||||
|
}
|
||||||
|
|
||||||
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
||||||
// reader path does.
|
// reader path does.
|
||||||
if (freeGrace && view.freeGrace) {
|
if (freeGrace && view.freeGrace) {
|
||||||
@@ -336,6 +381,25 @@ export class ExitFlow {
|
|||||||
return { accepted: false, direction: "exit", reason: rp.reason };
|
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
|
||||||
|
// automated lane to make the override decision, and exit fails open for safety, so we
|
||||||
|
// sign the suspicion anomaly (the control here) and still let the car out. The booth
|
||||||
|
// path (operator-mediated) is where the hold + override lives.
|
||||||
|
const swap = this.#reconcilePlateAtExit(e.value);
|
||||||
|
if (swap) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: e.value,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||||
|
plateSwapSuspected: true,
|
||||||
|
plate: swap.plate,
|
||||||
|
otherIdentity: swap.otherIdentity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
|
||||||
|
}
|
||||||
|
|
||||||
// Valid (a real payment within walk-back grace): sign + open.
|
// Valid (a real payment within walk-back grace): sign + open.
|
||||||
return this.#signExitAndOpen(resolved, e);
|
return this.#signExitAndOpen(resolved, e);
|
||||||
}
|
}
|
||||||
@@ -406,6 +470,43 @@ export class ExitFlow {
|
|||||||
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
|
||||||
|
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
|
||||||
|
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
|
||||||
|
* or two tickets were mixed up. We compare the EXITING plate against every open session's
|
||||||
|
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
|
||||||
|
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
|
||||||
|
* or null. See wiki/concepts/plate-reconciliation.md.
|
||||||
|
*/
|
||||||
|
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
|
||||||
|
// The exiting car's plate: prefer its own exit read, else its entry read.
|
||||||
|
const mine = plateForIdentity(this.#db, exitingId);
|
||||||
|
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
|
||||||
|
const wanted = mine.plate.trim().toUpperCase();
|
||||||
|
|
||||||
|
// All currently-open sessions (from the projection cache — a fast read-model; the check
|
||||||
|
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
|
||||||
|
const openIds = this.#db
|
||||||
|
.select({ id: sessions.id })
|
||||||
|
.from(sessions)
|
||||||
|
.where(eq(sessions.state, "open"))
|
||||||
|
.all()
|
||||||
|
.map((r) => r.id)
|
||||||
|
.filter((id) => id !== exitingId);
|
||||||
|
if (openIds.length === 0) return null;
|
||||||
|
|
||||||
|
const plates = platesForIdentities(this.#db, openIds);
|
||||||
|
for (const [otherId, pv] of plates) {
|
||||||
|
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
|
||||||
|
if (pv.plate.trim().toUpperCase() !== wanted) continue;
|
||||||
|
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
|
||||||
|
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
|
||||||
|
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||||
#sessionFor(identity: string): SessionView | null {
|
#sessionFor(identity: string): SessionView | null {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { eq, devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { LanePresence } from "./lane-presence.js";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// LanePresence: a vehicle-presence INPUT edge (loop/radar) on an entry/exit barrier marks
|
||||||
|
// that lane "present" — the same signal that blinks the physical button lamp (relay 3). It
|
||||||
|
// resolves the edge via relayForPresence (the SAME path relay 3 + the entry gate use), and
|
||||||
|
// emits a lane-presence change only when a lane's present/clear state actually flips.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const CTL = "ctl-1";
|
||||||
|
const ENTRY_RADAR = 2;
|
||||||
|
const EXIT_RADAR = 5;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
// Entry relay 1 with a radar on I2; exit relay 2 with a radar on I5.
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [
|
||||||
|
{ relay: 1, direction: "entry" },
|
||||||
|
{ relay: 2, direction: "exit" },
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{ input: ENTRY_RADAR, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
{ input: EXIT_RADAR, role: "presence", relay: 2, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
function edge(input: number, on: boolean): void {
|
||||||
|
const e: DeviceInputEvent = {
|
||||||
|
driverId: "dingtian",
|
||||||
|
deviceId: CTL,
|
||||||
|
input,
|
||||||
|
edge: on ? "on" : "off",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source: "poll",
|
||||||
|
};
|
||||||
|
deviceEvents.emitInput(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect lane-presence emissions while running `fn`. */
|
||||||
|
function capture(fn: () => void): LanePresenceEvent[] {
|
||||||
|
const seen: LanePresenceEvent[] = [];
|
||||||
|
const off = deviceEvents.onLanePresence((p) => seen.push(p));
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LanePresence", () => {
|
||||||
|
it("starts clear and snapshots clear", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
expect(lp.snapshot()).toEqual({ entry: false, exit: false });
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ENTRY radar edge marks the entry lane present, then clears", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(ENTRY_RADAR, true);
|
||||||
|
edge(ENTRY_RADAR, false);
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ entry: true, exit: false },
|
||||||
|
{ entry: false, exit: false },
|
||||||
|
]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an EXIT radar edge marks the exit lane independently", () => {
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(EXIT_RADAR, true);
|
||||||
|
});
|
||||||
|
expect(events).toEqual([{ entry: false, exit: true }]);
|
||||||
|
expect(lp.snapshot()).toEqual({ entry: false, exit: true });
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("de-dupes: a second 'on' from another presence input on the same lane emits once", () => {
|
||||||
|
// Two radars both serving the entry lane.
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [
|
||||||
|
{ input: 2, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
{ input: 3, role: "presence", relay: 1, kind: "radar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CTL))
|
||||||
|
.run();
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => {
|
||||||
|
edge(2, true); // entry → present (emit)
|
||||||
|
edge(3, true); // still present (no emit — same lane)
|
||||||
|
edge(2, false); // still present via I3 (no emit)
|
||||||
|
edge(3, false); // now clear (emit)
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ entry: true, exit: false },
|
||||||
|
{ entry: false, exit: false },
|
||||||
|
]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a non-presence input (e.g. a button terminal)", () => {
|
||||||
|
db.update(devices)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
host: "10.0.0.5",
|
||||||
|
relays: [{ relay: 1, direction: "entry" }],
|
||||||
|
inputs: [{ input: 1, role: "button", relay: 1 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(eq(devices.id, CTL))
|
||||||
|
.run();
|
||||||
|
const lp = new LanePresence(db, silentLogger());
|
||||||
|
lp.start();
|
||||||
|
const events = capture(() => edge(1, true));
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
lp.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { deviceEvents, type DeviceInputEvent, type LanePresenceEvent } from "./device-events.js";
|
||||||
|
import { presenceLaneOf } from "./device-resolve.js";
|
||||||
|
|
||||||
|
// Per-lane RADAR presence for the booth's barrier lights. A vehicle-presence INPUT
|
||||||
|
// (loop/radar) shorted at an entry/exit barrier means "something is in the lane vicinity"
|
||||||
|
// BEFORE the camera confirms a vehicle. This is the SAME signal that makes the physical
|
||||||
|
// button lamp (relay 3) blink — see button-light.ts (#onInput) — so the on-screen light
|
||||||
|
// and the lamp stay in lockstep: both react to a presence edge resolved the SAME way
|
||||||
|
// (relayForPresence, on an entry/both relay). ADVISORY ONLY: it gates nothing.
|
||||||
|
//
|
||||||
|
// A radar serving an entry (or "both") barrier marks the ENTRY lane present; an exit radar
|
||||||
|
// marks EXIT. The lane is resolved via `presenceLaneOf` (direction-agnostic — unlike the
|
||||||
|
// entry-gated `relayForPresence` the one-car-one-ticket gate uses), so both lanes blink.
|
||||||
|
|
||||||
|
export class LanePresence {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
/** Active presence terminals per lane, keyed `${deviceId}:${input}` (several radars may
|
||||||
|
* serve one lane). A lane is "present" while its set is non-empty. */
|
||||||
|
readonly #entry = new Set<string>();
|
||||||
|
readonly #exit = new Set<string>();
|
||||||
|
#unsub: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to presence input edges. */
|
||||||
|
start(): void {
|
||||||
|
this.#unsub = deviceEvents.onInput((e) => this.#onInput(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current snapshot (for the WS hello). */
|
||||||
|
snapshot(): LanePresenceEvent {
|
||||||
|
return { entry: this.#entry.size > 0, exit: this.#exit.size > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
#onInput(e: DeviceInputEvent): void {
|
||||||
|
const lane = presenceLaneOf(this.#db, e.deviceId, e.input);
|
||||||
|
if (!lane) return; // not a presence terminal on a barrier relay
|
||||||
|
const key = `${e.deviceId}:${e.input}`;
|
||||||
|
const set = lane === "entry" ? this.#entry : this.#exit;
|
||||||
|
const before = set.size > 0;
|
||||||
|
if (e.edge === "on") set.add(key);
|
||||||
|
else set.delete(key);
|
||||||
|
const after = set.size > 0;
|
||||||
|
if (before !== after) {
|
||||||
|
this.#logger.info(`lane-presence: ${lane} -> ${after ? "present" : "clear"}`);
|
||||||
|
deviceEvents.emitLanePresence(this.snapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe on shutdown. */
|
||||||
|
stop(): void {
|
||||||
|
this.#unsub?.();
|
||||||
|
this.#unsub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { appLogs, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { LogService, pinoDbStream } from "./log-service.js";
|
||||||
|
|
||||||
|
// pinoDbStream feeds backend warn+ lines into app_logs. Since 2026-07-04 the logger
|
||||||
|
// emits level NAMES ("warn") instead of pino's numeric codes (40) — for human-readable
|
||||||
|
// container logs — and the stream must accept BOTH encodings (numeric covers any
|
||||||
|
// default-configured pino). A level the tee can't resolve falls back to info → not
|
||||||
|
// persisted, never a crash.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let stream: { write: (line: string) => void };
|
||||||
|
let teed: string[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
teed = [];
|
||||||
|
stream = pinoDbStream(new LogService(db), {
|
||||||
|
write: (line: string) => {
|
||||||
|
teed.push(line);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
} as unknown as NodeJS.WritableStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = () => db.select().from(appLogs).all();
|
||||||
|
|
||||||
|
describe("pinoDbStream level encodings", () => {
|
||||||
|
it("persists a LABEL-level warn line (the current logger format)", () => {
|
||||||
|
stream.write(`{"level":"warn","time":"2026-07-04T18:14:11.453Z","msg":"label warn"}\n`);
|
||||||
|
expect(rows()).toHaveLength(1);
|
||||||
|
expect(rows()[0]).toMatchObject({ level: "warn", source: "backend", message: "label warn" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still persists a NUMERIC-level error line (legacy/default pino)", () => {
|
||||||
|
stream.write(`{"level":50,"time":1783179038453,"msg":"numeric error"}\n`);
|
||||||
|
expect(rows()[0]).toMatchObject({ level: "error", message: "numeric error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("info stays stdout-only in both encodings (teed, not persisted)", () => {
|
||||||
|
stream.write(`{"level":"info","msg":"label info"}\n`);
|
||||||
|
stream.write(`{"level":30,"msg":"numeric info"}\n`);
|
||||||
|
expect(rows()).toHaveLength(0);
|
||||||
|
expect(teed).toHaveLength(2); // stdout tee always happens
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unresolvable level falls back to info (dropped), never throws", () => {
|
||||||
|
stream.write(`{"level":"loud","msg":"weird"}\n`);
|
||||||
|
stream.write(`not json at all\n`);
|
||||||
|
expect(rows()).toHaveLength(0);
|
||||||
|
expect(teed).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Storm coalescing: a line identical to the LAST persisted row (level+source+message+
|
||||||
|
// path), arriving within 5 min of its previous occurrence, UPDATES that row (bumping
|
||||||
|
// context._repeat) instead of inserting — one screaming device can't evict unrelated
|
||||||
|
// history. The row's createdAt tracks the LATEST occurrence; the first is preserved in
|
||||||
|
// context._firstAt.
|
||||||
|
describe("storm coalescing", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds a burst of identical error lines into ONE row with a repeat counter", () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
stream.write(`{"level":"error","msg":"button-light setAux failed (ctl R3): send ENETUNREACH"}\n`);
|
||||||
|
}
|
||||||
|
const all = rows();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].context).toMatchObject({ _repeat: 200 });
|
||||||
|
expect(teed).toHaveLength(200); // stdout still gets every line
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps first-occurrence time in _firstAt while createdAt tracks the latest", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||||
|
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:02:00.000Z"));
|
||||||
|
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||||
|
const [row] = rows();
|
||||||
|
expect(row.createdAt).toBe("2026-07-08T10:02:00.000Z");
|
||||||
|
expect(row.context).toMatchObject({ _repeat: 2, _firstAt: "2026-07-08T10:00:00.000Z" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a different message (or level) breaks the run — separate rows", () => {
|
||||||
|
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||||
|
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||||
|
stream.write(`{"level":"error","msg":"boom B"}\n`);
|
||||||
|
stream.write(`{"level":"warn","msg":"boom B"}\n`);
|
||||||
|
expect(rows()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an occurrence past the 5-minute window starts a fresh row", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||||
|
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:06:00.000Z"));
|
||||||
|
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||||
|
expect(rows()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a CONTINUOUS storm stays one row past the window (each hit refreshes it)", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let t = new Date("2026-07-08T10:00:00.000Z").getTime();
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
vi.setSystemTime(new Date(t));
|
||||||
|
stream.write(`{"level":"error","msg":"storm"}\n`);
|
||||||
|
t += 240_000; // 4 min apart — each inside the window of the PREVIOUS hit
|
||||||
|
}
|
||||||
|
const all = rows();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].context).toMatchObject({ _repeat: 10 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,14 @@ const MAX_MESSAGE = 4_000;
|
|||||||
const MAX_STACK = 16_000;
|
const MAX_STACK = 16_000;
|
||||||
const MAX_CONTEXT_JSON = 16_000;
|
const MAX_CONTEXT_JSON = 16_000;
|
||||||
|
|
||||||
|
/** Storm coalescing: a line identical to the LAST persisted one (level+source+message+
|
||||||
|
* path) within this window of its previous occurrence UPDATES that row (bumping a
|
||||||
|
* `_repeat` counter in its context) instead of inserting a new one. A continuous storm
|
||||||
|
* keeps refreshing the window, so it stays ONE row however long it rages — repeated
|
||||||
|
* errors can't evict unrelated history or grind the appliance disk (field incident
|
||||||
|
* 2026-07-07: one unreachable controller ≈ hundreds of identical rows/minute). */
|
||||||
|
const COALESCE_WINDOW_MS = 300_000;
|
||||||
|
|
||||||
export interface LogRetention {
|
export interface LogRetention {
|
||||||
/** Delete logs older than this many days. */
|
/** Delete logs older than this many days. */
|
||||||
readonly maxAgeDays: number;
|
readonly maxAgeDays: number;
|
||||||
@@ -33,7 +41,10 @@ export interface LogRetention {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_RETENTION: LogRetention = {
|
export const DEFAULT_RETENTION: LogRetention = {
|
||||||
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 30),
|
// 60 days (~2 months) — the operator's chosen diagnostic window (2026-07-04),
|
||||||
|
// matched by the container-log rotation caps in docker-compose.prod.yml. The row
|
||||||
|
// cap below still bounds a burst regardless of age.
|
||||||
|
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 60),
|
||||||
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
|
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,6 +70,16 @@ export class LogService {
|
|||||||
readonly #retention: LogRetention;
|
readonly #retention: LogRetention;
|
||||||
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
||||||
#writing = false;
|
#writing = false;
|
||||||
|
/** The last persisted row, for storm coalescing (in-memory only; a restart just
|
||||||
|
* starts a fresh row — best-effort, like everything in this sink). */
|
||||||
|
#last: {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
count: number;
|
||||||
|
firstAt: string;
|
||||||
|
lastAtMs: number;
|
||||||
|
baseContext: Record<string, unknown> | null;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
|
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -82,22 +103,53 @@ export class LogService {
|
|||||||
if (this.#writing) return;
|
if (this.#writing) return;
|
||||||
this.#writing = true;
|
this.#writing = true;
|
||||||
try {
|
try {
|
||||||
|
const createdAt = row.createdAt ?? new Date().toISOString();
|
||||||
|
const message = clamp(row.message, MAX_MESSAGE) ?? "";
|
||||||
|
const path = clamp(row.path, 512);
|
||||||
|
const key = `${row.level}|${row.source}|${message}|${path ?? ""}`;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
// Storm coalescing: identical to the last persisted row, within the window →
|
||||||
|
// bump that row instead of inserting. createdAt moves to the LATEST occurrence
|
||||||
|
// (keeps the storm visible at the top of the newest-first viewer); the first
|
||||||
|
// occurrence's time is preserved in context._firstAt.
|
||||||
|
const last = this.#last;
|
||||||
|
if (last && last.key === key && nowMs - last.lastAtMs <= COALESCE_WINDOW_MS) {
|
||||||
|
const res = this.#db
|
||||||
|
.update(appLogs)
|
||||||
|
.set({
|
||||||
|
context: { ...(last.baseContext ?? {}), _repeat: last.count + 1, _firstAt: last.firstAt },
|
||||||
|
createdAt,
|
||||||
|
})
|
||||||
|
.where(eq(appLogs.id, last.id))
|
||||||
|
.run();
|
||||||
|
if ((res.changes ?? 0) > 0) {
|
||||||
|
last.count += 1;
|
||||||
|
last.lastAtMs = nowMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The row was pruned out from under us — fall through to a fresh insert.
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const baseContext = safeContext(row.context);
|
||||||
this.#db
|
this.#db
|
||||||
.insert(appLogs)
|
.insert(appLogs)
|
||||||
.values({
|
.values({
|
||||||
id: randomUUID(),
|
id,
|
||||||
level: row.level,
|
level: row.level,
|
||||||
source: row.source,
|
source: row.source,
|
||||||
message: clamp(row.message, MAX_MESSAGE) ?? "",
|
message,
|
||||||
context: safeContext(row.context),
|
context: baseContext,
|
||||||
httpStatus: row.httpStatus ?? null,
|
httpStatus: row.httpStatus ?? null,
|
||||||
path: clamp(row.path, 512),
|
path,
|
||||||
stack: clamp(row.stack, MAX_STACK),
|
stack: clamp(row.stack, MAX_STACK),
|
||||||
userId: row.userId ?? null,
|
userId: row.userId ?? null,
|
||||||
userAgent: clamp(row.userAgent, 512),
|
userAgent: clamp(row.userAgent, 512),
|
||||||
createdAt: row.createdAt ?? new Date().toISOString(),
|
createdAt,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
this.#last = { id, key, count: 1, firstAt: createdAt, lastAtMs: nowMs, baseContext };
|
||||||
} catch {
|
} catch {
|
||||||
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
||||||
// it; that's the recursion we're guarding against.)
|
// it; that's the recursion we're guarding against.)
|
||||||
@@ -192,9 +244,10 @@ export class LogService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
|
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
|
||||||
* Pino writes one JSON object per line to this stream; we parse, map the numeric level
|
* Pino writes one JSON object per line to this stream; we parse, resolve the level
|
||||||
* to a name, and persist. Returned as `{ write }` so it can be passed as pino's stream.
|
* (name or numeric encoding), and persist. Returned as `{ write }` so it can be passed
|
||||||
* stdout still receives the same line (we tee), so console logging is unchanged.
|
* as pino's stream. stdout still receives the same line (we tee), so console logging is
|
||||||
|
* unchanged.
|
||||||
*/
|
*/
|
||||||
export function pinoDbStream(
|
export function pinoDbStream(
|
||||||
service: LogService,
|
service: LogService,
|
||||||
@@ -218,12 +271,17 @@ export function pinoDbStream(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(line) as {
|
const obj = JSON.parse(line) as {
|
||||||
level?: number;
|
level?: number | string;
|
||||||
msg?: string;
|
msg?: string;
|
||||||
err?: { stack?: string; message?: string };
|
err?: { stack?: string; message?: string };
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
const level = NUM_TO_LEVEL[obj.level ?? 30] ?? "info";
|
// The logger emits level NAMES (formatters.level in server.ts, for human-
|
||||||
|
// readable container logs); a default pino config emits numbers. Accept both.
|
||||||
|
const level: LogLevel =
|
||||||
|
typeof obj.level === "string" && obj.level in LOG_LEVEL_ORDER
|
||||||
|
? (obj.level as LogLevel)
|
||||||
|
: NUM_TO_LEVEL[typeof obj.level === "number" ? obj.level : 30] ?? "info";
|
||||||
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
||||||
// Strip pino's noisy standard fields from the persisted context.
|
// Strip pino's noisy standard fields from the persisted context.
|
||||||
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
|
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
|
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
import { windowOwedBetween } from "./subscription-window.js";
|
import { windowOwedBetween } from "./subscription-window.js";
|
||||||
|
import { liveValidations } from "./validations.js";
|
||||||
|
|
||||||
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
||||||
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||||
@@ -38,8 +39,17 @@ export interface Quote {
|
|||||||
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
||||||
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||||
readonly periodStart: string;
|
readonly periodStart: string;
|
||||||
/** Amount owed now: the fee for [periodStart → now]. */
|
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
|
/** The pre-validation fee (= amountMinor when no validations apply). */
|
||||||
|
readonly grossMinor: number;
|
||||||
|
/** Total the merchant validations took off (gross − net). */
|
||||||
|
readonly discountMinor: number;
|
||||||
|
/** Per-validation receipt/display lines (empty when none apply). */
|
||||||
|
readonly validationLines: ValidationLine[];
|
||||||
|
/** The validation event ids this quote applied — the payment stamps them as
|
||||||
|
* CONSUMED so an overstay's fresh period never re-applies them. */
|
||||||
|
readonly validationIds: string[];
|
||||||
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
||||||
readonly overstay: boolean;
|
readonly overstay: boolean;
|
||||||
readonly currency: string;
|
readonly currency: string;
|
||||||
@@ -98,6 +108,11 @@ export interface SessionLookup {
|
|||||||
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
||||||
readonly amountMinor: number | null;
|
readonly amountMinor: number | null;
|
||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
|
/** Amount actually PAID (from the latest payment event), if any. Distinct from
|
||||||
|
* `amountMinor` (what's owed now): once a transient is settled `amountMinor` is null,
|
||||||
|
* but the operator still wants to see the sum that was collected. */
|
||||||
|
readonly paidMinor: number | null;
|
||||||
|
readonly paidCurrency: string | null;
|
||||||
/** True when paid AND still within the walk-back grace window. */
|
/** True when paid AND still within the walk-back grace window. */
|
||||||
readonly withinGrace: boolean;
|
readonly withinGrace: boolean;
|
||||||
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||||
@@ -112,6 +127,12 @@ export interface SessionLookup {
|
|||||||
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
||||||
* none. Display/audit only — never an access decision. */
|
* none. Display/audit only — never an access decision. */
|
||||||
readonly plate: string | null;
|
readonly plate: string | null;
|
||||||
|
/** Merchant validations folded into `amountMinor` (which is NET): the pre-discount
|
||||||
|
* fee, the total taken off, and the per-validation lines for the modal/receipt.
|
||||||
|
* grossMinor/discountMinor are null when no quote resolved. */
|
||||||
|
readonly grossMinor: number | null;
|
||||||
|
readonly discountMinor: number | null;
|
||||||
|
readonly validationLines: ValidationLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PayStation {
|
export class PayStation {
|
||||||
@@ -150,19 +171,28 @@ export class PayStation {
|
|||||||
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
||||||
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
||||||
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
||||||
|
// Merchant validations: fold the LIVE ones (applied, unvoided, not consumed by a
|
||||||
|
// prior payment) so the quote is NET — the payment then stamps their ids as
|
||||||
|
// consumed. See wiki/concepts/validation-discounts.md.
|
||||||
const last = this.#lastPayment(identity);
|
const last = this.#lastPayment(identity);
|
||||||
|
const validations = liveValidations(this.#db, identity);
|
||||||
const p = priceSession(
|
const p = priceSession(
|
||||||
entry.occurredAt,
|
entry.occurredAt,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
structure,
|
structure,
|
||||||
last ? [last] : [],
|
last ? [last] : [],
|
||||||
category,
|
category,
|
||||||
|
validations,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
identity,
|
identity,
|
||||||
enteredAt: entry.occurredAt,
|
enteredAt: entry.occurredAt,
|
||||||
periodStart: p.periodStart,
|
periodStart: p.periodStart,
|
||||||
amountMinor: p.amountMinor,
|
amountMinor: p.amountMinor,
|
||||||
|
grossMinor: p.grossMinor,
|
||||||
|
discountMinor: p.discountMinor,
|
||||||
|
validationLines: p.validationLines,
|
||||||
|
validationIds: validations.map((v) => v.eventId),
|
||||||
overstay: p.overstay,
|
overstay: p.overstay,
|
||||||
currency: tv.currency,
|
currency: tv.currency,
|
||||||
tariffVersionId: tv.id,
|
tariffVersionId: tv.id,
|
||||||
@@ -241,6 +271,18 @@ export class PayStation {
|
|||||||
// The exit flow reads graceExitMin off the payment to validate the
|
// The exit flow reads graceExitMin off the payment to validate the
|
||||||
// walk-back window without re-resolving the tariff.
|
// walk-back window without re-resolving the tariff.
|
||||||
graceExitMin: q.graceExitMin,
|
graceExitMin: q.graceExitMin,
|
||||||
|
// Merchant validations: record the gross/discount split + CONSUME the applied
|
||||||
|
// validation ids, so reporting sees the leakage and a later overstay period
|
||||||
|
// never re-applies them. A zero-net settlement (full comp) is still a signed
|
||||||
|
// payment — grace/voucher/exit work unchanged. See validation-discounts.md.
|
||||||
|
...(q.validationIds.length
|
||||||
|
? {
|
||||||
|
grossMinor: q.grossMinor,
|
||||||
|
discountMinor: q.discountMinor,
|
||||||
|
validationIds: q.validationIds,
|
||||||
|
validationLines: q.validationLines.map((l) => ({ ...l })),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -274,8 +316,10 @@ export class PayStation {
|
|||||||
if (!entry) {
|
if (!entry) {
|
||||||
return {
|
return {
|
||||||
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||||
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
|
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
||||||
|
withinGrace: false, graceExpiresAt: null,
|
||||||
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||||
|
grossMinor: null, discountMinor: null, validationLines: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||||
@@ -289,11 +333,17 @@ export class PayStation {
|
|||||||
|
|
||||||
let paidAt: string | null = null;
|
let paidAt: string | null = null;
|
||||||
let graceExitMin: number | null = null;
|
let graceExitMin: number | null = null;
|
||||||
|
let paidMinor: number | null = null;
|
||||||
|
let paidCurrency: string | null = null;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.type === "payment") {
|
if (r.type === "payment") {
|
||||||
paidAt = r.occurredAt;
|
paidAt = r.occurredAt;
|
||||||
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
const p = (r.payload ?? {}) as { graceExitMin?: number; amountMinor?: number; currency?: string };
|
||||||
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||||
|
// Sum payments (overstay top-ups append a second one) so the displayed paid total
|
||||||
|
// reflects everything collected for the session, not just the last slip.
|
||||||
|
if (typeof p.amountMinor === "number") paidMinor = (paidMinor ?? 0) + p.amountMinor;
|
||||||
|
if (typeof p.currency === "string") paidCurrency = p.currency;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const graceExpiresAt =
|
const graceExpiresAt =
|
||||||
@@ -307,11 +357,17 @@ export class PayStation {
|
|||||||
// exit gate clears. See wiki/entities/subscription.md.
|
// exit gate clears. See wiki/entities/subscription.md.
|
||||||
let amountMinor: number | null = null;
|
let amountMinor: number | null = null;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
|
let grossMinor: number | null = null;
|
||||||
|
let discountMinor: number | null = null;
|
||||||
|
let validationLines: ValidationLine[] = [];
|
||||||
if (open && !isSubscription) {
|
if (open && !isSubscription) {
|
||||||
try {
|
try {
|
||||||
const q = this.quote(id);
|
const q = this.quote(id);
|
||||||
amountMinor = q.amountMinor;
|
amountMinor = q.amountMinor;
|
||||||
currency = q.currency;
|
currency = q.currency;
|
||||||
|
grossMinor = q.grossMinor;
|
||||||
|
discountMinor = q.discountMinor;
|
||||||
|
validationLines = q.validationLines;
|
||||||
} catch {
|
} catch {
|
||||||
/* no active tariff — leave null; modal shows session without a price */
|
/* no active tariff — leave null; modal shows session without a price */
|
||||||
}
|
}
|
||||||
@@ -328,10 +384,11 @@ export class PayStation {
|
|||||||
return {
|
return {
|
||||||
identity: id, found: true, open,
|
identity: id, found: true, open,
|
||||||
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||||
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
|
paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay,
|
||||||
subscription: isSubscription, subscriptionId,
|
subscription: isSubscription, subscriptionId,
|
||||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||||
|
grossMinor, discountMinor, validationLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, deviceEvents as deviceEventsTable, ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ReadDispatcher } from "./read-dispatch.js";
|
||||||
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
|
import type { DeviceReadEvent } from "./device-events.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// STRUCTURAL FILTER at the dispatcher (2026-07-04): a reader value that matched
|
||||||
|
// nothing AND can't possibly be a credential we issued (no ticket Luhn shape, no
|
||||||
|
// SUB-/SUBSESS- prefix, not a confirmed-RF read) is refused with UNSIGNED telemetry
|
||||||
|
// instead of reaching the exit flow and signing a noSession anomaly. Born from the
|
||||||
|
// park-buzi phantom optical decodes: red "who is exiting?" rows for NOBODY train the
|
||||||
|
// operator to ignore the signed feed. Anything plausibly ours STILL signs normally.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let dispatcher: ReadDispatcher;
|
||||||
|
|
||||||
|
const READER = "reader-exit";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: "ctl-exit",
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: { relays: [{ relay: 1, direction: "exit" }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: READER,
|
||||||
|
category: "reader",
|
||||||
|
driverId: "dingtian-qr-reader",
|
||||||
|
config: { serial: "H05MA5B0", direction: "exit" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
const log = makeLog(db);
|
||||||
|
dispatcher = new ReadDispatcher(db, new ExitFlow(db, log, silentLogger()), new SubscriptionFlow(db, log, silentLogger()), silentLogger());
|
||||||
|
});
|
||||||
|
|
||||||
|
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
|
||||||
|
return {
|
||||||
|
driverId: "dingtian-qr-reader",
|
||||||
|
deviceId: READER,
|
||||||
|
value,
|
||||||
|
kind: opts.kind ?? "qr",
|
||||||
|
...(opts.channel ? { channel: opts.channel } : {}),
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ledger = () => db.select().from(ledgerEvents).all();
|
||||||
|
const unrecognized = () =>
|
||||||
|
db.select().from(deviceEventsTable).all()
|
||||||
|
.map((r) => r.detail as { unrecognizedRead?: boolean; value?: string })
|
||||||
|
.filter((d) => d.unrecognizedRead === true);
|
||||||
|
|
||||||
|
describe("read-dispatch structural filter", () => {
|
||||||
|
it("phantom 6-digit optical decode → refused, telemetry only, NOTHING signed", async () => {
|
||||||
|
const out = await dispatcher.dispatch(read("999459", { channel: "optical" }));
|
||||||
|
expect(out.accepted).toBe(false);
|
||||||
|
expect(out.reason).toMatch(/unrecognized/);
|
||||||
|
expect(ledger()).toHaveLength(0); // the whole point: no red row in the feed
|
||||||
|
expect(unrecognized()).toHaveLength(1);
|
||||||
|
expect(unrecognized()[0].value).toBe("999459");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy untagged garbage ('C') → filtered too (works before prefixes are deployed)", async () => {
|
||||||
|
const out = await dispatcher.dispatch(read("C"));
|
||||||
|
expect(out.accepted).toBe(false);
|
||||||
|
expect(ledger()).toHaveLength(0);
|
||||||
|
expect(unrecognized()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Luhn-valid unknown ticket → NOT filtered: the exit flow signs the noSession anomaly", async () => {
|
||||||
|
const out = await dispatcher.dispatch(read("00000000000")); // valid shape, no session
|
||||||
|
expect(out.accepted).toBe(false);
|
||||||
|
expect(unrecognized()).toHaveLength(0);
|
||||||
|
const anomalies = ledger().filter((r) => r.type === "anomaly");
|
||||||
|
expect(anomalies.length).toBeGreaterThan(0); // a real probe stays in the signed feed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown card on a CONFIRMED RF channel → NOT filtered (a physical card is a real event)", async () => {
|
||||||
|
await dispatcher.dispatch(read("1A86A158", { kind: "card", channel: "rf" }));
|
||||||
|
expect(unrecognized()).toHaveLength(0);
|
||||||
|
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown SUB- code → NOT filtered (our own prefix = an interesting probe)", async () => {
|
||||||
|
await dispatcher.dispatch(read("SUB-DOESNOTEXIST", { channel: "optical" }));
|
||||||
|
expect(unrecognized()).toHaveLength(0);
|
||||||
|
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ENROLLED credential is matched BEFORE the filter (never hidden by it)", async () => {
|
||||||
|
// A card UID that would fail every shape test — enrolled, so it must still match.
|
||||||
|
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: "sub-1", kind: "rf", value: "999459" }).run();
|
||||||
|
await dispatcher.dispatch(read("999459")); // legacy untagged read of it
|
||||||
|
expect(unrecognized()).toHaveLength(0); // reached the subscription flow, not the filter
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { devices, eq, type Db } from "@parking/db";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, deviceEvents as deviceEventsTable, eq, type Db } from "@parking/db";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
import type { ExitFlow } from "./exit-flow.js";
|
import type { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { validateTicketCode } from "./entry-flow.js";
|
||||||
import type { SubscriptionFlow } from "./subscription-flow.js";
|
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
import { relayForDevice } from "./device-resolve.js";
|
import { relayForDevice } from "./device-resolve.js";
|
||||||
|
|
||||||
@@ -17,6 +19,22 @@ import { relayForDevice } from "./device-resolve.js";
|
|||||||
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
||||||
// reader the exit side; "both" defers to the flow's own inference (subscription:
|
// reader the exit side; "both" defers to the flow's own inference (subscription:
|
||||||
// session state; transient: exit).
|
// session state; transient: exit).
|
||||||
|
//
|
||||||
|
// STRUCTURAL FILTER (2026-07-04, operator-requested). The DT-008's scan engine
|
||||||
|
// false-decodes sunlight stripe patterns into short garbage codes (phantom reads —
|
||||||
|
// see wiki/entities/dingtian-dt008-reader.md), and each one was reaching the exit
|
||||||
|
// flow and signing an exit.refused.noSession anomaly: red "who is trying to exit?"
|
||||||
|
// rows for NOBODY, training the operator to ignore the feed (alarm fatigue is the
|
||||||
|
// adversary's friend). So a reader value that matched nothing AND cannot possibly be
|
||||||
|
// a credential we issued is dropped to UNSIGNED telemetry (device_events, still
|
||||||
|
// auditable) instead of the signed ledger. "Possibly ours" stays deliberately wide —
|
||||||
|
// any of these still reaches the flows and signs the normal refusal anomaly:
|
||||||
|
// - a Luhn-valid ticket shape (validateTicketCode — a forged/expired ticket is a
|
||||||
|
// real probe),
|
||||||
|
// - our issued-code prefixes (SUB- / SUBSESS-),
|
||||||
|
// - ANY read on a CONFIRMED RF channel (a physically present card, enrolled or
|
||||||
|
// not, is a real event — RF is never sun noise),
|
||||||
|
// - plates (different population; never shape-filtered here).
|
||||||
|
|
||||||
export class ReadDispatcher {
|
export class ReadDispatcher {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
@@ -45,6 +63,20 @@ export class ReadDispatcher {
|
|||||||
if (sub) {
|
if (sub) {
|
||||||
return this.#subscription.run(resolved, e, sub);
|
return this.#subscription.run(resolved, e, sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matched nothing — if the value can't even BE one of ours, it's scanner noise
|
||||||
|
// (phantom optical decode): refuse with unsigned telemetry, keep the signed feed
|
||||||
|
// for events that involve an actual credential or an actual card.
|
||||||
|
if ((e.kind === "qr" || e.kind === "card" || e.kind === "ticket") && !plausibleCredential(e)) {
|
||||||
|
this.#recordUnrecognized(e);
|
||||||
|
this.#logger.info(`read filtered (not a credential shape): '${e.value}' from ${e.deviceId}${e.channel ? ` ch=${e.channel}` : ""}`);
|
||||||
|
return {
|
||||||
|
accepted: false,
|
||||||
|
direction: resolved.direction === "entry" ? "entry" : "exit",
|
||||||
|
reason: "unrecognized code (no credential shape — telemetry only)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
|
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
|
||||||
// transient exit (transient entry is the button flow, not a reader), so reject+log
|
// transient exit (transient entry is the button flow, not a reader), so reject+log
|
||||||
// rather than treat an entry scan as an exit.
|
// rather than treat an entry scan as an exit.
|
||||||
@@ -53,4 +85,39 @@ export class ReadDispatcher {
|
|||||||
}
|
}
|
||||||
return this.#exit.handleAt(resolved, e);
|
return this.#exit.handleAt(resolved, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Unsigned telemetry for a filtered read — auditable in device_events, out of the
|
||||||
|
* signed feed. Mirrors the entry flow's suppressed-press pattern. */
|
||||||
|
#recordUnrecognized(e: DeviceReadEvent): void {
|
||||||
|
try {
|
||||||
|
this.#db
|
||||||
|
.insert(deviceEventsTable)
|
||||||
|
.values({
|
||||||
|
id: randomUUID(),
|
||||||
|
deviceId: e.deviceId,
|
||||||
|
category: "reader",
|
||||||
|
kind: "read",
|
||||||
|
detail: {
|
||||||
|
unrecognizedRead: true,
|
||||||
|
value: e.value,
|
||||||
|
readKind: e.kind,
|
||||||
|
...(e.channel ? { channel: e.channel } : {}),
|
||||||
|
reason: "no credential shape (phantom decode / garbage scan)",
|
||||||
|
},
|
||||||
|
occurredAt: e.at,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`unrecognized-read telemetry insert failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Could this reader value possibly be a credential WE issued (or a real card)?
|
||||||
|
* Deliberately WIDE — only shapes that can't be anything of ours are filtered. */
|
||||||
|
function plausibleCredential(e: DeviceReadEvent): boolean {
|
||||||
|
if (e.channel === "rf") return true; // a physically present card — never sun noise
|
||||||
|
if (validateTicketCode(e.value)) return true; // ticket shape (10–14 digits + Luhn)
|
||||||
|
if (/^SUB(SESS)?-/.test(e.value)) return true; // our subscription QR / window-slip ids
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,3 +181,68 @@ describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
|||||||
expect(r.subscriptions.coveredCars).toBe(2);
|
expect(r.subscriptions.coveredCars).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reportSummary — occupancy, heatmap, stay histogram, look-closer counters (2026-07-05)", () => {
|
||||||
|
it("folds prior ledger into occupancyStart and walks occupancyEnd through the series", async () => {
|
||||||
|
// Before the range: 3 entries, 1 exit → 2 cars inside when June opens.
|
||||||
|
await entry(at("2026-05-20T08:00:00Z"));
|
||||||
|
await entry(at("2026-05-20T09:00:00Z"));
|
||||||
|
await entry(at("2026-05-21T10:00:00Z"));
|
||||||
|
await exit(at("2026-05-21T12:00:00Z"));
|
||||||
|
// In range: +2 on the 10th, −1 on the 11th.
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
await entry(at("2026-06-10T09:00:00Z"));
|
||||||
|
await exit(at("2026-06-11T09:00:00Z"));
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.occupancyStart).toBe(2);
|
||||||
|
expect(r.series.map((p) => [p.bucket, p.occupancyEnd])).toEqual([
|
||||||
|
["2026-06-10", 4],
|
||||||
|
["2026-06-11", 3],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a voided pre-range entry does not inflate occupancyStart", async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-05-20T08:00:00Z") });
|
||||||
|
await log.append({ type: "void", identity: id, occurredAt: at("2026-05-20T08:05:00Z"), payload: { reason: "misprint" } });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.occupancyStart).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entriesByDowHour lands on the local weekday/hour (row 0 = Monday)", async () => {
|
||||||
|
// 2026-06-10 is a WEDNESDAY; 08:00Z = 10:00 in Tirane (UTC+2 in June).
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.entriesByDowHour[2]![10]).toBe(1); // Wed row, 10h column
|
||||||
|
expect(r.entriesByDowHour.flat().reduce((a, b) => a + b, 0)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stay histogram buckets closed sessions; series carries the cash/card split", async () => {
|
||||||
|
db.insert(sessions).values({ id: "h1", identity: "h1", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T08:20:00Z"), state: "closed" }).run(); // 20m → ≤30
|
||||||
|
db.insert(sessions).values({ id: "h2", identity: "h2", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T09:30:00Z"), state: "closed" }).run(); // 90m → ≤120
|
||||||
|
db.insert(sessions).values({ id: "h3", identity: "h3", enteredAt: at("2026-06-08T08:00:00Z"), exitedAt: at("2026-06-10T09:00:00Z"), state: "closed" }).run(); // 2 days → >24h tail
|
||||||
|
await payment(at("2026-06-10T09:00:00Z"), 500, { tender: "cash" });
|
||||||
|
await payment(at("2026-06-10T09:30:00Z"), 700, { tender: "card" });
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
const counts = Object.fromEntries(r.stayHistogram.map((b) => [String(b.uptoMin), b.count]));
|
||||||
|
expect(counts["30"]).toBe(1);
|
||||||
|
expect(counts["120"]).toBe(1);
|
||||||
|
expect(counts["null"]).toBe(1);
|
||||||
|
const day = r.series.find((p) => p.bucket === "2026-06-10")!;
|
||||||
|
expect(day.cashMinor).toBe(500);
|
||||||
|
expect(day.cardMinor).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts voids and anomalies in range (the look-closer counters)", async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-06-10T08:00:00Z") });
|
||||||
|
await log.append({ type: "void", identity: id, occurredAt: at("2026-06-10T08:05:00Z"), payload: { reason: "misprint" } });
|
||||||
|
await log.append({ type: "anomaly", identity: "X", occurredAt: at("2026-06-10T09:00:00Z"), payload: { reason: "test" } });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.voids).toBe(1);
|
||||||
|
expect(r.totals.anomalies).toBe(1);
|
||||||
|
expect(r.totals.entries).toBe(0); // the voided entry stays excluded
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+105
-14
@@ -4,9 +4,11 @@ import {
|
|||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
gte,
|
gte,
|
||||||
|
lt,
|
||||||
lte,
|
lte,
|
||||||
ledgerEvents,
|
ledgerEvents,
|
||||||
sessions,
|
sessions,
|
||||||
|
siteConfig,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tariffVersions,
|
tariffVersions,
|
||||||
tariffs,
|
tariffs,
|
||||||
@@ -46,8 +48,13 @@ export interface SeriesPoint {
|
|||||||
readonly exits: number;
|
readonly exits: number;
|
||||||
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
||||||
readonly revenueMinor: number;
|
readonly revenueMinor: number;
|
||||||
|
/** Tender split of the bucket's revenue (cash = everything not card). */
|
||||||
|
readonly cashMinor: number;
|
||||||
|
readonly cardMinor: number;
|
||||||
/** Payment COUNT in the bucket (transactions, not amount). */
|
/** Payment COUNT in the bucket (transactions, not amount). */
|
||||||
readonly payments: number;
|
readonly payments: number;
|
||||||
|
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
|
||||||
|
readonly occupancyEnd: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportTotals {
|
export interface ReportTotals {
|
||||||
@@ -67,6 +74,10 @@ export interface ReportTotals {
|
|||||||
readonly totalParkedMinutes: number;
|
readonly totalParkedMinutes: number;
|
||||||
readonly avgParkedMinutes: number;
|
readonly avgParkedMinutes: number;
|
||||||
readonly medianParkedMinutes: number;
|
readonly medianParkedMinutes: number;
|
||||||
|
/** Cancelled tickets + signed anomalies in range — the "look closer" counters
|
||||||
|
* (the operator at the booth is the threat model's primary adversary). */
|
||||||
|
readonly voids: number;
|
||||||
|
readonly anomalies: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionStats {
|
export interface SubscriptionStats {
|
||||||
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
|
|||||||
readonly coveredCars: number;
|
readonly coveredCars: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One bar of the stay-duration histogram: stays up to `uptoMin` minutes (null = the
|
||||||
|
* open-ended tail). Edges chosen to mirror how tariffs are designed (see tariff.md). */
|
||||||
|
export interface StayBucket {
|
||||||
|
readonly uptoMin: number | null;
|
||||||
|
readonly count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReportSummary {
|
export interface ReportSummary {
|
||||||
readonly from: string;
|
readonly from: string;
|
||||||
readonly to: string;
|
readonly to: string;
|
||||||
@@ -89,25 +107,43 @@ export interface ReportSummary {
|
|||||||
readonly series: SeriesPoint[];
|
readonly series: SeriesPoint[];
|
||||||
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
||||||
readonly entriesByHour: number[];
|
readonly entriesByHour: number[];
|
||||||
|
/** Entries by [day-of-week][hour-of-day] — 7×24, row 0 = Monday. The heatmap that
|
||||||
|
* shows weekday-vs-weekend patterns (feeds tariff-window design). */
|
||||||
|
readonly entriesByDowHour: number[][];
|
||||||
|
/** Stay-duration histogram over closed sessions in range. */
|
||||||
|
readonly stayHistogram: StayBucket[];
|
||||||
|
/** Cars inside when the range OPENS (folded from the whole prior ledger). */
|
||||||
|
readonly occupancyStart: number;
|
||||||
|
/** Nominal capacity from site config (null = uncapped) — the reference line. */
|
||||||
|
readonly capacity: number | null;
|
||||||
readonly subscriptions: SubscriptionStats;
|
readonly subscriptions: SubscriptionStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
/** 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 fmtCache = new Map<string, Intl.DateTimeFormat>();
|
||||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
const DOW_INDEX: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
|
||||||
timeZone: tz,
|
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number; dow: number } {
|
||||||
year: "numeric",
|
// Cached per tz — this runs once per ledger row in a report.
|
||||||
month: "2-digit",
|
let fmt = fmtCache.get(tz);
|
||||||
day: "2-digit",
|
if (!fmt) {
|
||||||
hour: "2-digit",
|
fmt = new Intl.DateTimeFormat("en-US", {
|
||||||
hourCycle: "h23",
|
timeZone: tz,
|
||||||
});
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
weekday: "short",
|
||||||
|
});
|
||||||
|
fmtCache.set(tz, fmt);
|
||||||
|
}
|
||||||
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
||||||
return {
|
return {
|
||||||
y: Number(parts.year),
|
y: Number(parts.year),
|
||||||
mo: Number(parts.month),
|
mo: Number(parts.month),
|
||||||
d: Number(parts.day),
|
d: Number(parts.day),
|
||||||
h: Number(parts.hour),
|
h: Number(parts.hour),
|
||||||
|
dow: DOW_INDEX[parts.weekday ?? ""] ?? 0, // row 0 = Monday
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +198,7 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
|
|
||||||
const seriesMap = new Map<string, SeriesPoint>();
|
const seriesMap = new Map<string, SeriesPoint>();
|
||||||
const entriesByHour = new Array<number>(24).fill(0);
|
const entriesByHour = new Array<number>(24).fill(0);
|
||||||
|
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
|
||||||
const totals = {
|
const totals = {
|
||||||
entries: 0,
|
entries: 0,
|
||||||
exits: 0,
|
exits: 0,
|
||||||
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
ticketMinor: 0,
|
ticketMinor: 0,
|
||||||
subscriptionSalesMinor: 0,
|
subscriptionSalesMinor: 0,
|
||||||
subscriptionWindowMinor: 0,
|
subscriptionWindowMinor: 0,
|
||||||
|
voids: 0,
|
||||||
|
anomalies: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function point(label: string): SeriesPoint {
|
function point(label: string): SeriesPoint {
|
||||||
let p = seriesMap.get(label);
|
let p = seriesMap.get(label);
|
||||||
if (!p) {
|
if (!p) {
|
||||||
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
|
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
|
||||||
seriesMap.set(label, p);
|
seriesMap.set(label, p);
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
@@ -196,11 +235,16 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||||
totals.entries++;
|
totals.entries++;
|
||||||
p.entries++;
|
p.entries++;
|
||||||
const h = localParts(row.occurredAt, tz).h;
|
const lp = localParts(row.occurredAt, tz);
|
||||||
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
|
entriesByHour[lp.h] = (entriesByHour[lp.h] ?? 0) + 1;
|
||||||
|
entriesByDowHour[lp.dow]![lp.h] = (entriesByDowHour[lp.dow]![lp.h] ?? 0) + 1;
|
||||||
} else if (row.type === "vehicle_exit") {
|
} else if (row.type === "vehicle_exit") {
|
||||||
totals.exits++;
|
totals.exits++;
|
||||||
p.exits++;
|
p.exits++;
|
||||||
|
} else if (row.type === "void") {
|
||||||
|
totals.voids++;
|
||||||
|
} else if (row.type === "anomaly") {
|
||||||
|
totals.anomalies++;
|
||||||
} else if (row.type === "payment") {
|
} else if (row.type === "payment") {
|
||||||
const pl = (row.payload ?? {}) as PaymentPayload;
|
const pl = (row.payload ?? {}) as PaymentPayload;
|
||||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
totals.revenueMinor += amt;
|
totals.revenueMinor += amt;
|
||||||
p.payments++;
|
p.payments++;
|
||||||
p.revenueMinor += amt;
|
p.revenueMinor += amt;
|
||||||
if (pl.tender === "card") totals.cardMinor += amt;
|
if (pl.tender === "card") {
|
||||||
else totals.cashMinor += amt;
|
totals.cardMinor += amt;
|
||||||
|
p.cardMinor += amt;
|
||||||
|
} else {
|
||||||
|
totals.cashMinor += amt;
|
||||||
|
p.cashMinor += amt;
|
||||||
|
}
|
||||||
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
||||||
// (the rest is) transient ticket revenue.
|
// (the rest is) transient ticket revenue.
|
||||||
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
||||||
@@ -221,6 +270,31 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
|
|
||||||
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
||||||
|
|
||||||
|
// --- Occupancy: fold the PRIOR ledger for cars-inside at range start, then walk the
|
||||||
|
// series. Voided pre-range entries cancel out the same way the in-range pass does.
|
||||||
|
// Sparse buckets (no events) simply carry the previous level — the step line is exact
|
||||||
|
// at every plotted point.
|
||||||
|
const prior = db
|
||||||
|
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(lt(ledgerEvents.occurredAt, q.from))
|
||||||
|
.all();
|
||||||
|
const priorVoided = new Set<string>();
|
||||||
|
for (const r of prior) if (r.type === "void" && r.identity) priorVoided.add(r.identity);
|
||||||
|
let occupancyStart = 0;
|
||||||
|
for (const r of prior) {
|
||||||
|
if (r.type === "vehicle_entry" && !(r.identity && priorVoided.has(r.identity))) occupancyStart++;
|
||||||
|
else if (r.type === "vehicle_exit") occupancyStart--;
|
||||||
|
}
|
||||||
|
occupancyStart = Math.max(0, occupancyStart);
|
||||||
|
let running = occupancyStart;
|
||||||
|
for (const p of series) {
|
||||||
|
running = Math.max(0, running + p.entries - p.exits);
|
||||||
|
(p as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] }).occupancyEnd = running;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capacity = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get()?.capacity ?? null;
|
||||||
|
|
||||||
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
||||||
// zero-revenue range still labels its money column.
|
// zero-revenue range still labels its money column.
|
||||||
if (!currency) {
|
if (!currency) {
|
||||||
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
durations.sort((a, b) => a - b);
|
durations.sort((a, b) => a - b);
|
||||||
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// Stay-duration histogram. Edges mirror how rate cards are designed (30m/1h bands,
|
||||||
|
// the 8h working day, the 24h rolling day) so the chart answers "where should the
|
||||||
|
// ladder/up-to breakpoints sit". Last bucket is the open-ended >24h tail.
|
||||||
|
const STAY_EDGES_MIN = [30, 60, 120, 240, 480, 1440];
|
||||||
|
const stayHistogram: { uptoMin: number | null; count: number }[] = [
|
||||||
|
...STAY_EDGES_MIN.map((uptoMin) => ({ uptoMin, count: 0 })),
|
||||||
|
{ uptoMin: null, count: 0 },
|
||||||
|
];
|
||||||
|
for (const mins of durations) {
|
||||||
|
const i = STAY_EDGES_MIN.findIndex((edge) => mins <= edge);
|
||||||
|
stayHistogram[i === -1 ? STAY_EDGES_MIN.length : i]!.count++;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
||||||
const subs = db.select().from(subscriptions).all();
|
const subs = db.select().from(subscriptions).all();
|
||||||
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
||||||
@@ -283,6 +370,10 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
},
|
},
|
||||||
series,
|
series,
|
||||||
entriesByHour,
|
entriesByHour,
|
||||||
|
entriesByDowHour,
|
||||||
|
stayHistogram,
|
||||||
|
occupancyStart,
|
||||||
|
capacity,
|
||||||
subscriptions: subStats,
|
subscriptions: subStats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ interface ThemeBody {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UI font scale: percent of base, clamped to [80, 160] in steps of 10. Integer percent.
|
||||||
|
const FONT_SCALE_MIN = 80;
|
||||||
|
const FONT_SCALE_MAX = 160;
|
||||||
|
interface FontScaleBody {
|
||||||
|
fontScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Self-service profile: a signed-in user edits their OWN display name + email. This is
|
// 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
|
// 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
|
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
|
||||||
@@ -67,6 +74,7 @@ function sessionView(
|
|||||||
roleId: string;
|
roleId: string;
|
||||||
language: string;
|
language: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
|
fontScale: number;
|
||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
},
|
},
|
||||||
@@ -81,6 +89,7 @@ function sessionView(
|
|||||||
permissions,
|
permissions,
|
||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
|
fontScale: user.fontScale,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
};
|
};
|
||||||
@@ -171,6 +180,23 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Change MY own UI font scale (any signed-in user). Percent of base, clamped to
|
||||||
|
// [80, 160] in steps of 10. Persisted like `theme`, restored on the next login.
|
||||||
|
app.put<{ Body: FontScaleBody }>(
|
||||||
|
"/api/auth/font-scale",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (req, reply) => {
|
||||||
|
const raw = req.body?.fontScale;
|
||||||
|
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||||
|
return reply.code(400).send({ error: "fontScale must be a number" });
|
||||||
|
}
|
||||||
|
// Snap to a 10-step and clamp to the allowed band (defensive — the UI already does).
|
||||||
|
const fontScale = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round(raw / 10) * 10));
|
||||||
|
await db.update(users).set({ fontScale }).where(eq(users.id, req.user.sub)).run();
|
||||||
|
return { fontScale };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Edit MY own display name / email (any signed-in user; no permission needed — it only
|
// 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).
|
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
|
||||||
app.put<{ Body: ProfileBody }>(
|
app.put<{ Body: ProfileBody }>(
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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 the backup routes — the security seam + the unconfigured-state
|
||||||
|
// behaviour. The booted test app has no BACKUP_TARGET_DIR/BACKUP_KEY, so the service is
|
||||||
|
// "not configured": status reports it, and a manual run is a clean 409 (not a 500).
|
||||||
|
// See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
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("GET /api/backup/status", () => {
|
||||||
|
it("401 without a session", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/backup/status" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403 for a user lacking backup:read", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer", roleId: "viewer", permissions: ["site:read"],
|
||||||
|
});
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin sees the (unconfigured) status shape", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
configured: false,
|
||||||
|
targetDir: null,
|
||||||
|
keepLast: 7, // code defaults surfaced when unset
|
||||||
|
keepDailyDays: 30,
|
||||||
|
running: false,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/backup/config — admin-chosen target", () => {
|
||||||
|
it("403 for a user lacking backup:update", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer", roleId: "viewer", permissions: ["backup:read"],
|
||||||
|
});
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { targetDir: "/tmp/x" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the target dir and reflects it in status", 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/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { targetDir: " /mnt/backup " }, // trimmed server-side
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
expect(put.json()).toMatchObject({ targetDir: "/mnt/backup" });
|
||||||
|
|
||||||
|
const status = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||||
|
expect(status.json().targetDir).toBe("/mnt/backup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the target dir when given empty/null", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "/mnt/backup" },
|
||||||
|
});
|
||||||
|
const clear = await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "" },
|
||||||
|
});
|
||||||
|
expect(clear.json().targetDir).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists retention and resets to defaults on null", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
const set = await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { keepLast: 3, keepDailyDays: 14 },
|
||||||
|
});
|
||||||
|
expect(set.json()).toMatchObject({ keepLast: 3, keepDailyDays: 14 });
|
||||||
|
|
||||||
|
// null resets to the code default.
|
||||||
|
const reset = await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { keepLast: null, keepDailyDays: null },
|
||||||
|
});
|
||||||
|
expect(reset.json()).toMatchObject({ keepLast: 7, keepDailyDays: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a negative retention value (400)", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/backup/config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { keepLast: -1 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/backup/test — path probe", () => {
|
||||||
|
it("reports ok for a writable directory and a reason for a missing one", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
const good = await app.inject({
|
||||||
|
method: "POST", url: "/api/backup/test",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { targetDir: process.cwd() }, // an existing, writable dir
|
||||||
|
});
|
||||||
|
expect(good.json()).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
const bad = await app.inject({
|
||||||
|
method: "POST", url: "/api/backup/test",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { targetDir: "/no/such/path/here-xyz" },
|
||||||
|
});
|
||||||
|
expect(bad.json()).toMatchObject({ ok: false, reason: "missing" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/backup/run", () => {
|
||||||
|
it("403 for a user lacking backup:create", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer", roleId: "viewer", permissions: ["backup:read"], // read but not create
|
||||||
|
});
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST", url: "/api/backup/run",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires CSRF on the mutation", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST", url: "/api/backup/run",
|
||||||
|
headers: { cookie }, // no csrf header
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 409 backup_not_configured when no target/key is set (not a 500)", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST", url: "/api/backup/run",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
expect(res.json()).toMatchObject({ error: "backup_not_configured" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { checkTargetDir, type BackupService } from "../backup-service.js";
|
||||||
|
|
||||||
|
// On-site encrypted DB backup — admin-driven. See wiki/concepts/backup-recovery.md.
|
||||||
|
// - GET /api/backup/status : config + last-run success/error. (backup:read)
|
||||||
|
// - PUT /api/backup/config : set the admin-chosen target directory. (backup:update)
|
||||||
|
// - POST /api/backup/test : probe a candidate path (exists/dir/writable). (backup:update)
|
||||||
|
// - POST /api/backup/run : trigger a manual "back up now". (backup:create)
|
||||||
|
// The target dir lives in site_config (admin picks it from the UI); the encryption key stays an
|
||||||
|
// env secret. RESTORE is intentionally absent — out-of-band runbook on a fresh appliance.
|
||||||
|
|
||||||
|
interface ConfigBody {
|
||||||
|
targetDir?: string | null;
|
||||||
|
/** Retention: keep this many newest backups. null = reset to the code default. */
|
||||||
|
keepLast?: number | null;
|
||||||
|
/** Retention: keep one-per-day within this many days. null = reset to the code default. */
|
||||||
|
keepDailyDays?: number | null;
|
||||||
|
}
|
||||||
|
interface TestBody {
|
||||||
|
targetDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backupRoutes(app: FastifyInstance, db: Db, backups: BackupService): Promise<void> {
|
||||||
|
app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () =>
|
||||||
|
backups.status(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set (or clear) the target directory. Empty/null clears it (backups become a no-op).
|
||||||
|
app.put<{ Body: ConfigBody }>(
|
||||||
|
"/api/backup/config",
|
||||||
|
{ preHandler: requirePermission("backup:update") },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = req.body ?? {};
|
||||||
|
const patch: { backupTargetDir?: string | null; backupKeepLast?: number | null; backupKeepDailyDays?: number | null } = {};
|
||||||
|
|
||||||
|
if ("targetDir" in body) {
|
||||||
|
const raw = body.targetDir;
|
||||||
|
if (raw != null && typeof raw !== "string") {
|
||||||
|
return reply.code(400).send({ error: "targetDir must be a string or null" });
|
||||||
|
}
|
||||||
|
patch.backupTargetDir = raw == null ? null : raw.trim() || null;
|
||||||
|
}
|
||||||
|
// Retention: a non-negative integer, or null to reset to the code default.
|
||||||
|
for (const [field, col] of [
|
||||||
|
["keepLast", "backupKeepLast"],
|
||||||
|
["keepDailyDays", "backupKeepDailyDays"],
|
||||||
|
] as const) {
|
||||||
|
if (field in body) {
|
||||||
|
const v = body[field];
|
||||||
|
if (v != null && (!Number.isInteger(v) || v < 0)) {
|
||||||
|
return reply.code(400).send({ error: `${field} must be a non-negative integer or null` });
|
||||||
|
}
|
||||||
|
patch[col] = v ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
// Single-row site_config (id=1): upsert, since a fresh install may not have it yet.
|
||||||
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (existing) {
|
||||||
|
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||||
|
}
|
||||||
|
return backups.status();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Probe a candidate path before relying on it (the UI "Test target" button).
|
||||||
|
app.post<{ Body: TestBody }>(
|
||||||
|
"/api/backup/test",
|
||||||
|
{ preHandler: requirePermission("backup:update") },
|
||||||
|
async (req) => {
|
||||||
|
const dir = typeof req.body?.targetDir === "string" ? req.body.targetDir : "";
|
||||||
|
return checkTargetDir(dir);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post("/api/backup/run", { preHandler: requirePermission("backup:create") }, async (_req, reply) => {
|
||||||
|
if (!backups.configured) {
|
||||||
|
return reply.code(409).send({ error: "backup_not_configured" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await backups.run("manual");
|
||||||
|
return reply.send({ ok: true, path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles });
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(500).send({ error: "backup_failed", message: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
|
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
||||||
|
|
||||||
|
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
||||||
|
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
|
||||||
|
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
|
||||||
|
// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create)
|
||||||
|
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
||||||
|
// only their own; reviewers see all + can filter status.
|
||||||
|
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
||||||
|
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read)
|
||||||
|
// payments + vouchers over the whole chain — the
|
||||||
|
// amount that carries across shifts).
|
||||||
|
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
|
||||||
|
// judgment about the operator settled outside the app, never a cash reversal.
|
||||||
|
|
||||||
|
interface MovementBody {
|
||||||
|
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||||
|
* cash_out = Mandat Pagese (pay-OUT). */
|
||||||
|
type: "cash_in" | "cash_out";
|
||||||
|
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||||
|
amountMinor: number;
|
||||||
|
reason?: string;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewBody {
|
||||||
|
/** The cash_in/cash_out event id being decided on. */
|
||||||
|
refId: string;
|
||||||
|
decision: "authorize" | "deny";
|
||||||
|
/** Optional admin note (e.g. why denied). */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovementsQuery {
|
||||||
|
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
||||||
|
status?: MovementStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||||
|
const createGuard = requirePermission("drawer:create");
|
||||||
|
const reviewGuard = requirePermission("drawer:review");
|
||||||
|
const readGuard = requirePermission("shift:read");
|
||||||
|
|
||||||
|
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
||||||
|
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? ({} as MovementBody);
|
||||||
|
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||||
|
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await shift.recordVoucher({
|
||||||
|
type: b.type,
|
||||||
|
operator: req.user.username,
|
||||||
|
amountMinor: b.amountMinor,
|
||||||
|
reason: b.reason ?? "",
|
||||||
|
currency: b.currency,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List movements + review status. Operators are hard-scoped to their OWN movements; a
|
||||||
|
// reviewer sees ALL and may filter by status (the pending review queue).
|
||||||
|
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => {
|
||||||
|
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
||||||
|
const q = req.query ?? {};
|
||||||
|
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
||||||
|
const movements = shift.movementsWithStatus({
|
||||||
|
operator: canReview ? undefined : req.user.username,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
return { movements, scope: canReview ? "all" : "self" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// The physical drawer balance now. Same visibility as the open shift's X-report
|
||||||
|
// (shift:read) — the drawer is a single site-wide till, not per-operator data.
|
||||||
|
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance());
|
||||||
|
|
||||||
|
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
||||||
|
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? ({} as ReviewBody);
|
||||||
|
if (!b.refId || (b.decision !== "authorize" && b.decision !== "deny")) {
|
||||||
|
return reply.code(400).send({ error: "refId and decision (authorize|deny) are required" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await shift.reviewMovement({
|
||||||
|
refId: b.refId,
|
||||||
|
decision: b.decision,
|
||||||
|
reviewedBy: req.user.username,
|
||||||
|
note: b.note,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||||
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EntryFlow } from "../entry-flow.js";
|
||||||
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
|
import type { ShiftService } from "../shift-service.js";
|
||||||
|
import { NoShiftOpenError } from "../shift-service.js";
|
||||||
|
|
||||||
|
// Operator-issued entry (2026-07-01). When the physical entry button is broken, an operator
|
||||||
|
// may issue an entry ticket — a FLAGGED mint (vehicle_entry source=manual + operatorInitiated
|
||||||
|
// + a companion anomaly), gated EXACTLY like the physical button: a real vehicle must be
|
||||||
|
// present (radar/loop AND camera). The presence gate is enforced HERE (server-side), so a
|
||||||
|
// direct POST can't bypass a disabled UI button. Money-adjacent → requires an open shift.
|
||||||
|
// See wiki/concepts/operator-issued-entry.md.
|
||||||
|
|
||||||
|
export async function entryRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
entryFlow: EntryFlow,
|
||||||
|
laneStatus: LaneStatus,
|
||||||
|
shift: ShiftService,
|
||||||
|
): Promise<void> {
|
||||||
|
const guard = requirePermission("session:create");
|
||||||
|
|
||||||
|
app.post("/api/entry/issue", { preHandler: guard }, async (req, reply) => {
|
||||||
|
// Gate on an open shift (a minted entry belongs to an accountable operator).
|
||||||
|
if (!shift.currentOpenShift()) {
|
||||||
|
return reply.code(409).send({ error: new NoShiftOpenError().message });
|
||||||
|
}
|
||||||
|
// The camera side of the presence gate = the live entry lane-busy state; the radar/loop
|
||||||
|
// side is checked inside the flow (its per-relay presence guard).
|
||||||
|
const cameraBusy = laneStatus.snapshot().entry;
|
||||||
|
const res = await entryFlow.issueForOperator(req.user.username, cameraBusy);
|
||||||
|
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ interface PayBody {
|
|||||||
}
|
}
|
||||||
interface ExitBody {
|
interface ExitBody {
|
||||||
identity: string;
|
identity: string;
|
||||||
|
/** Operator consciously releases a suspected plate-swap exit (re-submit after the
|
||||||
|
* first call returned status "swap_suspected"). Signs an attributed override anomaly. */
|
||||||
|
override?: boolean;
|
||||||
}
|
}
|
||||||
interface VoucherBody {
|
interface VoucherBody {
|
||||||
identity: string;
|
identity: string;
|
||||||
@@ -109,8 +112,17 @@ export async function payRoutes(
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const identity = (req.body?.identity ?? "").trim();
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
const res = await exitFlow.exitForBooth(identity);
|
const res = await exitFlow.exitForBooth(identity, {
|
||||||
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
|
override: req.body?.override === true,
|
||||||
|
operator: req.user?.username,
|
||||||
|
});
|
||||||
|
// A suspected plate-swap returns the full detail so the modal can warn + offer override.
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === "swap_suspected") {
|
||||||
|
return reply.code(409).send({ error: res.reason, status: res.status, plate: res.plate, otherIdentity: res.otherIdentity, otherEnteredAt: res.otherEnteredAt });
|
||||||
|
}
|
||||||
|
return reply.code(409).send({ error: res.reason, status: res.status });
|
||||||
|
}
|
||||||
return reply.code(200).send(res);
|
return reply.code(200).send(res);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// PUT /api/site-config/presence-bypass toggles the entry presence-gate bypass. It's a
|
||||||
|
// DEDICATED, SIGNED endpoint: each signal that actually changes appends a config_change to
|
||||||
|
// the ledger (attributed), and it persists to site_config. Admin-only.
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
const configChanges = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
|
||||||
|
|
||||||
|
async function put(body: unknown, auth: { cookie: string; csrf: string }) {
|
||||||
|
return app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/api/site-config/presence-bypass",
|
||||||
|
headers: { cookie: auth.cookie, "x-csrf-token": auth.csrf },
|
||||||
|
payload: body as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PUT /api/site-config/presence-bypass", () => {
|
||||||
|
it("is admin-only: a non-site:update user is 403", async () => {
|
||||||
|
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
|
||||||
|
const auth = await login(app, "op", "pw");
|
||||||
|
const res = await put({ camera: true }, auth);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enabling a signal persists it AND signs an attributed config_change", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
|
||||||
|
const res = await put({ camera: true }, auth);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({ bypassPresenceCamera: true, bypassPresenceRadar: false });
|
||||||
|
|
||||||
|
const changes = configChanges();
|
||||||
|
expect(changes).toHaveLength(1);
|
||||||
|
expect(changes[0].source).toBe("manual");
|
||||||
|
expect(changes[0].signature.length).toBeGreaterThan(0);
|
||||||
|
expect(changes[0].payload).toMatchObject({
|
||||||
|
setting: "entryPresenceBypass.camera",
|
||||||
|
value: true,
|
||||||
|
prev: false,
|
||||||
|
operator: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a no-op toggle (already in that state) signs nothing", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
await put({ camera: true }, auth); // 1st: on → 1 event
|
||||||
|
await put({ camera: true }, auth); // 2nd: still on → no new event
|
||||||
|
expect(configChanges()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disabling signs the off transition too (auditable both ways)", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
await put({ radar: true }, auth);
|
||||||
|
await put({ radar: false }, auth);
|
||||||
|
const changes = configChanges();
|
||||||
|
expect(changes).toHaveLength(2);
|
||||||
|
expect(changes[1].payload).toMatchObject({ setting: "entryPresenceBypass.radar", value: false, prev: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-boolean and an empty body", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
expect((await put({ camera: "yes" }, auth)).statusCode).toBe(400);
|
||||||
|
expect((await put({}, auth)).statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -128,3 +128,57 @@ describe("PUT /api/auth/password (self-service)", () => {
|
|||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/auth/font-scale (self-service)", () => {
|
||||||
|
it("persists a valid scale and returns it on the next session", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f1", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: 120 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().fontScale).toBe(120);
|
||||||
|
// Persisted to the caller's row…
|
||||||
|
expect(db.select().from(users).where(eq(users.username, "f1")).get()?.fontScale).toBe(120);
|
||||||
|
// …and surfaced on /me (the session bootstrap).
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().fontScale).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps + snaps out-of-band / off-step values", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f2", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const tooBig = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: 999 },
|
||||||
|
});
|
||||||
|
expect(tooBig.json().fontScale).toBe(160); // clamped to max
|
||||||
|
const offStep = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: 113 },
|
||||||
|
});
|
||||||
|
expect(offStep.json().fontScale).toBe(110); // snapped to the 10-step
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-numeric scale (400)", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f3", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/auth/font-scale",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { fontScale: "big" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to 100 for a fresh user", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "f4", roleId: "viewer", permissions: [] });
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().fontScale).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Fastify from "fastify";
|
||||||
|
import { beforeEach, afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { qrReaderRoutes, splitChannel } from "./qr-reader.js";
|
||||||
|
import { CredentialCapture } from "../credential-capture.js";
|
||||||
|
import type { DeviceReadEvent, ReadOutcome } from "../device-events.js";
|
||||||
|
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||||
|
|
||||||
|
// CHANNEL TAGGING (2026-07-04): the DT-008's "QRCode Output Prefix" / "Card Output
|
||||||
|
// Prefix" (vendor tool) mark which engine produced a push — Q: = optical, K: = RF.
|
||||||
|
// The route strips the prefix, tags the read's confirmed channel, and enrollment
|
||||||
|
// capture stores the BARE value. Unprefixed reads stay the legacy untagged shape so
|
||||||
|
// an unconfigured reader keeps working. These tests pin the route-side contract;
|
||||||
|
// the match-side enforcement is pinned in ../subscription-channel.test.ts.
|
||||||
|
|
||||||
|
const SERIAL = "H05MA5B0";
|
||||||
|
const READER_ID = "reader-exit";
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let capture: CredentialCapture;
|
||||||
|
let seen: DeviceReadEvent[];
|
||||||
|
|
||||||
|
/** Dispatcher stub: records the event the route built, always rejects. */
|
||||||
|
const fakeDispatcher = {
|
||||||
|
dispatch: async (e: DeviceReadEvent): Promise<ReadOutcome> => {
|
||||||
|
seen.push(e);
|
||||||
|
return { accepted: false, reason: "test" };
|
||||||
|
},
|
||||||
|
} as unknown as ReadDispatcher;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: READER_ID,
|
||||||
|
category: "reader",
|
||||||
|
driverId: "dingtian-qr-reader",
|
||||||
|
config: { serial: SERIAL },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
seen = [];
|
||||||
|
capture = new CredentialCapture();
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await qrReaderRoutes(app as never, db, fakeDispatcher, capture);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const scan = (cardid: string) =>
|
||||||
|
app.inject({ method: "GET", url: `/qa/mcardsea.php?cardid=${encodeURIComponent(cardid)}&cjihao=${SERIAL}&mjihao=1&status=10` });
|
||||||
|
|
||||||
|
describe("splitChannel", () => {
|
||||||
|
it("K: prefix → bare value, kind card, channel rf", () => {
|
||||||
|
expect(splitChannel("K:86A158")).toEqual({ value: "86A158", kind: "card", channel: "rf" });
|
||||||
|
});
|
||||||
|
it("Q: prefix → bare value, kind qr, channel optical", () => {
|
||||||
|
expect(splitChannel("Q:12345678901")).toEqual({ value: "12345678901", kind: "qr", channel: "optical" });
|
||||||
|
});
|
||||||
|
it("no prefix → value untouched, legacy untagged qr", () => {
|
||||||
|
expect(splitChannel("86A158")).toEqual({ value: "86A158", kind: "qr" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("qr-reader route channel tagging", () => {
|
||||||
|
it("card-prefixed push dispatches a stripped, rf-tagged read", async () => {
|
||||||
|
const res = await scan("K:86A158");
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen[0]).toMatchObject({ value: "86A158", kind: "card", channel: "rf", deviceId: READER_ID });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("qr-prefixed push dispatches a stripped, optical-tagged read", async () => {
|
||||||
|
await scan("Q:00000000000");
|
||||||
|
expect(seen[0]).toMatchObject({ value: "00000000000", kind: "qr", channel: "optical" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unprefixed push stays legacy: kind qr, no channel", async () => {
|
||||||
|
await scan("86A158");
|
||||||
|
expect(seen[0]).toMatchObject({ value: "86A158", kind: "qr" });
|
||||||
|
expect(seen[0].channel).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a bare prefix (empty value after strip) dispatches nothing", async () => {
|
||||||
|
await scan("K:");
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enrollment capture stores the BARE value, not the prefixed one", async () => {
|
||||||
|
capture.arm(READER_ID);
|
||||||
|
const res = await scan("K:86A158");
|
||||||
|
expect(seen).toHaveLength(0); // intercepted — never dispatched to the access flow
|
||||||
|
const state = capture.state();
|
||||||
|
expect(state.status).toBe("captured");
|
||||||
|
if (state.status === "captured") expect(state.value).toBe("86A158");
|
||||||
|
// Beeps "ok" so the operator knows the card was read.
|
||||||
|
expect(res.json().data[0].status).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,10 +4,10 @@ import type { DeviceReadEvent } from "../device-events.js";
|
|||||||
import type { ReadDispatcher } from "../read-dispatch.js";
|
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||||
import type { CredentialCapture } from "../credential-capture.js";
|
import type { CredentialCapture } from "../credential-capture.js";
|
||||||
|
|
||||||
// GEE/Dingtian QR reader endpoint. The reader is configured (vendor tool) with our
|
// Dingtian DT-008 QR/RFID reader endpoint. The reader is configured (vendor tool) with
|
||||||
// host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
// our host as its "server"; on each scan it sends an HTTP GET and BEEPS/acts based on
|
||||||
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
|
// our JSON reply — host-in-the-loop and synchronous. Protocol from the QRCode SDK
|
||||||
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/gee-qr-er80.md.
|
// v1.6.5; see wiki/sources/qrcode-sdk.md and wiki/entities/dingtian-dt008-reader.md.
|
||||||
//
|
//
|
||||||
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
|
// reader → GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2ch>&time=<utc>
|
||||||
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
|
// server → {"data":[{cardid,cjihao,mjihao,status,time,output}],"code":0,"message":""}
|
||||||
@@ -27,6 +27,36 @@ interface ReaderQuery {
|
|||||||
time?: string;
|
time?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CHANNEL TAGGING (2026-07-04) ────────────────────────────────────────────────
|
||||||
|
// The DT-008 push carries one opaque `cardid` whether its OPTICAL engine decoded a
|
||||||
|
// QR/barcode or its RF engine read a card — the server can't tell them apart. That
|
||||||
|
// enabled a cheap clone: print a card's UID (often written on the card face) as a
|
||||||
|
// barcode and the optical decode matches the RF credential. Fix: the vendor tool's
|
||||||
|
// "QRCode Output Prefix" / "Card Output Prefix" are set to the markers below on every
|
||||||
|
// reader; the route strips the prefix and tags the read's confirmed channel, and the
|
||||||
|
// subscription match refuses a channel-mismatched credential. A read with NO prefix
|
||||||
|
// stays the legacy untagged shape (kind "qr", channel undefined) so an unconfigured
|
||||||
|
// reader keeps working — the enforcement only bites where prefixes are deployed.
|
||||||
|
// ⚠️ Prefixes must MATCH the vendor tool; also FREEZE "Card Input format" (6H) — that
|
||||||
|
// setting defines the UID shape we enroll. See wiki/entities/dingtian-dt008-reader.md.
|
||||||
|
const QR_CHANNEL_PREFIX = process.env.READER_QR_PREFIX ?? "Q:";
|
||||||
|
const CARD_CHANNEL_PREFIX = process.env.READER_CARD_PREFIX ?? "K:";
|
||||||
|
|
||||||
|
/** Split a raw pushed `cardid` into its bare value + confirmed channel (if prefixed). */
|
||||||
|
export function splitChannel(raw: string): {
|
||||||
|
value: string;
|
||||||
|
kind: "qr" | "card";
|
||||||
|
channel?: "optical" | "rf";
|
||||||
|
} {
|
||||||
|
if (CARD_CHANNEL_PREFIX.length > 0 && raw.startsWith(CARD_CHANNEL_PREFIX)) {
|
||||||
|
return { value: raw.slice(CARD_CHANNEL_PREFIX.length), kind: "card", channel: "rf" };
|
||||||
|
}
|
||||||
|
if (QR_CHANNEL_PREFIX.length > 0 && raw.startsWith(QR_CHANNEL_PREFIX)) {
|
||||||
|
return { value: raw.slice(QR_CHANNEL_PREFIX.length), kind: "qr", channel: "optical" };
|
||||||
|
}
|
||||||
|
return { value: raw, kind: "qr" }; // legacy: unprefixed reader, channel unknown
|
||||||
|
}
|
||||||
|
|
||||||
export async function qrReaderRoutes(
|
export async function qrReaderRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -35,7 +65,7 @@ export async function qrReaderRoutes(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
// Resolve the lane_devices row whose config.serial matches the reader's reported
|
||||||
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
// serial (cjihao). The row id is a normal UUID; the serial is config the admin
|
||||||
// enters when assigning the gee-qr-reader. Returns the row id, or null if no
|
// enters when assigning the dingtian-qr-reader. Returns the row id, or null if no
|
||||||
// reader is assigned for that serial. (Small device set → scan in JS.)
|
// reader is assigned for that serial. (Small device set → scan in JS.)
|
||||||
const readerRowIdForSerial = (serial: string): string | null => {
|
const readerRowIdForSerial = (serial: string): string | null => {
|
||||||
if (!serial) return null;
|
if (!serial) return null;
|
||||||
@@ -52,9 +82,10 @@ export async function qrReaderRoutes(
|
|||||||
// drive output) once the socket CLOSES — every vendor demo replies
|
// drive output) once the socket CLOSES — every vendor demo replies
|
||||||
// `Connection: close` and shuts the socket. Without it the reader waits out a
|
// `Connection: close` and shuts the socket. Without it the reader waits out a
|
||||||
// ~10 s keep-alive timeout before beeping. So force-close the connection.
|
// ~10 s keep-alive timeout before beeping. So force-close the connection.
|
||||||
// See wiki/sources/qrcode-sdk.md, entities/gee-qr-er80.md.
|
// See wiki/sources/qrcode-sdk.md, entities/dingtian-dt008-reader.md.
|
||||||
reply.header("connection", "close");
|
reply.header("connection", "close");
|
||||||
const cardid = (q.cardid ?? "").trim();
|
const cardid = (q.cardid ?? "").trim();
|
||||||
|
const scan = splitChannel(cardid); // bare value + confirmed channel (if prefixed)
|
||||||
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
||||||
const serial = (q.cjihao ?? "").trim();
|
const serial = (q.cjihao ?? "").trim();
|
||||||
|
|
||||||
@@ -65,35 +96,37 @@ export async function qrReaderRoutes(
|
|||||||
const deviceId = matchedRowId ?? serial;
|
const deviceId = matchedRowId ?? serial;
|
||||||
|
|
||||||
let accepted = false;
|
let accepted = false;
|
||||||
if (cardid) {
|
if (scan.value) {
|
||||||
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
|
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
|
||||||
// value for the subscription form and do NOT run the access flow (we must not
|
// value for the subscription form and do NOT run the access flow (we must not
|
||||||
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
|
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
|
||||||
// Reads from the OTHER reader are untouched and dispatch normally below.
|
// Reads from the OTHER reader are untouched and dispatch normally below.
|
||||||
if (capture.tryConsume(deviceId, cardid)) {
|
// Captured BARE (prefix stripped) so enrolled values match future stripped reads.
|
||||||
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
|
if (capture.tryConsume(deviceId, scan.value)) {
|
||||||
|
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""}`);
|
||||||
accepted = true; // beep "ok" so the operator knows the card was read
|
accepted = true; // beep "ok" so the operator knows the card was read
|
||||||
} else {
|
} else {
|
||||||
const read: DeviceReadEvent = {
|
const read: DeviceReadEvent = {
|
||||||
driverId: "gee-qr-reader",
|
driverId: "dingtian-qr-reader",
|
||||||
deviceId,
|
deviceId,
|
||||||
value: cardid,
|
value: scan.value,
|
||||||
kind: "qr",
|
kind: scan.kind,
|
||||||
|
...(scan.channel ? { channel: scan.channel } : {}),
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const outcome = await dispatcher.dispatch(read);
|
const outcome = await dispatcher.dispatch(read);
|
||||||
accepted = outcome.accepted;
|
accepted = outcome.accepted;
|
||||||
// Per-read diagnostic: which reader (serial) sent it, which configured device
|
// Per-read diagnostic: which reader (serial) sent it, which configured device
|
||||||
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
|
// it mapped to, the confirmed channel (if prefixed), and the verdict — so a
|
||||||
// the logs (e.g. an entry-side scan resolving to the exit relay).
|
// barrier/serial mismatch or a channel anomaly is visible in the logs.
|
||||||
app.log.info(
|
app.log.info(
|
||||||
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
|
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
|
||||||
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
`card=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
||||||
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
|
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
app.log.error(`QR dispatch failed for ${scan.value}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const summary = reportSummary(db, parseQuery(req.query));
|
const summary = reportSummary(db, parseQuery(req.query));
|
||||||
const lines = [
|
const lines = [
|
||||||
"bucket,entries,exits,payments,revenue",
|
"bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
|
||||||
...summary.series.map((p) =>
|
...summary.series.map((p) =>
|
||||||
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
|
[
|
||||||
|
p.bucket,
|
||||||
|
p.entries,
|
||||||
|
p.exits,
|
||||||
|
p.payments,
|
||||||
|
(p.revenueMinor / 100).toFixed(2),
|
||||||
|
(p.cashMinor / 100).toFixed(2),
|
||||||
|
(p.cardMinor / 100).toFixed(2),
|
||||||
|
p.occupancyEnd,
|
||||||
|
].join(","),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
reply
|
reply
|
||||||
|
|||||||
@@ -56,6 +56,37 @@ describe("auth guard — no token", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/version", () => {
|
||||||
|
it("without a session is 401", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/version" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a site:read user gets the BUILD_VERSION env var, null when unset", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer2", roleId: "viewer2", permissions: ["site:read"],
|
||||||
|
});
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ buildVersion: null }); // no BUILD_VERSION set in the test env
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects a real BUILD_VERSION when the env var is set", async () => {
|
||||||
|
process.env.BUILD_VERSION = "stage-abc1234";
|
||||||
|
try {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer3", roleId: "viewer3", permissions: ["site:read"],
|
||||||
|
});
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||||
|
expect(res.json()).toEqual({ buildVersion: "stage-abc1234" });
|
||||||
|
} finally {
|
||||||
|
delete process.env.BUILD_VERSION;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("RBAC permission gate", () => {
|
describe("RBAC permission gate", () => {
|
||||||
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||||
const { username, password } = await seedUser(db, {
|
const { username, password } = await seedUser(db, {
|
||||||
@@ -101,3 +132,21 @@ describe("CSRF double-submit on mutations", () => {
|
|||||||
expect(put.statusCode).toBe(403);
|
expect(put.statusCode).toBe(403);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("drawer balance (the till NOW)", () => {
|
||||||
|
it("shift:read gets the balance; a role without it is 403; no auth 401", async () => {
|
||||||
|
const anon = await app.inject({ method: "GET", url: "/api/drawer/balance" });
|
||||||
|
expect(anon.statusCode).toBe(401);
|
||||||
|
|
||||||
|
const viewer = await seedUser(db, { username: "till", roleId: "till", permissions: ["shift:read"] });
|
||||||
|
const { cookie } = await login(app, viewer.username, viewer.password);
|
||||||
|
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
|
||||||
|
expect(ok.statusCode).toBe(200);
|
||||||
|
expect(ok.json()).toEqual({ balanceMinor: 0, currency: null });
|
||||||
|
|
||||||
|
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
|
||||||
|
const other = await login(app, outsider.username, outsider.password);
|
||||||
|
const denied = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie: other.cookie } });
|
||||||
|
expect(denied.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { devices, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// POST /api/setup/test-relay pulses a SAVED controller's barrier relay to prove the
|
||||||
|
// wiring — it physically opens the barrier. Because "a physical open with no matching
|
||||||
|
// signed command is the fraud signal" (append-only-event-chain / reconciliation), the
|
||||||
|
// route must SIGN a barrier_open_command (reason setup.relayTest) BEFORE it fires, and it
|
||||||
|
// must be admin-only. These tests use the `stub-access` controller (pulseOpen only logs —
|
||||||
|
// no real hardware) so they exercise the validate → sign → pulse path safely.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
const CTL = "ctl-stub";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: CTL,
|
||||||
|
category: "access",
|
||||||
|
driverId: "stub-access",
|
||||||
|
config: { relays: [{ relay: 1, direction: "entry" }, { relay: 2, direction: "exit" }] },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function pulse(
|
||||||
|
body: unknown,
|
||||||
|
auth?: { cookie: string; csrf: string },
|
||||||
|
) {
|
||||||
|
return app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/setup/test-relay",
|
||||||
|
headers: auth ? { cookie: auth.cookie, "x-csrf-token": auth.csrf } : {},
|
||||||
|
payload: body as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/setup/test-relay", () => {
|
||||||
|
it("is admin-only: a non-site:update user is 403", async () => {
|
||||||
|
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
|
||||||
|
const auth = await login(app, "op", "pw");
|
||||||
|
const res = await pulse({ id: CTL, relay: 1 }, auth);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires CSRF on the mutation", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const { cookie } = await login(app, "admin", "pw");
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/setup/test-relay",
|
||||||
|
headers: { cookie }, // no x-csrf-token
|
||||||
|
payload: { id: CTL, relay: 1 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs a barrier_open_command (reason setup.relayTest) BEFORE firing, then reports ok", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
|
||||||
|
const res = await pulse({ id: CTL, relay: 2 }, auth);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
// The deliberate open is EXPLAINED in the signed ledger — not an anomaly.
|
||||||
|
const rows = db.select().from(ledgerEvents).all();
|
||||||
|
const testOpen = rows.find((r) => r.type === "barrier_open_command");
|
||||||
|
expect(testOpen, "a barrier_open_command must be signed").toBeTruthy();
|
||||||
|
expect(testOpen!.source).toBe("manual"); // deliberate human action
|
||||||
|
expect(testOpen!.signature.length).toBeGreaterThan(0);
|
||||||
|
const payload = testOpen!.payload as Record<string, unknown>;
|
||||||
|
expect(payload.relayTest).toBe(true);
|
||||||
|
expect(payload.reasonCode).toBe("setup.relayTest");
|
||||||
|
expect(payload.relay).toBe(2);
|
||||||
|
expect(payload.controllerId).toBe(CTL);
|
||||||
|
expect(payload.operator).toBe("admin"); // attributed to the acting admin
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a relay the controller does not declare (400, no ledger row)", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
const res = await pulse({ id: CTL, relay: 9 }, auth);
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // nothing signed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s an unknown controller id", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
const res = await pulse({ id: "nope", relay: 1 }, auth);
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a bad relay value (non-positive-integer)", async () => {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw" });
|
||||||
|
const auth = await login(app, "admin", "pw");
|
||||||
|
expect((await pulse({ id: CTL, relay: 0 }, auth)).statusCode).toBe(400);
|
||||||
|
expect((await pulse({ id: CTL, relay: -1 }, auth)).statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { devices, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { storedSecrets } from "./setup.js";
|
||||||
|
|
||||||
|
// storedSecrets re-merges a device's machine-only secrets (relayPassword/pushPassword)
|
||||||
|
// into a test/save — but ONLY when the submitted config addresses the SAME device at the
|
||||||
|
// SAME host/port. This guards against a redirected probe exfiltrating the secret to an
|
||||||
|
// attacker host (an admin keeps a real device id but swaps the host). The booth operator
|
||||||
|
// is the threat-model adversary, so an authenticated-admin redirect must NOT leak.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const ID = "ctl-secret";
|
||||||
|
const HOST = "10.0.10.5";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(devices).values({
|
||||||
|
id: ID,
|
||||||
|
category: "access",
|
||||||
|
driverId: "dingtian",
|
||||||
|
config: { host: HOST, binaryPort: 60000, relayPassword: 1996, pushPassword: "p-secret" },
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storedSecrets identity guard", () => {
|
||||||
|
it("re-merges secrets when host/port/driver match the stored device", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 60000 });
|
||||||
|
expect(out.relayPassword).toBe(1996);
|
||||||
|
expect(out.pushPassword).toBe("p-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-merges when identity fields are OMITTED (fall back to the stored device)", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", {});
|
||||||
|
expect(out.relayPassword).toBe(1996);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when the host is redirected (exfiltration attempt)", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: "10.66.66.66", binaryPort: 60000 });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when a control port is changed", () => {
|
||||||
|
const out = storedSecrets(db, ID, "dingtian", { host: HOST, binaryPort: 9999 });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REFUSES secrets when the driver doesn't match the stored row", () => {
|
||||||
|
const out = storedSecrets(db, ID, "stub-access", { host: HOST });
|
||||||
|
expect(out).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for an unknown device id", () => {
|
||||||
|
expect(storedSecrets(db, randomUUID(), "dingtian", { host: HOST })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
isCamera,
|
isCamera,
|
||||||
isDiscoverable,
|
isDiscoverable,
|
||||||
isHardenable,
|
isHardenable,
|
||||||
|
isPrinter,
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
@@ -14,7 +15,9 @@ import {
|
|||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
type DeviceConfig,
|
type DeviceConfig,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
|
import { reasonPayload } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||||
import type { VisionClient } from "../vision-client.js";
|
import type { VisionClient } from "../vision-client.js";
|
||||||
|
|
||||||
@@ -36,6 +39,11 @@ interface AssignBody {
|
|||||||
interface TestBody {
|
interface TestBody {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
config: Record<string, string | number | boolean>;
|
config: Record<string, string | number | boolean>;
|
||||||
|
/** When editing an EXISTING device, its id — so the test re-merges the stored
|
||||||
|
* machine secrets (relayPassword/pushPassword) the client never received. Without
|
||||||
|
* this, testing an edited device would send no relay password → the device ignores
|
||||||
|
* the probe → a false "offline". Omitted when testing a brand-new device. */
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
||||||
@@ -54,6 +62,47 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Feature-detect the barrier-pulse capability on a built device adapter (the Setup
|
||||||
|
* relay test needs it; a stub/reader/camera won't have it). */
|
||||||
|
function hasPulseOpen(d: unknown): d is { pulseOpen(doorId: number): Promise<void> } {
|
||||||
|
return typeof (d as { pulseOpen?: unknown } | null)?.pulseOpen === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
|
||||||
|
// secret may only be re-merged when these match the stored row — otherwise an admin
|
||||||
|
// could point a test at an attacker host while keeping a real device id and have the
|
||||||
|
// secret sent there (exfiltration). host/port/binaryPort/httpPort cover the Dingtian's
|
||||||
|
// UDP + CGI targets; serial covers serial-bound readers.
|
||||||
|
const IDENTITY_KEYS = ["host", "port", "binaryPort", "httpPort", "serial"] as const;
|
||||||
|
|
||||||
|
/** Stored machine-only secrets (relayPassword/pushPassword) for a device `id`, but ONLY
|
||||||
|
* when the submitted config addresses the SAME device — same driver, and every
|
||||||
|
* connection-identity field (host/port/…) that the submitted config sets equals the
|
||||||
|
* stored value. If the admin redirected the probe (different host/port) or the driver
|
||||||
|
* doesn't match, NO secret is returned: they must re-enter it explicitly. This stops a
|
||||||
|
* redirected test from exfiltrating the secret to an attacker host. */
|
||||||
|
export function storedSecrets(
|
||||||
|
db: Db,
|
||||||
|
id: string,
|
||||||
|
driverId: string,
|
||||||
|
submitted: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||||
|
if (!row || row.driverId !== driverId) return {};
|
||||||
|
const cfg = row.config as Record<string, unknown>;
|
||||||
|
// Any identity field the client SENT must equal the stored value. (A field the client
|
||||||
|
// omits falls back to the stored device, so it can't be used to redirect.)
|
||||||
|
for (const k of IDENTITY_KEYS) {
|
||||||
|
const sent = submitted[k];
|
||||||
|
if (sent !== undefined && sent !== "" && String(sent) !== String(cfg[k] ?? "")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const k of SECRET_CONFIG_KEYS) if (cfg[k] !== undefined) out[k] = cfg[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
/** Result of the device configure pipeline: a ready-to-persist config, or an
|
||||||
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
* HTTP error to send back. Shared by assign (create) and patch (edit). */
|
||||||
type ConfigureOutcome =
|
type ConfigureOutcome =
|
||||||
@@ -179,6 +228,7 @@ export async function setupRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
vision?: VisionClient | null,
|
vision?: VisionClient | null,
|
||||||
|
eventLog?: EventLog | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
@@ -249,13 +299,30 @@ export async function setupRoutes(
|
|||||||
"/api/setup/test",
|
"/api/setup/test",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { driverId, config } = req.body;
|
const { driverId, config, id } = req.body;
|
||||||
const driver = registry.get(driverId);
|
const driver = registry.get(driverId);
|
||||||
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
|
||||||
|
// When editing an existing device, re-merge its stored machine secrets (e.g.
|
||||||
|
// relayPassword) — redacted from the client, so the submitted config omits them.
|
||||||
|
// Submitted values win (an admin can override), but a blank/0 field falls back to
|
||||||
|
// the stored secret so the probe authenticates. Without this, an edited Dingtian
|
||||||
|
// tests with no relay password → false "offline". The submitted-value-wins rule:
|
||||||
|
// only fill a secret from the store when the form didn't send a real one.
|
||||||
|
// Re-merge stored secrets ONLY when this addresses the same device at the same
|
||||||
|
// host/port (storedSecrets enforces identity) — so a redirected probe can't leak
|
||||||
|
// the secret to an attacker host. Submitted values still win.
|
||||||
|
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||||
|
if (id) {
|
||||||
|
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||||
|
const sent = merged[k];
|
||||||
|
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let device;
|
let device;
|
||||||
try {
|
try {
|
||||||
device = registry.create(driverId, config);
|
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.code(400).send({ error: (err as Error).message });
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
@@ -334,6 +401,153 @@ export async function setupRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Print a TEST SLIP on a printer config WITHOUT saving. healthCheck only opens the
|
||||||
|
// transport (TCP connect / USB open) — it proves reachability, NOT that paper feeds
|
||||||
|
// and the head fires. This pushes a real short slip through the device-agnostic
|
||||||
|
// printReport(), so the admin can physically confirm the printer is live (the USB
|
||||||
|
// /dev/usb/lpN path or the network printer). Fail-soft like test-anpr: a print error
|
||||||
|
// is reported, never a 500. Mirrors /test's stored-secret re-merge so an edited
|
||||||
|
// network printer still authenticates.
|
||||||
|
app.post<{ Body: TestBody }>(
|
||||||
|
"/api/setup/test-print",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { driverId, config, id } = req.body;
|
||||||
|
const driver = registry.get(driverId);
|
||||||
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
if (driver.category !== "printer") {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} is not a printer` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: Record<string, string | number | boolean | undefined> = { ...config };
|
||||||
|
if (id) {
|
||||||
|
for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) {
|
||||||
|
const sent = merged[k];
|
||||||
|
if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(driverId, merged as Record<string, string | number | boolean>);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!isPrinter(device)) {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} cannot print` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await device.printReport({
|
||||||
|
title: "TEST PRINT",
|
||||||
|
lines: [
|
||||||
|
"Parking System",
|
||||||
|
"Printer test slip",
|
||||||
|
new Date().toLocaleString("sv"), // YYYY-MM-DD HH:MM:SS, locale-stable
|
||||||
|
"",
|
||||||
|
"If you can read this, the",
|
||||||
|
"printer is connected and",
|
||||||
|
"printing correctly.",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// The failure we're testing for (paper out, head fault, transport drop) —
|
||||||
|
// report it, don't 500.
|
||||||
|
return reply.send({
|
||||||
|
ok: false,
|
||||||
|
reason: "print-failed",
|
||||||
|
detail: (err as Error).message,
|
||||||
|
tookMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true, tookMs: Date.now() - startedAt });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// PULSE a controller's barrier relay from Setup, to test the wiring — WITHOUT any
|
||||||
|
// vehicle/session. This physically opens the barrier, so unlike the other tests it
|
||||||
|
// runs only against a SAVED controller (real id → clean attribution) and it SIGNS a
|
||||||
|
// `barrier_open_command` into the ledger FIRST, with reason `setup.relayTest` + the
|
||||||
|
// admin's identity. That is the whole point of doing it this way: a physical open with
|
||||||
|
// no matching signed command is the fraud signal ([[append-only-event-chain]],
|
||||||
|
// [[reconciliation]]) — a deliberate test must therefore be an EXPLAINED open, not a
|
||||||
|
// silent one. Sign-before-fire mirrors exit-flow's manual re-open: the intervention is
|
||||||
|
// recorded whether or not the physical pulse then succeeds. Admin-only (site:update).
|
||||||
|
app.post<{ Body: { id: string; relay: number } }>(
|
||||||
|
"/api/setup/test-relay",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { id, relay } = req.body;
|
||||||
|
if (typeof id !== "string" || !id) return reply.code(400).send({ error: "missing controller id" });
|
||||||
|
if (!Number.isInteger(relay) || relay < 1) {
|
||||||
|
return reply.code(400).send({ error: "relay must be a 1-based channel number" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A relay test fires REAL hardware, so it must target a persisted controller — no
|
||||||
|
// firing an unsaved/redirected config (that would let a probe open an arbitrary host's
|
||||||
|
// barrier). Load the saved row and build straight from its stored config (relayPassword
|
||||||
|
// included — it's on the row, never in the request).
|
||||||
|
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||||
|
if (!row) return reply.code(404).send({ error: "controller not found" });
|
||||||
|
if (row.category !== "access") {
|
||||||
|
return reply.code(400).send({ error: `device ${id} is not a controller` });
|
||||||
|
}
|
||||||
|
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
||||||
|
const relays = Array.isArray(cfg.relays) ? (cfg.relays as { relay?: number }[]) : [];
|
||||||
|
if (!relays.some((r) => r.relay === relay)) {
|
||||||
|
return reply.code(400).send({ error: `controller ${id} has no relay ${relay}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(row.driverId, cfg as Record<string, string | number | boolean>);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!hasPulseOpen(device)) {
|
||||||
|
return reply.code(400).send({ error: `driver ${row.driverId} cannot pulse a relay` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the deliberate open FIRST — recorded whether or not the physical pulse then
|
||||||
|
// succeeds. Skip only if no ledger is wired (test/degraded), in which case we still
|
||||||
|
// refuse rather than fire an unrecorded open.
|
||||||
|
const operator = req.user?.username ?? "unknown";
|
||||||
|
if (!eventLog) {
|
||||||
|
return reply.code(503).send({ error: "ledger unavailable — refusing an unrecorded relay open" });
|
||||||
|
}
|
||||||
|
await eventLog.append({
|
||||||
|
type: "barrier_open_command",
|
||||||
|
// A deliberate human action from the admin console → "manual" (the top-level
|
||||||
|
// IdentitySource). The relayTest marker + reason distinguish it in the payload.
|
||||||
|
source: "manual",
|
||||||
|
identity: `relay-test:${id}:${relay}`,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("setup.relayTest", { operator, relay, controller: row.driverId }),
|
||||||
|
relayTest: true,
|
||||||
|
controllerId: id,
|
||||||
|
relay,
|
||||||
|
operator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await device.pulseOpen(relay);
|
||||||
|
} catch (err) {
|
||||||
|
// The failure we're testing for (relay unreachable, wrong password). The open is
|
||||||
|
// already signed; report the pulse failure, don't 500.
|
||||||
|
return reply.send({
|
||||||
|
ok: false,
|
||||||
|
reason: "pulse-failed",
|
||||||
|
detail: (err as Error).message,
|
||||||
|
tookMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true, firedAt: new Date().toISOString(), tookMs: Date.now() - startedAt });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -346,6 +560,39 @@ export async function setupRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// USB printers PRESENT on the box: enumerate /dev/usb/lpN (the usblp nodes the
|
||||||
|
// container sees via the /dev/usb bind-mount) and enrich each with the printer's
|
||||||
|
// self-reported make/model from sysfs (ieee1284_id — readable through Docker's
|
||||||
|
// default ro /sys). The wizard offers these as a SELECT so the admin never has to
|
||||||
|
// shell in and `ls /dev/usb` to learn the kernel picked lp1 (field friction,
|
||||||
|
// park-buzi 2026-07-07). Empty list = no usblp printer plugged/visible.
|
||||||
|
app.get("/api/setup/usb-printers", { preHandler: adminGuard }, async () => {
|
||||||
|
const { readdir, readFile } = await import("node:fs/promises");
|
||||||
|
let names: string[] = [];
|
||||||
|
try {
|
||||||
|
names = (await readdir("/dev/usb")).filter((n) => /^lp\d+$/.test(n)).sort();
|
||||||
|
} catch {
|
||||||
|
return { printers: [] }; // no /dev/usb at all — nothing plugged (or no mount)
|
||||||
|
}
|
||||||
|
const printers = await Promise.all(
|
||||||
|
names.map(async (n) => {
|
||||||
|
// ieee1284_id: "MFG:Xprinter;CMD:ESCPOS;MDL:XP-K200L;…" — best-effort.
|
||||||
|
let description: string | null = null;
|
||||||
|
try {
|
||||||
|
const id = await readFile(`/sys/class/usbmisc/${n}/device/ieee1284_id`, "utf8");
|
||||||
|
const pick = (key: string) => id.match(new RegExp(`(?:^|;)\\s*${key}:([^;]+)`, "i"))?.[1]?.trim();
|
||||||
|
const mfg = pick("MFG") ?? pick("MANUFACTURER");
|
||||||
|
const mdl = pick("MDL") ?? pick("MODEL");
|
||||||
|
description = [mfg, mdl].filter(Boolean).join(" ") || null;
|
||||||
|
} catch {
|
||||||
|
/* sysfs not readable / attribute absent — path alone is still useful */
|
||||||
|
}
|
||||||
|
return { path: `/dev/usb/${n}`, description };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { printers };
|
||||||
|
});
|
||||||
|
|
||||||
// Assign a device. Validates the chosen driver + config, configures the device
|
// Assign a device. Validates the chosen driver + config, configures the device
|
||||||
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
||||||
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
||||||
|
|||||||
@@ -1,27 +1,6 @@
|
|||||||
import bcrypt from "bcrypt";
|
|
||||||
import { eq, users, type Db } from "@parking/db";
|
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
import {
|
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
|
||||||
InvalidCashMovementError,
|
|
||||||
NoOpenShiftError,
|
|
||||||
ShiftAlreadyOpenError,
|
|
||||||
type ShiftService,
|
|
||||||
} from "../shift-service.js";
|
|
||||||
|
|
||||||
interface CashVoucherBody {
|
|
||||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
|
||||||
* cash_out = Mandat Pagese (pay-OUT). */
|
|
||||||
type: "cash_in" | "cash_out";
|
|
||||||
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
|
||||||
amountMinor: number;
|
|
||||||
reason?: string;
|
|
||||||
currency?: string;
|
|
||||||
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
|
|
||||||
authorizedBy: string;
|
|
||||||
/** That admin's password — re-entered to sign off on the drawer movement. */
|
|
||||||
authorizerPassword: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShiftsQuery {
|
interface ShiftsQuery {
|
||||||
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
||||||
@@ -35,7 +14,7 @@ interface ShiftsQuery {
|
|||||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||||
|
|
||||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
|
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||||
// Reading the shift state vs. opening/closing one's own shift.
|
// Reading the shift state vs. opening/closing one's own shift.
|
||||||
const readGuard = requirePermission("shift:read");
|
const readGuard = requirePermission("shift:read");
|
||||||
const guard = requirePermission("shift:create");
|
const guard = requirePermission("shift:create");
|
||||||
@@ -84,52 +63,14 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db:
|
|||||||
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||||
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||||
const shifts = shift.listShifts({ operator, from, to });
|
const shifts = shift.listShifts({ operator, from, to });
|
||||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
// Admins also get the distinct operator list (unfiltered) for the filter
|
||||||
|
// dropdown — operators don't see other names, so it's scope-gated.
|
||||||
|
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() };
|
||||||
|
return { shifts, scope: "self" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||||
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
|
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
||||||
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
|
|
||||||
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
|
|
||||||
// (`shift:cash`) who re-enters their password. This keeps the float control —
|
|
||||||
// an operator cannot move the float alone — while letting them raise the slip.
|
|
||||||
// See wiki/concepts/shift.md.
|
|
||||||
app.post<{ Body: CashVoucherBody }>(
|
|
||||||
"/api/cash-voucher",
|
|
||||||
{ preHandler: guard },
|
|
||||||
async (req, reply) => {
|
|
||||||
const b = req.body ?? ({} as CashVoucherBody);
|
|
||||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
|
||||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
|
||||||
}
|
|
||||||
const authName = (b.authorizedBy ?? "").trim();
|
|
||||||
if (!authName || !b.authorizerPassword) {
|
|
||||||
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
|
|
||||||
}
|
|
||||||
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
|
|
||||||
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
|
|
||||||
// Always run a bcrypt compare (constant-time wrt whether the user exists).
|
|
||||||
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
|
||||||
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
|
|
||||||
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
|
|
||||||
if (!authUser || !passwordOk || !isAdminGrade) {
|
|
||||||
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await shift.recordVoucher({
|
|
||||||
type: b.type,
|
|
||||||
operator: req.user.username, // who RAISED it
|
|
||||||
authorizedBy: authUser.username, // who signed off (canonical case)
|
|
||||||
amountMinor: b.amountMinor,
|
|
||||||
reason: b.reason ?? "",
|
|
||||||
currency: b.currency,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
|
||||||
return reply.code(500).send({ error: (err as Error).message });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, siteConfig, type Db } from "@parking/db";
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
import { getOccupancy } from "../occupancy.js";
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
||||||
@@ -38,13 +39,15 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||||
* + every metadata field. */
|
* + the entry presence-bypass flags + every metadata field. */
|
||||||
type SiteConfig = {
|
type SiteConfig = {
|
||||||
capacity: number | null;
|
capacity: number | null;
|
||||||
exitVoucherDefault: boolean;
|
exitVoucherDefault: boolean;
|
||||||
subscriptionMonthlyPriceMinor: number | null;
|
subscriptionMonthlyPriceMinor: number | null;
|
||||||
reserveSubscriberSpots: boolean;
|
reserveSubscriberSpots: boolean;
|
||||||
anprEntryEnabled: boolean;
|
anprEntryEnabled: boolean;
|
||||||
|
bypassPresenceRadar: boolean;
|
||||||
|
bypassPresenceCamera: 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 {
|
||||||
@@ -54,6 +57,8 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
|||||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||||
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
||||||
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
||||||
|
bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
|
||||||
|
bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
|
||||||
} 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;
|
||||||
@@ -66,13 +71,22 @@ function normText(v: unknown): string | null {
|
|||||||
return s === "" ? null : s;
|
return s === "" ? null : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog | null): Promise<void> {
|
||||||
const readGuard = requirePermission("site:read");
|
const readGuard = requirePermission("site:read");
|
||||||
const writeGuard = requirePermission("site:update");
|
const writeGuard = requirePermission("site:update");
|
||||||
|
|
||||||
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||||
|
|
||||||
|
// Running build version ("<branch>-<short-sha>", matching the Komodo Stack's TAG in
|
||||||
|
// komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile
|
||||||
|
// BUILD_VERSION ARG), read here from the running process env. null on a local/dev
|
||||||
|
// build with no CI-supplied value. Purely informational (Setup nav display); not
|
||||||
|
// site config, so it isn't stored in site_config.
|
||||||
|
app.get("/api/version", { preHandler: readGuard }, async () => ({
|
||||||
|
buildVersion: process.env.BUILD_VERSION?.trim() || null,
|
||||||
|
}));
|
||||||
|
|
||||||
// Read site config (capacity + park metadata).
|
// Read site config (capacity + park metadata).
|
||||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
@@ -131,4 +145,64 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
return toSiteConfig(row);
|
return toSiteConfig(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Entry presence-gate BYPASS — a DEDICATED, SIGNED endpoint (not the generic PUT above),
|
||||||
|
// because dropping a radar/camera requirement weakens an anti-fraud gate. The admin is not
|
||||||
|
// the adversary (a faulty device blocks legit entry until support fixes it), but the change
|
||||||
|
// must be attributed + auditable: each toggled signal appends a signed `config_change`
|
||||||
|
// {setting, value, prev, operator}. Granular per signal. See wiki/concepts/entry-presence-bypass.md.
|
||||||
|
app.put<{ Body: { radar?: boolean; camera?: boolean } }>(
|
||||||
|
"/api/site-config/presence-bypass",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = req.body ?? {};
|
||||||
|
for (const k of ["radar", "camera"] as const) {
|
||||||
|
if (k in body && typeof body[k] !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: `${k} must be a boolean` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!("radar" in body) && !("camera" in body)) {
|
||||||
|
return reply.code(400).send({ error: "nothing to change (send radar and/or camera)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const prev = {
|
||||||
|
radar: existing?.bypassPresenceRadar ?? false,
|
||||||
|
camera: existing?.bypassPresenceCamera ?? false,
|
||||||
|
};
|
||||||
|
const next = {
|
||||||
|
radar: "radar" in body ? (body.radar as boolean) : prev.radar,
|
||||||
|
camera: "camera" in body ? (body.camera as boolean) : prev.camera,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sign a config_change for each signal that ACTUALLY changed (before persisting, so the
|
||||||
|
// audit record exists whether or not a later write hiccups). No-op toggles sign nothing.
|
||||||
|
const operator = req.user?.username ?? "unknown";
|
||||||
|
for (const signal of ["radar", "camera"] as const) {
|
||||||
|
if (next[signal] !== prev[signal]) {
|
||||||
|
await eventLog?.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: `presence-bypass:${signal}`,
|
||||||
|
payload: {
|
||||||
|
setting: `entryPresenceBypass.${signal}`,
|
||||||
|
value: next[signal],
|
||||||
|
prev: prev[signal],
|
||||||
|
operator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const patch = { bypassPresenceRadar: next.radar, bypassPresenceCamera: next.camera, updatedAt };
|
||||||
|
if (existing) {
|
||||||
|
db.update(siteConfig).set(patch).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, ...patch }).run();
|
||||||
|
}
|
||||||
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return toSiteConfig(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { cleanType } from "../snapshot.js";
|
||||||
|
|
||||||
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
||||||
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
|
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
|
||||||
@@ -116,7 +117,10 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
|
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
|
||||||
if (!row) return reply.code(404).send({ error: "no such snapshot" });
|
if (!row) return reply.code(404).send({ error: "no such snapshot" });
|
||||||
reply.header("content-type", row.contentType);
|
// Normalize on the way OUT too: legacy rows stored a camera's malformed
|
||||||
|
// `image/jpeg; charset="UTF-8"`, which browsers refuse to render. cleanType strips
|
||||||
|
// the bogus params back to a bare `image/jpeg` so every stored image displays.
|
||||||
|
reply.header("content-type", cleanType(row.contentType));
|
||||||
reply.header("cache-control", "private, max-age=31536000, immutable");
|
reply.header("cache-control", "private, max-age=31536000, immutable");
|
||||||
return reply.send(row.bytes);
|
return reply.send(row.bytes);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// Tariff-lab drafts: the MUTABLE experiment scratchpad next to the immutable
|
||||||
|
// published versions. The contract under test: drafts are validated + tz-stamped on
|
||||||
|
// save exactly like a publish (so "publish this draft" can never fail on a card that
|
||||||
|
// saved fine), mutations need tariff:update, and publishing a draft goes through the
|
||||||
|
// normal immutable-version path untouched.
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
const V1_STRUCTURE = {
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }],
|
||||||
|
dailyCapMinor: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A V2 card with a night package — tz left blank on purpose: the server must stamp it.
|
||||||
|
const V2_STRUCTURE = {
|
||||||
|
version: 2,
|
||||||
|
tz: "",
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null },
|
||||||
|
windowedCards: [{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function editor() {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "editor",
|
||||||
|
roleId: "editor",
|
||||||
|
permissions: ["tariff:read", "tariff:update"],
|
||||||
|
});
|
||||||
|
return login(app, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tariff drafts", () => {
|
||||||
|
it("requires auth", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/tariff/drafts" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a tariff:read-only user can list but not create", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer",
|
||||||
|
roleId: "viewer",
|
||||||
|
permissions: ["tariff:read"],
|
||||||
|
});
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||||
|
expect(list.statusCode).toBe(200);
|
||||||
|
expect(list.json().drafts).toEqual([]);
|
||||||
|
|
||||||
|
const create = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/drafts",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { name: "x", currency: "ALL", structure: V1_STRUCTURE },
|
||||||
|
});
|
||||||
|
expect(create.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create → list → update → delete roundtrip", async () => {
|
||||||
|
const { cookie, csrf } = await editor();
|
||||||
|
const headers = { cookie, "x-csrf-token": csrf };
|
||||||
|
|
||||||
|
const create = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/drafts",
|
||||||
|
headers,
|
||||||
|
payload: { name: "Winter proposal", currency: "all", structure: V1_STRUCTURE },
|
||||||
|
});
|
||||||
|
expect(create.statusCode).toBe(201);
|
||||||
|
const draft = create.json();
|
||||||
|
expect(draft.name).toBe("Winter proposal");
|
||||||
|
expect(draft.currency).toBe("ALL"); // normalised to upper case
|
||||||
|
expect(draft.createdBy).toBe("editor");
|
||||||
|
|
||||||
|
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||||
|
expect(list.json().drafts).toHaveLength(1);
|
||||||
|
|
||||||
|
const update = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/tariff/drafts/${draft.id}`,
|
||||||
|
headers,
|
||||||
|
payload: { name: "Winter v2", currency: "ALL", structure: V1_STRUCTURE },
|
||||||
|
});
|
||||||
|
expect(update.statusCode).toBe(200);
|
||||||
|
expect(update.json().name).toBe("Winter v2");
|
||||||
|
|
||||||
|
const del = await app.inject({ method: "DELETE", url: `/api/tariff/drafts/${draft.id}`, headers });
|
||||||
|
expect(del.statusCode).toBe(204);
|
||||||
|
const after = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||||
|
expect(after.json().drafts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid structure with problems (validated like a publish)", async () => {
|
||||||
|
const { cookie, csrf } = await editor();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/drafts",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { name: "broken", currency: "ALL", structure: { ...V1_STRUCTURE, blocks: [] } },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().problems?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stamps the site timezone on a V2 draft, and the draft simulates + publishes as-is", async () => {
|
||||||
|
const { cookie, csrf } = await editor();
|
||||||
|
const headers = { cookie, "x-csrf-token": csrf };
|
||||||
|
|
||||||
|
const create = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/drafts",
|
||||||
|
headers,
|
||||||
|
payload: { name: "Night package", currency: "ALL", structure: V2_STRUCTURE },
|
||||||
|
});
|
||||||
|
expect(create.statusCode).toBe(201);
|
||||||
|
const draft = create.json();
|
||||||
|
expect(draft.structure.tz).toBe("Europe/Tirane");
|
||||||
|
|
||||||
|
// The lab prices the draft by sending its stored structure inline.
|
||||||
|
const sim = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/simulate",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
enteredAt: "2026-07-03T21:00:00.000+02:00",
|
||||||
|
asOf: "2026-07-03T23:00:00.000+02:00",
|
||||||
|
structure: draft.structure,
|
||||||
|
currency: draft.currency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(sim.statusCode).toBe(200);
|
||||||
|
expect(sim.json().pricing.amountMinor).toBe(40000); // one night package
|
||||||
|
|
||||||
|
// "Publish this draft" = the normal immutable-version path with the draft's card;
|
||||||
|
// the draft's name rides along as the version's optional label.
|
||||||
|
const publish = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tariff/versions",
|
||||||
|
headers,
|
||||||
|
payload: { currency: draft.currency, structure: draft.structure, name: draft.name },
|
||||||
|
});
|
||||||
|
expect(publish.statusCode).toBe(201);
|
||||||
|
const state = await app.inject({ method: "GET", url: "/api/tariff", headers: { cookie } });
|
||||||
|
expect(state.json().active?.name).toBe("Night package");
|
||||||
|
expect(state.json().active?.structure?.windowedCards?.[0]?.packageMinor).toBe(40000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
|
explainFee,
|
||||||
isTariffV2,
|
isTariffV2,
|
||||||
priceSession,
|
priceSession,
|
||||||
validateTariffStructure,
|
validateTariffStructure,
|
||||||
@@ -25,10 +26,19 @@ interface PublishBody {
|
|||||||
structure: TariffStructure;
|
structure: TariffStructure;
|
||||||
/** When this version takes effect (ISO-8601). Defaults to now. */
|
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||||
effectiveFrom?: string;
|
effectiveFrom?: string;
|
||||||
|
/** Optional human label (e.g. carried from the lab draft being published). */
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SITE_TARIFF_NAME = "Site tariff";
|
const SITE_TARIFF_NAME = "Site tariff";
|
||||||
|
|
||||||
|
/** Body for saving a lab draft (create + update share the shape). */
|
||||||
|
interface DraftBody {
|
||||||
|
name: string;
|
||||||
|
currency: string;
|
||||||
|
structure: TariffStructure;
|
||||||
|
}
|
||||||
|
|
||||||
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
|
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
|
||||||
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
|
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
|
||||||
* neither (uses the active version). */
|
* neither (uses the active version). */
|
||||||
@@ -80,19 +90,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
"/api/tariff/versions",
|
"/api/tariff/versions",
|
||||||
{ preHandler: writeGuard },
|
{ preHandler: writeGuard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
|
const { currency, structure, effectiveFrom, name } = req.body ?? ({} as PublishBody);
|
||||||
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||||
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||||
}
|
}
|
||||||
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
||||||
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
|
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
|
||||||
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
||||||
let toStore: TariffStructure = structure;
|
const toStore = stampSiteTz(structure);
|
||||||
if (structure && isTariffV2(structure)) {
|
|
||||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
|
||||||
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
|
||||||
toStore = { ...structure, tz };
|
|
||||||
}
|
|
||||||
|
|
||||||
const problems = validateTariffStructure(toStore);
|
const problems = validateTariffStructure(toStore);
|
||||||
if (problems.length) {
|
if (problems.length) {
|
||||||
@@ -128,6 +133,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
const row = {
|
const row = {
|
||||||
id,
|
id,
|
||||||
tariffId,
|
tariffId,
|
||||||
|
name: typeof name === "string" && name.trim() ? name.trim() : null,
|
||||||
effectiveFrom: effective,
|
effectiveFrom: effective,
|
||||||
currency,
|
currency,
|
||||||
structure: toStore as unknown as Record<string, unknown>,
|
structure: toStore as unknown as Record<string, unknown>,
|
||||||
@@ -183,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
const payments = Array.isArray(b.payments) ? b.payments : [];
|
const payments = Array.isArray(b.payments) ? b.payments : [];
|
||||||
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
||||||
|
|
||||||
|
// HOW the amount is produced — the same engine walk with a trace collector
|
||||||
|
// (Σ lines ≡ amountMinor by construction). Null when settled (nothing billed).
|
||||||
|
const breakdown = pricing.withinGrace
|
||||||
|
? null
|
||||||
|
: explainFee(pricing.periodStart, b.asOf, structure, b.category);
|
||||||
|
|
||||||
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
||||||
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
||||||
const enteredMs = Date.parse(b.enteredAt);
|
const enteredMs = Date.parse(b.enteredAt);
|
||||||
@@ -191,7 +203,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
return { currency, pricing, breakdown, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
||||||
@@ -230,6 +242,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Lab drafts ---------------------------------------------------------------
|
||||||
|
// The lab's scratchpad: MUTABLE experimental rate cards (see tariff_drafts in the
|
||||||
|
// schema for why mutability is safe here — a draft prices nothing and signs
|
||||||
|
// nothing). Saved drafts are validated + tz-stamped exactly like a publish, so the
|
||||||
|
// simulator can always price them and "publish this draft" can never surprise the
|
||||||
|
// admin with a card that saved fine but won't go live. Publishing a draft is just
|
||||||
|
// POST /api/tariff/versions with the draft's structure — same guard, same
|
||||||
|
// validation, same immutability.
|
||||||
|
app.get("/api/tariff/drafts", { preHandler: readGuard }, async () => {
|
||||||
|
const drafts = db.select().from(tariffDrafts).orderBy(desc(tariffDrafts.updatedAt)).all();
|
||||||
|
return { drafts };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: DraftBody }>("/api/tariff/drafts", { preHandler: writeGuard }, async (req, reply) => {
|
||||||
|
const parsed = parseDraftBody(req.body);
|
||||||
|
if ("error" in parsed) return reply.code(400).send(parsed);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const row = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name: parsed.name,
|
||||||
|
currency: parsed.currency,
|
||||||
|
structure: parsed.structure as unknown as Record<string, unknown>,
|
||||||
|
createdBy: req.user?.username ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
db.insert(tariffDrafts).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string }; Body: DraftBody }>(
|
||||||
|
"/api/tariff/drafts/:id",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
|
||||||
|
if (!existing) return reply.code(404).send({ error: "draft not found" });
|
||||||
|
const parsed = parseDraftBody(req.body);
|
||||||
|
if ("error" in parsed) return reply.code(400).send(parsed);
|
||||||
|
const patch = {
|
||||||
|
name: parsed.name,
|
||||||
|
currency: parsed.currency,
|
||||||
|
structure: parsed.structure as unknown as Record<string, unknown>,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run();
|
||||||
|
return { ...existing, ...patch };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/api/tariff/drafts/:id",
|
||||||
|
{ preHandler: writeGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
|
||||||
|
if (!existing) return reply.code(404).send({ error: "draft not found" });
|
||||||
|
db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */
|
||||||
|
function parseDraftBody(
|
||||||
|
body: DraftBody | undefined,
|
||||||
|
): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } {
|
||||||
|
const b = body ?? ({} as DraftBody);
|
||||||
|
const name = (b.name ?? "").trim();
|
||||||
|
if (!name) return { error: "name required" };
|
||||||
|
const currency = (b.currency ?? "").trim().toUpperCase();
|
||||||
|
if (currency.length < 3) return { error: "currency (ISO 4217) required" };
|
||||||
|
const structure = stampSiteTz(b.structure);
|
||||||
|
const problems = validateTariffStructure(structure);
|
||||||
|
if (problems.length) return { error: "invalid tariff structure", problems };
|
||||||
|
return { name, currency, structure };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the
|
||||||
|
* client); a V1 (bare) structure passes through untouched. */
|
||||||
|
function stampSiteTz(structure: TariffStructure): TariffStructure {
|
||||||
|
if (structure && isTariffV2(structure)) {
|
||||||
|
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||||
|
return { ...structure, tz };
|
||||||
|
}
|
||||||
|
return structure;
|
||||||
|
}
|
||||||
|
|
||||||
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
|
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
|
||||||
function tariffVersionIdFor(whenIso: string): string | null {
|
function tariffVersionIdFor(whenIso: string): string | null {
|
||||||
const tariffId = ensureSiteTariff();
|
const tariffId = ensureSiteTariff();
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { eq, ledgerEvents, users, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../test-helpers.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
|
|
||||||
|
// Merchant validations (bar/lavazh): the merchant user scans a ticket and applies
|
||||||
|
// their program (a SIGNED, attributed ledger event); the booth settlement quotes NET
|
||||||
|
// and the payment CONSUMES the validation ids. These tests pin the route guards
|
||||||
|
// (binding, caps, session state), the signed apply/void events, and the money cycle
|
||||||
|
// through /api/pay/quote + /api/pay. See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
type Auth = { cookie: string; csrf: string };
|
||||||
|
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||||
|
|
||||||
|
async function seedMerchant(username = "bari"): Promise<{ auth: Auth; userId: string }> {
|
||||||
|
await seedUser(db, { username, password: "pw123456", roleId: "validues", permissions: ["validation:create"] });
|
||||||
|
const auth = await login(app, username, "pw123456");
|
||||||
|
const row = db.select().from(users).where(eq(users.username, username)).get()!;
|
||||||
|
return { auth, userId: row.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAdmin(): Promise<Auth> {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw123456" });
|
||||||
|
return login(app, "admin", "pw123456");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin-upserts the "bar" program bound to the given user. */
|
||||||
|
async function putProgram(auth: Auth, body: Record<string, unknown>, id = "bar") {
|
||||||
|
return app.inject({ method: "PUT", url: `/api/validation/programs/${id}`, headers: hdrs(auth), payload: body });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixedProgram = (userId: string, over: Record<string, unknown> = {}) => ({
|
||||||
|
name: "Bar",
|
||||||
|
mode: "fixed",
|
||||||
|
maxAmountMinor: 100000,
|
||||||
|
active: true,
|
||||||
|
userIds: [userId],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("merchant validations", () => {
|
||||||
|
let log: EventLog;
|
||||||
|
beforeEach(() => {
|
||||||
|
log = makeLog(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mint = (identity: string, minAgo: number, payload: Record<string, unknown> | null = null) =>
|
||||||
|
log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: minutesAgo(minAgo), payload });
|
||||||
|
|
||||||
|
it("program upsert is admin-gated and signs a config_change; a no-op save signs nothing", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
|
||||||
|
expect((await putProgram(merchant, fixedProgram(userId))).statusCode).toBe(403);
|
||||||
|
|
||||||
|
const res = await putProgram(admin, fixedProgram(userId));
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({ id: "bar", mode: "fixed", active: true, userIds: [userId] });
|
||||||
|
|
||||||
|
const changes = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
|
||||||
|
expect(changes()).toHaveLength(1);
|
||||||
|
expect(changes()[0].payload).toMatchObject({ setting: "validationProgram.bar", operator: "admin" });
|
||||||
|
|
||||||
|
// Identical second save → no second config_change.
|
||||||
|
await putProgram(admin, fixedProgram(userId));
|
||||||
|
expect(changes()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("per-mode validation: timeCredit needs minutes, percent needs percent, fixed needs a cap", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
expect((await putProgram(admin, { name: "X", mode: "timeCredit", active: true })).statusCode).toBe(400);
|
||||||
|
expect((await putProgram(admin, { name: "X", mode: "percent", active: true })).statusCode).toBe(400);
|
||||||
|
expect((await putProgram(admin, { name: "X", mode: "fixed", active: true })).statusCode).toBe(400);
|
||||||
|
expect((await putProgram(admin, { name: "X", mode: "timeCredit", minutes: 60, active: true })).statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /mine returns only MY bound, active programs", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
await putProgram(admin, fixedProgram(userId));
|
||||||
|
await putProgram(admin, { name: "Lavazh", mode: "comp", active: true, userIds: [] }, "lavazh");
|
||||||
|
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/validation/mine", headers: hdrs(merchant) });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const programs = res.json().programs as { id: string }[];
|
||||||
|
expect(programs.map((p) => p.id)).toEqual(["bar"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("apply: binding, session-state, duplicate and amount guards", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
const { auth: other } = await seedMerchant("tjetri");
|
||||||
|
await putProgram(admin, fixedProgram(userId));
|
||||||
|
seedTariff(db);
|
||||||
|
await mint("T1", 120);
|
||||||
|
|
||||||
|
const apply = (auth: Auth, payload: Record<string, unknown>) =>
|
||||||
|
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(auth), payload });
|
||||||
|
|
||||||
|
// Unbound merchant → 403; unknown ticket → 404; missing amount (fixed) → 400;
|
||||||
|
// amount above the cap → 400.
|
||||||
|
expect((await apply(other, { identity: "T1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(403);
|
||||||
|
expect((await apply(merchant, { identity: "NOPE", programId: "bar", amountMinor: 5000 })).statusCode).toBe(404);
|
||||||
|
expect((await apply(merchant, { identity: "T1", programId: "bar" })).statusCode).toBe(400);
|
||||||
|
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 999999 })).statusCode).toBe(400);
|
||||||
|
|
||||||
|
// Subscriber sessions are never validated (prepaid).
|
||||||
|
await mint("SUB1", 60, { permit: true, permitId: "s-1" });
|
||||||
|
expect((await apply(merchant, { identity: "SUB1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(409);
|
||||||
|
|
||||||
|
// Success → a SIGNED validation event with resolved values + the merchant username.
|
||||||
|
const ok = await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 5000 });
|
||||||
|
expect(ok.statusCode).toBe(201);
|
||||||
|
const ev = db.select().from(ledgerEvents).all().find((r) => r.type === "validation")!;
|
||||||
|
expect(ev.payload).toMatchObject({
|
||||||
|
programId: "bar",
|
||||||
|
programLabel: "Bar",
|
||||||
|
mode: "fixed",
|
||||||
|
amountMinor: 5000,
|
||||||
|
operator: "bari",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same program twice on one ticket → 409.
|
||||||
|
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 1000 })).statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the money cycle: quote nets the validation, pay records gross/discount and CONSUMES it", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
await putProgram(admin, fixedProgram(userId));
|
||||||
|
// 100/h flat; 2h → gross 20000.
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
|
||||||
|
await mint("T1", 119);
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/validation/apply",
|
||||||
|
headers: hdrs(merchant),
|
||||||
|
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const q1 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||||
|
expect(q1.json()).toMatchObject({
|
||||||
|
grossMinor: 20000,
|
||||||
|
discountMinor: 5000,
|
||||||
|
amountMinor: 15000,
|
||||||
|
});
|
||||||
|
expect(q1.json().validationLines).toEqual([
|
||||||
|
{ programId: "bar", label: "Bar", mode: "fixed", discountMinor: 5000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Pay (needs an open shift) → the payment carries the split + consumed ids.
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||||
|
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||||
|
expect(pay.statusCode).toBe(201);
|
||||||
|
expect(pay.json().amountMinor).toBe(15000);
|
||||||
|
|
||||||
|
const payment = db.select().from(ledgerEvents).all().find((r) => r.type === "payment")!;
|
||||||
|
expect(payment.payload).toMatchObject({ amountMinor: 15000, grossMinor: 20000, discountMinor: 5000 });
|
||||||
|
expect((payment.payload as { validationIds?: string[] }).validationIds).toHaveLength(1);
|
||||||
|
|
||||||
|
// Settled: the follow-up quote owes 0 and applies nothing further.
|
||||||
|
const q2 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||||
|
expect(q2.json().amountMinor).toBe(0);
|
||||||
|
expect(q2.json().validationLines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a full comp settles at 0 through the normal pay path (grace starts, chain verifies)", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
await putProgram(admin, { name: "Lavazh falas", mode: "comp", active: true, userIds: [userId] }, "lavazh");
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await mint("T1", 90);
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/validation/apply",
|
||||||
|
headers: hdrs(merchant),
|
||||||
|
payload: { identity: "T1", programId: "lavazh" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||||
|
expect(q.json().amountMinor).toBe(0);
|
||||||
|
expect(q.json().grossMinor).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||||
|
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||||
|
expect(pay.statusCode).toBe(201);
|
||||||
|
expect(pay.json().amountMinor).toBe(0);
|
||||||
|
|
||||||
|
// The 0-net settlement still grants walk-back grace (the session reads settled).
|
||||||
|
const view = await app.inject({ method: "GET", url: "/api/session/T1", headers: hdrs(admin) });
|
||||||
|
expect(view.json()).toMatchObject({ withinGrace: true, amountMinor: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("void: own unused only; a consumed validation is locked", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
const { auth: other, userId: otherId } = await seedMerchant("tjetri");
|
||||||
|
await putProgram(admin, fixedProgram(userId, { userIds: [userId, otherId] }));
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await mint("T1", 90);
|
||||||
|
|
||||||
|
const applied = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/validation/apply",
|
||||||
|
headers: hdrs(merchant),
|
||||||
|
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||||
|
});
|
||||||
|
const eventId = applied.json().eventId as string;
|
||||||
|
|
||||||
|
const voidReq = (auth: Auth) =>
|
||||||
|
app.inject({ method: "POST", url: "/api/validation/void", headers: hdrs(auth), payload: { eventId, identity: "T1" } });
|
||||||
|
|
||||||
|
// Someone else's validation → 403. Own → ok, and the quote returns to gross.
|
||||||
|
expect((await voidReq(other)).statusCode).toBe(403);
|
||||||
|
expect((await voidReq(merchant)).statusCode).toBe(200);
|
||||||
|
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||||
|
expect(q.json().discountMinor).toBe(0);
|
||||||
|
|
||||||
|
// Re-apply (the void freed the per-session slot), consume it with a payment, then
|
||||||
|
// a void must refuse — the settlement already happened.
|
||||||
|
const re = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/validation/apply",
|
||||||
|
headers: hdrs(merchant),
|
||||||
|
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||||
|
});
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||||
|
await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||||
|
const locked = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/validation/void",
|
||||||
|
headers: hdrs(merchant),
|
||||||
|
payload: { eventId: re.json().eventId, identity: "T1" },
|
||||||
|
});
|
||||||
|
expect(locked.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maxPerDay caps applications across tickets", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
await putProgram(admin, { name: "Lavazh", mode: "comp", maxPerDay: 1, active: true, userIds: [userId] }, "lavazh");
|
||||||
|
seedTariff(db);
|
||||||
|
await mint("T1", 60);
|
||||||
|
await mint("T2", 30);
|
||||||
|
|
||||||
|
const apply = (identity: string) =>
|
||||||
|
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(merchant), payload: { identity, programId: "lavazh" } });
|
||||||
|
expect((await apply("T1")).statusCode).toBe(201);
|
||||||
|
expect((await apply("T2")).statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
isNull,
|
||||||
|
inArray,
|
||||||
|
ledgerEvents,
|
||||||
|
users,
|
||||||
|
validationProgramUsers,
|
||||||
|
validationPrograms,
|
||||||
|
type Db,
|
||||||
|
} from "@parking/db";
|
||||||
|
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
|
import { liveValidations, sessionValidations } from "../validations.js";
|
||||||
|
|
||||||
|
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
|
||||||
|
// customer's ticket on their own device and apply their program — all money and paper
|
||||||
|
// stay at the booth, which settles net of these events. Program config is admin-composed
|
||||||
|
// on /setup/site (site:read/update — no dedicated permission); applying is the merchant
|
||||||
|
// user's `validation:create`, guarded FURTHER by the program↔user binding so a bar user
|
||||||
|
// can never apply the lavazh program. Every apply/void is a signed, attributed ledger
|
||||||
|
// event. See wiki/concepts/validation-discounts.md.
|
||||||
|
// - GET /api/validation/programs : all programs + bound users. (site:read)
|
||||||
|
// - PUT /api/validation/programs/:id : upsert config + bindings; (site:update)
|
||||||
|
// signs a config_change.
|
||||||
|
// - GET /api/validation/mine : my bound ACTIVE programs. (validation:create)
|
||||||
|
// - GET /api/validation/session/:identity : minimal session view for (validation:create)
|
||||||
|
// the merchant screen (no money data).
|
||||||
|
// - POST /api/validation/apply : apply my program (signed). (validation:create)
|
||||||
|
// - POST /api/validation/void : void my OWN unused apply. (validation:create)
|
||||||
|
|
||||||
|
/** Well-formed program ids: kebab slugs ("bar", "lavazh", a future "hotel-2"). */
|
||||||
|
const ID_RE = /^[a-z][a-z0-9-]{1,31}$/;
|
||||||
|
|
||||||
|
interface ProgramBody {
|
||||||
|
name?: string;
|
||||||
|
mode?: ValidationMode;
|
||||||
|
minutes?: number | null;
|
||||||
|
percent?: number | null;
|
||||||
|
maxAmountMinor?: number | null;
|
||||||
|
maxPerDay?: number | null;
|
||||||
|
active?: boolean;
|
||||||
|
/** Full replacement set of bound user ids. */
|
||||||
|
userIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApplyBody {
|
||||||
|
identity: string;
|
||||||
|
programId: string;
|
||||||
|
/** fixed mode only: the discount the merchant grants (minor units, ≤ maxAmountMinor). */
|
||||||
|
amountMinor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoidBody {
|
||||||
|
eventId: string;
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null when valid, else the 400 message. Checks the per-mode parameter. */
|
||||||
|
function validateProgram(b: ProgramBody): string | null {
|
||||||
|
if (!b.name || !String(b.name).trim()) return "name is required";
|
||||||
|
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
|
||||||
|
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
|
||||||
|
if (!intOrNull(b.minutes)) return "minutes must be a positive integer";
|
||||||
|
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
|
||||||
|
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
|
||||||
|
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
|
||||||
|
return "percent must be 1..100";
|
||||||
|
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
|
||||||
|
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
|
||||||
|
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
|
||||||
|
const siteRead = requirePermission("site:read");
|
||||||
|
const siteWrite = requirePermission("site:update");
|
||||||
|
const applyGuard = requirePermission("validation:create");
|
||||||
|
|
||||||
|
const liveProgram = (id: string) =>
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(validationPrograms)
|
||||||
|
.where(and(eq(validationPrograms.id, id), isNull(validationPrograms.deletedAt)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const boundUserIds = (programId: string): string[] =>
|
||||||
|
db
|
||||||
|
.select({ userId: validationProgramUsers.userId })
|
||||||
|
.from(validationProgramUsers)
|
||||||
|
.where(eq(validationProgramUsers.programId, programId))
|
||||||
|
.all()
|
||||||
|
.map((r) => r.userId);
|
||||||
|
|
||||||
|
// The setup panel's read: every live program with its bound users.
|
||||||
|
app.get("/api/validation/programs", { preHandler: siteRead }, async () => {
|
||||||
|
const programs = db.select().from(validationPrograms).where(isNull(validationPrograms.deletedAt)).all();
|
||||||
|
return {
|
||||||
|
programs: programs.map((p) => ({ ...p, userIds: boundUserIds(p.id) })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upsert a program (the /setup/site checkbox + panel). Creates the well-known row on
|
||||||
|
// first enable; replaces the binding set; signs an attributed config_change when
|
||||||
|
// anything actually changed (the entry-presence-bypass precedent — enabling a discount
|
||||||
|
// program is fraud-relevant config).
|
||||||
|
app.put<{ Params: { id: string }; Body: ProgramBody }>(
|
||||||
|
"/api/validation/programs/:id",
|
||||||
|
{ preHandler: siteWrite },
|
||||||
|
async (req, reply) => {
|
||||||
|
const id = (req.params.id ?? "").trim();
|
||||||
|
if (!ID_RE.test(id)) return reply.code(400).send({ error: "invalid program id" });
|
||||||
|
const b = req.body ?? ({} as ProgramBody);
|
||||||
|
const bad = validateProgram(b);
|
||||||
|
if (bad) return reply.code(400).send({ error: bad });
|
||||||
|
|
||||||
|
const userIds = Array.isArray(b.userIds) ? [...new Set(b.userIds)] : [];
|
||||||
|
if (userIds.length) {
|
||||||
|
const found = db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(and(inArray(users.id, userIds), isNull(users.deletedAt)))
|
||||||
|
.all();
|
||||||
|
if (found.length !== userIds.length) return reply.code(400).send({ error: "unknown user in userIds" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = liveProgram(id);
|
||||||
|
const prevUserIds = prev ? boundUserIds(id).sort() : [];
|
||||||
|
const next = {
|
||||||
|
name: String(b.name).trim(),
|
||||||
|
mode: b.mode as ValidationMode,
|
||||||
|
minutes: b.minutes ?? null,
|
||||||
|
percent: b.percent ?? null,
|
||||||
|
maxAmountMinor: b.maxAmountMinor ?? null,
|
||||||
|
maxPerDay: b.maxPerDay ?? null,
|
||||||
|
active: b.active === true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prev) {
|
||||||
|
db.update(validationPrograms).set(next).where(eq(validationPrograms.id, id)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(validationPrograms).values({ id, ...next }).run();
|
||||||
|
}
|
||||||
|
db.delete(validationProgramUsers).where(eq(validationProgramUsers.programId, id)).run();
|
||||||
|
for (const userId of userIds) {
|
||||||
|
db.insert(validationProgramUsers).values({ programId: id, userId }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the change (attributed) — enabling/reshaping a discount program is
|
||||||
|
// fraud-relevant config. Compare against the previous row + binding set so a
|
||||||
|
// no-op save signs nothing.
|
||||||
|
const summary = (row: typeof next, ids: string[]) => JSON.stringify({ ...row, userIds: [...ids].sort() });
|
||||||
|
const prevSummary = prev
|
||||||
|
? summary(
|
||||||
|
{ name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
|
||||||
|
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active },
|
||||||
|
prevUserIds,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (prevSummary !== summary(next, userIds)) {
|
||||||
|
await eventLog.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: `validation-program:${id}`,
|
||||||
|
payload: {
|
||||||
|
setting: `validationProgram.${id}`,
|
||||||
|
value: { ...next, userCount: userIds.length },
|
||||||
|
prev: prev
|
||||||
|
? { name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
|
||||||
|
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active }
|
||||||
|
: null,
|
||||||
|
operator: req.user?.username ?? "unknown",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = liveProgram(id);
|
||||||
|
return { ...row, userIds: boundUserIds(id) };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The merchant screen's program list: MY bound, active programs.
|
||||||
|
app.get("/api/validation/mine", { preHandler: applyGuard }, async (req) => {
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(validationPrograms)
|
||||||
|
.innerJoin(validationProgramUsers, eq(validationProgramUsers.programId, validationPrograms.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(validationProgramUsers.userId, req.user.sub),
|
||||||
|
eq(validationPrograms.active, true),
|
||||||
|
isNull(validationPrograms.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return { programs: rows.map((r) => r.validation_programs) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minimal session view for the merchant screen — deliberately NO money data (the
|
||||||
|
// merchant validates; the booth settles): found/open/entry time + the validations
|
||||||
|
// already on the session (so the UI can show "already validated" and offer void).
|
||||||
|
app.get<{ Params: { identity: string } }>(
|
||||||
|
"/api/validation/session/:identity",
|
||||||
|
{ preHandler: applyGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.params.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const rows = db
|
||||||
|
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return { identity, found: false, open: false, enteredAt: null, subscription: false, validations: [] };
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
const subscription = entryPl.permit === true || entryPl.permitId != null;
|
||||||
|
const open = !rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
found: true,
|
||||||
|
open,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
subscription,
|
||||||
|
validations: sessionValidations(db, identity),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// APPLY: the merchant's one action. Guards, in order: program live+active → the
|
||||||
|
// user is BOUND to it → the session is an OPEN TRANSIENT → not already carrying a
|
||||||
|
// live application of this program → per-day cap → fixed-amount bounds. Appends the
|
||||||
|
// signed validation event with the RESOLVED values.
|
||||||
|
app.post<{ Body: ApplyBody }>("/api/validation/apply", { preHandler: applyGuard }, async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
const programId = (req.body?.programId ?? "").trim();
|
||||||
|
if (!identity || !programId) return reply.code(400).send({ error: "identity and programId required" });
|
||||||
|
|
||||||
|
const program = liveProgram(programId);
|
||||||
|
if (!program || !program.active) return reply.code(404).send({ error: "program not found or inactive" });
|
||||||
|
if (!boundUserIds(programId).includes(req.user.sub)) {
|
||||||
|
return reply.code(403).send({ error: "you are not bound to this program" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session state — an open transient (subscriptions are prepaid; nothing to discount).
|
||||||
|
const rows = db
|
||||||
|
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return reply.code(404).send({ error: "no session for ticket" });
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
if (entryPl.permit === true || entryPl.permitId != null) {
|
||||||
|
return reply.code(409).send({ error: "subscription sessions cannot be validated" });
|
||||||
|
}
|
||||||
|
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
|
||||||
|
return reply.code(409).send({ error: "session is closed" });
|
||||||
|
}
|
||||||
|
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
|
||||||
|
return reply.code(409).send({ error: "this program is already applied to the ticket" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
|
||||||
|
// appliance runs in site time).
|
||||||
|
if (program.maxPerDay != null) {
|
||||||
|
const midnight = new Date();
|
||||||
|
midnight.setHours(0, 0, 0, 0);
|
||||||
|
const todays = db
|
||||||
|
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "validation"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
|
||||||
|
const voidedIds = new Set(
|
||||||
|
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
|
||||||
|
);
|
||||||
|
const count = todays.filter((r) => {
|
||||||
|
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
|
||||||
|
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
|
||||||
|
}).length;
|
||||||
|
if (count >= program.maxPerDay) {
|
||||||
|
return reply.code(409).send({ error: "daily cap reached for this program" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the values off the program row (frozen into the signed event).
|
||||||
|
let amountMinor: number | undefined;
|
||||||
|
if (program.mode === "fixed") {
|
||||||
|
const a = req.body?.amountMinor;
|
||||||
|
if (a == null || !Number.isInteger(a) || a <= 0) {
|
||||||
|
return reply.code(400).send({ error: "amountMinor (positive integer) required for this program" });
|
||||||
|
}
|
||||||
|
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
|
||||||
|
return reply.code(400).send({ error: `amount exceeds the program cap (${program.maxAmountMinor})` });
|
||||||
|
}
|
||||||
|
amountMinor = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ev = await eventLog.append({
|
||||||
|
type: "validation",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
programId,
|
||||||
|
programLabel: program.name,
|
||||||
|
mode: program.mode,
|
||||||
|
...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}),
|
||||||
|
...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}),
|
||||||
|
...(amountMinor != null ? { amountMinor } : {}),
|
||||||
|
operator: req.user.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.code(201).send({
|
||||||
|
ok: true,
|
||||||
|
eventId: ev.id,
|
||||||
|
programId,
|
||||||
|
label: program.name,
|
||||||
|
mode: program.mode,
|
||||||
|
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
|
||||||
|
percent: program.mode === "percent" ? program.percent : undefined,
|
||||||
|
amountMinor,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
|
||||||
|
// a validation event with refId, never a delete. Refused once a payment consumed it
|
||||||
|
// (the settlement already happened — that dispute goes to the booth/admin).
|
||||||
|
app.post<{ Body: VoidBody }>("/api/validation/void", { preHandler: applyGuard }, async (req, reply) => {
|
||||||
|
const eventId = (req.body?.eventId ?? "").trim();
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
if (!eventId || !identity) return reply.code(400).send({ error: "eventId and identity required" });
|
||||||
|
const target = sessionValidations(db, identity).find((v) => v.eventId === eventId);
|
||||||
|
if (!target) return reply.code(404).send({ error: "validation not found" });
|
||||||
|
if (target.operator !== req.user.username) {
|
||||||
|
return reply.code(403).send({ error: "you may only void your own validation" });
|
||||||
|
}
|
||||||
|
if (target.voided) return reply.code(409).send({ error: "already voided" });
|
||||||
|
if (target.consumedBy != null) {
|
||||||
|
return reply.code(409).send({ error: "already used in a payment — ask the booth/admin" });
|
||||||
|
}
|
||||||
|
await eventLog.append({
|
||||||
|
type: "validation",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
refId: eventId,
|
||||||
|
programId: target.programId,
|
||||||
|
programLabel: target.label,
|
||||||
|
operator: req.user.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,10 +2,16 @@ 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, type LaneStatusEvent } from "../device-events.js";
|
import {
|
||||||
|
deviceEvents,
|
||||||
|
type LaneStatusEvent,
|
||||||
|
type LanePresenceEvent,
|
||||||
|
type PlateRecognizedEvent,
|
||||||
|
} 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 type { LaneStatus } from "../lane-status.js";
|
||||||
|
import type { LanePresence } from "../lane-presence.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
|
||||||
@@ -53,17 +59,26 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OutMsg =
|
type OutMsg =
|
||||||
| { kind: "hello"; occupancy: ReturnType<typeof getOccupancy>; devices: unknown; lanes: LaneStatusEvent }
|
| {
|
||||||
|
kind: "hello";
|
||||||
|
occupancy: ReturnType<typeof getOccupancy>;
|
||||||
|
devices: unknown;
|
||||||
|
lanes: LaneStatusEvent;
|
||||||
|
radar: LanePresenceEvent;
|
||||||
|
}
|
||||||
| { 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 };
|
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
||||||
|
| { kind: "lane-presence"; radar: LanePresenceEvent }
|
||||||
|
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
||||||
|
|
||||||
export async function wsRoutes(
|
export async function wsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
deviceMonitor: DeviceMonitor,
|
deviceMonitor: DeviceMonitor,
|
||||||
laneStatus: LaneStatus,
|
laneStatus: LaneStatus,
|
||||||
|
lanePresence: LanePresence,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
app.get(
|
app.get(
|
||||||
"/api/ws",
|
"/api/ws",
|
||||||
@@ -96,7 +111,13 @@ export async function wsRoutes(
|
|||||||
|
|
||||||
// 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(), lanes: laneStatus.snapshot() });
|
send({
|
||||||
|
kind: "hello",
|
||||||
|
occupancy: getOccupancy(db),
|
||||||
|
devices: deviceMonitor.snapshot(),
|
||||||
|
lanes: laneStatus.snapshot(),
|
||||||
|
radar: lanePresence.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.
|
||||||
@@ -117,12 +138,22 @@ export async function wsRoutes(
|
|||||||
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
||||||
send({ kind: "lane-status", lanes });
|
send({ kind: "lane-status", lanes });
|
||||||
});
|
});
|
||||||
|
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
||||||
|
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||||
|
send({ kind: "lane-presence", radar });
|
||||||
|
});
|
||||||
|
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
|
||||||
|
const offPlate = deviceEvents.onPlateRecognized((plate) => {
|
||||||
|
send({ kind: "plate-recognized", plate });
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("close", () => {
|
socket.on("close", () => {
|
||||||
offLedger();
|
offLedger();
|
||||||
offPrinter();
|
offPrinter();
|
||||||
offDevice();
|
offDevice();
|
||||||
offLane();
|
offLane();
|
||||||
|
offPresence();
|
||||||
|
offPlate();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+106
-18
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
import { createDb, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||||
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
import { TOKEN_COOKIE, requireJwtSecret, initAuth } from "./auth.js";
|
||||||
import { deviceEvents } from "./device-events.js";
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import { ButtonLightController } from "./button-light.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";
|
||||||
@@ -19,6 +20,9 @@ import { PrinterMonitor } from "./printer-monitor.js";
|
|||||||
import { DeviceMonitor } from "./device-monitor.js";
|
import { DeviceMonitor } from "./device-monitor.js";
|
||||||
import { buildSigner, buildVerifier } from "./signer.js";
|
import { buildSigner, buildVerifier } from "./signer.js";
|
||||||
import { LogService, pinoDbStream } from "./log-service.js";
|
import { LogService, pinoDbStream } from "./log-service.js";
|
||||||
|
import { pruneSnapshots } from "./snapshot-retention.js";
|
||||||
|
import { BackupService } from "./backup-service.js";
|
||||||
|
import { backupRoutes } from "./routes/backup.js";
|
||||||
import { logRoutes } from "./routes/logs.js";
|
import { logRoutes } from "./routes/logs.js";
|
||||||
import { VisionClient } from "./vision-client.js";
|
import { VisionClient } from "./vision-client.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
@@ -27,6 +31,7 @@ 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 { hikvisionAlarmRoutes } from "./routes/hikvision-alarm.js";
|
||||||
import { LaneStatus } from "./lane-status.js";
|
import { LaneStatus } from "./lane-status.js";
|
||||||
|
import { LanePresence } from "./lane-presence.js";
|
||||||
import { AnprBridge } from "./anpr-entry.js";
|
import { AnprBridge } from "./anpr-entry.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { reportRoutes } from "./routes/reports.js";
|
import { reportRoutes } from "./routes/reports.js";
|
||||||
@@ -37,7 +42,10 @@ import { subscriptionRoutes } from "./routes/subscriptions.js";
|
|||||||
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||||
import { shiftRoutes } from "./routes/shift.js";
|
import { shiftRoutes } from "./routes/shift.js";
|
||||||
|
import { drawerRoutes } from "./routes/drawer.js";
|
||||||
|
import { entryRoutes } from "./routes/entry.js";
|
||||||
import { siteRoutes } from "./routes/site.js";
|
import { siteRoutes } from "./routes/site.js";
|
||||||
|
import { validationRoutes } from "./routes/validations.js";
|
||||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
@@ -64,7 +72,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const logService = new LogService(db);
|
const logService = new LogService(db);
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
|
// Level knob: trace|debug|info|warn|error|fatal (pino). Default info; a booth
|
||||||
|
// being diagnosed can run LOG_LEVEL=debug without a code change.
|
||||||
level: process.env.LOG_LEVEL ?? "info",
|
level: process.env.LOG_LEVEL ?? "info",
|
||||||
|
// Container logs are read by humans (`docker logs` / Komodo), so stamp
|
||||||
|
// ISO-8601 UTC instead of pino's epoch-ms, and level NAMES instead of the
|
||||||
|
// numeric codes (30/40/50). pinoDbStream accepts both encodings.
|
||||||
|
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
||||||
|
formatters: { level: (label) => ({ level: label }) },
|
||||||
stream: pinoDbStream(logService, process.stdout),
|
stream: pinoDbStream(logService, process.stdout),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -108,11 +123,24 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const visionClient = new VisionClient(app.log);
|
const visionClient = new VisionClient(app.log);
|
||||||
if (visionClient.enabled) app.log.info("vision client enabled");
|
if (visionClient.enabled) app.log.info("vision client enabled");
|
||||||
|
|
||||||
|
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||||
|
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||||
|
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||||
|
// in device_events. The entry flow turns an input into a signed vehicle_entry once
|
||||||
|
// a ticket prints + the barrier is commanded. See event-streams-split.md.
|
||||||
|
// Constructed HERE (before setupRoutes) so the Setup relay-test can sign its
|
||||||
|
// deliberate barrier open into the ledger; the read routes are wired further down.
|
||||||
|
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
||||||
|
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
||||||
|
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
||||||
|
deviceEvents.emitLedger(row),
|
||||||
|
);
|
||||||
|
|
||||||
// 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, visionClient);
|
await setupRoutes(app, db, visionClient, eventLog);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -124,6 +152,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const laneStatus = new LaneStatus(db, app.log);
|
const laneStatus = new LaneStatus(db, app.log);
|
||||||
app.addHook("onClose", async () => laneStatus.stop());
|
app.addHook("onClose", async () => laneStatus.stop());
|
||||||
|
|
||||||
|
// Per-lane RADAR presence (presence-input edges → barrier-light blink). Mirrors the
|
||||||
|
// physical button lamp (relay 3): the SAME presence signal, surfaced to the booth UI.
|
||||||
|
const lanePresence = new LanePresence(db, app.log);
|
||||||
|
lanePresence.start();
|
||||||
|
app.addHook("onClose", async () => lanePresence.stop());
|
||||||
|
|
||||||
// NB: the Hikvision Alarm Server routes are registered LOWER DOWN — after the read
|
// 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
|
// flows are constructed — because the ANPR bridge they carry depends on the
|
||||||
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
// SubscriptionFlow. See the hikvisionAlarmRoutes() call below the read-flow wiring.
|
||||||
@@ -145,17 +179,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
app.addHook("onReady", async () => deviceMonitor.start());
|
app.addHook("onReady", async () => deviceMonitor.start());
|
||||||
app.addHook("onClose", async () => deviceMonitor.stop());
|
app.addHook("onClose", async () => deviceMonitor.stop());
|
||||||
|
|
||||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
// Read routes for the signed ledger (constructed above, before setupRoutes).
|
||||||
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
|
||||||
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
|
||||||
// in device_events. The entry flow (TODO) turns an input into a signed
|
|
||||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
|
||||||
// See wiki/decisions/event-streams-split.md.
|
|
||||||
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
|
||||||
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
|
||||||
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
|
||||||
deviceEvents.emitLedger(row),
|
|
||||||
);
|
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
||||||
@@ -168,7 +192,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
|
|
||||||
// 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, laneStatus);
|
await wsRoutes(app, db, deviceMonitor, laneStatus, lanePresence);
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -187,6 +211,17 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
void entryFlow.onInput(e);
|
void entryFlow.onInput(e);
|
||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeEntry());
|
app.addHook("onClose", async () => unsubscribeEntry());
|
||||||
|
// The camera press-gate: the entry flow mirrors the entry lane's camera state so a
|
||||||
|
// physical press is live only in the lamp's SOLID state (see entry-flow.ts).
|
||||||
|
const unsubscribeEntryLane = deviceEvents.onLaneStatus((s) => entryFlow.onLaneStatus(s));
|
||||||
|
app.addHook("onClose", async () => unsubscribeEntryLane());
|
||||||
|
|
||||||
|
// Button-light indicator: drives the entry button's lamp on a spare relay from the
|
||||||
|
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
|
||||||
|
// off otherwise). A non-barrier aux output; fails OFF. See button-light.ts.
|
||||||
|
const buttonLight = new ButtonLightController(db, app.log);
|
||||||
|
buttonLight.start();
|
||||||
|
app.addHook("onClose", async () => buttonLight.stop());
|
||||||
|
|
||||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||||
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||||
@@ -218,10 +253,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
// reader's live flow. Single-shot + TTL. See credential-capture.ts.
|
||||||
const credentialCapture = new CredentialCapture();
|
const credentialCapture = new CredentialCapture();
|
||||||
|
|
||||||
// GEE/Dingtian QR reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
// Dingtian DT-008 QR/RFID reader: it HTTP-GETs on each scan and beeps/acts on our JSON
|
||||||
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
|
// verdict (host-in-the-loop, synchronous). The capture service can intercept a read
|
||||||
// on an armed reader for enrollment; otherwise the read routes through the
|
// on an armed reader for enrollment; otherwise the read routes through the
|
||||||
// dispatcher. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
|
// dispatcher. See wiki/entities/dingtian-dt008-reader.md, qrcode-sdk.md.
|
||||||
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
|
await qrReaderRoutes(app, db, readDispatcher, credentialCapture);
|
||||||
|
|
||||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||||
@@ -247,17 +282,33 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||||
await subscriptionPlanRoutes(app, db);
|
await subscriptionPlanRoutes(app, db);
|
||||||
|
|
||||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
// Shift open/close (shiftService constructed above).
|
||||||
await shiftRoutes(app, shiftService, db);
|
await shiftRoutes(app, shiftService);
|
||||||
|
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
||||||
|
await drawerRoutes(app, shiftService);
|
||||||
|
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
|
||||||
|
await entryRoutes(app, entryFlow, laneStatus, shiftService);
|
||||||
|
|
||||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
await siteRoutes(app, db);
|
await siteRoutes(app, db, eventLog);
|
||||||
|
|
||||||
|
// Merchant validations (bar / lavazh): setup panel config + the merchant user's
|
||||||
|
// scan-and-apply. The booth settlement folds the applied validations into its
|
||||||
|
// quote (pay-station.ts). See wiki/concepts/validation-discounts.md.
|
||||||
|
await validationRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
||||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||||
await logRoutes(app, logService);
|
await logRoutes(app, logService);
|
||||||
|
|
||||||
|
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: the target
|
||||||
|
// directory is admin-chosen (site_config), the key is an env secret; status + a manual "back
|
||||||
|
// up now"; the scheduled run is the daily timer below. A no-op until a target dir is set AND
|
||||||
|
// BACKUP_KEY is present. See wiki/concepts/backup-recovery.md.
|
||||||
|
const backupService = new BackupService(db, app.log);
|
||||||
|
await backupRoutes(app, db, backupService);
|
||||||
|
|
||||||
// Periodic retention prune (age + row cap) so the log table stays bounded on the
|
// Periodic retention prune (age + row cap) so the log table stays bounded on the
|
||||||
// offline appliance. Runs hourly; unref'd so it never holds the process open.
|
// offline appliance. Runs hourly; unref'd so it never holds the process open.
|
||||||
const pruneTimer = setInterval(() => {
|
const pruneTimer = setInterval(() => {
|
||||||
@@ -268,6 +319,43 @@ 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));
|
||||||
|
|
||||||
|
// Snapshot retention prune — DISK-PRESSURE safety valve: only when the DB's filesystem
|
||||||
|
// crosses the high-water mark do we delete the oldest snapshots + VACUUM. A no-op the rest
|
||||||
|
// of the time. Daily, unref'd, plus once at startup. See snapshot-retention.ts.
|
||||||
|
const runSnapPrune = async () => {
|
||||||
|
const res = await pruneSnapshots(db, {}, app.log);
|
||||||
|
if (res.deletedRows > 0) {
|
||||||
|
app.log.info(
|
||||||
|
`pruned ${res.deletedRows} snapshots, freed ~${(res.freedBytesEst / 1048576).toFixed(0)} MB ` +
|
||||||
|
`(disk was ${res.usedPctBefore.toFixed(0)}% used${res.vacuumed ? ", vacuumed" : ""})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const snapPruneTimer = setInterval(() => void runSnapPrune(), 24 * 60 * 60 * 1000);
|
||||||
|
snapPruneTimer.unref();
|
||||||
|
void runSnapPrune(); // once at startup
|
||||||
|
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||||
|
|
||||||
|
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
|
||||||
|
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
|
||||||
|
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
|
||||||
|
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
|
||||||
|
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
|
||||||
|
// always`), which could push a day's backup out arbitrarily far AND — before last-success was
|
||||||
|
// persisted — made the admin UI show "Never" despite valid backups already on disk
|
||||||
|
// (2026-08-30 field incident, park-buzi). A short poll against a persisted, wall-clock
|
||||||
|
// timestamp is immune to both restart timing and to any single restart cadence. A no-op
|
||||||
|
// (silent) until BACKUP_TARGET_DIR + BACKUP_KEY are configured; tolerates an
|
||||||
|
// unreachable/unmounted target by recording the error and trying again next check. NOT run
|
||||||
|
// once at startup (a just-booted appliance after a power cut shouldn't immediately write to a
|
||||||
|
// possibly-not-yet-mounted disk). See wiki/concepts/backup-recovery.md.
|
||||||
|
const backupTimer = setInterval(() => void backupService.runScheduled(), 15 * 60 * 1000);
|
||||||
|
backupTimer.unref();
|
||||||
|
app.addHook("onClose", async () => clearInterval(backupTimer));
|
||||||
|
if (backupService.configured) {
|
||||||
|
app.log.info("backup: scheduled daily encrypted backup enabled");
|
||||||
|
}
|
||||||
|
|
||||||
// Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the
|
// 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
|
// retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs
|
||||||
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
|
// every 6h, unref'd, plus once at startup. See recycle-bin.ts.
|
||||||
|
|||||||
@@ -117,27 +117,100 @@ describe("drawer carry-forward", () => {
|
|||||||
expect(next.openingFloatMinor).toBe(25000); // inherited
|
expect(next.openingFloatMinor).toBe(25000); // inherited
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cash_in / cash_out vouchers adjust the drawer", async () => {
|
it("cash_in / cash_out movements adjust the drawer", async () => {
|
||||||
await shift.open("alice");
|
await shift.open("alice");
|
||||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 100000, reason: "float load" });
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float load" });
|
||||||
await shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: 30000, reason: "bank drop" });
|
await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 30000, reason: "bank drop" });
|
||||||
const r = shift.currentReport()!;
|
const r = shift.currentReport()!;
|
||||||
expect(r.cashAddedMinor).toBe(100000);
|
expect(r.cashAddedMinor).toBe(100000);
|
||||||
expect(r.cashRemovedMinor).toBe(30000);
|
expect(r.cashRemovedMinor).toBe(30000);
|
||||||
expect(r.expectedDrawerMinor).toBe(70000);
|
expect(r.expectedDrawerMinor).toBe(70000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a non-positive voucher amount", async () => {
|
it("rejects a non-positive movement amount", async () => {
|
||||||
await shift.open("alice");
|
await shift.open("alice");
|
||||||
await expect(
|
await expect(
|
||||||
shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 0, reason: "x" }),
|
shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 0, reason: "x" }),
|
||||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
await expect(
|
await expect(
|
||||||
shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: -5, reason: "x" }),
|
shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: -5, reason: "x" }),
|
||||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("drawer review (operator records, admin reviews after)", () => {
|
||||||
|
it("a new movement starts pending; review sets authorized/denied", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
const m = await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 5000, reason: "supplies" });
|
||||||
|
// Find the movement's ledger id via the status list.
|
||||||
|
let list = shift.movementsWithStatus({ operator: "alice" });
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].status).toBe("pending");
|
||||||
|
expect(list[0].voucherNo).toBe(m.voucherNo);
|
||||||
|
|
||||||
|
await shift.reviewMovement({ refId: list[0].id, decision: "deny", reviewedBy: "admin", note: "not genuine" });
|
||||||
|
list = shift.movementsWithStatus({ operator: "alice" });
|
||||||
|
expect(list[0].status).toBe("denied");
|
||||||
|
expect(list[0].reviewedBy).toBe("admin");
|
||||||
|
expect(list[0].reviewNote).toBe("not genuine");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DENY is a flag only — it does NOT reverse the movement or touch the drawer", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 10000, reason: "x" });
|
||||||
|
const before = shift.drawerBalance().balanceMinor;
|
||||||
|
expect(before).toBe(-10000); // the disbursement counted immediately
|
||||||
|
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||||||
|
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||||||
|
// Balance UNCHANGED by the denial — the correction is settled outside the app.
|
||||||
|
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a denied movement in a CLOSED shift never leaks into the next operator's drawer", async () => {
|
||||||
|
// The regression that motivated the redesign: op1 disburses, shift closes, op2
|
||||||
|
// inherits; op1's disbursement is later DENIED. op2's drawer must be untouched.
|
||||||
|
await shift.open("op1");
|
||||||
|
await shift.recordVoucher({ type: "cash_out", operator: "op1", amountMinor: 10000, reason: "questionable" });
|
||||||
|
const closed = await shift.close("op1");
|
||||||
|
expect(closed.expectedDrawerMinor).toBe(-10000);
|
||||||
|
|
||||||
|
const next = await shift.open("op2");
|
||||||
|
expect(next.openingFloatMinor).toBe(-10000); // op2 inherits the real till balance
|
||||||
|
|
||||||
|
const id = shift.movementsWithStatus({ operator: "op1" })[0].id;
|
||||||
|
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||||||
|
|
||||||
|
// op2's drawer is STILL -10000 — the denial added no reversing cash.
|
||||||
|
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||||||
|
expect(shift.currentReport()!.openingFloatMinor).toBe(-10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects reviewing a non-movement or an already-reviewed movement", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 5000, reason: "x" });
|
||||||
|
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||||||
|
await expect(
|
||||||
|
shift.reviewMovement({ refId: "not-a-real-id", decision: "authorize", reviewedBy: "admin" }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
|
await shift.reviewMovement({ refId: id, decision: "authorize", reviewedBy: "admin" });
|
||||||
|
await expect(
|
||||||
|
shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidCashMovementError); // already reviewed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes movements by operator", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 1000, reason: "a" });
|
||||||
|
await shift.close("alice");
|
||||||
|
await shift.open("bob");
|
||||||
|
await shift.recordVoucher({ type: "cash_out", operator: "bob", amountMinor: 2000, reason: "b" });
|
||||||
|
expect(shift.movementsWithStatus({ operator: "alice" })).toHaveLength(1);
|
||||||
|
expect(shift.movementsWithStatus({ operator: "bob" })).toHaveLength(1);
|
||||||
|
expect(shift.movementsWithStatus()).toHaveLength(2); // reviewer sees all
|
||||||
|
expect(shift.movementsWithStatus({ status: "pending" })).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("close signs a Z-report; listShifts reads it back", () => {
|
describe("close signs a Z-report; listShifts reads it back", () => {
|
||||||
it("a closed shift appears in history with its split figures", async () => {
|
it("a closed shift appears in history with its split figures", async () => {
|
||||||
await shift.open("alice");
|
await shift.open("alice");
|
||||||
@@ -161,4 +234,11 @@ describe("close signs a Z-report; listShifts reads it back", () => {
|
|||||||
await shift.open("bob"); await shift.close("bob");
|
await shift.open("bob"); await shift.close("bob");
|
||||||
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("listOperators: distinct + sorted, includes the OPEN shift's operator", async () => {
|
||||||
|
await shift.open("bob"); await shift.close("bob");
|
||||||
|
await shift.open("bob"); await shift.close("bob"); // twice — must stay distinct
|
||||||
|
await shift.open("alice"); // open, no z-report yet
|
||||||
|
expect(shift.listOperators()).toEqual(["alice", "bob"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface ShiftSummary {
|
|||||||
readonly subscriptionTotalMinor: number;
|
readonly subscriptionTotalMinor: number;
|
||||||
readonly subscriptionSalesMinor: number;
|
readonly subscriptionSalesMinor: number;
|
||||||
readonly subscriptionWindowMinor: number;
|
readonly subscriptionWindowMinor: number;
|
||||||
|
readonly discountTotalMinor: number;
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
readonly cashAddedMinor: number;
|
readonly cashAddedMinor: number;
|
||||||
readonly cashRemovedMinor: number;
|
readonly cashRemovedMinor: number;
|
||||||
@@ -78,6 +79,9 @@ export interface ShiftReport {
|
|||||||
readonly subscriptionSalesMinor: number;
|
readonly subscriptionSalesMinor: number;
|
||||||
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
||||||
readonly subscriptionWindowMinor: number;
|
readonly subscriptionWindowMinor: number;
|
||||||
|
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
|
||||||
|
* cash/card figures above are already NET of it). See validation-discounts.md. */
|
||||||
|
readonly discountTotalMinor: 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;
|
||||||
@@ -90,6 +94,27 @@ export interface ShiftReport {
|
|||||||
readonly printed: boolean;
|
readonly printed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A drawer movement's admin-review status, derived from its latest `cash_review`. */
|
||||||
|
export type MovementStatus = "pending" | "authorized" | "denied";
|
||||||
|
|
||||||
|
/** One drawer cash movement (cash_in/cash_out) with its review status — the row shape for
|
||||||
|
* the operator's own list and the admin review queue. `status` is derived, not stored. */
|
||||||
|
export interface DrawerMovement {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: "cash_in" | "cash_out";
|
||||||
|
/** Positive magnitude; direction is the `type`. */
|
||||||
|
readonly amountMinor: number;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly reason: string | null;
|
||||||
|
readonly operator: string;
|
||||||
|
readonly voucherNo: string | null;
|
||||||
|
readonly at: string;
|
||||||
|
readonly status: MovementStatus;
|
||||||
|
readonly reviewedBy: string | null;
|
||||||
|
readonly reviewNote: string | null;
|
||||||
|
readonly reviewedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class InvalidCashMovementError extends Error {
|
export class InvalidCashMovementError extends Error {
|
||||||
constructor(msg: string) {
|
constructor(msg: string) {
|
||||||
super(msg);
|
super(msg);
|
||||||
@@ -156,6 +181,28 @@ export class ShiftService {
|
|||||||
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
||||||
* completed accountability period. Use `currentOpenShift()` for the live one.
|
* completed accountability period. Use `currentOpenShift()` for the live one.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Every operator that HAS a shift (closed z_reports + the open one, if any),
|
||||||
|
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
|
||||||
|
* for an operator that exists (the filter is an exact username match).
|
||||||
|
*/
|
||||||
|
listOperators(): string[] {
|
||||||
|
const rows = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "shift_z_report"))
|
||||||
|
.all();
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
|
||||||
|
if (op) names.add(op);
|
||||||
|
}
|
||||||
|
const open = this.currentOpenShift();
|
||||||
|
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
|
||||||
|
if (openOp) names.add(openOp);
|
||||||
|
return [...names].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
.select()
|
.select()
|
||||||
@@ -177,6 +224,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor?: number;
|
subscriptionTotalMinor?: number;
|
||||||
subscriptionSalesMinor?: number;
|
subscriptionSalesMinor?: number;
|
||||||
subscriptionWindowMinor?: number;
|
subscriptionWindowMinor?: number;
|
||||||
|
discountTotalMinor?: number;
|
||||||
openingFloatMinor?: number;
|
openingFloatMinor?: number;
|
||||||
cashAddedMinor?: number;
|
cashAddedMinor?: number;
|
||||||
cashRemovedMinor?: number;
|
cashRemovedMinor?: number;
|
||||||
@@ -207,6 +255,8 @@ export class ShiftService {
|
|||||||
ticketTotalMinor:
|
ticketTotalMinor:
|
||||||
pl.ticketTotalMinor ??
|
pl.ticketTotalMinor ??
|
||||||
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
||||||
|
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
|
||||||
|
discountTotalMinor: pl.discountTotalMinor ?? 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,
|
||||||
@@ -281,24 +331,24 @@ export class ShiftService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a drawer cash VOUCHER — the direction is the event TYPE, not the sign of
|
* Record a drawer cash MOVEMENT — the direction is the event TYPE, not the sign of an
|
||||||
* an amount (a receipt and a disbursement are different financial documents):
|
* amount (a receipt and a disbursement are different financial documents):
|
||||||
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
|
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
|
||||||
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
|
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
|
||||||
* `amountMinor` is always a POSITIVE magnitude. The voucher is OPERATOR-RAISED and
|
* `amountMinor` is always a POSITIVE magnitude. The movement is OPERATOR-RECORDED FREELY
|
||||||
* ADMIN-AUTHORIZED: `operator` raised it, `authorizedBy` signed off (verified at the
|
* (no admin sign-off at creation — 2026-07-01); an admin REVIEWS it after the fact via
|
||||||
* route). Returns the new drawer balance + the assigned voucher number, and prints
|
* `reviewMovement` (authorize/deny — a flag that never moves cash). It counts in the
|
||||||
* a slip best-effort (the signed event is the record). See wiki/concepts/shift.md.
|
* drawer immediately (the cash physically moved). Returns the new drawer balance + the
|
||||||
|
* assigned voucher number, and prints a slip best-effort. See wiki/concepts/shift.md.
|
||||||
*/
|
*/
|
||||||
async recordVoucher(args: {
|
async recordVoucher(args: {
|
||||||
type: "cash_in" | "cash_out";
|
type: "cash_in" | "cash_out";
|
||||||
operator: string;
|
operator: string;
|
||||||
authorizedBy: string;
|
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||||
const { type, operator, authorizedBy, reason } = args;
|
const { type, operator, reason } = args;
|
||||||
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
||||||
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
||||||
}
|
}
|
||||||
@@ -308,25 +358,122 @@ export class ShiftService {
|
|||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type,
|
type,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
identity: operator, // who RAISED the voucher (the operator at the booth)
|
identity: operator, // who RECORDED the movement (the operator at the booth)
|
||||||
payload: {
|
payload: {
|
||||||
amountMinor, // positive magnitude — direction is the type
|
amountMinor, // positive magnitude — direction is the type
|
||||||
...(reason ? { reason } : {}),
|
...(reason ? { reason } : {}),
|
||||||
...(args.currency ? { currency: args.currency } : {}),
|
...(args.currency ? { currency: args.currency } : {}),
|
||||||
operator,
|
operator,
|
||||||
authorizedBy,
|
|
||||||
voucherNo,
|
voucherNo,
|
||||||
},
|
},
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
});
|
});
|
||||||
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
||||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now });
|
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now });
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
`${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||||
);
|
);
|
||||||
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Appends a signed `cash_review`
|
||||||
|
* referencing the movement. This is a FLAG ONLY — a `deny` does NOT reverse the movement
|
||||||
|
* and does NOT touch the drawer balance (a denial is a judgment about the operator,
|
||||||
|
* settled outside the app). Rejects an unknown/ non-movement refId, and a movement that
|
||||||
|
* was already decided (one decision per movement; a clean audit trail). Idempotent by
|
||||||
|
* design: the drawer fold never reads `cash_review`. See wiki/concepts/shift.md.
|
||||||
|
*/
|
||||||
|
async reviewMovement(args: {
|
||||||
|
refId: string;
|
||||||
|
decision: "authorize" | "deny";
|
||||||
|
reviewedBy: string;
|
||||||
|
note?: string;
|
||||||
|
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
|
||||||
|
const { refId, decision, reviewedBy } = args;
|
||||||
|
if (decision !== "authorize" && decision !== "deny") {
|
||||||
|
throw new InvalidCashMovementError("decision must be authorize or deny");
|
||||||
|
}
|
||||||
|
const movement = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.id, refId)).get();
|
||||||
|
if (!movement || (movement.type !== "cash_in" && movement.type !== "cash_out")) {
|
||||||
|
throw new InvalidCashMovementError("refId is not a cash movement");
|
||||||
|
}
|
||||||
|
// One decision per movement — reject a re-review so the audit stays unambiguous.
|
||||||
|
const already = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "cash_review"))
|
||||||
|
.all()
|
||||||
|
.some((r) => (r.payload as LedgerPayload | null)?.refId === refId);
|
||||||
|
if (already) throw new InvalidCashMovementError("movement already reviewed");
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "cash_review",
|
||||||
|
source: "manual",
|
||||||
|
identity: reviewedBy, // the admin who decided
|
||||||
|
payload: {
|
||||||
|
refId,
|
||||||
|
decision,
|
||||||
|
reviewedBy,
|
||||||
|
...(args.note ? { note: args.note } : {}),
|
||||||
|
},
|
||||||
|
occurredAt: now,
|
||||||
|
});
|
||||||
|
this.#logger.info(`cash_review ${decision} of ${movement.type} ${refId} by ${reviewedBy}`);
|
||||||
|
return { refId, decision, reviewedBy, at: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All drawer cash movements (cash_in/cash_out) with their review STATUS, newest first.
|
||||||
|
* Status is derived from the latest `cash_review` referencing each movement: none →
|
||||||
|
* `pending`, else `authorized`/`denied`. Powers the operator's own list and the admin
|
||||||
|
* review queue. `operator` (optional) scopes to one operator's movements (an operator
|
||||||
|
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
|
||||||
|
*/
|
||||||
|
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] {
|
||||||
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
|
// Latest review decision per movement id.
|
||||||
|
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "cash_review") continue;
|
||||||
|
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||||
|
if (!pl.refId || (pl.decision !== "authorize" && pl.decision !== "deny")) continue;
|
||||||
|
reviewByRef.set(pl.refId, {
|
||||||
|
decision: pl.decision,
|
||||||
|
reviewedBy: pl.reviewedBy ?? "",
|
||||||
|
...(pl.note ? { note: pl.note } : {}),
|
||||||
|
at: r.occurredAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const out: DrawerMovement[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "cash_in" && r.type !== "cash_out") continue;
|
||||||
|
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||||
|
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
|
||||||
|
if (filter?.operator && operator !== filter.operator) continue;
|
||||||
|
const review = reviewByRef.get(r.id);
|
||||||
|
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
|
||||||
|
if (filter?.status && status !== filter.status) continue;
|
||||||
|
out.push({
|
||||||
|
id: r.id,
|
||||||
|
type: r.type,
|
||||||
|
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
|
||||||
|
currency: pl.currency ?? null,
|
||||||
|
reason: pl.reason ?? null,
|
||||||
|
operator,
|
||||||
|
voucherNo: pl.voucherNo ?? null,
|
||||||
|
at: r.occurredAt,
|
||||||
|
status,
|
||||||
|
reviewedBy: review?.reviewedBy ?? null,
|
||||||
|
reviewNote: review?.note ?? null,
|
||||||
|
reviewedAt: review?.at ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Newest first.
|
||||||
|
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||||
* inherited from the chain = the drawer balance at the start instant. */
|
* inherited from the chain = the drawer balance at the start instant. */
|
||||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||||
@@ -382,6 +529,9 @@ export class ShiftService {
|
|||||||
// the subscription sale path).
|
// the subscription sale path).
|
||||||
let subscriptionSalesMinor = 0;
|
let subscriptionSalesMinor = 0;
|
||||||
let subscriptionWindowMinor = 0;
|
let subscriptionWindowMinor = 0;
|
||||||
|
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
|
||||||
|
// tender totals are already NET; this is the "given away" figure beside them.
|
||||||
|
let discountTotalMinor = 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 & {
|
||||||
@@ -394,6 +544,7 @@ export class ShiftService {
|
|||||||
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
||||||
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||||||
// (else → transient ticket; derived below as total − subscription)
|
// (else → transient ticket; derived below as total − subscription)
|
||||||
|
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
|
||||||
if (pl.currency) currency = pl.currency;
|
if (pl.currency) currency = pl.currency;
|
||||||
}
|
}
|
||||||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||||
@@ -449,6 +600,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -487,6 +639,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -509,6 +662,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -548,16 +702,20 @@ export class ShiftService {
|
|||||||
"",
|
"",
|
||||||
"-- Arkëtime sipas burimit --",
|
"-- Arkëtime sipas burimit --",
|
||||||
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
||||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||||
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
|
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||||
|
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||||
|
// Merchant-validation leakage — printed only when the shift actually gave any
|
||||||
|
// (older slips stay byte-identical). The takings above are already NET of it.
|
||||||
|
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
|
||||||
"",
|
"",
|
||||||
"-- Arka --",
|
"-- Arka --",
|
||||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
`Para të grumbulluara: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
`Arkëtime: ${money(r.cashAddedMinor)} ${cur}`,
|
||||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||||
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
|
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||||
@@ -577,7 +735,6 @@ export class ShiftService {
|
|||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
authorizedBy: string;
|
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
at: string;
|
at: string;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
@@ -596,8 +753,7 @@ export class ShiftService {
|
|||||||
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
||||||
`Arsyeja: ${v.reason || "-"}`,
|
`Arsyeja: ${v.reason || "-"}`,
|
||||||
"",
|
"",
|
||||||
`Hapur nga: ${v.operator}`,
|
`Regjistroi: ${v.operator}`,
|
||||||
`Autorizoi: ${v.authorizedBy}`,
|
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
await printer.printReport({ title, lines });
|
await printer.printReport({ title, lines });
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { snapshots, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { pruneSnapshots, type DiskUsage, type SnapshotRetention } from "./snapshot-retention.js";
|
||||||
|
|
||||||
|
// Snapshot retention: DISK-PRESSURE prune. No-op unless the DB's filesystem is over the
|
||||||
|
// high-water mark; then delete the OLDEST until ~freeTargetPct of disk is freed (estimated from
|
||||||
|
// the deleted BLOB sizes), honoring a MIN_KEEP floor, then VACUUM once. Disk usage is injected
|
||||||
|
// so the test controls the trigger without touching the real filesystem.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Insert `n` snapshots, oldest first (s-0 is the oldest), each `bytes` long. */
|
||||||
|
function seed(n: number, bytes = 1000): void {
|
||||||
|
const t0 = Date.now() - n * 1000;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
db.insert(snapshots)
|
||||||
|
.values({
|
||||||
|
id: `s-${i}`,
|
||||||
|
direction: "entry",
|
||||||
|
deviceId: "cam",
|
||||||
|
identity: `s-${i}`,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
bytes: Buffer.alloc(bytes, 1),
|
||||||
|
capturedAt: new Date(t0 + i * 1000).toISOString(), // s-0 oldest … s-(n-1) newest
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(): number {
|
||||||
|
return db.select().from(snapshots).all().length;
|
||||||
|
}
|
||||||
|
function ids(): string[] {
|
||||||
|
return db.select().from(snapshots).all().map((r) => r.id).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fake disk at a given used% on a 1 GB volume. */
|
||||||
|
const disk = (usedPct: number, totalBytes = 1_000_000_000): (() => Promise<DiskUsage>) =>
|
||||||
|
() => Promise.resolve({ usedPct, totalBytes });
|
||||||
|
|
||||||
|
const ret = (o: Partial<SnapshotRetention>): SnapshotRetention => ({
|
||||||
|
highPct: 70,
|
||||||
|
freeTargetPct: 10,
|
||||||
|
minKeep: 2,
|
||||||
|
batch: 5,
|
||||||
|
...o,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pruneSnapshots (disk-pressure)", () => {
|
||||||
|
it("no-op when disk is below the high-water mark", async () => {
|
||||||
|
seed(10);
|
||||||
|
const res = await pruneSnapshots(db, { retention: ret({}), diskUsage: disk(50) });
|
||||||
|
expect(res.deletedRows).toBe(0);
|
||||||
|
expect(res.vacuumed).toBe(false);
|
||||||
|
expect(count()).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("over the mark: deletes the OLDEST until ~freeTargetPct is freed, then VACUUMs", async () => {
|
||||||
|
// 1 GB disk, target 10% = 100 MB. Each snapshot 20 MB → ~5 deletions reach the target.
|
||||||
|
seed(20, 20 * 1048576);
|
||||||
|
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
|
||||||
|
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, batch: 100 }), diskUsage: disk(80) });
|
||||||
|
expect(res.deletedRows).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(res.freedBytesEst).toBeGreaterThanOrEqual(0.1 * 1_000_000_000);
|
||||||
|
expect(res.vacuumed).toBe(true);
|
||||||
|
expect(vacuumSpy).toHaveBeenCalledWith("VACUUM");
|
||||||
|
// The survivors are the NEWEST (oldest went first).
|
||||||
|
const survivors = ids();
|
||||||
|
expect(survivors).toContain(`s-19`); // newest kept
|
||||||
|
expect(survivors).not.toContain(`s-0`); // oldest pruned
|
||||||
|
vacuumSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors the MIN_KEEP floor even when still over target", async () => {
|
||||||
|
// Target 10% of 1 GB = 100 MB, but only 3 tiny snapshots exist and minKeep=2 → at most 1 deleted.
|
||||||
|
seed(3, 1000);
|
||||||
|
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, freeTargetPct: 90 }), diskUsage: disk(95) });
|
||||||
|
expect(res.deletedRows).toBe(1); // 3 − minKeep(2)
|
||||||
|
expect(count()).toBe(2);
|
||||||
|
expect(res.floorHitWhileOver).toBe(true); // couldn't reach target without crossing the floor
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips VACUUM when nothing was deleted", async () => {
|
||||||
|
seed(2); // == minKeep, so nothing to delete even over the mark
|
||||||
|
const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec");
|
||||||
|
const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2 }), diskUsage: disk(99) });
|
||||||
|
expect(res.deletedRows).toBe(0);
|
||||||
|
expect(res.vacuumed).toBe(false);
|
||||||
|
expect(vacuumSpy).not.toHaveBeenCalled();
|
||||||
|
vacuumSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { statfs } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { asc, snapshots, sql, type Db } from "@parking/db";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
|
||||||
|
// Snapshot retention — DISK-PRESSURE model. Camera snapshots are unsigned, advisory, prunable
|
||||||
|
// BLOBs (see snapshot.ts); they're referenced by the signed ledger only by id, so pruning an
|
||||||
|
// old image never affects the chain. They no longer dominate the DB day-to-day (captures are
|
||||||
|
// re-encoded small at SNAPSHOT_MAX_EDGE/JPEG_QUALITY), so this is a SAFETY VALVE: only when the
|
||||||
|
// filesystem holding the DB crosses a high-water mark do we delete the OLDEST snapshots and
|
||||||
|
// VACUUM to return disk to the OS.
|
||||||
|
//
|
||||||
|
// Why estimated-bytes, not live disk%: a DELETE only frees SQLite *pages* — the file (and thus
|
||||||
|
// OS disk usage) doesn't shrink until VACUUM. So the prune loop can't watch usedPct fall in real
|
||||||
|
// time. Instead it sums LENGTH(bytes) of the rows it deletes and stops when that estimate reaches
|
||||||
|
// the free-target, then VACUUMs ONCE at the end to realize the space. A MIN_KEEP floor always
|
||||||
|
// wins — we never delete evidence below it, even under pressure (if the disk is full of something
|
||||||
|
// else, that's not ours to fix).
|
||||||
|
|
||||||
|
export interface SnapshotRetention {
|
||||||
|
/** Prune when the DB's filesystem is at least this % used. */
|
||||||
|
readonly highPct: number;
|
||||||
|
/** Try to free roughly this % of the disk per run (the delete target). */
|
||||||
|
readonly freeTargetPct: number;
|
||||||
|
/** Never prune below this many snapshots (the floor). */
|
||||||
|
readonly minKeep: number;
|
||||||
|
/** Delete oldest in batches of this size (re-checks between batches). */
|
||||||
|
readonly batch: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SNAPSHOT_RETENTION: SnapshotRetention = {
|
||||||
|
highPct: Number(process.env.SNAPSHOT_DISK_HIGH_PCT ?? 70),
|
||||||
|
freeTargetPct: Number(process.env.SNAPSHOT_DISK_FREE_TARGET_PCT ?? 10),
|
||||||
|
minKeep: Number(process.env.SNAPSHOT_MIN_KEEP ?? 500),
|
||||||
|
batch: Number(process.env.SNAPSHOT_PRUNE_BATCH ?? 200),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Disk usage of the filesystem holding the DB. Injectable so tests don't touch the real FS. */
|
||||||
|
export interface DiskUsage {
|
||||||
|
readonly usedPct: number;
|
||||||
|
readonly totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PruneOptions {
|
||||||
|
readonly retention?: SnapshotRetention;
|
||||||
|
/** Override how disk usage is read (tests inject a fake; default = statfs the DB's FS). */
|
||||||
|
readonly diskUsage?: () => Promise<DiskUsage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PruneResult {
|
||||||
|
readonly deletedRows: number;
|
||||||
|
readonly freedBytesEst: number;
|
||||||
|
readonly vacuumed: boolean;
|
||||||
|
readonly usedPctBefore: number;
|
||||||
|
/** True if we hit the MIN_KEEP floor while the disk was still over the high-water mark. */
|
||||||
|
readonly floorHitWhileOver: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the used% + total bytes of the filesystem holding the DB file. */
|
||||||
|
async function diskUsageForDb(db: Db): Promise<DiskUsage> {
|
||||||
|
const file = (db.$client as { name?: string }).name ?? process.env.DATABASE_URL ?? "./parking.sqlite";
|
||||||
|
const st = await statfs(dirname(resolve(file)));
|
||||||
|
const total = st.blocks * st.bsize;
|
||||||
|
const avail = st.bavail * st.bsize;
|
||||||
|
const usedPct = total > 0 ? (1 - avail / total) * 100 : 0;
|
||||||
|
return { usedPct, totalBytes: total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune snapshots under DISK PRESSURE. No-op unless the DB's filesystem is ≥ highPct used. When
|
||||||
|
* over, deletes the OLDEST snapshots until an estimated freeTargetPct of the disk is freed (or the
|
||||||
|
* minKeep floor is hit, or no rows remain), then VACUUMs once. Best-effort; safe on a timer.
|
||||||
|
*/
|
||||||
|
export async function pruneSnapshots(
|
||||||
|
db: Db,
|
||||||
|
opts: PruneOptions = {},
|
||||||
|
logger?: FastifyBaseLogger,
|
||||||
|
): Promise<PruneResult> {
|
||||||
|
const r = opts.retention ?? DEFAULT_SNAPSHOT_RETENTION;
|
||||||
|
const readDisk = opts.diskUsage ?? (() => diskUsageForDb(db));
|
||||||
|
|
||||||
|
let usedPctBefore = 0;
|
||||||
|
try {
|
||||||
|
const disk = await readDisk();
|
||||||
|
usedPctBefore = disk.usedPct;
|
||||||
|
|
||||||
|
// The overwhelmingly common case: plenty of headroom → do nothing.
|
||||||
|
if (disk.usedPct < r.highPct) {
|
||||||
|
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target bytes to free this run (≈ freeTargetPct of the whole disk).
|
||||||
|
const targetBytes = (r.freeTargetPct / 100) * disk.totalBytes;
|
||||||
|
|
||||||
|
let freedBytesEst = 0;
|
||||||
|
let deletedRows = 0;
|
||||||
|
let floorHitWhileOver = false;
|
||||||
|
|
||||||
|
// Delete the oldest in batches, summing their BLOB sizes, until we've freed the target — or
|
||||||
|
// we'd cross the MIN_KEEP floor — or there are no more rows.
|
||||||
|
for (;;) {
|
||||||
|
const count = db.select({ c: sql<number>`count(*)` }).from(snapshots).get()?.c ?? 0;
|
||||||
|
if (count <= r.minKeep) {
|
||||||
|
floorHitWhileOver = true; // still over the high-water mark but can't delete below the floor
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (freedBytesEst >= targetBytes) break;
|
||||||
|
|
||||||
|
const room = count - r.minKeep; // how many we may still delete before the floor
|
||||||
|
const take = Math.min(r.batch, room);
|
||||||
|
const oldest = db
|
||||||
|
.select({ id: snapshots.id, len: sql<number>`length(${snapshots.bytes})` })
|
||||||
|
.from(snapshots)
|
||||||
|
.orderBy(asc(snapshots.capturedAt))
|
||||||
|
.limit(take)
|
||||||
|
.all();
|
||||||
|
if (oldest.length === 0) break;
|
||||||
|
|
||||||
|
const ids = oldest.map((o) => o.id);
|
||||||
|
db.delete(snapshots).where(sql`${snapshots.id} in (${sql.join(ids, sql`, `)})`).run();
|
||||||
|
deletedRows += oldest.length;
|
||||||
|
freedBytesEst += oldest.reduce((s, o) => s + (o.len ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Realize the freed space: VACUUM returns pages to the OS (the file shrinks). Only if we
|
||||||
|
// actually deleted something. Non-fatal on failure — pages are still freed for reuse.
|
||||||
|
let vacuumed = false;
|
||||||
|
if (deletedRows > 0) {
|
||||||
|
try {
|
||||||
|
(db.$client as { exec: (sql: string) => void }).exec("VACUUM");
|
||||||
|
vacuumed = true;
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn(`snapshot prune: VACUUM failed (pages freed for reuse): ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (floorHitWhileOver) {
|
||||||
|
logger?.warn(
|
||||||
|
`snapshot prune: disk ${usedPctBefore.toFixed(0)}% used but hit MIN_KEEP floor (${r.minKeep}) ` +
|
||||||
|
`after deleting ${deletedRows} — disk pressure is not from snapshots`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { deletedRows, freedBytesEst, vacuumed, usedPctBefore, floorHitWhileOver };
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn(`snapshot prune failed: ${(err as Error).message}`);
|
||||||
|
return { deletedRows: 0, freedBytesEst: 0, vacuumed: false, usedPctBefore, floorHitWhileOver: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import type { CameraDevice, Snapshot } from "@parking/devices";
|
||||||
|
import { captureSnapshotShared, cleanType, encodeForStorage } from "./snapshot.js";
|
||||||
|
import { silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves
|
||||||
|
// snapshots SINGLE-THREADED (a 2nd concurrent GET → HTTP 503). On an entry the ANPR
|
||||||
|
// bridge AND the advisory snapshotAsync both capture the same camera within ~1s, each
|
||||||
|
// from a SEPARATE adapter instance — so this deviceId-keyed cache coalesces in-flight
|
||||||
|
// captures and serves a brief freshness window, collapsing the two into one real pull.
|
||||||
|
// (Root cause of the slow 2026-06-25 subscriber entry.)
|
||||||
|
|
||||||
|
/** A fake camera whose captureSnapshot is controllable (count calls, delay, fail). */
|
||||||
|
function fakeCamera(opts: { delayMs?: number; fail?: boolean; tag?: string } = {}): {
|
||||||
|
camera: CameraDevice;
|
||||||
|
calls: () => number;
|
||||||
|
} {
|
||||||
|
let calls = 0;
|
||||||
|
const tag = opts.tag ?? "x";
|
||||||
|
const camera = {
|
||||||
|
async captureSnapshot(): Promise<Snapshot> {
|
||||||
|
calls++;
|
||||||
|
if (opts.delayMs) await new Promise((r) => setTimeout(r, opts.delayMs));
|
||||||
|
if (opts.fail) throw new Error("HTTP 503");
|
||||||
|
// Tag distinguishes frames from different cameras (the per-camera keying test).
|
||||||
|
return { bytes: Buffer.from(`shot-${tag}-${calls}`), contentType: "image/jpeg", capturedAt: new Date().toISOString() };
|
||||||
|
},
|
||||||
|
} as unknown as CameraDevice;
|
||||||
|
return { camera, calls: () => calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A unique deviceId per test so the module-level cache never bleeds across cases. */
|
||||||
|
function id(): string {
|
||||||
|
return `cam-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("captureSnapshotShared", () => {
|
||||||
|
it("coalesces CONCURRENT captures into a single hardware pull (the 503 fix)", async () => {
|
||||||
|
const { camera, calls } = fakeCamera({ delayMs: 20 });
|
||||||
|
const dev = id();
|
||||||
|
// The bridge and the advisory path fire at nearly the same instant.
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
captureSnapshotShared(dev, camera, { direction: "entry" }),
|
||||||
|
captureSnapshotShared(dev, camera, { direction: "entry" }),
|
||||||
|
]);
|
||||||
|
expect(calls()).toBe(1); // ONE GET, not two — no concurrent 503
|
||||||
|
expect(a.bytes.equals(b.bytes)).toBe(true); // both got the same frame
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses a fresh capture within the TTL (sequential, same vehicle)", async () => {
|
||||||
|
const { camera, calls } = fakeCamera();
|
||||||
|
const dev = id();
|
||||||
|
const a = await captureSnapshotShared(dev, camera, { direction: "entry" });
|
||||||
|
const b = await captureSnapshotShared(dev, camera, { direction: "entry" }); // ~0ms later
|
||||||
|
expect(calls()).toBe(1); // 2nd call served from the freshness cache
|
||||||
|
expect(a.bytes.equals(b.bytes)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pulls AGAIN after the TTL lapses (a later, different vehicle)", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { camera, calls } = fakeCamera();
|
||||||
|
const dev = id();
|
||||||
|
await captureSnapshotShared(dev, camera, { direction: "entry" });
|
||||||
|
expect(calls()).toBe(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000); // past SNAPSHOT_TTL_MS (1500)
|
||||||
|
await captureSnapshotShared(dev, camera, { direction: "entry" });
|
||||||
|
expect(calls()).toBe(2); // stale → a real new pull (never a stale frame for a new car)
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT cache a failure — the next caller retries", async () => {
|
||||||
|
const dev = id();
|
||||||
|
const failing = fakeCamera({ fail: true });
|
||||||
|
await expect(captureSnapshotShared(dev, failing.camera, { direction: "entry" })).rejects.toThrow("503");
|
||||||
|
// A subsequent capture (camera recovered) must actually pull, not inherit the error.
|
||||||
|
const ok = fakeCamera();
|
||||||
|
const shot = await captureSnapshotShared(dev, ok.camera, { direction: "entry" });
|
||||||
|
expect(shot.bytes.toString()).toBe("shot-x-1");
|
||||||
|
expect(ok.calls()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys by deviceId — different cameras never share a frame", async () => {
|
||||||
|
const c1 = fakeCamera({ tag: "A" });
|
||||||
|
const c2 = fakeCamera({ tag: "B" });
|
||||||
|
const s1 = await captureSnapshotShared("cam-A", c1.camera, { direction: "entry" });
|
||||||
|
const s2 = await captureSnapshotShared("cam-B", c2.camera, { direction: "entry" });
|
||||||
|
expect(c1.calls()).toBe(1);
|
||||||
|
expect(c2.calls()).toBe(1);
|
||||||
|
expect(s1.bytes.equals(s2.bytes)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// encodeForStorage: downscale + re-compress a captured frame for STORAGE (smaller, plate
|
||||||
|
// still readable). Recognition uses the original; this never runs on the OCR path. Fail-soft.
|
||||||
|
describe("encodeForStorage", () => {
|
||||||
|
/** A big synthetic JPEG (2688×1520, the Hikvision main-stream size) to downscale. */
|
||||||
|
async function bigJpeg(): Promise<Buffer> {
|
||||||
|
return sharp({
|
||||||
|
create: { width: 2688, height: 1520, channels: 3, background: { r: 120, g: 130, b: 140 } },
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 95 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("downscales the long edge to ≤1280 and emits clean image/jpeg", async () => {
|
||||||
|
const bytes = await bigJpeg();
|
||||||
|
const shot: Snapshot = { bytes, contentType: 'image/jpeg; charset="UTF-8"', capturedAt: new Date().toISOString() };
|
||||||
|
const out = await encodeForStorage(shot, silentLogger());
|
||||||
|
expect(out.contentType).toBe("image/jpeg"); // charset cruft stripped
|
||||||
|
const meta = await sharp(out.bytes).metadata();
|
||||||
|
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1280);
|
||||||
|
expect(out.bytes.length).toBeLessThan(bytes.length); // smaller than the original
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never enlarges an already-small image", async () => {
|
||||||
|
const small = await sharp({ create: { width: 640, height: 360, channels: 3, background: { r: 0, g: 0, b: 0 } } })
|
||||||
|
.jpeg()
|
||||||
|
.toBuffer();
|
||||||
|
const out = await encodeForStorage(
|
||||||
|
{ bytes: small, contentType: "image/jpeg", capturedAt: new Date().toISOString() },
|
||||||
|
silentLogger(),
|
||||||
|
);
|
||||||
|
const meta = await sharp(out.bytes).metadata();
|
||||||
|
expect(meta.width).toBe(640); // withoutEnlargement
|
||||||
|
expect(meta.height).toBe(360);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft: a non-image body is stored unchanged with a cleaned type", async () => {
|
||||||
|
const garbage = Buffer.from("this is not an image");
|
||||||
|
const out = await encodeForStorage(
|
||||||
|
{ bytes: garbage, contentType: 'text/plain; charset="UTF-8"', capturedAt: new Date().toISOString() },
|
||||||
|
silentLogger(),
|
||||||
|
);
|
||||||
|
expect(out.bytes.equals(garbage)).toBe(true); // original bytes, never dropped
|
||||||
|
expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanType", () => {
|
||||||
|
it("strips a camera's charset cruft so a binary JPEG renders", () => {
|
||||||
|
// The exact malformed value some cameras (Hikvision) return, which broke the
|
||||||
|
// snapshot strip for every legacy row until the serve route normalized it.
|
||||||
|
expect(cleanType('image/jpeg; charset="UTF-8"')).toBe("image/jpeg");
|
||||||
|
expect(cleanType("image/jpeg; charset=utf-8")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a clean type through and defaults a missing one", () => {
|
||||||
|
expect(cleanType("image/jpeg")).toBe("image/jpeg");
|
||||||
|
expect(cleanType("image/png")).toBe("image/png");
|
||||||
|
expect(cleanType(null)).toBe("image/jpeg");
|
||||||
|
expect(cleanType(undefined)).toBe("image/jpeg");
|
||||||
|
expect(cleanType("")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
});
|
||||||
+199
-7
@@ -1,8 +1,12 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
import sharp from "sharp";
|
||||||
import { registry, type CameraDevice } from "@parking/devices";
|
import { and, eq, gte, sessions, deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||||
|
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
||||||
|
import { reasonPayload } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||||
|
import { deviceEvents } from "./device-events.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
||||||
@@ -26,6 +30,47 @@ import type { VisionClient } from "./vision-client.js";
|
|||||||
// fire-and-forget: it never blocks the open and never changes the entry/exit decision —
|
// fire-and-forget: it never blocks the open and never changes the entry/exit decision —
|
||||||
// it's a record ("session X entered on plate AA558EE"). No polling; recognition only
|
// it's a record ("session X entered on plate AA558EE"). No polling; recognition only
|
||||||
// happens on a real entry/exit. See wiki/entities/opencv-anpr-service.md.
|
// happens on a real entry/exit. See wiki/entities/opencv-anpr-service.md.
|
||||||
|
//
|
||||||
|
// STORAGE RE-ENCODE (2026-06-28). Cameras serve full-res JPEGs (a Hikvision main stream is
|
||||||
|
// 2688×1520 / ~600 KB); stored raw, snapshots dominated the appliance DB (~72%). Each frame
|
||||||
|
// is now downscaled (long edge ≤ SNAPSHOT_MAX_EDGE) + re-compressed (q SNAPSHOT_JPEG_QUALITY)
|
||||||
|
// BEFORE storage — ~6–10× smaller, plate still clearly readable. RECOGNITION runs on the
|
||||||
|
// ORIGINAL full-res bytes (downscaling hurts OCR); the re-encode is storage-only. Fail-soft:
|
||||||
|
// a re-encode error stores the original, never drops the snapshot or blocks the open.
|
||||||
|
|
||||||
|
/** Long-edge cap (px) + JPEG quality for the STORED snapshot. Env-overridable per appliance. */
|
||||||
|
const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280);
|
||||||
|
const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80);
|
||||||
|
|
||||||
|
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). A bare
|
||||||
|
* `image/jpeg` renders; `image/jpeg; charset="UTF-8"` (what some cameras return, e.g.
|
||||||
|
* Hikvision) is malformed for a binary body and browsers refuse to decode it. Applied
|
||||||
|
* both on capture AND when serving, so legacy rows stored before this normalization
|
||||||
|
* existed still serve a clean type. */
|
||||||
|
export function cleanType(ct: string | null | undefined): string {
|
||||||
|
const base = ct?.split(";")[0]?.trim();
|
||||||
|
return base || "image/jpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downscale + re-encode a captured frame for STORAGE (evidence, not OCR). Caps the long edge
|
||||||
|
* and re-compresses to JPEG. Fail-soft: any error (e.g. a non-image body) returns the original
|
||||||
|
* bytes with a cleaned content type, so a snapshot is never lost. */
|
||||||
|
export async function encodeForStorage(
|
||||||
|
shot: Snapshot,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): Promise<{ bytes: Buffer; contentType: string }> {
|
||||||
|
try {
|
||||||
|
const out = await sharp(shot.bytes, { failOn: "none" })
|
||||||
|
.rotate() // honor EXIF orientation before we drop the metadata
|
||||||
|
.resize({ width: SNAP_MAX_EDGE, height: SNAP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: SNAP_QUALITY, mozjpeg: true })
|
||||||
|
.toBuffer();
|
||||||
|
return { bytes: out, contentType: "image/jpeg" };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`snapshot re-encode failed, storing original: ${(err as Error).message}`);
|
||||||
|
return { bytes: shot.bytes, contentType: cleanType(shot.contentType) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface SnapshotJob {
|
interface SnapshotJob {
|
||||||
readonly db: Db;
|
readonly db: Db;
|
||||||
@@ -36,6 +81,10 @@ interface SnapshotJob {
|
|||||||
/** Optional vision client — when present, ANPR runs on each captured image from an
|
/** Optional vision client — when present, ANPR runs on each captured image from an
|
||||||
* `anpr`-enabled camera and records the plate against `identity`. Advisory only. */
|
* `anpr`-enabled camera and records the plate against `identity`. Advisory only. */
|
||||||
readonly vision?: VisionClient | null;
|
readonly vision?: VisionClient | null;
|
||||||
|
/** Optional signed ledger — when present (the transient ENTRY path passes it), a
|
||||||
|
* recognized entry plate that is already OPEN under another recent session signs an
|
||||||
|
* `entry.duplicatePlate` anomaly (same car, second ticket). Post-hoc; never a gate. */
|
||||||
|
readonly log?: EventLog | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Camera config flag opting it into snapshot-triggered ANPR. */
|
/** Camera config flag opting it into snapshot-triggered ANPR. */
|
||||||
@@ -50,7 +99,7 @@ interface CameraConfig {
|
|||||||
* The caller must NOT block its open path on this.
|
* The caller must NOT block its open path on this.
|
||||||
*/
|
*/
|
||||||
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||||
const { db, direction, identity, logger, vision } = job;
|
const { db, direction, identity, logger, vision, log } = job;
|
||||||
const rows = devicesByDirection(db, "camera", direction);
|
const rows = devicesByDirection(db, "camera", direction);
|
||||||
if (rows.length === 0) return Promise.resolve([]);
|
if (rows.length === 0) return Promise.resolve([]);
|
||||||
|
|
||||||
@@ -62,16 +111,21 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const shot = await camera.captureSnapshot({ direction });
|
// Shared capture: if the ANPR bridge just pulled this camera's frame for the
|
||||||
|
// same vehicle, reuse it instead of a 2nd concurrent GET (which 503s).
|
||||||
|
const shot = await captureSnapshotShared(row.id, camera, { direction });
|
||||||
const id: string = randomUUID();
|
const id: string = randomUUID();
|
||||||
|
// Re-encode for STORAGE only (downscale + recompress). Recognition below still
|
||||||
|
// uses the original full-res `shot`.
|
||||||
|
const stored = await encodeForStorage(shot, logger);
|
||||||
db.insert(snapshots)
|
db.insert(snapshots)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
direction,
|
direction,
|
||||||
deviceId: row.id,
|
deviceId: row.id,
|
||||||
identity,
|
identity,
|
||||||
contentType: shot.contentType,
|
contentType: stored.contentType,
|
||||||
bytes: shot.bytes,
|
bytes: stored.bytes,
|
||||||
capturedAt: shot.capturedAt,
|
capturedAt: shot.capturedAt,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
@@ -81,7 +135,7 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
|||||||
// ANPR off the SAME image, tied to the SAME session — when vision is enabled
|
// ANPR off the SAME image, tied to the SAME session — when vision is enabled
|
||||||
// and this camera opts in. Fire-and-forget: never delays the open path.
|
// and this camera opts in. Fire-and-forget: never delays the open path.
|
||||||
if (vision?.enabled && (row.config as CameraConfig)?.anpr === true) {
|
if (vision?.enabled && (row.config as CameraConfig)?.anpr === true) {
|
||||||
void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger);
|
void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger, log);
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -109,6 +163,7 @@ async function recognizePlate(
|
|||||||
snapshotId: string,
|
snapshotId: string,
|
||||||
shot: { bytes: Buffer; contentType: string },
|
shot: { bytes: Buffer; contentType: string },
|
||||||
logger: FastifyBaseLogger,
|
logger: FastifyBaseLogger,
|
||||||
|
log?: EventLog | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const result = await vision.analyze(shot.bytes, shot.contentType);
|
const result = await vision.analyze(shot.bytes, shot.contentType);
|
||||||
@@ -136,11 +191,84 @@ async function recognizePlate(
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
|
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`);
|
||||||
|
// The session's entry/exit event already shipped without this (async) plate — tell the
|
||||||
|
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
|
||||||
|
deviceEvents.emitPlateRecognized({ identity, plate, direction });
|
||||||
|
|
||||||
|
// ENTRY-SIDE duplicate check: this plate already OPEN under another recent session is
|
||||||
|
// most likely the SAME car that minted a second ticket (a motion radar drops a
|
||||||
|
// stationary car → the button re-arms). Signed anomaly for the operator to void.
|
||||||
|
if (direction === "entry" && log) {
|
||||||
|
await flagDuplicateEntryPlate({ db, log, identity, plate, snapshotId, logger });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How far back a recognized entry plate is compared against other OPEN sessions'
|
||||||
|
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car
|
||||||
|
* re-pressing within minutes; a long window would flag legit re-visits. */
|
||||||
|
function dupPlateWindowMs(): number {
|
||||||
|
const raw = Number(process.env.ENTRY_DUP_PLATE_WINDOW_MIN ?? 15);
|
||||||
|
return (Number.isFinite(raw) && raw > 0 ? raw : 15) * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag a freshly-recognized ENTRY plate that is already open under a DIFFERENT recent
|
||||||
|
* session: sign ONE `entry.duplicatePlate` anomaly keyed to the new session, pointing at
|
||||||
|
* the prior one. Mirrors the exit-side plateSwapSuspected pattern (advisory, post-hoc —
|
||||||
|
* the barrier already opened; the operator voids the duplicate ticket). Exported for tests.
|
||||||
|
*/
|
||||||
|
export async function flagDuplicateEntryPlate(opts: {
|
||||||
|
db: Db;
|
||||||
|
log: EventLog;
|
||||||
|
/** The session the plate was just recognized for (the NEW ticket). */
|
||||||
|
identity: string;
|
||||||
|
plate: string;
|
||||||
|
snapshotId: string;
|
||||||
|
logger: FastifyBaseLogger;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { db, log, identity, plate, snapshotId, logger } = opts;
|
||||||
|
try {
|
||||||
|
const cutoff = new Date(Date.now() - dupPlateWindowMs()).toISOString();
|
||||||
|
// Recent entry-plate reads (unsigned `kind:"read"` telemetry, written above) for the
|
||||||
|
// same plate under a different identity. detail is JSON — filter in JS; read volume
|
||||||
|
// inside the window is tiny (one row per entry).
|
||||||
|
const reads = db
|
||||||
|
.select()
|
||||||
|
.from(deviceEventsTable)
|
||||||
|
.where(and(eq(deviceEventsTable.kind, "read"), gte(deviceEventsTable.occurredAt, cutoff)))
|
||||||
|
.all();
|
||||||
|
const prior = reads
|
||||||
|
.map((r) => r.detail as { identity?: string; direction?: string; plate?: string })
|
||||||
|
.find((d) => d.direction === "entry" && d.plate === plate && d.identity && d.identity !== identity);
|
||||||
|
if (!prior?.identity) return;
|
||||||
|
// Only a still-OPEN prior session is a duplicate suspect (a closed one drove off).
|
||||||
|
const open = db
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(and(eq(sessions.id, prior.identity), eq(sessions.state, "open")))
|
||||||
|
.get();
|
||||||
|
if (!open) return;
|
||||||
|
await log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("entry.duplicatePlate", { plate, otherIdentity: prior.identity }),
|
||||||
|
duplicateEntrySuspected: true,
|
||||||
|
plate,
|
||||||
|
otherIdentity: prior.identity,
|
||||||
|
snapshotId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.warn(`duplicate entry suspected: plate ${plate} on ${identity} already open under ${prior.identity}`);
|
||||||
|
} catch (err) {
|
||||||
|
// Best-effort, post-hoc — never let the duplicate check surface on the open path.
|
||||||
|
logger.error(`duplicate-plate check failed (${identity}): ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
|
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
|
||||||
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
|
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
|
||||||
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
||||||
@@ -153,6 +281,70 @@ export function buildCamera(row: { driverId: string; config: unknown }): CameraD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- shared snapshot capture (one HTTP pull per camera per vehicle) -----------
|
||||||
|
// A Hikvision camera serves /ISAPI/.../picture SINGLE-THREADED: two concurrent
|
||||||
|
// snapshot GETs to the same unit return HTTP 503 "service busy". On a vehicle entry
|
||||||
|
// TWO paths capture the SAME camera within ~1s — the ANPR bridge (barrier-driving,
|
||||||
|
// anpr-entry.ts) and the advisory snapshotAsync (evidence + telemetry, below). They
|
||||||
|
// each `buildCamera()` a SEPARATE adapter instance, so a per-instance cache can't
|
||||||
|
// dedupe them. This module-level, deviceId-keyed cache does: it coalesces in-flight
|
||||||
|
// captures (the 2nd caller awaits the 1st's pull) AND serves a result captured within
|
||||||
|
// SNAPSHOT_TTL_MS, so the bridge + advisory share ONE frame instead of colliding into
|
||||||
|
// a 503 (which then burned the bridge's 12s debounce → the slow entry observed
|
||||||
|
// 2026-06-25; see wiki/concepts/lane-presence-and-anpr-entry.md).
|
||||||
|
|
||||||
|
/** How long a fresh capture is reused for the same camera. A car is one event for a
|
||||||
|
* couple of seconds; 1.5s comfortably spans the bridge→advisory gap without ever
|
||||||
|
* serving a stale frame for a *different* vehicle (entries are seconds apart). */
|
||||||
|
const SNAPSHOT_TTL_MS = 1500;
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
/** A capture in flight — concurrent callers await this instead of issuing a 2nd GET. */
|
||||||
|
inflight?: Promise<Snapshot>;
|
||||||
|
/** The last SUCCESSFUL capture + when it resolved, for the freshness window. */
|
||||||
|
last?: { shot: Snapshot; at: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotCache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a snapshot for a camera, sharing ONE HTTP pull across concurrent/near-
|
||||||
|
* simultaneous callers (the ANPR bridge and the advisory snapshot). Same contract as
|
||||||
|
* `camera.captureSnapshot` (throws on failure) — a failed pull is NOT cached, so the
|
||||||
|
* next caller retries rather than inheriting the error. Key by the stable `deviceId`.
|
||||||
|
*/
|
||||||
|
export function captureSnapshotShared(
|
||||||
|
deviceId: string,
|
||||||
|
camera: CameraDevice,
|
||||||
|
ctx: { direction: FlowDirection },
|
||||||
|
): Promise<Snapshot> {
|
||||||
|
const now = Date.now();
|
||||||
|
let entry = snapshotCache.get(deviceId);
|
||||||
|
if (!entry) {
|
||||||
|
entry = {};
|
||||||
|
snapshotCache.set(deviceId, entry);
|
||||||
|
}
|
||||||
|
// Fresh enough → reuse the last frame (same vehicle, no second hardware hit).
|
||||||
|
if (entry.last && now - entry.last.at < SNAPSHOT_TTL_MS) {
|
||||||
|
return Promise.resolve(entry.last.shot);
|
||||||
|
}
|
||||||
|
// A capture is already running → join it (this is what prevents the 503 collision).
|
||||||
|
if (entry.inflight) return entry.inflight;
|
||||||
|
// Otherwise issue the single real pull; record it as the in-flight promise.
|
||||||
|
const pull = camera
|
||||||
|
.captureSnapshot(ctx)
|
||||||
|
.then((shot) => {
|
||||||
|
entry.last = { shot, at: Date.now() };
|
||||||
|
return shot;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// Clear the in-flight slot whether it resolved or threw; a failure is never cached.
|
||||||
|
if (entry.inflight === pull) entry.inflight = undefined;
|
||||||
|
});
|
||||||
|
entry.inflight = pull;
|
||||||
|
return pull;
|
||||||
|
}
|
||||||
|
|
||||||
function recordFailure(
|
function recordFailure(
|
||||||
db: Db,
|
db: Db,
|
||||||
direction: FlowDirection,
|
direction: FlowDirection,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
|
import type { DeviceReadEvent } from "./device-events.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// CHANNEL AGREEMENT in SubscriptionFlow.match (2026-07-04): when the reader CONFIRMED
|
||||||
|
// the physical channel (DT-008 output prefixes → DeviceReadEvent.channel), the
|
||||||
|
// credential kind must agree. An OPTICAL decode claiming an RF credential is the
|
||||||
|
// cheap clone (print the card's UID as a barcode) — refused + ONE signed anomaly.
|
||||||
|
// Legacy untagged reads (channel undefined) match as before, so readers without
|
||||||
|
// prefixes keep working.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let flow: SubscriptionFlow;
|
||||||
|
|
||||||
|
const SUB = "sub-1";
|
||||||
|
const CARD_UID = "86A158";
|
||||||
|
const QR_CODE = "SUB-TESTQR";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "rf", value: CARD_UID }).run();
|
||||||
|
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "qr", value: QR_CODE }).run();
|
||||||
|
flow = new SubscriptionFlow(db, makeLog(db), silentLogger());
|
||||||
|
});
|
||||||
|
|
||||||
|
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
|
||||||
|
return {
|
||||||
|
driverId: "dingtian-qr-reader",
|
||||||
|
deviceId: "reader-1",
|
||||||
|
value,
|
||||||
|
kind: opts.kind ?? "qr",
|
||||||
|
...(opts.channel ? { channel: opts.channel } : {}),
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const anomalies = () =>
|
||||||
|
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||||
|
|
||||||
|
describe("subscription match — credential channel agreement", () => {
|
||||||
|
it("OPTICAL read of an RF card's UID → no match + signed channelMismatch anomaly (the clone)", async () => {
|
||||||
|
const m = flow.match(read(CARD_UID, { kind: "qr", channel: "optical" }));
|
||||||
|
expect(m).toBeNull();
|
||||||
|
await vi.waitFor(() => expect(anomalies()).toHaveLength(1)); // append is fire-and-forget
|
||||||
|
expect(anomalies()[0].identity).toBe(SUB);
|
||||||
|
expect(anomalies()[0].payload).toMatchObject({
|
||||||
|
reasonCode: "sub.refused.channelMismatch",
|
||||||
|
channelMismatch: true,
|
||||||
|
credentialKind: "rf",
|
||||||
|
channel: "optical",
|
||||||
|
value: CARD_UID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RF read of the same card → matches (via card), nothing signed", () => {
|
||||||
|
const m = flow.match(read(CARD_UID, { kind: "card", channel: "rf" }));
|
||||||
|
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy untagged read of the card → still matches (unprefixed readers keep working)", () => {
|
||||||
|
const m = flow.match(read(CARD_UID)); // kind qr, channel undefined — today's shape
|
||||||
|
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("OPTICAL read of a QR credential → matches (the legit path)", () => {
|
||||||
|
const m = flow.match(read(QR_CODE, { kind: "qr", channel: "optical" }));
|
||||||
|
expect(m).toMatchObject({ subscriptionId: SUB, via: "qr" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RF read claiming a QR credential → refused symmetrically (mis-encoded clone tag)", async () => {
|
||||||
|
const m = flow.match(read(QR_CODE, { kind: "card", channel: "rf" }));
|
||||||
|
expect(m).toBeNull();
|
||||||
|
await vi.waitFor(() => expect(anomalies()).toHaveLength(1));
|
||||||
|
expect(anomalies()[0].payload).toMatchObject({ credentialKind: "qr", channel: "rf" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown value → plain no-match, no anomaly (a phantom/typo is not a clone attempt)", () => {
|
||||||
|
const m = flow.match(read("999459", { kind: "qr", channel: "optical" }));
|
||||||
|
expect(m).toBeNull();
|
||||||
|
expect(anomalies()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,6 +77,40 @@ export class SubscriptionFlow {
|
|||||||
.where(eq(subscriptionCredentials.value, e.value))
|
.where(eq(subscriptionCredentials.value, e.value))
|
||||||
.get();
|
.get();
|
||||||
if (cred) {
|
if (cred) {
|
||||||
|
// CHANNEL AGREEMENT (clone defense, 2026-07-04). When the reader CONFIRMED the
|
||||||
|
// physical channel (DT-008 output prefixes), the credential kind must agree: an
|
||||||
|
// OPTICAL decode may not claim an RF credential — otherwise printing a card's
|
||||||
|
// UID (often written on the card face) as a barcode clones the card. Symmetric
|
||||||
|
// for an RF read claiming a QR credential (a mis-encoded clone tag). A legacy
|
||||||
|
// untagged read (channel undefined) matches as before — enforcement only bites
|
||||||
|
// where prefixes are deployed. The attempt itself is a fraud signal → signed
|
||||||
|
// anomaly, then treated as no-match (the flows refuse it as unknown).
|
||||||
|
const mismatch =
|
||||||
|
(e.channel === "optical" && cred.kind === "rf") ||
|
||||||
|
(e.channel === "rf" && cred.kind === "qr");
|
||||||
|
if (mismatch) {
|
||||||
|
this.#logger.warn(
|
||||||
|
`credential channel mismatch: ${cred.kind} credential '${e.value}' presented via ${e.channel} (sub ${cred.subscriptionId}) — possible clone`,
|
||||||
|
);
|
||||||
|
void this.#log
|
||||||
|
.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: cred.subscriptionId,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("sub.refused.channelMismatch", {
|
||||||
|
credentialKind: cred.kind,
|
||||||
|
channel: e.channel === "optical" ? "optical" : "rf",
|
||||||
|
}),
|
||||||
|
channelMismatch: true,
|
||||||
|
credentialKind: cred.kind,
|
||||||
|
channel: e.channel,
|
||||||
|
value: e.value,
|
||||||
|
deviceId: e.deviceId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((err) => this.#logger.error(`channel-mismatch anomaly append failed: ${(err as Error).message}`));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
|
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
|
||||||
}
|
}
|
||||||
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
|
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
|
||||||
@@ -310,6 +344,15 @@ export class SubscriptionFlow {
|
|||||||
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
* subscription, (b) pick which occurrence a read closes, and (c) enforce
|
||||||
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
* `maxConcurrent`. The on-chain field is `permitId`, so we match against that.
|
||||||
*/
|
*/
|
||||||
|
/** How many occurrences this subscription currently has OPEN (entries not yet exited).
|
||||||
|
* Public so the ANPR bridge can detect a credential (card/QR) exit landing mid-poll — if
|
||||||
|
* the count drops while it's polling, the subscriber already transacted and the bridge must
|
||||||
|
* NOT also emit (which would exit the NEXT open occurrence — a phantom double-exit, esp. for
|
||||||
|
* a fleet sub). See anpr-entry.ts. */
|
||||||
|
openOccurrenceCount(subscriptionId: string): number {
|
||||||
|
return this.#openOccurrences(subscriptionId).length;
|
||||||
|
}
|
||||||
|
|
||||||
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
#openOccurrences(subscriptionId: string): { identity: string; index: number }[] {
|
||||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
// Net entries−exits per occurrence identity, keeping the entry order (oldest first).
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { eq, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import type { SessionValidation, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
|
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
|
||||||
|
// session (never a mutable flag): payload carries the RESOLVED values (programId,
|
||||||
|
// label, mode, minutes/amountMinor/percent) + the merchant username. A validation
|
||||||
|
// event with `refId` set VOIDS the referenced one; a payment's `validationIds` marks
|
||||||
|
// which validations it CONSUMED (so an overstay's fresh period never re-applies
|
||||||
|
// them). See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
/** A validation event folded with its lifecycle state. */
|
||||||
|
export interface AppliedValidation extends SessionValidation {
|
||||||
|
readonly eventId: string;
|
||||||
|
readonly occurredAt: string;
|
||||||
|
/** The merchant username who applied it. */
|
||||||
|
readonly operator: string | null;
|
||||||
|
/** Voided by a later validation event referencing it. */
|
||||||
|
readonly voided: boolean;
|
||||||
|
/** The payment event id that consumed it, if settled. */
|
||||||
|
readonly consumedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All validations ever applied to a session (newest last), with voided/consumed
|
||||||
|
* state folded from the chain. One identity-scoped ledger scan. */
|
||||||
|
export function sessionValidations(db: Db, identity: string): AppliedValidation[] {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
id: ledgerEvents.id,
|
||||||
|
type: ledgerEvents.type,
|
||||||
|
occurredAt: ledgerEvents.occurredAt,
|
||||||
|
payload: ledgerEvents.payload,
|
||||||
|
})
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const voided = new Set<string>();
|
||||||
|
const consumedBy = new Map<string, string>();
|
||||||
|
const applies: AppliedValidation[] = [];
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const p = (r.payload ?? {}) as {
|
||||||
|
refId?: string;
|
||||||
|
programId?: string;
|
||||||
|
programLabel?: string;
|
||||||
|
mode?: ValidationMode;
|
||||||
|
minutes?: number;
|
||||||
|
amountMinor?: number;
|
||||||
|
percent?: number;
|
||||||
|
operator?: string;
|
||||||
|
validationIds?: string[];
|
||||||
|
};
|
||||||
|
if (r.type === "validation") {
|
||||||
|
if (p.refId) {
|
||||||
|
voided.add(p.refId);
|
||||||
|
} else if (p.programId && p.mode) {
|
||||||
|
applies.push({
|
||||||
|
eventId: r.id,
|
||||||
|
occurredAt: r.occurredAt,
|
||||||
|
programId: p.programId,
|
||||||
|
label: p.programLabel ?? p.programId,
|
||||||
|
mode: p.mode,
|
||||||
|
...(typeof p.minutes === "number" ? { minutes: p.minutes } : {}),
|
||||||
|
...(typeof p.amountMinor === "number" ? { amountMinor: p.amountMinor } : {}),
|
||||||
|
...(typeof p.percent === "number" ? { percent: p.percent } : {}),
|
||||||
|
operator: p.operator ?? null,
|
||||||
|
voided: false,
|
||||||
|
consumedBy: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (r.type === "payment" && Array.isArray(p.validationIds)) {
|
||||||
|
for (const vid of p.validationIds) consumedBy.set(vid, r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return applies.map((a) => ({
|
||||||
|
...a,
|
||||||
|
voided: voided.has(a.eventId),
|
||||||
|
consumedBy: consumedBy.get(a.eventId) ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The LIVE validations for pricing: applied, not voided, not consumed by a prior
|
||||||
|
* payment. This is exactly what `priceSession(..., validations)` expects. */
|
||||||
|
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
|
||||||
|
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
|
||||||
|
}
|
||||||
@@ -3,14 +3,16 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
|
"//": "Thin shim so this Python service is a first-class node in the Turbo task graph (it is NOT a JS package — deps are managed by uv/pyproject.toml). Each script shells to Python tooling. See wiki/decisions/vision-service-packaging.md.",
|
||||||
|
"//alpr": "DEV self-heals real ANPR: `dev`/`start` run `uv sync --extra alpr` FIRST, because a plain `uv run` re-resolves the venv to the lockfile DEFAULTS and STRIPS fast-alpr (the cause of silent 'snapshot but no plate' after a prior pnpm dev). Syncing the extra here guarantees the recognizer survives every run. Use `dev:stub` for a lean, model-free local run. The BOOTH is unaffected — it runs the Docker image, which bakes `--extra alpr` at build (see Dockerfile + docker-compose.prod.yml).",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
"dev": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||||
"start": "uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
|
"dev:stub": "uv run uvicorn vision_service.app:app --reload --host 0.0.0.0 --port 8089",
|
||||||
|
"start": "uv sync --extra alpr && uv run uvicorn vision_service.app:app --host 0.0.0.0 --port 8089",
|
||||||
"lint": "uv run ruff check .",
|
"lint": "uv run ruff check .",
|
||||||
"format": "uv run ruff format .",
|
"format": "uv run ruff format .",
|
||||||
"typecheck": "uv run mypy vision_service",
|
"typecheck": "uv run mypy vision_service",
|
||||||
"test": "uv run pytest -q",
|
"test": "uv run pytest -q",
|
||||||
"recognize": "uv run python -m vision_service.cli",
|
"recognize": "uv sync --extra alpr && uv run python -m vision_service.cli",
|
||||||
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
"build": "echo 'no build step (Python service; models fetched at deploy)'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# Production build env for the SPA (auto-loaded by `vite build`, which the Tauri
|
# Production build env for the SPA (auto-loaded by `vite build`). NOT loaded by `vite` dev.
|
||||||
# desktop bundle runs via beforeBuildCommand). NOT loaded by `vite` dev.
|
|
||||||
#
|
#
|
||||||
# The desktop shell serves the bundled SPA from tauri://localhost (no proxy, not
|
# RELATIVE /api base (empty value). The booth serves the SPA same-origin (Fastify serves
|
||||||
# same-origin), so the SPA must reach Fastify by absolute origin. This is the
|
# dist/, reached via Caddy on :80), so requests must stay relative — baking an absolute
|
||||||
# appliance's local Fastify address. Not a secret — committed for reproducible
|
# origin here would point the browser at the wrong host. This matches the deploy
|
||||||
# desktop builds. Override per-deployment if Fastify binds elsewhere.
|
# (wiki/decisions/container-deployment.md "Web access"; the 77b2acb fix).
|
||||||
#
|
#
|
||||||
# NOTE: a plain browser prod build (Fastify serving dist/ same-origin) does NOT
|
# DESKTOP (Tauri) NOTE: the desktop shell serves the SPA from tauri://localhost (no proxy,
|
||||||
# want this set. If you build the SPA for that, override VITE_API_BASE="" .
|
# not same-origin) and DOES need an absolute Fastify origin — but the desktop app is a
|
||||||
VITE_API_BASE=http://127.0.0.1:3000
|
# DEFERRED, separate task (it's currently hardcoded to localhost:3000; see apps/desktop +
|
||||||
|
# the desktop-app-hardcoded-localhost note). When that work resumes, set VITE_API_BASE to
|
||||||
|
# the appliance's Fastify origin for the desktop build only (e.g. via apps/desktop or an
|
||||||
|
# exported override), NOT here.
|
||||||
|
VITE_API_BASE=
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="data:," />
|
<link rel="icon" href="data:," />
|
||||||
|
<!-- Self-hosted primary face (offline appliance — no webfont CDN). Preload the
|
||||||
|
two weights on every screen so first paint doesn't flash the fallback. -->
|
||||||
|
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-400.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-600.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
<title>Parking System</title>
|
<title>Parking System</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+91
-133
@@ -1,10 +1,9 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
import { fetchActiveSessions } from "./api.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { formatCountdown, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||||
|
|
||||||
@@ -12,13 +11,8 @@ 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, settle a subscriber's
|
// click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
|
||||||
// out-of-window charge, assist-open a prepaid subscriber, or review),
|
// out-of-window charge, assist-open a prepaid subscriber, or review).
|
||||||
// - "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
|
||||||
@@ -26,33 +20,10 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
|||||||
// reconciles via the pay/exit modal — never a free barrier open.
|
// reconciles via the pay/exit modal — never a free barrier open.
|
||||||
// See wiki/concepts/booth-exit-flow.md.
|
// See wiki/concepts/booth-exit-flow.md.
|
||||||
|
|
||||||
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
|
|
||||||
type KindFilter = "transient" | "subscription";
|
type KindFilter = "transient" | "subscription";
|
||||||
|
|
||||||
function statusOf(s: ActiveSession): StatusFilter | "subscription" {
|
|
||||||
if (s.subscription) return "subscription";
|
|
||||||
if (s.overstay) return "overstay";
|
|
||||||
if (!s.open && s.withinGrace) return "exiting";
|
|
||||||
if (s.paidAt) return "paid";
|
|
||||||
return "unpaid";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } {
|
|
||||||
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
|
|
||||||
if (s.overstay)
|
|
||||||
return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" };
|
|
||||||
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
|
||||||
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
|
||||||
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
|
||||||
// The audited barrier re-open is a money-path action (server-gated on an open
|
|
||||||
// shift); disable it unless this operator's shift is open.
|
|
||||||
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
|
|
||||||
const shiftReady = shiftOpen && shiftMine;
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: qk.activeSessions,
|
queryKey: qk.activeSessions,
|
||||||
queryFn: fetchActiveSessions,
|
queryFn: fetchActiveSessions,
|
||||||
@@ -61,18 +32,17 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const reopen = useMutation({
|
// A 1-second clock so the within-grace countdown badge ticks live (the query only
|
||||||
mutationFn: (identity: string) => reopenBarrier(identity),
|
// refetches every 15s; the badge needs per-second resolution).
|
||||||
onSettled: () => {
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
useEffect(() => {
|
||||||
void qc.invalidateQueries({ queryKey: qk.events });
|
const id = setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
},
|
return () => clearInterval(id);
|
||||||
});
|
}, []);
|
||||||
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
|
||||||
|
|
||||||
// Filters: free-text search, status, and transient-vs-subscriber.
|
// Filters: free-text search + transient-vs-subscriber. (No status filter — the status
|
||||||
|
// column was dropped; an unpaid transient is normal and a subscriber is marked ★.)
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [status, setStatus] = useState<StatusFilter | "">("");
|
|
||||||
const [kind, setKind] = useState<KindFilter | "">("");
|
const [kind, setKind] = useState<KindFilter | "">("");
|
||||||
|
|
||||||
const sessions = useMemo(() => data?.sessions ?? [], [data]);
|
const sessions = useMemo(() => data?.sessions ?? [], [data]);
|
||||||
@@ -81,45 +51,25 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
return sessions.filter((s) => {
|
return sessions.filter((s) => {
|
||||||
if (kind === "transient" && s.subscription) return false;
|
if (kind === "transient" && s.subscription) return false;
|
||||||
if (kind === "subscription" && !s.subscription) return false;
|
if (kind === "subscription" && !s.subscription) return false;
|
||||||
if (status && statusOf(s) !== status) return false;
|
|
||||||
if (q) {
|
if (q) {
|
||||||
const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase();
|
// Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits.
|
||||||
|
const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase();
|
||||||
if (!hay.includes(q)) return false;
|
if (!hay.includes(q)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [sessions, search, status, kind]);
|
}, [sessions, search, kind]);
|
||||||
|
|
||||||
const statusOpts: SegOption<StatusFilter>[] = [
|
|
||||||
{ value: "unpaid", label: t("booth.fStatusUnpaid") },
|
|
||||||
{ value: "paid", label: t("booth.fStatusPaid") },
|
|
||||||
{ value: "exiting", label: t("booth.fStatusExiting") },
|
|
||||||
{ value: "overstay", label: t("booth.fStatusOverstay") },
|
|
||||||
];
|
|
||||||
const kindOpts: SegOption<KindFilter>[] = [
|
const kindOpts: SegOption<KindFilter>[] = [
|
||||||
{ value: "transient", label: t("booth.fKindTransient") },
|
{ value: "transient", label: t("booth.fKindTransient") },
|
||||||
{ value: "subscription", label: t("booth.fKindSubscription") },
|
{ value: "subscription", label: t("booth.fKindSubscription") },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function handleReopen(s: ActiveSession) {
|
|
||||||
setReopenMsg(null);
|
|
||||||
try {
|
|
||||||
const r = await reopen.mutateAsync(s.identity);
|
|
||||||
setReopenMsg({
|
|
||||||
id: s.identity,
|
|
||||||
ok: r.opened,
|
|
||||||
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title={t("booth.activeSessions")}
|
title={t("booth.activeSessions")}
|
||||||
right={
|
right={
|
||||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
{filtered.length}
|
{filtered.length}
|
||||||
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
|
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
|
||||||
</span>
|
</span>
|
||||||
@@ -128,7 +78,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
|
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
|
||||||
<SegGroup value={status} options={statusOpts} onChange={setStatus} allLabel={t("booth.filterAll")} />
|
|
||||||
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
|
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
@@ -142,70 +91,79 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
: t("booth.noMatch")}
|
: t("booth.noMatch")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((s) => {
|
// A real table — aligned columns (who · plate · entry · elapsed). No status
|
||||||
const badge = statusBadge(s);
|
// column: an unpaid transient is the normal case, and a subscriber is already
|
||||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
// marked with ★ + holder name. Overstay (a top-up is owed) keeps a row tint so
|
||||||
return (
|
// that fraud-relevant signal isn't lost. The whole row is clickable (→ pay/exit
|
||||||
<div
|
// modal).
|
||||||
key={s.identity}
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
>
|
<tr>
|
||||||
<button
|
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colWho")}</th>
|
||||||
type="button"
|
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th>
|
||||||
onClick={() => onPick(s.identity)}
|
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th>
|
||||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th>
|
||||||
title={t("booth.openPayExit")}
|
</tr>
|
||||||
>
|
</thead>
|
||||||
<span className="text-term-text">
|
<tbody>
|
||||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
{filtered.map((s) => {
|
||||||
</span>
|
// EXITED-WITHIN-GRACE: a paid transient whose exit is recorded but the
|
||||||
{s.plate && (
|
// barrier didn't confirm — it lingers here until grace runs out. Mark it
|
||||||
<span
|
// so the operator can tell it apart from a still-inside car (clicking it
|
||||||
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
// opens the modal's manual barrier re-open, not a pay flow).
|
||||||
title={t("booth.plateTitle")}
|
const closedInGrace = !s.open && s.withinGrace && !s.subscription;
|
||||||
>
|
// Live grace-remaining for the badge (M:SS). Null once it lapses — the
|
||||||
{s.plate}
|
// next refetch (≤15s) reclassifies the row (overstay / gone); until then
|
||||||
</span>
|
// we show a generic label so the badge doesn't flicker empty.
|
||||||
)}
|
const graceLeft = closedInGrace ? formatCountdown(s.graceExpiresAt, nowMs) : null;
|
||||||
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
return (
|
||||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
<tr
|
||||||
<span
|
key={s.identity}
|
||||||
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}
|
onClick={() => onPick(s.identity)}
|
||||||
title={badge.titleKey ? t(badge.titleKey) : undefined}
|
className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
|
||||||
|
s.overstay ? "bg-term-red/5" : closedInGrace ? "bg-term-amber/5 text-term-muted" : ""
|
||||||
|
}`}
|
||||||
|
title={closedInGrace ? t("booth.openReopenBarrier") : t("booth.openPayExit")}
|
||||||
>
|
>
|
||||||
{t(badge.key)}
|
<td className="px-2 py-1.5 text-term-text">
|
||||||
</span>
|
{s.subscription ? (
|
||||||
</button>
|
<span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
|
||||||
|
) : (
|
||||||
{/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
|
<span className="inline-flex items-center gap-1.5">
|
||||||
re-pulse for a car that paid but the barrier didn't confirm. NOT an
|
{s.identity}
|
||||||
OVERSTAY (grace expired → owes a top-up; routes to the pay/exit modal)
|
{closedInGrace && (
|
||||||
and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
|
<span
|
||||||
live in the pay/exit modal — the list must not offer a one-click open,
|
className="rounded border border-term-amber/60 px-1 text-[0.5625rem] uppercase tracking-wider tabular-nums text-term-amber"
|
||||||
which would bypass an unpaid window charge). An unpaid transient has no
|
title={t("booth.exitedGraceTitle")}
|
||||||
button either (no-unpaid-bypass). Mirrors reopenBarrier's server guard. */}
|
>
|
||||||
{s.paidAt && !s.overstay && !s.subscription ? (
|
{graceLeft ? t("booth.exitedGraceLeft", { time: graceLeft }) : t("booth.exitedGrace")}
|
||||||
<button
|
</span>
|
||||||
type="button"
|
)}
|
||||||
disabled={reopen.isPending || !shiftReady}
|
</span>
|
||||||
onClick={() => handleReopen(s)}
|
)}
|
||||||
className="btn btn-pay btn-sm shrink-0"
|
</td>
|
||||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
<td className="px-2 py-1.5">
|
||||||
>
|
{s.plate && (
|
||||||
{t("booth.openBarrier")}
|
<span
|
||||||
</button>
|
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
||||||
) : (
|
title={t("booth.plateTitle")}
|
||||||
<span className="w-[88px] shrink-0" />
|
>
|
||||||
)}
|
{s.plate}
|
||||||
|
</span>
|
||||||
{msg && (
|
)}
|
||||||
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
</td>
|
||||||
{msg.text}
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||||
</span>
|
{formatRelativeDateTime(s.enteredAt, t)}
|
||||||
)}
|
</td>
|
||||||
</div>
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||||
);
|
{/* Freeze the elapsed at the recorded exit for a closed-in-grace row. */}
|
||||||
})
|
{formatDuration(s.enteredAt, (closedInGrace ? s.exitedAt : null) ?? new Date().toISOString())}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fetchMe, type SessionUser } from "./api.js";
|
|||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
import { queryClient } from "./lib/query.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { router } from "./router.js";
|
import { router } from "./router.js";
|
||||||
|
|
||||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||||
@@ -24,15 +24,17 @@ export function App() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply the signed-in user's preferred language + theme whenever they resolve/
|
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
||||||
// change (login, bootstrap, or a toggle). Albanian + dark are the defaults before
|
// resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults
|
||||||
// auth resolves; on logout, fall back to dark so the Login screen is consistent.
|
// before auth resolves; on logout, fall back so the Login screen is consistent.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setLanguage(user.language);
|
setLanguage(user.language);
|
||||||
applyTheme(user.theme);
|
applyTheme(user.theme);
|
||||||
|
applyFontScale(user.fontScale);
|
||||||
} else {
|
} else {
|
||||||
applyTheme("dark");
|
applyTheme("dark");
|
||||||
|
applyFontScale(100);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
fetchBackupStatus,
|
||||||
|
runBackup,
|
||||||
|
setBackupConfig,
|
||||||
|
testBackupTarget,
|
||||||
|
type BackupStatus,
|
||||||
|
type TargetCheck,
|
||||||
|
} from "./api.js";
|
||||||
|
import { formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
|
||||||
|
// Admin screen for the on-site encrypted DB backup. The admin picks the TARGET DIRECTORY here
|
||||||
|
// (stored in site_config; a mounted USB/SATA/SMB/NFS path) — the encryption key stays a server
|
||||||
|
// secret. Shows status + last-run outcome, a "Test target" probe, and a manual "Back up now".
|
||||||
|
// Gated by backup:read (config/test by backup:update, run by backup:create). RESTORE is absent
|
||||||
|
// by design — out-of-band on a fresh appliance. See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
const mb = n / 1048576;
|
||||||
|
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||||
|
return `${(mb / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a target-check result to a localized message. */
|
||||||
|
function checkMessage(c: TargetCheck, t: (k: string) => string): string {
|
||||||
|
if (c.ok) return t("backup.testOk");
|
||||||
|
switch (c.reason) {
|
||||||
|
case "empty":
|
||||||
|
return t("backup.testEmpty");
|
||||||
|
case "not_a_dir":
|
||||||
|
return t("backup.testNotDir");
|
||||||
|
case "not_writable":
|
||||||
|
return t("backup.testNotWritable");
|
||||||
|
default:
|
||||||
|
return t("backup.testMissing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: BackupStatus }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!status.configured) {
|
||||||
|
return <span className="text-[0.75rem] font-semibold text-term-muted">{t("backup.notConfigured")}</span>;
|
||||||
|
}
|
||||||
|
if (status.running) {
|
||||||
|
return <span className="text-[0.75rem] font-semibold text-term-amber">{t("backup.running")}</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-[0.75rem] font-semibold text-term-green">{t("backup.configured")}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [toast, setToast] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
|
||||||
|
const [target, setTarget] = useState("");
|
||||||
|
const [keepLast, setKeepLast] = useState("");
|
||||||
|
const [keepDaily, setKeepDaily] = useState("");
|
||||||
|
const [check, setCheck] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["backup-status"],
|
||||||
|
queryFn: fetchBackupStatus,
|
||||||
|
refetchInterval: (query) => (query.state.data?.running ? 2000 : false),
|
||||||
|
});
|
||||||
|
const status = q.data;
|
||||||
|
|
||||||
|
// Seed the editable fields from the saved values once they load (and on server-side change).
|
||||||
|
useEffect(() => {
|
||||||
|
if (status) {
|
||||||
|
setTarget(status.targetDir ?? "");
|
||||||
|
setKeepLast(String(status.keepLast));
|
||||||
|
setKeepDaily(String(status.keepDailyDays));
|
||||||
|
}
|
||||||
|
}, [status?.targetDir, status?.keepLast, status?.keepDailyDays]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
setBackupConfig({
|
||||||
|
targetDir: target.trim() || null,
|
||||||
|
keepLast: keepLast.trim() === "" ? null : Number(keepLast),
|
||||||
|
keepDailyDays: keepDaily.trim() === "" ? null : Number(keepDaily),
|
||||||
|
}),
|
||||||
|
onSuccess: (next) => {
|
||||||
|
setToast({ kind: "ok", msg: t("backup.saved") });
|
||||||
|
setCheck(null);
|
||||||
|
qc.setQueryData(["backup-status"], next);
|
||||||
|
},
|
||||||
|
onError: () => setToast({ kind: "err", msg: t("backup.runFailed") }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const test = useMutation({
|
||||||
|
mutationFn: () => testBackupTarget(target.trim()),
|
||||||
|
onSuccess: (res) => setCheck({ kind: res.ok ? "ok" : "err", msg: checkMessage(res, t) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = useMutation({
|
||||||
|
mutationFn: runBackup,
|
||||||
|
onSuccess: () => {
|
||||||
|
setToast({ kind: "ok", msg: t("backup.runSuccess") });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["backup-status"] });
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const code = err instanceof ApiError ? err.message : "";
|
||||||
|
setToast({
|
||||||
|
kind: "err",
|
||||||
|
msg: code === "backup_not_configured" ? t("backup.notConfiguredError") : t("backup.runFailed"),
|
||||||
|
});
|
||||||
|
void qc.invalidateQueries({ queryKey: ["backup-status"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dirty =
|
||||||
|
(status?.targetDir ?? "") !== target.trim() ||
|
||||||
|
String(status?.keepLast ?? "") !== keepLast.trim() ||
|
||||||
|
String(status?.keepDailyDays ?? "") !== keepDaily.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("backup.title")}</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={!status?.configured || status?.running || run.isPending || dirty}
|
||||||
|
onClick={() => {
|
||||||
|
setToast(null);
|
||||||
|
run.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status?.running || run.isPending ? t("backup.running") : t("backup.runNow")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-3 max-w-2xl text-[0.75rem] text-term-muted">{t("backup.intro")}</p>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
className={`mb-3 rounded-term border px-3 py-2 text-[0.75rem] ${
|
||||||
|
toast.kind === "ok"
|
||||||
|
? "border-term-green/40 bg-term-green/5 text-term-green"
|
||||||
|
: "border-term-red/40 bg-term-red/5 text-term-red"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Config — admin-chosen destination + retention policy. */}
|
||||||
|
<div className="card mb-3 p-4">
|
||||||
|
{/* Target directory + its Test probe. */}
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("backup.targetLabel")}</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input w-96 max-w-full"
|
||||||
|
value={target}
|
||||||
|
placeholder={t("backup.targetPlaceholder")}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTarget(e.target.value);
|
||||||
|
setCheck(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
disabled={test.isPending || !target.trim()}
|
||||||
|
onClick={() => test.mutate()}
|
||||||
|
>
|
||||||
|
{t("backup.test")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.targetHint")}</span>
|
||||||
|
{check && (
|
||||||
|
<span className={`mt-1 text-[0.75rem] ${check.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
|
||||||
|
{check.msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Retention — admin-tuned policy (how many backups to keep at the target). */}
|
||||||
|
<div className="mt-4 flex flex-wrap items-start gap-6">
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("backup.keepLastLabel")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="input w-28"
|
||||||
|
value={keepLast}
|
||||||
|
onChange={(e) => setKeepLast(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.keepLastHint")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("backup.keepDailyLabel")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="input w-28"
|
||||||
|
value={keepDaily}
|
||||||
|
onChange={(e) => setKeepDaily(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.keepDailyHint")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={save.isPending || !dirty}
|
||||||
|
onClick={() => {
|
||||||
|
setToast(null);
|
||||||
|
save.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("backup.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
{q.isLoading || !status ? (
|
||||||
|
<div className="text-[0.75rem] text-term-muted">{t("common.loading")}</div>
|
||||||
|
) : (
|
||||||
|
<dl className="grid grid-cols-[10rem_1fr] gap-x-4 gap-y-2 text-[0.8125rem]">
|
||||||
|
<dt className="text-term-muted">{t("backup.statusTitle")}</dt>
|
||||||
|
<dd>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
{!status.keyPresent && (
|
||||||
|
<>
|
||||||
|
<dt className="text-term-muted" />
|
||||||
|
<dd className="text-[0.75rem] text-term-amber">{t("backup.keyMissing")}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<dt className="text-term-muted">{t("backup.lastSuccess")}</dt>
|
||||||
|
<dd className="text-term-text">
|
||||||
|
{status.lastSuccessAt ? formatRelativeDateTime(status.lastSuccessAt, t) : t("backup.never")}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
{status.lastResult && (
|
||||||
|
<>
|
||||||
|
<dt className="text-term-muted">{t("backup.size")}</dt>
|
||||||
|
<dd className="text-term-text tabular-nums">
|
||||||
|
{formatBytes(status.lastResult.bytes)}
|
||||||
|
{status.lastResult.prunedFiles > 0 && (
|
||||||
|
<span className="ml-2 text-term-muted">
|
||||||
|
({t("backup.pruned")}: {status.lastResult.prunedFiles})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.lastError && (
|
||||||
|
<>
|
||||||
|
<dt className="text-term-muted">{t("backup.lastError")}</dt>
|
||||||
|
<dd className="text-term-red">
|
||||||
|
{status.lastError}
|
||||||
|
{status.lastErrorAt && (
|
||||||
|
<span className="ml-2 text-term-muted">
|
||||||
|
({formatRelativeDateTime(status.lastErrorAt, t)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 max-w-2xl text-[0.6875rem] text-term-muted">{t("backup.restoreNote")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+219
-62
@@ -18,8 +18,10 @@ import {
|
|||||||
import { rootRoute } from "./router.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, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||||
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
|
|
||||||
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
|
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
|
||||||
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
|
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
|
||||||
@@ -59,6 +61,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
const canVoid = can(user, "event:void");
|
const canVoid = can(user, "event:void");
|
||||||
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
||||||
|
// Plate-swap: set when boothExit returns swap_suspected. Holds the detail for the warning
|
||||||
|
// panel; the operator must consciously "Override & release". See plate-reconciliation.md.
|
||||||
|
const [swap, setSwap] = useState<{ plate: string; otherIdentity: string; otherEnteredAt: string | null } | null>(null);
|
||||||
const [voidReason, setVoidReason] = useState("");
|
const [voidReason, setVoidReason] = useState("");
|
||||||
|
|
||||||
const s: SessionLookup | undefined = session.data;
|
const s: SessionLookup | undefined = session.data;
|
||||||
@@ -73,6 +78,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
// exit. A normal within-grace paid session is NOT payable (it's settled). See
|
// exit. A normal within-grace paid session is NOT payable (it's settled). See
|
||||||
// booth-exit-flow.md / reopenBarrier server guard.
|
// booth-exit-flow.md / reopenBarrier server guard.
|
||||||
const isOverstay = s?.overstay === true;
|
const isOverstay = s?.overstay === true;
|
||||||
|
// CLOSED-WITHIN-GRACE: a paid transient whose exit was already signed but the barrier
|
||||||
|
// didn't confirm — it lingers in the active list until grace runs out (the "phantom
|
||||||
|
// re-close" / damaged-ticket case). `s.open` is false, so it's not payable and not the
|
||||||
|
// normal review flow; the only action is an audited manual re-pulse of the barrier.
|
||||||
|
// (A grace-EXPIRED closed session falls through to the plain "already closed" notice.)
|
||||||
|
const closedWithinGrace = !!(s?.found && !s.open && s.withinGrace && !isSubscription);
|
||||||
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
|
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
|
||||||
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
|
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
|
||||||
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
|
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
|
||||||
@@ -171,7 +182,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePayAndExit() {
|
async function handlePayAndExit(override = false) {
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -179,7 +190,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
// session is "already paid" but a new period accrued — we still charge (canPay
|
// session is "already paid" but a new period accrued — we still charge (canPay
|
||||||
// is true). A settled within-grace session is not payable (canPay false) and is
|
// is true). A settled within-grace session is not payable (canPay false) and is
|
||||||
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
|
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
|
||||||
if (canPay) {
|
// On an OVERRIDE re-submit the payment already happened; don't double-charge.
|
||||||
|
if (canPay && !override) {
|
||||||
setPhase("paying");
|
setPhase("paying");
|
||||||
await paySession(identity, tender);
|
await paySession(identity, tender);
|
||||||
}
|
}
|
||||||
@@ -190,7 +202,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
const r = await printVoucher(identity);
|
const r = await printVoucher(identity);
|
||||||
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
||||||
} else {
|
} else {
|
||||||
const r = await boothExit(identity);
|
const r = await boothExit(identity, override);
|
||||||
|
// PLATE-SWAP suspected → don't exit; surface the warning + offer an override.
|
||||||
|
if (!r.ok) {
|
||||||
|
setSwap({ plate: r.plate, otherIdentity: r.otherIdentity, otherEnteredAt: r.otherEnteredAt });
|
||||||
|
setPhase("review");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSwap(null);
|
||||||
// No voucher → auto-print a standalone payment receipt for transparency.
|
// No voucher → auto-print a standalone payment receipt for transparency.
|
||||||
// Best-effort: a printer fault must NOT block the exit that already happened;
|
// Best-effort: a printer fault must NOT block the exit that already happened;
|
||||||
// the operator can reprint from the done screen.
|
// the operator can reprint from the done screen.
|
||||||
@@ -226,7 +245,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
|
||||||
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
<Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
{isSubscription
|
{isSubscription
|
||||||
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
|
||||||
: `${t("pay.ticket")} ${identity}`}
|
: `${t("pay.ticket")} ${identity}`}
|
||||||
@@ -244,26 +263,32 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
|
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
|
||||||
{blockedByOther ? (
|
{blockedByOther ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
{t("shift.gateOtherTitle")}
|
{t("shift.gateOtherTitle")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12px] text-term-text">
|
<div className="mt-1 text-[0.75rem] text-term-text">
|
||||||
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
|
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
{t("shift.gateTitle")}
|
{t("shift.gateTitle")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
|
<div className="mt-1 text-[0.75rem] text-term-text">{t("shift.gateBody")}</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenShift}
|
onClick={handleOpenShift}
|
||||||
disabled={openingShift}
|
disabled={openingShift}
|
||||||
className="btn btn-go btn-sm mt-2"
|
className="btn btn-go btn-sm mt-2"
|
||||||
>
|
>
|
||||||
{openingShift ? t("shift.opening") : t("shift.openNow")}
|
{openingShift ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Spinner /> {t("shift.opening")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("shift.openNow")
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -278,21 +303,58 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{s && s.found && !s.open && (
|
{s && s.found && !s.open && !closedWithinGrace && (
|
||||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
// A fully-closed session (exited, grace expired): no action to take, but the
|
||||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
// operator may still need to REVIEW the evidence (entry/exit snapshots + plate)
|
||||||
</div>
|
// — e.g. a dispute about a car that just left. Show the closed notice, the
|
||||||
|
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
||||||
|
<>
|
||||||
|
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||||
|
{t("pay.alreadyClosed", { time: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||||
|
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||||
|
<Row label={t("pay.exit")} value={formatRelativeDateTime(s.exitedAt, t, { seconds: true })} />
|
||||||
|
<Row
|
||||||
|
label={t("pay.duration")}
|
||||||
|
value={
|
||||||
|
s.enteredAt ? formatDuration(s.enteredAt, s.exitedAt ?? new Date().toISOString()) : "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{alreadyPaid && s.paidMinor != null && s.paidCurrency && (
|
||||||
|
<Row label={t("pay.paidAmount")} value={formatMoney(s.paidMinor, s.paidCurrency)} valueClass="text-term-green" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SnapshotStrip identity={identity} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{s && s.found && s.open && (
|
{s && s.found && (s.open || closedWithinGrace) && (
|
||||||
<>
|
<>
|
||||||
{/* Session figures */}
|
{/* Session figures */}
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||||
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
|
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
||||||
|
<Row
|
||||||
|
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
||||||
|
value={formatRelativeDateTime(
|
||||||
|
closedWithinGrace ? s.exitedAt : new Date().toISOString(),
|
||||||
|
t,
|
||||||
|
{ seconds: true },
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.duration")}
|
label={t("pay.duration")}
|
||||||
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
|
value={
|
||||||
|
s.enteredAt
|
||||||
|
? formatDuration(
|
||||||
|
s.enteredAt,
|
||||||
|
(closedWithinGrace ? s.exitedAt : null) ?? new Date().toISOString(),
|
||||||
|
)
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.statusLabel")}
|
label={t("pay.statusLabel")}
|
||||||
@@ -301,28 +363,65 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
? t("pay.subscription")
|
? t("pay.subscription")
|
||||||
: isOverstay
|
: isOverstay
|
||||||
? t("pay.overstay")
|
? t("pay.overstay")
|
||||||
: alreadyPaid
|
: closedWithinGrace
|
||||||
? t("pay.paid")
|
? t("pay.closedWithinGrace")
|
||||||
: t("pay.unpaid")
|
: alreadyPaid
|
||||||
|
? t("pay.paid")
|
||||||
|
: t("pay.unpaid")
|
||||||
}
|
}
|
||||||
valueClass={
|
valueClass={
|
||||||
isSubscription
|
isSubscription
|
||||||
? "text-term-cyan"
|
? "text-term-cyan"
|
||||||
: isOverstay
|
: isOverstay
|
||||||
? "text-term-red"
|
? "text-term-red"
|
||||||
: alreadyPaid
|
: closedWithinGrace
|
||||||
? "text-term-green"
|
? "text-term-amber"
|
||||||
: "text-term-amber"
|
: alreadyPaid
|
||||||
|
? "text-term-green"
|
||||||
|
: "text-term-amber"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Merchant validations (bar/lavazh): the gross fee + one line per
|
||||||
|
discount — the Total below is the NET the customer pays. The lines
|
||||||
|
ride the quote (SessionLookup.validationLines) and reprint on the
|
||||||
|
receipt. See wiki/concepts/validation-discounts.md. */}
|
||||||
|
{!isSubscription &&
|
||||||
|
(s.validationLines ?? []).length > 0 &&
|
||||||
|
s.currency != null &&
|
||||||
|
s.amountMinor != null && (
|
||||||
|
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||||
|
<div className="flex justify-between text-term-text">
|
||||||
|
<span>{t("val.gross")}</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatMoney(s.grossMinor ?? s.amountMinor, s.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(s.validationLines ?? []).map((v, i) => (
|
||||||
|
<div key={i} className="flex justify-between text-term-green">
|
||||||
|
<span>{v.label}</span>
|
||||||
|
<span className="tabular-nums">−{formatMoney(v.discountMinor, s.currency!)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
||||||
out-of-window window charge; then show that amount. For an overstay the
|
out-of-window window charge; then show that amount. For an overstay the
|
||||||
amount is the TOP-UP delta, not the whole stay. */}
|
amount is the TOP-UP delta, not the whole stay. */}
|
||||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">
|
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
|
{subWindowDue
|
||||||
|
? t("pay.windowCharge")
|
||||||
|
: isSubscription
|
||||||
|
? t("pay.plan")
|
||||||
|
: isOverstay
|
||||||
|
? t("pay.topUp")
|
||||||
|
: alreadyPaid && s.paidMinor != null
|
||||||
|
? // Settled session — the figure is the sum collected, not a quote.
|
||||||
|
t("pay.paidAmount")
|
||||||
|
: t("pay.total")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-3xl font-bold text-term-cyan">
|
<span className="text-3xl font-bold text-term-cyan">
|
||||||
{subWindowDue && s.amountMinor != null && s.currency
|
{subWindowDue && s.amountMinor != null && s.currency
|
||||||
@@ -331,9 +430,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
? t("pay.prepaid")
|
? t("pay.prepaid")
|
||||||
: s.amountMinor != null && s.currency
|
: s.amountMinor != null && s.currency
|
||||||
? formatMoney(s.amountMinor, s.currency)
|
? formatMoney(s.amountMinor, s.currency)
|
||||||
: alreadyPaid
|
: alreadyPaid && s.paidMinor != null && s.paidCurrency
|
||||||
? t("booth.badgePaid")
|
? // Settled (within-grace / closed): show the sum actually collected.
|
||||||
: t("pay.noTariff")}
|
formatMoney(s.paidMinor, s.paidCurrency)
|
||||||
|
: alreadyPaid
|
||||||
|
? t("booth.badgePaid")
|
||||||
|
: t("pay.noTariff")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -341,34 +443,44 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
gate; once paid, prompt the operator to open the barrier; a prepaid
|
gate; once paid, prompt the operator to open the barrier; a prepaid
|
||||||
subscriber sees the assist explanation only after revealing it. */}
|
subscriber sees the assist explanation only after revealing it. */}
|
||||||
{subWindowDue && !windowPaid ? (
|
{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-[0.75rem] text-term-text">
|
||||||
{t("pay.windowChargeHint")}
|
{t("pay.windowChargeHint")}
|
||||||
</div>
|
</div>
|
||||||
) : isSubscription && windowPaid ? (
|
) : isSubscription && windowPaid ? (
|
||||||
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[12px] text-term-text">
|
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||||
{t("pay.windowPaidHint")}
|
{t("pay.windowPaidHint")}
|
||||||
</div>
|
</div>
|
||||||
) : isSubscription && assistRevealed ? (
|
) : 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-[0.75rem] text-term-text">
|
||||||
{t("pay.subAssistHint")}
|
{t("pay.subAssistHint")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 && (
|
||||||
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[12px] text-term-text">
|
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||||
{t("pay.overstayHint")}
|
{t("pay.overstayHint")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Closed-within-grace: the exit is already paid + recorded; the barrier
|
||||||
|
just didn't confirm. Explain that the only action is a manual re-pulse. */}
|
||||||
|
{closedWithinGrace && (
|
||||||
|
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||||
|
{t("pay.closedWithinGraceHint")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Snapshots */}
|
{/* Snapshots */}
|
||||||
<SnapshotStrip identity={identity} />
|
<SnapshotStrip identity={identity} />
|
||||||
|
|
||||||
{/* Tender — shown for any payable case (transient, overstay, OR a
|
{/* Tender — shown for any payable case (transient, overstay, OR a
|
||||||
subscriber window charge that's still unpaid). */}
|
subscriber window charge that's still unpaid). Card is hidden until a
|
||||||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED) — see
|
||||||
|
lib/features.ts + wiki/concepts/card-payments.md. */}
|
||||||
|
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && CARD_PAYMENTS_ENABLED && (
|
||||||
<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-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||||
{(["cash", "card"] as const).map((tn) => (
|
{(["cash", "card"] as const).map((tn) => (
|
||||||
<button
|
<button
|
||||||
key={tn}
|
key={tn}
|
||||||
@@ -382,9 +494,10 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). Not
|
||||||
{phase !== "done" && !isSubscription && (
|
for a closed-within-grace session — its exit is already recorded. */}
|
||||||
<label className="flex items-center gap-2 text-[12px]">
|
{phase !== "done" && !isSubscription && !closedWithinGrace && (
|
||||||
|
<label className="flex items-center gap-2 text-[0.75rem]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -401,10 +514,10 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
signed `void` event — the entry is never edited. */}
|
signed `void` event — the entry is never edited. */}
|
||||||
{voiding && phase !== "done" && (
|
{voiding && phase !== "done" && (
|
||||||
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
|
<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">
|
<div className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
{t("pay.cancelTicketTitle")}
|
{t("pay.cancelTicketTitle")}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[12px] text-term-text">{t("pay.cancelTicketHint")}</div>
|
<div className="mt-1 text-[0.75rem] text-term-text">{t("pay.cancelTicketHint")}</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
||||||
<button
|
<button
|
||||||
@@ -426,6 +539,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PLATE-SWAP warning: the exiting plate is already inside under another
|
||||||
|
ticket. A prominent, deliberate hold — the operator must consciously
|
||||||
|
override to release. See wiki/concepts/plate-reconciliation.md. */}
|
||||||
|
{swap && (
|
||||||
|
<div className="rounded-term border border-term-red bg-term-red/10 px-3 py-2">
|
||||||
|
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-red">
|
||||||
|
{t("pay.swapTitle")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[0.75rem] text-term-text">
|
||||||
|
{t("pay.swapBody", {
|
||||||
|
plate: swap.plate,
|
||||||
|
other: swap.otherIdentity,
|
||||||
|
when: swap.otherEnteredAt ? formatRelativeDateTime(swap.otherEnteredAt, t) : "—",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[0.6875rem] text-term-muted">{t("pay.swapHint")}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
||||||
{result && (
|
{result && (
|
||||||
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
||||||
@@ -464,7 +596,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
>
|
>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
{isSubscription ? (
|
{closedWithinGrace ? (
|
||||||
|
// Paid + exited but the barrier didn't confirm — the only action is
|
||||||
|
// an audited manual re-pulse (the server re-opens without signing a
|
||||||
|
// second exit). No payment, no voucher; mirrors reopenBarrier's guard.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenBarrier}
|
||||||
|
disabled={!shiftReady || phase === "finishing"}
|
||||||
|
className="btn btn-pay btn-lg"
|
||||||
|
>
|
||||||
|
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||||
|
</button>
|
||||||
|
) : isSubscription ? (
|
||||||
subWindowDue && !windowPaid ? (
|
subWindowDue && !windowPaid ? (
|
||||||
// Step 1 — a window charge is owed: take payment first. The
|
// Step 1 — a window charge is owed: take payment first. The
|
||||||
// barrier open is the explicit next step (revealed once paid).
|
// barrier open is the explicit next step (revealed once paid).
|
||||||
@@ -523,26 +667,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
{t("pay.cancelTicket")}
|
{t("pay.cancelTicket")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{swap ? (
|
||||||
type="button"
|
// Plate-swap held → the only forward action is a conscious
|
||||||
onClick={handlePayAndExit}
|
// override (re-submit with override:true; payment already taken).
|
||||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
<button
|
||||||
className="btn btn-go btn-lg"
|
type="button"
|
||||||
>
|
onClick={() => handlePayAndExit(true)}
|
||||||
{phase === "paying"
|
disabled={!shiftReady || phase === "finishing"}
|
||||||
? t("pay.takingPayment")
|
className="btn btn-danger btn-lg"
|
||||||
: phase === "finishing"
|
>
|
||||||
? voucher
|
{phase === "finishing" ? t("pay.opening") : t("pay.swapOverride")}
|
||||||
? t("pay.printingVoucher")
|
</button>
|
||||||
: t("pay.opening")
|
) : (
|
||||||
: alreadyPaid
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePayAndExit()}
|
||||||
|
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||||
|
className="btn btn-go btn-lg"
|
||||||
|
>
|
||||||
|
{phase === "paying"
|
||||||
|
? t("pay.takingPayment")
|
||||||
|
: phase === "finishing"
|
||||||
? voucher
|
? voucher
|
||||||
? t("pay.printVoucher")
|
? t("pay.printingVoucher")
|
||||||
: t("pay.openBarrier")
|
: t("pay.opening")
|
||||||
: voucher
|
: alreadyPaid
|
||||||
? t("pay.payAndVoucher")
|
? voucher
|
||||||
: t("pay.payAndOpen")}
|
? t("pay.printVoucher")
|
||||||
</button>
|
: t("pay.openBarrier")
|
||||||
|
: voucher
|
||||||
|
? t("pay.payAndVoucher")
|
||||||
|
: t("pay.payAndOpen")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -560,7 +717,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
|
function Row({ label, value, valueClass = "" }: { label: string; value: string; valueClass?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
|
||||||
<span className={`text-sm ${valueClass}`}>{value}</span>
|
<span className={`text-sm ${valueClass}`}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+110
-31
@@ -1,7 +1,8 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
import { can, fetchEvents, fetchOccupancy, fetchSiteConfig, issueEntryTicket, type LedgerEvent, type Occupancy } from "./api.js";
|
||||||
|
import { rootRoute } from "./router.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";
|
||||||
@@ -49,13 +50,13 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
|||||||
<div className="flex items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
<div className="text-6xl font-bold leading-none tabular-nums text-term-text">{occ.count}</div>
|
||||||
<div className="pb-1 text-term-muted">
|
<div className="pb-1 text-term-muted">
|
||||||
<div className="text-[11px] uppercase tracking-wider">{t("booth.inside")}</div>
|
<div className="text-[0.6875rem] uppercase tracking-wider">{t("booth.inside")}</div>
|
||||||
<div className="text-sm tabular-nums">
|
<div className="text-sm tabular-nums">
|
||||||
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
|
{occ.capacity == null ? t("booth.uncapped") : `${t("booth.of")} ${occ.capacity}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto text-right">
|
<div className="ml-auto text-right">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.free")}</div>
|
||||||
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
<div className={`text-3xl font-bold tabular-nums ${occ.full ? "text-term-red" : "text-term-green"}`}>
|
||||||
{occ.free == null ? "∞" : occ.free}
|
{occ.free == null ? "∞" : occ.free}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +68,7 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{occ.full && (
|
{occ.full && (
|
||||||
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[11px] font-bold uppercase tracking-widest text-term-red">
|
<div className="rounded-term border border-term-red px-2 py-1 text-center text-[0.6875rem] font-bold uppercase tracking-widest text-term-red">
|
||||||
{t("booth.lotFull")}
|
{t("booth.lotFull")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -106,46 +107,129 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
|||||||
className="input h-11 flex-1 px-3 text-lg tabular-nums"
|
className="input h-11 flex-1 px-3 text-lg tabular-nums"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary btn-lg">
|
<button type="submit" className="btn btn-primary btn-lg">
|
||||||
{t("booth.open")}
|
{t("booth.openTicket")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One barrier light — green = free, red = busy (a vehicle is at the lane vicinity,
|
/** One barrier light — a 3-state indicator mirroring the physical button lamp (relay 3):
|
||||||
* from camera detection). Advisory only; it gates nothing. */
|
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
|
||||||
function BarrierLight({ label, busy }: { label: string; busy: boolean }) {
|
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
|
||||||
|
* - otherwise → SOLID green: free
|
||||||
|
* Advisory only; it gates nothing. On the ENTRY light, when the operator holds `session:create`
|
||||||
|
* and BOTH presence conditions meet (radar present AND camera busy = a real car at the entry),
|
||||||
|
* the light becomes a CLICKABLE issue-ticket control (broken physical button). Same presence
|
||||||
|
* rule as the physical button; the server re-checks it. See operator-issued-entry.md. */
|
||||||
|
function BarrierLight({
|
||||||
|
label,
|
||||||
|
busy,
|
||||||
|
radar,
|
||||||
|
onIssue,
|
||||||
|
issuing,
|
||||||
|
bypassRadar,
|
||||||
|
bypassCamera,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
busy: boolean;
|
||||||
|
radar: boolean;
|
||||||
|
/** When set (entry light + permission), clicking issues an entry ticket — enabled when
|
||||||
|
* both presence conditions are satisfied, treating a BYPASSED signal as satisfied. */
|
||||||
|
onIssue?: () => void;
|
||||||
|
issuing?: boolean;
|
||||||
|
/** Admin bypass of a faulty device: a bypassed signal counts as present (server re-checks). */
|
||||||
|
bypassRadar?: boolean;
|
||||||
|
bypassCamera?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Blink only when the radar sees something the camera hasn't confirmed.
|
||||||
|
const blinking = radar && !busy;
|
||||||
|
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
|
||||||
|
// A bypassed signal counts as satisfied (its device is faulty). The SERVER re-checks the
|
||||||
|
// effective gate authoritatively; this only governs button affordance.
|
||||||
|
const radarOk = radar || !!bypassRadar;
|
||||||
|
const cameraOk = busy || !!bypassCamera;
|
||||||
|
const canIssue = !!onIssue && radarOk && cameraOk && !issuing;
|
||||||
|
const clickable = !!onIssue && radarOk && cameraOk;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${
|
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid} ${
|
||||||
busy ? "border-term-red bg-term-red/10" : "border-term-green bg-term-green/10"
|
clickable ? "cursor-pointer hover:brightness-125" : ""
|
||||||
}`}
|
}`}
|
||||||
title={label}
|
title={clickable ? t("booth.issueEntryTitle") : label}
|
||||||
|
onClick={canIssue ? onIssue : undefined}
|
||||||
|
role={clickable ? "button" : undefined}
|
||||||
>
|
>
|
||||||
{/* Barrier glyph: a post + an arm. Colour carries the state. */}
|
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) 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">
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<line x1="5" y1="21" x2="5" y2="9" />
|
<line x1="5" y1="21" x2="5" y2="9" />
|
||||||
<line x1="5" y1="10" x2="21" y2="6" />
|
<line x1="5" y1="10" x2="21" y2="6" />
|
||||||
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
<circle cx="5" cy="7" r="1.6" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<div className="text-[10px] uppercase tracking-wider text-term-muted">{label}</div>
|
<div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||||
<div className={`text-xs font-bold ${busy ? "text-term-red" : "text-term-green"}`}>
|
<div className="text-xs font-bold">
|
||||||
{busy ? "●" : "○"}
|
{issuing ? "…" : clickable ? t("booth.issueEntry") : busy ? "●" : blinking ? "◐" : "○"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The two lane barrier lights (entry / exit) fed by the live lane-status. */
|
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
|
||||||
|
* and lane-presence (radar). The ENTRY light doubles as an operator issue-ticket control when
|
||||||
|
* the physical button is broken (permission + presence gated). */
|
||||||
function LaneIndicators() {
|
function LaneIndicators() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lanes = useLiveStore((s) => s.lanes);
|
const lanes = useLiveStore((s) => s.lanes);
|
||||||
|
const radar = useLiveStore((s) => s.radar);
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
const { isOpen: shiftOpen, isMine } = useShift();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const canIssue = can(user, "session:create") && shiftOpen && isMine;
|
||||||
|
// Presence-gate bypass flags (admin, for faulty radar/camera). Refetched on interval so a
|
||||||
|
// toggle reaches the booth without a reload; the server still re-checks authoritatively.
|
||||||
|
const { data: site } = useQuery({
|
||||||
|
queryKey: qk.siteConfig,
|
||||||
|
queryFn: fetchSiteConfig,
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||||
|
|
||||||
|
const issue = useMutation({
|
||||||
|
mutationFn: issueEntryTicket,
|
||||||
|
onSuccess: (r) => {
|
||||||
|
setMsg({ ok: true, text: t("booth.issueEntryOk", { ticket: r.ticketId }) });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
|
setTimeout(() => setMsg(null), 4000);
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
setMsg({ ok: false, text: (e as Error).message });
|
||||||
|
setTimeout(() => setMsg(null), 4000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onIssue() {
|
||||||
|
if (window.confirm(t("booth.issueEntryConfirm"))) issue.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} />
|
<BarrierLight
|
||||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} />
|
label={t("booth.laneEntry")}
|
||||||
|
busy={lanes?.entry ?? false}
|
||||||
|
radar={radar?.entry ?? false}
|
||||||
|
onIssue={canIssue ? onIssue : undefined}
|
||||||
|
issuing={issue.isPending}
|
||||||
|
bypassRadar={site?.bypassPresenceRadar ?? false}
|
||||||
|
bypassCamera={site?.bypassPresenceCamera ?? false}
|
||||||
|
/>
|
||||||
|
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
||||||
|
{msg && (
|
||||||
|
<span className={`text-[0.6875rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -176,10 +260,10 @@ export function BoothScreen() {
|
|||||||
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
|
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
|
||||||
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
|
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 type, and source. (No direction filter —
|
||||||
|
// HYRJE/DALJE there just duplicated the entry/exit options already in the Type filter.)
|
||||||
const [feedSearch, setFeedSearch] = useState("");
|
const [feedSearch, setFeedSearch] = useState("");
|
||||||
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
||||||
const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">("");
|
|
||||||
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
|
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
|
||||||
|
|
||||||
// Live overlays from the WS store.
|
// Live overlays from the WS store.
|
||||||
@@ -202,17 +286,17 @@ export function BoothScreen() {
|
|||||||
|
|
||||||
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
||||||
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
|
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
|
||||||
// subscriber label, and any advisory plate on the payload.
|
// subscriber label, and the enriched advisory plate (`e.plate` — the displayed field;
|
||||||
|
// the plate is NOT in the signed payload, so `payload.plate` would never match).
|
||||||
const fq = feedSearch.trim().toLowerCase();
|
const fq = feedSearch.trim().toLowerCase();
|
||||||
const events = scoped.filter((e) => {
|
const events = scoped.filter((e) => {
|
||||||
if (feedType && feedCat(e.type) !== feedType) return false;
|
if (feedType && feedCat(e.type) !== feedType) return false;
|
||||||
if (feedDir && e.direction !== feedDir) return false;
|
|
||||||
if (feedSrc) {
|
if (feedSrc) {
|
||||||
const isBooth = e.source === "manual";
|
const isBooth = e.source === "manual";
|
||||||
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
|
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
|
||||||
}
|
}
|
||||||
if (fq) {
|
if (fq) {
|
||||||
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase();
|
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.plate ?? ""}`.toLowerCase();
|
||||||
if (!hay.includes(fq)) return false;
|
if (!hay.includes(fq)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -225,10 +309,6 @@ export function BoothScreen() {
|
|||||||
{ value: "void", label: t("booth.fEvtVoid") },
|
{ value: "void", label: t("booth.fEvtVoid") },
|
||||||
{ value: "anomaly", label: t("booth.fEvtAnomaly") },
|
{ value: "anomaly", label: t("booth.fEvtAnomaly") },
|
||||||
];
|
];
|
||||||
const feedDirOpts: SegOption<"entry" | "exit">[] = [
|
|
||||||
{ value: "entry", label: t("booth.fDirEntry") },
|
|
||||||
{ value: "exit", label: t("booth.fDirExit") },
|
|
||||||
];
|
|
||||||
const feedSrcOpts: SegOption<"booth" | "reader">[] = [
|
const feedSrcOpts: SegOption<"booth" | "reader">[] = [
|
||||||
{ value: "booth", label: t("booth.fSrcBooth") },
|
{ value: "booth", label: t("booth.fSrcBooth") },
|
||||||
{ value: "reader", label: t("booth.fSrcReader") },
|
{ value: "reader", label: t("booth.fSrcReader") },
|
||||||
@@ -266,7 +346,7 @@ export function BoothScreen() {
|
|||||||
<Panel
|
<Panel
|
||||||
title={t("booth.liveFeed")}
|
title={t("booth.liveFeed")}
|
||||||
right={
|
right={
|
||||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
{events.length}
|
{events.length}
|
||||||
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
|
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
|
||||||
</span>
|
</span>
|
||||||
@@ -282,7 +362,6 @@ export function BoothScreen() {
|
|||||||
onChange={setFeedType}
|
onChange={setFeedType}
|
||||||
allLabel={t("booth.filterAll")}
|
allLabel={t("booth.filterAll")}
|
||||||
/>
|
/>
|
||||||
<SegGroup value={feedDir} options={feedDirOpts} onChange={setFeedDir} allLabel={t("booth.filterAll")} />
|
|
||||||
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
|
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
fetchDrawerBalance,
|
||||||
|
fetchDrawerMovements,
|
||||||
|
fetchEvents,
|
||||||
|
fetchShift,
|
||||||
|
fetchShiftReport,
|
||||||
|
fetchShifts,
|
||||||
|
recordDrawerMovement,
|
||||||
|
reviewDrawerMovement,
|
||||||
|
type DrawerMovement,
|
||||||
|
type MovementStatus,
|
||||||
|
type ShiftSummary,
|
||||||
|
} from "./api.js";
|
||||||
|
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { Panel } from "./ui/Panel.js";
|
||||||
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
|
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
|
||||||
|
// answers "what's in the till and why": the CURRENT drawer balance with the open
|
||||||
|
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
|
||||||
|
// activity (every cash payment and voucher, live), the movement record/review flow
|
||||||
|
// (unchanged), and the closed-shift drawer history. All figures come from the signed
|
||||||
|
// chain — the drawer is a single site-wide till that carries across shifts. See
|
||||||
|
// wiki/concepts/shift.md.
|
||||||
|
|
||||||
|
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
||||||
|
|
||||||
|
/** Local midnight, ISO — the "today" window for the activity feed. */
|
||||||
|
function startOfToday(): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: MovementStatus }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cls =
|
||||||
|
status === "authorized"
|
||||||
|
? "border-term-green/60 text-term-green"
|
||||||
|
: status === "denied"
|
||||||
|
? "border-term-red/60 text-term-red"
|
||||||
|
: "border-term-amber/60 text-term-amber";
|
||||||
|
return (
|
||||||
|
<span className={`rounded border px-1 text-[0.625rem] uppercase tracking-wider ${cls}`}>
|
||||||
|
{t(`drawer.status.${status}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const refresh = () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||||
|
// A voucher moves the open shift's added/removed figures too (the X-report).
|
||||||
|
void qc.invalidateQueries({ queryKey: ["shift"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
|
||||||
|
{/* Row 1: the till NOW + the record form. */}
|
||||||
|
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
|
||||||
|
<StatePanel />
|
||||||
|
{canCreate && <RecordPanel onDone={refresh} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
|
||||||
|
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
|
||||||
|
<TodayPanel />
|
||||||
|
<MovementsPanel canReview={canReview} onChanged={refresh} />
|
||||||
|
<ShiftHistoryPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The drawer NOW ---------------------------------------------------------
|
||||||
|
// Balance from the chain + the open shift's running X-report breakdown, so the big
|
||||||
|
// number is always explainable: float + cash takings + in − out = expected = balance.
|
||||||
|
|
||||||
|
function StatePanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 });
|
||||||
|
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
|
||||||
|
const report = useQuery({
|
||||||
|
queryKey: ["shift", "xreport"],
|
||||||
|
queryFn: fetchShiftReport,
|
||||||
|
enabled: status.data?.open != null,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
const x = status.data?.open ? report.data : null;
|
||||||
|
const cur = balance.data?.currency ?? x?.currency ?? null;
|
||||||
|
// The current SHIFT's own balance: what this shift changed in the till
|
||||||
|
// (takings + vouchers), i.e. everything above the inherited opening float.
|
||||||
|
const shiftDelta = x ? x.expectedDrawerMinor - x.openingFloatMinor : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title={t("drawer.stateTitle")}>
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold text-term-cyan tabular-nums">
|
||||||
|
{balance.data ? money(balance.data.balanceMinor, cur) : "…"}
|
||||||
|
</div>
|
||||||
|
{shiftDelta != null && (
|
||||||
|
<div className="mt-0.5 text-[0.8125rem] tabular-nums">
|
||||||
|
<span className="text-term-muted">{t("drawer.thisShift")} </span>
|
||||||
|
<span className={shiftDelta < 0 ? "font-semibold text-term-red" : "font-semibold text-term-green"}>
|
||||||
|
{shiftDelta >= 0 ? "+" : ""}
|
||||||
|
{money(shiftDelta, cur)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-0.5 text-[0.6875rem] text-term-muted">
|
||||||
|
{status.data?.open
|
||||||
|
? t("drawer.openShift", { operator: status.data.open.operator }) +
|
||||||
|
" · " +
|
||||||
|
formatRelativeDateTime(status.data.open.startedAt, t)
|
||||||
|
: t("drawer.noShiftOpen")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* The running breakdown, only while a shift is open (it's the X-report). */}
|
||||||
|
{x && (
|
||||||
|
<dl className="grid grid-cols-[max-content_max-content] gap-x-4 gap-y-0.5 text-[0.75rem] tabular-nums">
|
||||||
|
<dt className="text-term-muted">{t("shifts.openingFloat")}</dt>
|
||||||
|
<dd className="text-right text-term-text">{money(x.openingFloatMinor, cur)}</dd>
|
||||||
|
<dt className="text-term-muted">
|
||||||
|
{t("shifts.cashTaken")} · {t("shifts.payments")} {x.paymentCount}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-right text-term-green">{money(x.cashTotalMinor, cur)}</dd>
|
||||||
|
<dt className="text-term-muted">{t("shifts.cashAdded")}</dt>
|
||||||
|
<dd className="text-right text-term-text">{money(x.cashAddedMinor, cur)}</dd>
|
||||||
|
<dt className="text-term-muted">{t("shifts.cashRemoved")}</dt>
|
||||||
|
<dd className="text-right text-term-red">{money(-x.cashRemovedMinor, cur)}</dd>
|
||||||
|
<dt className="border-t border-term-border pt-0.5 font-semibold text-term-muted">{t("shifts.expectedDrawer")}</dt>
|
||||||
|
<dd className="border-t border-term-border pt-0.5 text-right font-semibold text-term-text">
|
||||||
|
{money(x.expectedDrawerMinor, cur)}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Today's cash activity ---------------------------------------------------
|
||||||
|
// Every drawer-touching event since local midnight: cash payments (the current
|
||||||
|
// shift's incomings, live) + vouchers. Card payments never enter the till.
|
||||||
|
|
||||||
|
function TodayPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["drawer", "today"],
|
||||||
|
queryFn: () => fetchEvents(1000, startOfToday()),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = (q.data?.events ?? []).filter((e) => {
|
||||||
|
if (e.type === "cash_in" || e.type === "cash_out") return true;
|
||||||
|
if (e.type !== "payment") return false;
|
||||||
|
return (e.payload as { tender?: string } | null)?.tender !== "card";
|
||||||
|
});
|
||||||
|
|
||||||
|
let cashIn = 0;
|
||||||
|
let vouchersNet = 0;
|
||||||
|
let payments = 0;
|
||||||
|
let cur: string | null = null;
|
||||||
|
for (const e of rows) {
|
||||||
|
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
|
||||||
|
const amt = pl.amountMinor ?? 0;
|
||||||
|
if (pl.currency) cur = pl.currency;
|
||||||
|
if (e.type === "payment") {
|
||||||
|
cashIn += amt;
|
||||||
|
payments++;
|
||||||
|
} else {
|
||||||
|
vouchersNet += e.type === "cash_in" ? Math.abs(amt) : -Math.abs(amt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title={t("drawer.todayTitle")}
|
||||||
|
right={
|
||||||
|
rows.length > 0 ? (
|
||||||
|
<span className="text-[0.6875rem] tabular-nums text-term-muted">
|
||||||
|
{t("drawer.todayPayments", { count: payments })} · <span className="text-term-green">{money(cashIn, cur)}</span>
|
||||||
|
{vouchersNet !== 0 && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· <span className={vouchersNet < 0 ? "text-term-red" : "text-term-green"}>{money(vouchersNet, cur)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
className="min-h-0"
|
||||||
|
>
|
||||||
|
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||||
|
{q.isError ? (
|
||||||
|
<div className="text-[0.75rem] text-term-red">{(q.error as Error).message}</div>
|
||||||
|
) : q.isLoading ? (
|
||||||
|
<div className="text-term-muted">{t("common.loading")}</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="text-term-muted">{t("drawer.noActivity")}</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
|
<tbody>
|
||||||
|
{rows.map((e) => (
|
||||||
|
<TodayRow key={e.id} e={e} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodayRow({ e }: { e: LedgerEvent }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string; voucherNo?: string; reason?: string };
|
||||||
|
const amt = pl.amountMinor ?? 0;
|
||||||
|
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
|
||||||
|
const time = formatClock(e.occurredAt);
|
||||||
|
const label =
|
||||||
|
e.type === "payment"
|
||||||
|
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
||||||
|
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
|
||||||
|
return (
|
||||||
|
<tr className="border-t border-term-border/40">
|
||||||
|
<td className="whitespace-nowrap py-1 pr-2 text-term-muted">{time}</td>
|
||||||
|
<td className="max-w-0 truncate py-1 pr-2 text-term-text" title={pl.reason || undefined}>
|
||||||
|
{label}
|
||||||
|
</td>
|
||||||
|
<td className={`whitespace-nowrap py-1 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
|
||||||
|
{money(signed, pl.currency ?? null)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
|
||||||
|
|
||||||
|
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Reviewers can filter the list (the pending queue); operators always see their own, all.
|
||||||
|
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["drawer", "movements", canReview ? statusFilter : ""],
|
||||||
|
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined),
|
||||||
|
});
|
||||||
|
const movements = q.data?.movements ?? [];
|
||||||
|
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
||||||
|
right={
|
||||||
|
canReview && pendingCount > 0 ? (
|
||||||
|
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||||
|
{t("drawer.pendingCount", { count: pendingCount })}
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
className="min-h-0"
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
{canReview && (
|
||||||
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
|
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s || "all"}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(s)}
|
||||||
|
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||||
|
>
|
||||||
|
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
|
{q.isLoading ? (
|
||||||
|
<div className="text-term-muted">{t("common.loading")}</div>
|
||||||
|
) : movements.length === 0 ? (
|
||||||
|
<div className="text-term-muted">{t("drawer.empty")}</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
|
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
|
||||||
|
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
|
||||||
|
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
|
||||||
|
{canReview && <th className="px-2 py-1.5" />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{movements.map((m) => (
|
||||||
|
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={onChanged} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Closed shifts, drawer-focused -------------------------------------------
|
||||||
|
// Scope follows /api/shifts: operators see their own, admins all.
|
||||||
|
|
||||||
|
function ShiftHistoryPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() });
|
||||||
|
const shifts = (q.data?.shifts ?? []).slice(0, 50);
|
||||||
|
const showOperator = q.data?.scope === "all";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title={t("drawer.historyTitle")} className="min-h-0">
|
||||||
|
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||||
|
{q.isLoading ? (
|
||||||
|
<div className="text-term-muted">{t("common.loading")}</div>
|
||||||
|
) : shifts.length === 0 ? (
|
||||||
|
<div className="text-term-muted">{t("drawer.noShifts")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{shifts.map((s) => (
|
||||||
|
<ShiftDrawerCard key={s.id} s={s} showOperator={showOperator} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cur = s.currency;
|
||||||
|
return (
|
||||||
|
<div className="card p-2.5 text-[0.75rem]">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-semibold text-term-text">
|
||||||
|
{showOperator ? `${s.operator} · ` : ""}
|
||||||
|
{formatRelativeDateTime(s.startedAt, t)}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-term-text tabular-nums" title={t("shifts.expectedDrawer")}>
|
||||||
|
{money(s.expectedDrawerMinor, cur)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap gap-x-3 text-term-muted tabular-nums">
|
||||||
|
<span title={t("shifts.openingFloat")}>{money(s.openingFloatMinor, cur)} →</span>
|
||||||
|
<span className="text-term-green" title={t("shifts.cashTaken")}>
|
||||||
|
+{money(s.cashTotalMinor, cur)}
|
||||||
|
</span>
|
||||||
|
{s.cashAddedMinor > 0 && (
|
||||||
|
<span className="text-term-green" title={t("shifts.cashAdded")}>
|
||||||
|
+{money(s.cashAddedMinor, cur)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.cashRemovedMinor > 0 && (
|
||||||
|
<span className="text-term-red" title={t("shifts.cashRemoved")}>
|
||||||
|
−{money(s.cashRemovedMinor, cur)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Record form (unchanged from the pre-redesign feature) ------------------
|
||||||
|
|
||||||
|
function RecordPanel({ onDone }: { onDone: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||||
|
const record = useMutation({
|
||||||
|
mutationFn: (type: "cash_in" | "cash_out") =>
|
||||||
|
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
|
||||||
|
setAmount("");
|
||||||
|
setReason("");
|
||||||
|
onDone();
|
||||||
|
},
|
||||||
|
onError: (e) => setMsg({ ok: false, text: (e as Error).message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(type: "cash_in" | "cash_out") {
|
||||||
|
setMsg(null);
|
||||||
|
const major = Number(amount);
|
||||||
|
if (!Number.isFinite(major) || major <= 0) {
|
||||||
|
setMsg({ ok: false, text: t("drawer.enterPositive") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
record.mutate(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title={t("drawer.recordTitle")}>
|
||||||
|
<div className="flex flex-col gap-2 text-[0.8125rem]">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input w-28"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder={t("drawer.amount")}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input min-w-40 flex-1"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder={t("drawer.reasonPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.6875rem] text-term-muted">{t("drawer.recordHint")}</div>
|
||||||
|
{msg && (
|
||||||
|
<div className={`text-[0.75rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-go btn-sm" disabled={record.isPending} onClick={() => submit("cash_in")}>
|
||||||
|
{t("drawer.mandatArketimi")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" disabled={record.isPending} onClick={() => submit("cash_out")}>
|
||||||
|
{t("drawer.mandatPagese")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MovementRow({ m, canReview, onReviewed }: { m: DrawerMovement; canReview: boolean; onReviewed: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [noteOpen, setNoteOpen] = useState(false);
|
||||||
|
const review = useMutation({
|
||||||
|
mutationFn: (decision: "authorize" | "deny") =>
|
||||||
|
reviewDrawerMovement({ refId: m.id, decision, note: note.trim() || undefined }),
|
||||||
|
onSuccess: onReviewed,
|
||||||
|
});
|
||||||
|
// Direction sign for display: cash_in is +, cash_out is −.
|
||||||
|
const signed = m.type === "cash_in" ? m.amountMinor : -m.amountMinor;
|
||||||
|
return (
|
||||||
|
<tr className="border-t border-term-border/50 align-top">
|
||||||
|
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">{formatRelativeDateTime(m.at, t)}</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<span className={m.type === "cash_in" ? "text-term-green" : "text-term-red"}>
|
||||||
|
{m.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}
|
||||||
|
</span>
|
||||||
|
{m.voucherNo && <span className="ml-1 text-[0.625rem] text-term-muted">{m.voucherNo}</span>}
|
||||||
|
</td>
|
||||||
|
<td className={`whitespace-nowrap px-2 py-1.5 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
|
||||||
|
{money(signed, m.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-term-text">{m.reason || "—"}</td>
|
||||||
|
{canReview && <td className="px-2 py-1.5 text-term-muted">{m.operator}</td>}
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<StatusBadge status={m.status} />
|
||||||
|
{m.status !== "pending" && m.reviewedBy && (
|
||||||
|
<div className="mt-0.5 text-[0.5625rem] text-term-muted">
|
||||||
|
{m.reviewedBy}
|
||||||
|
{m.reviewNote ? ` · ${m.reviewNote}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{canReview && (
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
{m.status === "pending" ? (
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button type="button" className="btn btn-go btn-sm" disabled={review.isPending} onClick={() => review.mutate("authorize")}>
|
||||||
|
{t("drawer.authorize")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
disabled={review.isPending}
|
||||||
|
onClick={() => (noteOpen ? review.mutate("deny") : setNoteOpen(true))}
|
||||||
|
>
|
||||||
|
{t("drawer.deny")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{noteOpen && (
|
||||||
|
<input
|
||||||
|
className="input w-44 text-[0.6875rem]"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder={t("drawer.denyNotePlaceholder")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{review.isError && <span className="text-[0.625rem] text-term-red">{(review.error as Error).message}</span>}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ export function Login({ onLoggedIn }: { onLoggedIn: (u: SessionUser) => void })
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="mb-3 text-[12px] text-term-red">{error}</p>}
|
{error && <p className="mb-3 text-[0.75rem] text-term-red">{error}</p>}
|
||||||
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
|
<button type="submit" className="btn btn-primary btn-lg w-full" disabled={busy || !username || !password}>
|
||||||
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
{busy ? t("auth.signingIn") : t("auth.signIn")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,36 +25,53 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const hasDetail = (log.context && Object.keys(log.context).length > 0) || log.stack;
|
const hasDetail = (log.context && Object.keys(log.context).length > 0) || log.stack;
|
||||||
|
// Storm-coalesced row: the server folds repeated identical lines into one row and
|
||||||
|
// counts them in context._repeat (first occurrence kept in _firstAt).
|
||||||
|
const repeat = typeof log.context?._repeat === "number" ? (log.context?._repeat as number) : null;
|
||||||
|
const firstAt = typeof log.context?._firstAt === "string" ? (log.context?._firstAt as string) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`border-b border-term-border/50 ${log.level === "error" || log.level === "fatal" ? "bg-term-red/5" : ""}`}>
|
<div className={`border-b border-term-border/50 ${log.level === "error" || log.level === "fatal" ? "bg-term-red/5" : ""}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => hasDetail && setOpen((v) => !v)}
|
onClick={() => hasDetail && setOpen((v) => !v)}
|
||||||
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[12px] ${
|
className={`grid w-full grid-cols-[auto_4rem_5rem_1fr_auto] items-center gap-x-3 px-1 py-1 text-left text-[0.75rem] ${
|
||||||
hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
|
hasDetail ? "hover:bg-term-panel-2" : "cursor-default"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-term-muted tabular-nums">{formatRelativeDateTime(log.createdAt, t)}</span>
|
<span className="text-term-muted tabular-nums">{formatRelativeDateTime(log.createdAt, t)}</span>
|
||||||
<span className={`font-semibold uppercase ${LEVEL_COLOR[log.level]}`}>{log.level}</span>
|
<span className={`font-semibold uppercase ${LEVEL_COLOR[log.level]}`}>{log.level}</span>
|
||||||
<span className="text-term-muted">{t(log.source === "frontend" ? "logs.frontend" : "logs.backend")}</span>
|
<span className="text-term-muted">{t(log.source === "frontend" ? "logs.frontend" : "logs.backend")}</span>
|
||||||
<span className="truncate text-term-text">{log.message}</span>
|
<span className="truncate text-term-text">
|
||||||
|
{repeat != null && repeat > 1 && (
|
||||||
|
<span
|
||||||
|
className="mr-1.5 rounded-term border border-term-amber/50 px-1 text-[0.625rem] font-semibold text-term-amber"
|
||||||
|
title={t("logs.repeated", {
|
||||||
|
count: repeat,
|
||||||
|
firstAt: firstAt ? formatRelativeDateTime(firstAt, t) : "—",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
×{repeat}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.message}
|
||||||
|
</span>
|
||||||
<span className="text-term-muted tabular-nums">{log.httpStatus ?? ""}</span>
|
<span className="text-term-muted tabular-nums">{log.httpStatus ?? ""}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && hasDetail && (
|
{open && hasDetail && (
|
||||||
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
|
<div className="border-t border-term-border/40 bg-term-bg px-3 py-2">
|
||||||
{log.path && (
|
{log.path && (
|
||||||
<div className="mb-1 text-[11px] text-term-muted">
|
<div className="mb-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("logs.path")}: <code className="text-term-text">{log.path}</code>
|
{t("logs.path")}: <code className="text-term-text">{log.path}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{log.context && Object.keys(log.context).length > 0 && (
|
{log.context && Object.keys(log.context).length > 0 && (
|
||||||
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-text">
|
<pre className="mb-2 overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-text">
|
||||||
{JSON.stringify(log.context, null, 2)}
|
{JSON.stringify(log.context, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
{log.stack && (
|
{log.stack && (
|
||||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[11px] text-term-red/90">
|
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-panel-2 p-2 text-[0.6875rem] text-term-red/90">
|
||||||
{log.stack}
|
{log.stack}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -136,12 +153,12 @@ export function LogsViewer() {
|
|||||||
|
|
||||||
<div className="card p-2">
|
<div className="card p-2">
|
||||||
{q.isLoading ? (
|
{q.isLoading ? (
|
||||||
<div className="p-3 text-[12px] text-term-muted">{t("common.loading")}</div>
|
<div className="p-3 text-[0.75rem] text-term-muted">{t("common.loading")}</div>
|
||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
<div className="p-3 text-[12px] text-term-muted">{t("logs.empty")}</div>
|
<div className="p-3 text-[0.75rem] text-term-muted">{t("logs.empty")}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[10px] uppercase tracking-wider text-term-muted">
|
<div className="grid grid-cols-[auto_4rem_5rem_1fr_auto] gap-x-3 border-b border-term-border px-1 pb-1 text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
<span>{t("logs.time")}</span>
|
<span>{t("logs.time")}</span>
|
||||||
<span>{t("logs.level")}</span>
|
<span>{t("logs.level")}</span>
|
||||||
<span>{t("logs.source")}</span>
|
<span>{t("logs.source")}</span>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function Profile({
|
|||||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||||
{t("profile.accountSection")}
|
{t("profile.accountSection")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted">
|
<div className="grid grid-cols-2 gap-3 text-[0.6875rem] text-term-muted">
|
||||||
<div>
|
<div>
|
||||||
<span className="block">{t("profile.username")}</span>
|
<span className="block">{t("profile.username")}</span>
|
||||||
<span className="text-sm text-term-text">{user.username}</span>
|
<span className="text-sm text-term-text">{user.username}</span>
|
||||||
@@ -92,7 +92,7 @@ export function Profile({
|
|||||||
<span className="text-sm text-term-text">{user.roleName}</span>
|
<span className="text-sm text-term-text">{user.roleName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("profile.fullName")}
|
{t("profile.fullName")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -101,7 +101,7 @@ export function Profile({
|
|||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("profile.email")}
|
{t("profile.email")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -115,7 +115,7 @@ export function Profile({
|
|||||||
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
||||||
{t("profile.saveProfile")}
|
{t("profile.saveProfile")}
|
||||||
</button>
|
</button>
|
||||||
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
|
{accountMsg && <span className="text-[0.6875rem] text-term-muted">{accountMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export function Profile({
|
|||||||
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
||||||
{t("profile.passwordSection")}
|
{t("profile.passwordSection")}
|
||||||
</h2>
|
</h2>
|
||||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("profile.currentPassword")}
|
{t("profile.currentPassword")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -134,7 +134,7 @@ export function Profile({
|
|||||||
onChange={(e) => setCurrent(e.target.value)}
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("profile.newPassword")}
|
{t("profile.newPassword")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -144,7 +144,7 @@ export function Profile({
|
|||||||
onChange={(e) => setNext(e.target.value)}
|
onChange={(e) => setNext(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
||||||
{t("profile.confirmPassword")}
|
{t("profile.confirmPassword")}
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -163,7 +163,7 @@ export function Profile({
|
|||||||
>
|
>
|
||||||
{t("profile.changePassword")}
|
{t("profile.changePassword")}
|
||||||
</button>
|
</button>
|
||||||
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
|
{pwMsg && <span className="text-[0.6875rem] text-term-muted">{pwMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,13 +76,13 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|||||||
{t("recycleBin.title")}
|
{t("recycleBin.title")}
|
||||||
</h1>
|
</h1>
|
||||||
{retentionDays > 0 && (
|
{retentionDays > 0 && (
|
||||||
<span className="text-[12px] text-term-muted">
|
<span className="text-[0.75rem] text-term-muted">
|
||||||
{t("recycleBin.retentionNote", { days: retentionDays })}
|
{t("recycleBin.retentionNote", { days: retentionDays })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
|
{error && <p className="mb-2 text-[0.75rem] text-term-red">{error}</p>}
|
||||||
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
|
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
|
||||||
|
|
||||||
{!binQ.isLoading && items.length === 0 ? (
|
{!binQ.isLoading && items.length === 0 ? (
|
||||||
@@ -90,9 +90,9 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|||||||
{t("recycleBin.empty")}
|
{t("recycleBin.empty")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-[13px]">
|
<table className="w-full text-[0.8125rem]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
|
<tr className="border-b border-term-border text-left text-[0.6875rem] 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.type")}</th>
|
||||||
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</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 pr-3">{t("recycleBin.col.deleted")}</th>
|
||||||
@@ -103,7 +103,7 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
|
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
|
||||||
<td className="py-1.5 pr-3">
|
<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">
|
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[0.6875rem] text-term-muted">
|
||||||
{t(KIND_KEY[it.kind])}
|
{t(KIND_KEY[it.kind])}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -146,10 +146,10 @@ export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|||||||
|
|
||||||
{purging && (
|
{purging && (
|
||||||
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
|
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
|
||||||
<p className="text-[13px] text-term-text">
|
<p className="text-[0.8125rem] text-term-text">
|
||||||
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
|
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
|
<p className="mt-1 text-[0.75rem] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
|
||||||
<div className="mt-3 flex justify-end gap-2">
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
|
|||||||
+101
-18
@@ -1,8 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
LineChart,
|
LineChart,
|
||||||
Pie,
|
Pie,
|
||||||
PieChart,
|
PieChart,
|
||||||
|
ReferenceLine,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -37,6 +40,7 @@ const C = {
|
|||||||
border: "#2a2f38",
|
border: "#2a2f38",
|
||||||
text: "#f2f2ee",
|
text: "#f2f2ee",
|
||||||
panel: "#14171c",
|
panel: "#14171c",
|
||||||
|
panel2: "#1e222a",
|
||||||
};
|
};
|
||||||
|
|
||||||
type PresetKey = "today" | "7d" | "30d" | "90d";
|
type PresetKey = "today" | "7d" | "30d" | "90d";
|
||||||
@@ -95,7 +99,7 @@ export function Reports() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 flex items-center gap-1 text-[12px] text-term-muted">
|
<div className="ml-2 flex items-center gap-1 text-[0.75rem] text-term-muted">
|
||||||
<span>{t("reports.groupBy")}</span>
|
<span>{t("reports.groupBy")}</span>
|
||||||
<select
|
<select
|
||||||
className="select input-sm w-auto"
|
className="select input-sm w-auto"
|
||||||
@@ -140,27 +144,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
...p,
|
...p,
|
||||||
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
|
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
|
||||||
revenue: p.revenueMinor / 100,
|
revenue: p.revenueMinor / 100,
|
||||||
|
cash: p.cashMinor / 100,
|
||||||
|
card: p.cardMinor / 100,
|
||||||
}));
|
}));
|
||||||
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
|
|
||||||
const mix = [
|
const mix = [
|
||||||
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
|
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
|
||||||
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
|
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
|
||||||
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
|
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
|
||||||
].filter((s) => s.value > 0);
|
].filter((s) => s.value > 0);
|
||||||
|
const peakOcc = Math.max(data.occupancyStart, ...data.series.map((p) => p.occupancyEnd));
|
||||||
|
// Stay-duration bars: "≤30m … ≤24h" + the open-ended tail.
|
||||||
|
const stay = data.stayHistogram.map((b) => ({
|
||||||
|
label: b.uptoMin == null ? `>24${t("reports.stay.h")}` : b.uptoMin < 60 ? `≤${b.uptoMin}${t("reports.stay.m")}` : `≤${b.uptoMin / 60}${t("reports.stay.h")}`,
|
||||||
|
count: b.count,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* KPI cards. */}
|
{/* KPI cards. */}
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-8">
|
||||||
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
|
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
|
||||||
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
|
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
|
||||||
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
|
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
|
||||||
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
|
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
|
||||||
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
|
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
|
||||||
<Kpi
|
<Kpi
|
||||||
label={t("reports.kpi.subscribers")}
|
label={t("reports.kpi.peakOcc")}
|
||||||
value={String(data.subscriptions.currentlyValid)}
|
value={data.capacity ? `${peakOcc} / ${data.capacity}` : String(peakOcc)}
|
||||||
/>
|
/>
|
||||||
|
{/* The "look closer" counters — a spike here is what the signed chain is FOR. */}
|
||||||
|
<Kpi label={t("reports.kpi.voids")} value={String(tot.voids)} accent={tot.voids > 0 ? "amber" : undefined} />
|
||||||
|
<Kpi label={t("reports.kpi.anomalies")} value={String(tot.anomalies)} accent={tot.anomalies > 0 ? "red" : undefined} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Entry / exit over time. */}
|
{/* Entry / exit over time. */}
|
||||||
@@ -192,8 +206,38 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{/* Occupancy over time — THE parking curve: cars inside vs capacity. Step-shaped
|
||||||
|
(occupancy only moves at entries/exits); the red line is the configured cap. */}
|
||||||
|
<Panel title={t("reports.chart.occupancy")}>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<AreaChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
|
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
|
{data.capacity != null && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={data.capacity}
|
||||||
|
stroke={C.red}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
label={{ value: t("reports.capacityLine"), fill: C.red, fontSize: 11, position: "insideTopRight" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Area
|
||||||
|
type="stepAfter"
|
||||||
|
dataKey="occupancyEnd"
|
||||||
|
name={t("reports.chart.occupancySeries")}
|
||||||
|
stroke={C.cyan}
|
||||||
|
fill={C.cyan}
|
||||||
|
fillOpacity={0.15}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
{/* Revenue per bucket. */}
|
{/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */}
|
||||||
<Panel title={t("reports.chart.revenue", { currency: cur })}>
|
<Panel title={t("reports.chart.revenue", { currency: cur })}>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
@@ -201,7 +245,9 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
<YAxis stroke={C.muted} fontSize={11} />
|
<YAxis stroke={C.muted} fontSize={11} />
|
||||||
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
|
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
|
||||||
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
<Bar dataKey="cash" stackId="tender" name={t("reports.row.cash")} fill={C.amber} />
|
||||||
|
<Bar dataKey="card" stackId="tender" name={t("reports.row.card")} fill={C.cyan} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -232,22 +278,22 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Peak hours (entries by hour-of-day). */}
|
{/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */}
|
||||||
<Panel title={t("reports.chart.peakHours")}>
|
<Panel title={t("reports.chart.stay")}>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
<BarChart data={stay} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||||
<Tooltip contentStyle={tooltipStyle} />
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
|
<Bar dataKey="count" name={t("reports.row.closed")} fill={C.green} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Cash / card + duration + subscription breakdown (numbers). */}
|
{/* Cash / card + duration + subscription breakdown (numbers). */}
|
||||||
<Panel title={t("reports.chart.breakdown")}>
|
<Panel title={t("reports.chart.breakdown")}>
|
||||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[13px]">
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[0.8125rem]">
|
||||||
<Row label={t("reports.row.cash")} value={money(tot.cashMinor)} />
|
<Row label={t("reports.row.cash")} value={money(tot.cashMinor)} />
|
||||||
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
|
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
|
||||||
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
|
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
|
||||||
@@ -262,13 +308,50 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] text-term-muted">
|
{/* Entries heatmap: hour × day-of-week. Weekday-vs-weekend patterns at a glance —
|
||||||
|
the direct input for tariff windows (night rates, weekend cards, early bird). */}
|
||||||
|
<Panel title={t("reports.chart.heatmap")}>
|
||||||
|
<Heatmap matrix={data.entriesByDowHour} dows={t("reports.dowShort", { returnObjects: true }) as string[]} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<p className="text-[0.6875rem] text-term-muted">
|
||||||
{t("reports.footnote", { tz: data.tz })}
|
{t("reports.footnote", { tz: data.tz })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hour-of-day × day-of-week entries heatmap: pure CSS grid, amber intensity scaled to
|
||||||
|
* the busiest cell. Row 0 = Monday (server contract). Cell tooltip = exact count. */
|
||||||
|
function Heatmap({ matrix, dows }: { matrix: number[][]; dows: string[] }) {
|
||||||
|
const max = Math.max(1, ...matrix.flat());
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="grid min-w-[560px] grid-cols-[max-content_repeat(24,1fr)] gap-px text-[0.625rem]">
|
||||||
|
<span />
|
||||||
|
{Array.from({ length: 24 }, (_, h) => (
|
||||||
|
<span key={h} className="pb-0.5 text-center text-term-muted">
|
||||||
|
{h % 3 === 0 ? h : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{matrix.map((row, d) => (
|
||||||
|
<Fragment key={d}>
|
||||||
|
<span className="pr-1.5 leading-4 text-term-muted">{dows[d]}</span>
|
||||||
|
{row.map((v, h) => (
|
||||||
|
<span
|
||||||
|
key={h}
|
||||||
|
title={`${dows[d]} ${String(h).padStart(2, "0")}:00 — ${v}`}
|
||||||
|
className="h-4 rounded-[1px]"
|
||||||
|
style={{ background: v === 0 ? C.panel2 : C.amber, opacity: v === 0 ? 1 : 0.25 + 0.75 * (v / max) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
background: C.panel,
|
background: C.panel,
|
||||||
border: `1px solid ${C.border}`,
|
border: `1px solid ${C.border}`,
|
||||||
@@ -290,7 +373,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
|
|||||||
: "text-term-text";
|
: "text-term-text";
|
||||||
return (
|
return (
|
||||||
<div className="rounded-term border border-term-border bg-term-panel p-2.5">
|
<div className="rounded-term border border-term-border bg-term-panel p-2.5">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{label}</div>
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||||
<div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
|
<div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -299,7 +382,7 @@ function Kpi({ label, value, accent }: { label: string; value: string; accent?:
|
|||||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-term border border-term-border bg-term-panel p-3">
|
<div className="rounded-term border border-term-border bg-term-panel p-3">
|
||||||
<h2 className="mb-2 text-[11px] uppercase tracking-wider text-term-muted">{title}</h2>
|
<h2 className="mb-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -315,5 +398,5 @@ function Row({ label, value }: { label: string; value: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Empty({ t }: { t: TFunction }) {
|
function Empty({ t }: { t: TFunction }) {
|
||||||
return <p className="py-12 text-center text-[12px] text-term-muted">{t("reports.noData")}</p>;
|
return <p className="py-12 text-center text-[0.75rem] text-term-muted">{t("reports.noData")}</p>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
|
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={editing != null}
|
open={editing != null}
|
||||||
@@ -93,13 +93,13 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
|||||||
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
|
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[13px] font-semibold text-term-text">{r.name}</span>
|
<span className="text-[0.8125rem] font-semibold text-term-text">{r.name}</span>
|
||||||
{r.builtin && (
|
{r.builtin && (
|
||||||
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-term-amber">
|
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||||
{t("roles.builtin")}
|
{t("roles.builtin")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[11px] text-term-muted">
|
<span className="text-[0.6875rem] text-term-muted">
|
||||||
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,11 +155,11 @@ function RoleEditor({
|
|||||||
<div className="mt-1 grid grid-cols-1 gap-1">
|
<div className="mt-1 grid grid-cols-1 gap-1">
|
||||||
{Object.entries(grouped).map(([resource, list]) => (
|
{Object.entries(grouped).map(([resource, list]) => (
|
||||||
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
|
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
|
||||||
<span className="w-28 shrink-0 text-[12px] font-semibold text-term-text">{resource}</span>
|
<span className="w-28 shrink-0 text-[0.75rem] font-semibold text-term-text">{resource}</span>
|
||||||
{list.map((p) => {
|
{list.map((p) => {
|
||||||
const action = p.split(":")[1]!;
|
const action = p.split(":")[1]!;
|
||||||
return (
|
return (
|
||||||
<label key={p} className="flex items-center gap-1 text-[12px] text-term-text">
|
<label key={p} className="flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||||
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
|
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
|
||||||
{action}
|
{action}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
+805
-112
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
closeShift,
|
closeShift,
|
||||||
fetchEvents,
|
fetchEvents,
|
||||||
@@ -8,13 +8,14 @@ import {
|
|||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
fetchShifts,
|
fetchShifts,
|
||||||
openShift,
|
openShift,
|
||||||
recordCashVoucher,
|
|
||||||
type ShiftReport,
|
type ShiftReport,
|
||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
type SessionUser,
|
type SessionUser,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { user: SessionUser | null; canManage?: boolean; canVoucher?: boolean }) {
|
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [preset, setPreset] = useState<Preset>("week");
|
const [preset, setPreset] = useState<Preset>("week");
|
||||||
const [operator, setOperator] = useState("");
|
const [operator, setOperator] = useState("");
|
||||||
@@ -105,9 +106,17 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const q = useQuery({ queryKey: ["shifts", applied], queryFn: () => fetchShifts(applied) });
|
// keepPreviousData: every filter change makes a NEW query key; without it the
|
||||||
|
// data (and with it `scope`) goes undefined for the fetch round-trip, which
|
||||||
|
// unmounted the admin filter controls mid-interaction and blanked the list.
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: ["shifts", applied],
|
||||||
|
queryFn: () => fetchShifts(applied),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
const isAdmin = q.data?.scope === "all";
|
const isAdmin = q.data?.scope === "all";
|
||||||
const closed = q.data?.shifts ?? [];
|
const closed = q.data?.shifts ?? [];
|
||||||
|
const operators = q.data?.operators ?? [];
|
||||||
|
|
||||||
// The current/open shift sits at the TOP of the list (when present + visible to me).
|
// The current/open shift sits at the TOP of the list (when present + visible to me).
|
||||||
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
||||||
@@ -168,14 +177,21 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("shifts.operator")}</span>
|
{/* <span className="label">{t("shifts.operator")}</span> */}
|
||||||
<input className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)} placeholder={t("shifts.allOperators")} />
|
{/* A select over operators that HAVE shifts — the server filter is an
|
||||||
|
exact username match, so free text could only miss. */}
|
||||||
|
<select className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)}>
|
||||||
|
<option value="">{t("shifts.allOperators")}</option>
|
||||||
|
{operators.map((op) => (
|
||||||
|
<option key={op} value={op}>{op}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{q.isError && (
|
{q.isError && (
|
||||||
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div>
|
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{t("shifts.loadFailed")}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Two-pane: shift list (left) + selected shift's activity log (right). Both
|
{/* Two-pane: shift list (left) + selected shift's activity log (right). Both
|
||||||
@@ -183,7 +199,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
<div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
<div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
||||||
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
|
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
|
||||||
{!q.isLoading && list.length === 0 && (
|
{!q.isLoading && list.length === 0 && (
|
||||||
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
|
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
|
||||||
)}
|
)}
|
||||||
{list.map((s) => (
|
{list.map((s) => (
|
||||||
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
||||||
@@ -198,11 +214,10 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
isMine={isMine}
|
isMine={isMine}
|
||||||
showOperator={isAdmin}
|
showOperator={isAdmin}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
canVoucher={canVoucher}
|
|
||||||
onChanged={refreshAll}
|
onChanged={refreshAll}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="px-3 py-6 text-center text-[12px] text-term-muted">{t("shifts.selectAShift")}</p>
|
<p className="px-3 py-6 text-center text-[0.75rem] text-term-muted">{t("shifts.selectAShift")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,9 +243,15 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{err && <span className="text-[12px] text-term-red">{err}</span>}
|
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
||||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
{busy ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Spinner /> {t("shift.starting")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("shift.startShift")
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -244,11 +265,11 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`card w-full p-2.5 text-left text-[12px] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
|
className={`card w-full p-2.5 text-left text-[0.75rem] transition-colors ${selected ? "border-term-amber bg-term-panel-2" : "hover:bg-term-panel-2"}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||||
{open && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||||
{showOperator ? s.operator : when(s.startedAt)}
|
{showOperator ? s.operator : when(s.startedAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||||
@@ -257,7 +278,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
|||||||
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
|
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
|
||||||
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
||||||
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
|
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
|
||||||
<span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>
|
{CARD_PAYMENTS_ENABLED && <span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>}
|
||||||
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
|
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -270,7 +291,6 @@ function ShiftActivityLog({
|
|||||||
isMine,
|
isMine,
|
||||||
showOperator,
|
showOperator,
|
||||||
canManage,
|
canManage,
|
||||||
canVoucher,
|
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
shift: ShiftSummary;
|
shift: ShiftSummary;
|
||||||
@@ -278,11 +298,10 @@ function ShiftActivityLog({
|
|||||||
isMine: boolean;
|
isMine: boolean;
|
||||||
showOperator: boolean;
|
showOperator: boolean;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
canVoucher: boolean;
|
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
|
const [modal, setModal] = useState<null | "end" | "takings">(null);
|
||||||
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
|
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
|
||||||
// (full signed payload + snapshots + chain provenance).
|
// (full signed payload + snapshots + chain provenance).
|
||||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||||
@@ -300,9 +319,9 @@ function ShiftActivityLog({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
|
<div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[12px]">
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
|
||||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||||
{isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||||
{showOperator && `${shift.operator} · `}
|
{showOperator && `${shift.operator} · `}
|
||||||
{formatRelativeDateTime(shift.startedAt, t)}
|
{formatRelativeDateTime(shift.startedAt, t)}
|
||||||
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
||||||
@@ -311,28 +330,27 @@ function ShiftActivityLog({
|
|||||||
{isCurrent && isMine && canManage && (
|
{isCurrent && isMine && canManage && (
|
||||||
<span className="flex flex-wrap gap-1.5">
|
<span className="flex flex-wrap gap-1.5">
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setModal("takings")}>{t("shift.viewTakings")}</button>
|
<button type="button" className="btn btn-sm" onClick={() => setModal("takings")}>{t("shift.viewTakings")}</button>
|
||||||
{canVoucher && <button type="button" className="btn btn-sm" onClick={() => setModal("voucher")}>{t("shift.drawerVoucher")}</button>}
|
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => setModal("end")}>{t("shift.endShift")}</button>
|
<button type="button" className="btn btn-sm btn-danger" onClick={() => setModal("end")}>{t("shift.endShift")}</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[0.6875rem] tabular-nums sm:grid-cols-4">
|
||||||
<Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
<Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||||
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||||
<Figure label={t("shifts.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||||
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
|
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
|
||||||
<Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||||
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
||||||
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>}
|
{q.isLoading && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("common.loading")}</p>}
|
||||||
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>}
|
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.noActivity")}</p>}
|
||||||
{events.map((e) => (
|
{events.map((e) => (
|
||||||
<EventRow key={e.id} e={e} onOpen={setDetailEvent} />
|
<EventRow key={e.id} e={e} onOpen={setDetailEvent} />
|
||||||
))}
|
))}
|
||||||
@@ -340,7 +358,6 @@ function ShiftActivityLog({
|
|||||||
|
|
||||||
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||||
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||||
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
|
|
||||||
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -372,19 +389,20 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
<Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md">
|
<Modal open onClose={onClose} title={t("shift.endShift")} width="max-w-md">
|
||||||
{report ? (
|
{report ? (
|
||||||
// Result — the signed Z-report.
|
// Result — the signed Z-report.
|
||||||
<div className="text-[13px] tabular-nums">
|
<div className="text-[0.8125rem] tabular-nums">
|
||||||
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
|
<Figure label={t("shift.payments")} value={String(report.paymentCount)} />
|
||||||
<span />
|
<span />
|
||||||
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
|
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
|
||||||
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
|
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
|
||||||
<Figure label={t("shift.srcSubSales")} value={money(report.subscriptionSalesMinor, report.currency)} sub />
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
|
<span />
|
||||||
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
|
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||||
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
|
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
|
||||||
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />}
|
||||||
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
|
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
|
||||||
<Figure label={t("shift.cashAdded")} value={money(report.cashAddedMinor, report.currency)} />
|
<Figure label={t("shift.cashAdded")} value={money(report.cashAddedMinor, report.currency)} />
|
||||||
<Figure label={t("shift.cashRemoved")} value={money(report.cashRemovedMinor, report.currency)} />
|
<Figure label={t("shift.cashRemoved")} value={money(report.cashRemovedMinor, report.currency)} />
|
||||||
@@ -399,20 +417,24 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Confirm — show the live takings (split by source) + drawer before closing.
|
// Confirm — show the live takings (split by source) + drawer before closing.
|
||||||
<div className="text-[13px] tabular-nums">
|
<div className="text-[0.8125rem] tabular-nums">
|
||||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
|
<span />
|
||||||
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||||
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||||
|
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
|
||||||
|
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||||
|
<span />
|
||||||
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||||
</div>
|
</div>
|
||||||
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
|
{err && <p className="mt-2 text-[0.75rem] text-term-red">{err}</p>}
|
||||||
<div className="mt-3 flex justify-end gap-2">
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("subs.cancel")}</button>
|
<button type="button" className="btn btn-sm" onClick={onClose}>{t("subs.cancel")}</button>
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}>
|
<button type="button" className="btn btn-sm btn-danger" onClick={confirm} disabled={busy}>
|
||||||
@@ -425,54 +447,6 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VoucherModal({ currency, onClose, onDone }: { currency: string | null; onClose: () => void; onDone: () => void }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [amount, setAmount] = useState("");
|
|
||||||
const [reason, setReason] = useState("");
|
|
||||||
const [authName, setAuthName] = useState("");
|
|
||||||
const [authPassword, setAuthPassword] = useState("");
|
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function submit(type: "cash_in" | "cash_out") {
|
|
||||||
setMsg(null);
|
|
||||||
const major = Number(amount);
|
|
||||||
if (!Number.isFinite(major) || major <= 0) return setMsg(t("shift.enterPositive"));
|
|
||||||
if (!authName.trim() || !authPassword) return setMsg(t("shift.authRequired"));
|
|
||||||
try {
|
|
||||||
const r = await recordCashVoucher({ type, amountMinor: Math.round(major * 100), reason: reason.trim(), authorizedBy: authName.trim(), authorizerPassword: authPassword });
|
|
||||||
setMsg(t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }));
|
|
||||||
setAmount("");
|
|
||||||
setReason("");
|
|
||||||
setAuthPassword("");
|
|
||||||
onDone();
|
|
||||||
} catch (e) {
|
|
||||||
setMsg((e as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
|
|
||||||
<div className="flex flex-col gap-2 text-[13px]">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" />
|
|
||||||
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" />
|
|
||||||
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
|
|
||||||
{msg && <div className="text-[12px] text-term-muted">{msg}</div>}
|
|
||||||
<div className="mt-1 flex justify-end gap-2">
|
|
||||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
|
|
||||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => submit("cash_out")}>{t("shift.mandatPagese")}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TakingsModal({ onClose }: { onClose: () => void }) {
|
function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
||||||
@@ -480,27 +454,28 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
||||||
{!x ? (
|
{!x ? (
|
||||||
<p className="text-[12px] text-term-muted">{t("common.loading")}</p>
|
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[13px] tabular-nums">
|
<div className="text-[0.8125rem] tabular-nums">
|
||||||
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
|
<div className="text-term-muted">{t("shift.asOf")} {formatDateTime(x.asOf, t)}</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||||
<span />
|
<span />
|
||||||
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
|
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
|
||||||
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
|
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
|
||||||
<Figure label={t("shift.srcSubSales")} value={money(x.subscriptionSalesMinor, x.currency)} sub />
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
|
<span />
|
||||||
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||||
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
|
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
|
||||||
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
|
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />}
|
||||||
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
|
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
|
||||||
<Figure label={t("shift.cashAdded")} value={money(x.cashAddedMinor, x.currency)} />
|
<Figure label={t("shift.cashAdded")} value={money(x.cashAddedMinor, x.currency)} />
|
||||||
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
||||||
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
|
<Figure label={t("shift.expectedDrawer")} value={money(x.expectedDrawerMinor, x.currency)} bold />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-[11px] text-term-muted">{t("shift.xReportHint")}</div>
|
<div className="mt-2 text-[0.6875rem] text-term-muted">{t("shift.xReportHint")}</div>
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -513,8 +488,9 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||||
<span className={sub ? "text-term-muted/70" : "text-term-muted"}>{label}</span>
|
<span className={`whitespace-nowrap ${sub ? "text-term-muted/70" : "text-term-muted"}`}>{label}</span>
|
||||||
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
|
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||||||
|
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
import {
|
||||||
|
fetchOccupancy,
|
||||||
|
fetchSiteConfig,
|
||||||
|
fetchValidationPrograms,
|
||||||
|
saveSiteConfig,
|
||||||
|
saveValidationProgram,
|
||||||
|
type Occupancy,
|
||||||
|
type SiteConfig,
|
||||||
|
type ValidationProgramView,
|
||||||
|
} from "./api.js";
|
||||||
|
import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js";
|
||||||
|
|
||||||
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||||
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
||||||
@@ -28,12 +38,21 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
const [reserveSubs, setReserveSubs] = useState(false);
|
const [reserveSubs, setReserveSubs] = useState(false);
|
||||||
const [anprEntry, setAnprEntry] = useState(true);
|
const [anprEntry, setAnprEntry] = useState(true);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
// Merchant-validation programs (bar / lavazh). The checkboxes below toggle a
|
||||||
|
// station's `active` (persisted at once — each flip signs a config_change); the
|
||||||
|
// right-column panel edits the enabled stations. See validation-discounts.md.
|
||||||
|
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
fetchOccupancy().then(setOcc).catch(() => {});
|
fetchOccupancy().then(setOcc).catch(() => {});
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
|
if (canEdit) {
|
||||||
|
fetchValidationPrograms()
|
||||||
|
.then((r) => setPrograms(r.programs))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
fetchSiteConfig()
|
fetchSiteConfig()
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||||
@@ -45,7 +64,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
setMeta(m);
|
setMeta(m);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, [canEdit]);
|
||||||
|
|
||||||
|
/** Flip a merchant station's checkbox: persist `active` at once (a signed
|
||||||
|
* config_change server-side), creating the well-known row with comp defaults on
|
||||||
|
* the first enable. Config details are edited in the right-column panel. */
|
||||||
|
async function toggleStation(id: StationId, active: boolean) {
|
||||||
|
const existing = programs.find((p) => p.id === id);
|
||||||
|
const body = existing
|
||||||
|
? { ...existing, active }
|
||||||
|
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active };
|
||||||
|
try {
|
||||||
|
const saved = await saveValidationProgram(id, body);
|
||||||
|
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
@@ -68,8 +103,9 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card mt-6 max-w-md p-4">
|
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||||
<div className="flex flex-wrap items-center gap-1.5 text-[13px]">
|
<section className="card w-full max-w-md p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
||||||
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||||
{occ == null ? (
|
{occ == null ? (
|
||||||
<span className="text-term-muted">…</span>
|
<span className="text-term-muted">…</span>
|
||||||
@@ -93,7 +129,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="label">{t("site.capacityLabel")}</span>
|
<span className="label">{t("site.capacityLabel")}</span>
|
||||||
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -103,7 +139,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
{t("site.printExitDefault")}
|
{t("site.printExitDefault")}
|
||||||
<span className="hint">{t("site.printExitHint")}</span>
|
<span className="hint">{t("site.printExitHint")}</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-0.5 accent-term-amber"
|
className="mt-0.5 accent-term-amber"
|
||||||
@@ -115,7 +151,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-0.5 accent-term-amber"
|
className="mt-0.5 accent-term-amber"
|
||||||
@@ -127,7 +163,24 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="hint block">{t("site.anprEntryHint")}</span>
|
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
{t("val.sectionTitle")}
|
||||||
|
</div>
|
||||||
|
<span className="hint -mt-2">{t("val.sectionHint")}</span>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{STATIONS.map((id) => (
|
||||||
|
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={programs.find((p) => p.id === id)?.active ?? false}
|
||||||
|
onChange={(e) => toggleStation(id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
{t("site.parkDetails")}
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
||||||
@@ -153,10 +206,17 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
||||||
{msg && <span className="text-[12px] text-term-muted">{msg}</span>}
|
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{canEdit && (
|
||||||
|
<ValidationStationsPanel
|
||||||
|
programs={programs}
|
||||||
|
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import {
|
|||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
type SubscriptionQuote,
|
type SubscriptionQuote,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { formatDateTime, type TFn } from "./lib/format.js";
|
||||||
|
|
||||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||||||
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
|
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
|
||||||
@@ -178,9 +180,9 @@ function daysLabel(days: number[] | undefined, t: (k: string) => string): string
|
|||||||
|
|
||||||
/** A one-line label for a plan VERSION in the correction picker: effective date + its
|
/** A one-line label for a plan VERSION in the correction picker: effective date + its
|
||||||
* timeframe summary (or "24/7" when the version has no window). */
|
* timeframe summary (or "24/7" when the version has no window). */
|
||||||
function versionLabel(v: SubscriptionPlan, t: (k: string) => string): string {
|
function versionLabel(v: SubscriptionPlan, t: TFn): string {
|
||||||
const eff = new Date(v.effectiveFrom);
|
const eff = new Date(v.effectiveFrom);
|
||||||
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : eff.toLocaleString();
|
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : formatDateTime(v.effectiveFrom, t);
|
||||||
const tf = v.timeframes;
|
const tf = v.timeframes;
|
||||||
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
|
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
|
||||||
return `${date} · ${rules}`;
|
return `${date} · ${rules}`;
|
||||||
@@ -412,7 +414,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
|
<h2 className="mb-3 text-h4 font-semibold text-term-text">{t("subs.title")}</h2>
|
||||||
<ul className="mb-3 list-none p-0">
|
<ul className="mb-3 list-none p-0">
|
||||||
{subs.map((s) => (
|
{subs.map((s) => (
|
||||||
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[12px]">
|
<li key={s.id} className="flex flex-wrap items-center gap-2 border-b border-term-border/60 py-2 text-[0.75rem]">
|
||||||
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
|
<strong className="text-term-text">{s.holderName ?? t("subs.unnamed")}</strong>
|
||||||
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
|
<span className={s.status === "active" ? "text-term-green" : "text-term-amber"}>{t(STATUS_KEY[s.status])}</span>
|
||||||
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
|
<span className="tabular-nums text-term-cyan">{priceLabel(s, t)}</span>
|
||||||
@@ -464,13 +466,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{plans.length === 0 && <span className="text-[12px] text-term-amber">{t("subs.planNoneAvail")}</span>}
|
{plans.length === 0 && <span className="text-[0.75rem] text-term-amber">{t("subs.planNoneAvail")}</span>}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<label className="label">{t("subs.plan")}</label>
|
<label className="label">{t("subs.plan")}</label>
|
||||||
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
<span className="text-[0.8125rem] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
||||||
{/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may
|
{/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may
|
||||||
move the sub to a different VERSION of that same plan (e.g. one with
|
move the sub to a different VERSION of that same plan (e.g. one with
|
||||||
different timeframes). Price stays as billed. Only shown when the sub has
|
different timeframes). Price stays as billed. Only shown when the sub has
|
||||||
@@ -486,7 +488,15 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
if (cur) versions.unshift(cur);
|
if (cur) versions.unshift(cur);
|
||||||
}
|
}
|
||||||
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) {
|
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) {
|
||||||
return <span className="text-[12px] text-term-muted">{t("subs.versionOnlyOne")}</span>;
|
// Keep the 2-col grid flow intact: a lone cell here would shift every
|
||||||
|
// following row by one column (label↔input swap). Emit a full row —
|
||||||
|
// the version label + the "only one version" hint as its control.
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="label">{t("subs.version")}</label>
|
||||||
|
<span className="text-[0.75rem] text-term-muted">{t("subs.versionOnlyOne")}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -504,7 +514,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-[12px] text-term-muted">{t("subs.versionHint")}</span>
|
<span className="text-[0.75rem] text-term-muted">{t("subs.versionHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -523,17 +533,21 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span>
|
<span className="text-[0.75rem] text-term-muted">{t("subs.quantityHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
|
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
|
||||||
signed payment so the money shows in the feed/drawer/Z-report. */}
|
signed payment so the money shows in the feed/drawer/Z-report. */}
|
||||||
{form.planId.trim() !== "" && editing === "new" && (
|
{/* Tender picker — only meaningful when there's a choice. Card is hidden until a
|
||||||
|
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED); with cash-only there's
|
||||||
|
nothing to pick, so the whole row is suppressed (form.tender stays "cash").
|
||||||
|
See lib/features.ts + wiki/concepts/card-payments.md. */}
|
||||||
|
{form.planId.trim() !== "" && editing === "new" && CARD_PAYMENTS_ENABLED && (
|
||||||
<>
|
<>
|
||||||
<label className="label">{t("subs.tender")}</label>
|
<label className="label">{t("subs.tender")}</label>
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="tender"
|
name="tender"
|
||||||
@@ -543,7 +557,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
/>
|
/>
|
||||||
{t("subs.tenderCash")}
|
{t("subs.tenderCash")}
|
||||||
</label>
|
</label>
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="tender"
|
name="tender"
|
||||||
@@ -553,13 +567,13 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
/>
|
/>
|
||||||
{t("subs.tenderCard")}
|
{t("subs.tenderCard")}
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[12px] text-term-muted">{t("subs.tenderHint")}</span>
|
<span className="text-[0.75rem] text-term-muted">{t("subs.tenderHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<label className="label">{t("subs.carLimit")}</label>
|
<label className="label">{t("subs.carLimit")}</label>
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-text">
|
||||||
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
<input type="checkbox" className="accent-term-amber" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
|
||||||
</label>
|
</label>
|
||||||
{form.carBound && (
|
{form.carBound && (
|
||||||
@@ -580,7 +594,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<span className="text-[12px] text-term-muted">
|
<span className="text-[0.75rem] text-term-muted">
|
||||||
× {t(PERIOD_KEY[selectedPlan.period])}
|
× {t(PERIOD_KEY[selectedPlan.period])}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -592,7 +606,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
|
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
|
||||||
override it — this is exactly what will be charged + signed. */}
|
override it — this is exactly what will be charged + signed. */}
|
||||||
{editing === "new" && form.planId.trim() !== "" && (
|
{editing === "new" && form.planId.trim() !== "" && (
|
||||||
<span className="text-[12px] text-term-cyan">
|
<span className="text-[0.75rem] text-term-cyan">
|
||||||
{quoting
|
{quoting
|
||||||
? t("subs.quoting")
|
? t("subs.quoting")
|
||||||
: quote
|
: quote
|
||||||
@@ -610,7 +624,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 className="mt-4 mb-1 text-[12px] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
|
<h4 className="mt-4 mb-1 text-[0.75rem] font-semibold uppercase tracking-wider text-term-muted">{t("subs.credentials")}</h4>
|
||||||
{form.credentials.map((c, i) => (
|
{form.credentials.map((c, i) => (
|
||||||
<div key={i} className="mb-1.5 flex items-center gap-2">
|
<div key={i} className="mb-1.5 flex items-center gap-2">
|
||||||
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||||||
@@ -625,7 +639,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
c.value.trim() ? (
|
c.value.trim() ? (
|
||||||
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
|
<input className="input input-sm flex-1 opacity-70" value={c.value} readOnly />
|
||||||
) : (
|
) : (
|
||||||
<span className="flex-1 self-center text-[12px] italic text-term-muted">{t("subs.qrAutoGen")}</span>
|
<span className="flex-1 self-center text-[0.75rem] italic text-term-muted">{t("subs.qrAutoGen")}</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
// RFID: the value is read off a physical card (or typed). "Read card"
|
// RFID: the value is read off a physical card (or typed). "Read card"
|
||||||
@@ -643,7 +657,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
{/* Capture panel: pick a reader, present the card; the captured value fills
|
{/* Capture panel: pick a reader, present the card; the captured value fills
|
||||||
the credential. The OTHER reader keeps serving the live flow. */}
|
the credential. The OTHER reader keeps serving the live flow. */}
|
||||||
{capture && (
|
{capture && (
|
||||||
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[12px]">
|
<div className="mt-3 rounded-term border border-term-cyan/50 bg-term-cyan/5 p-3 text-[0.75rem]">
|
||||||
{capture.phase === "pick" ? (
|
{capture.phase === "pick" ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
|
<div className="mb-1.5 text-term-text">{t("subs.captureChooseReader")}</div>
|
||||||
@@ -673,7 +687,7 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
|||||||
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
<button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[12px] text-term-green" : "mt-3 text-[12px] text-term-red"}>{msg.text}</p>}
|
{msg && <p className={msg.kind === "ok" ? "mt-3 text-[0.75rem] text-term-green" : "mt-3 text-[0.75rem] text-term-red"}>{msg.text}</p>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { formatDate } from "./lib/format.js";
|
||||||
|
import { currencyOptions } from "./lib/currencies.js";
|
||||||
|
|
||||||
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
||||||
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
|
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
|
||||||
@@ -237,19 +239,19 @@ export function SubscriptionPlansManager() {
|
|||||||
return (
|
return (
|
||||||
<section className="px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
<h3 className="text-[0.8125rem] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
||||||
{t("plans.add")}
|
{t("plans.add")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-3 text-[12px] text-term-muted">{t("plans.intro")}</p>
|
<p className="mb-3 text-[0.75rem] text-term-muted">{t("plans.intro")}</p>
|
||||||
|
|
||||||
{msg && (
|
{msg && (
|
||||||
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
<div className={`mb-3 text-[0.75rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
|
<p className="text-[0.8125rem] text-term-muted">{t("plans.noneYet")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{groups.map(({ planId, head: p, active, versions }) => {
|
{groups.map(({ planId, head: p, active, versions }) => {
|
||||||
@@ -263,24 +265,24 @@ export function SubscriptionPlansManager() {
|
|||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="font-semibold text-term-text">{p.name}</span>
|
<span className="font-semibold text-term-text">{p.name}</span>
|
||||||
{active ? (
|
{active ? (
|
||||||
<span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>
|
<span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("plans.inForce")}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span>
|
<span className="rounded border border-term-border px-1 text-[0.625rem] text-term-muted">{t("plans.retired")}</span>
|
||||||
)}
|
)}
|
||||||
{versions > 1 && <span className="text-[10px] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
|
{versions > 1 && <span className="text-[0.625rem] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details: price · hours · effective */}
|
{/* Details: price · hours · effective */}
|
||||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] text-term-muted">
|
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[0.75rem] text-term-muted">
|
||||||
<span className="tabular-nums text-term-text">
|
<span className="tabular-nums text-term-text">
|
||||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||||
</span>
|
</span>
|
||||||
<span>{timeframesSummary(p.timeframes, t)}</span>
|
<span>{timeframesSummary(p.timeframes, t)}</span>
|
||||||
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
|
<span>{t("plans.colEffective")}: {formatDate(p.effectiveFrom, t)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Used by */}
|
{/* Used by */}
|
||||||
<div className="mt-1 text-[12px]">
|
<div className="mt-1 text-[0.75rem]">
|
||||||
{users.length > 0 ? (
|
{users.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -295,12 +297,12 @@ export function SubscriptionPlansManager() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isOpen && users.length > 0 && (
|
{isOpen && users.length > 0 && (
|
||||||
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[12px]">
|
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[0.75rem]">
|
||||||
{users.map((s) => (
|
{users.map((s) => (
|
||||||
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
|
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
|
||||||
{s.holderName || t("subs.unnamed")}
|
{s.holderName || t("subs.unnamed")}
|
||||||
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
|
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
|
||||||
{s.status !== "active" && <span className="ml-1 text-[10px]">({t(STATUS_KEY[s.status])})</span>}
|
{s.status !== "active" && <span className="ml-1 text-[0.625rem]">({t(STATUS_KEY[s.status])})</span>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -348,15 +350,19 @@ export function SubscriptionPlansManager() {
|
|||||||
<label className="label">{t("plans.pricePer")}</label>
|
<label className="label">{t("plans.pricePer")}</label>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
||||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
|
<select className="input w-auto" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })}>
|
||||||
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
{currencyOptions(form.currency).map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
|
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
|
||||||
window they're charged the transient tariff for the gap. Off = 24/7. */}
|
window they're charged the transient tariff for the gap. Off = 24/7. */}
|
||||||
<div className="mt-3 border-t border-term-border pt-3">
|
<div className="mt-3 border-t border-term-border pt-3">
|
||||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -370,7 +376,7 @@ export function SubscriptionPlansManager() {
|
|||||||
<label className="label">{t("plans.days")}</label>
|
<label className="label">{t("plans.days")}</label>
|
||||||
<span className="flex flex-wrap gap-2">
|
<span className="flex flex-wrap gap-2">
|
||||||
{DOW_ORDER.map((d) => (
|
{DOW_ORDER.map((d) => (
|
||||||
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="accent-term-amber"
|
||||||
@@ -386,7 +392,7 @@ export function SubscriptionPlansManager() {
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<label className="label">{t("plans.window")}</label>
|
<label className="label">{t("plans.window")}</label>
|
||||||
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted">
|
<span className="flex flex-wrap items-center gap-2 text-[0.75rem] text-term-muted">
|
||||||
{t("plans.enterAfter")}
|
{t("plans.enterAfter")}
|
||||||
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
|
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
|
||||||
{t("plans.exitBefore")}
|
{t("plans.exitBefore")}
|
||||||
@@ -395,14 +401,14 @@ export function SubscriptionPlansManager() {
|
|||||||
<label className="label">{t("plans.grace")}</label>
|
<label className="label">{t("plans.grace")}</label>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
|
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
|
||||||
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span>
|
<span className="text-[0.75rem] text-term-muted">{t("plans.graceHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p>
|
<p className="mt-1.5 text-[0.6875rem] text-term-muted">{t("plans.timeframesHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
|
{form.planId && <p className="mt-2 text-[0.6875rem] text-term-amber">{t("plans.newVersionHint")}</p>}
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
|
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
|
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
|
||||||
|
|||||||
+90
-561
@@ -1,267 +1,28 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
|
||||||
ApiError,
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
fetchTariff,
|
import { formatDateTime } from "./lib/format.js";
|
||||||
isTariffV2,
|
|
||||||
publishTariffVersion,
|
|
||||||
type TariffBlock,
|
|
||||||
type TariffCard,
|
|
||||||
type TariffStep,
|
|
||||||
type TariffStructure,
|
|
||||||
type TariffState,
|
|
||||||
} from "./api.js";
|
|
||||||
|
|
||||||
// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
|
// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
|
||||||
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||||
// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for
|
// sessions reprice correctly. A right sidebar lists the published history (named
|
||||||
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
|
// since 2026-07-05); clicking a version loads it into the editor as the STARTING
|
||||||
|
// POINT — publishing always creates a new version effective now, it never edits the
|
||||||
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
// clicked one. The form machinery is shared with the Tariff Lab's draft modal — see
|
||||||
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
// TariffEditorForm.tsx. To experiment without publishing, use the lab. See
|
||||||
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
// wiki/concepts/tariff.md.
|
||||||
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
|
||||||
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
|
||||||
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
|
||||||
interface BlockForm {
|
|
||||||
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
|
||||||
price: string; // major units, e.g. "2.00"
|
|
||||||
}
|
|
||||||
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
|
|
||||||
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
|
|
||||||
interface StepForm {
|
|
||||||
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
|
|
||||||
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
|
|
||||||
}
|
|
||||||
// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped
|
|
||||||
// (up-to) total-by-duration table.
|
|
||||||
interface PricingForm {
|
|
||||||
mode: "ladder" | "flat" | "stepped";
|
|
||||||
flat: string; // major units (used when mode==="flat")
|
|
||||||
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
|
||||||
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
|
|
||||||
dailyCap: string; // "" = no cap (ladder only)
|
|
||||||
}
|
|
||||||
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
|
||||||
interface TierForm {
|
|
||||||
name: string;
|
|
||||||
priority: string;
|
|
||||||
category: string; // "" = applies to all categories
|
|
||||||
dow: number[]; // selected days 0..6; empty = every day
|
|
||||||
fromHour: string; // "" = all day
|
|
||||||
toHour: string;
|
|
||||||
dateFrom: string; // "" = unbounded
|
|
||||||
dateTo: string;
|
|
||||||
pricing: PricingForm;
|
|
||||||
}
|
|
||||||
interface FormState {
|
|
||||||
currency: string;
|
|
||||||
gracePeriodEntryMin: string;
|
|
||||||
incrementMin: string;
|
|
||||||
lostTicket: string;
|
|
||||||
gracePeriodExitMin: string;
|
|
||||||
// The default (always-active) card — its own flat/ladder body + daily cap.
|
|
||||||
base: PricingForm;
|
|
||||||
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
|
||||||
tiers: TierForm[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
|
||||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
|
||||||
|
|
||||||
function emptySteps(): StepForm[] {
|
|
||||||
return [
|
|
||||||
{ hours: "1", total: "2.00" },
|
|
||||||
{ hours: "3", total: "5.00" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
function emptyLadder(): PricingForm {
|
|
||||||
return {
|
|
||||||
mode: "ladder",
|
|
||||||
flat: "0.00",
|
|
||||||
dailyCap: "",
|
|
||||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
|
||||||
steps: emptySteps(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function emptyTier(): TierForm {
|
|
||||||
return {
|
|
||||||
name: "",
|
|
||||||
priority: "10",
|
|
||||||
category: "",
|
|
||||||
dow: [],
|
|
||||||
fromHour: "",
|
|
||||||
toHour: "",
|
|
||||||
dateFrom: "",
|
|
||||||
dateTo: "",
|
|
||||||
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyForm(): FormState {
|
|
||||||
return {
|
|
||||||
currency: "EUR",
|
|
||||||
gracePeriodEntryMin: "15",
|
|
||||||
incrementMin: "60",
|
|
||||||
lostTicket: "20.00",
|
|
||||||
gracePeriodExitMin: "15",
|
|
||||||
base: emptyLadder(),
|
|
||||||
tiers: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
|
|
||||||
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
|
|
||||||
// load (shown as their own band).
|
|
||||||
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
|
||||||
let prev = 0;
|
|
||||||
return blocks.map((b) => {
|
|
||||||
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
|
||||||
const hours = (b.uptoMin - prev) / 60;
|
|
||||||
prev = b.uptoMin;
|
|
||||||
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
|
|
||||||
function stepsToForm(steps: TariffStep[]): StepForm[] {
|
|
||||||
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped).
|
|
||||||
function pricingFromCard(c: {
|
|
||||||
flatMinor?: number;
|
|
||||||
blocks?: TariffBlock[];
|
|
||||||
steps?: TariffStep[];
|
|
||||||
dailyCapMinor?: number | null;
|
|
||||||
}): PricingForm {
|
|
||||||
if (c.steps != null && c.steps.length > 0) {
|
|
||||||
return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) };
|
|
||||||
}
|
|
||||||
if (c.flatMinor != null) {
|
|
||||||
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
mode: "ladder",
|
|
||||||
flat: "0.00",
|
|
||||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
|
||||||
blocks: blocksToForm(c.blocks ?? []),
|
|
||||||
steps: emptySteps(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function tierFromCard(c: TariffCard): TierForm {
|
|
||||||
const w = c.window ?? {};
|
|
||||||
return {
|
|
||||||
name: c.name,
|
|
||||||
priority: String(c.priority),
|
|
||||||
category: c.category ?? "",
|
|
||||||
dow: w.dow ? [...w.dow] : [],
|
|
||||||
fromHour: w.fromHour ?? "",
|
|
||||||
toHour: w.toHour ?? "",
|
|
||||||
dateFrom: w.dateFrom ?? "",
|
|
||||||
dateTo: w.dateTo ?? "",
|
|
||||||
pricing: pricingFromCard(c),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formFromActive(s: TariffState): FormState {
|
|
||||||
const v = s.active;
|
|
||||||
if (!v) return emptyForm();
|
|
||||||
const st = v.structure;
|
|
||||||
const common = {
|
|
||||||
currency: v.currency,
|
|
||||||
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
|
||||||
incrementMin: String(st.incrementMin),
|
|
||||||
lostTicket: toMajor(st.lostTicketMinor),
|
|
||||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
|
||||||
};
|
|
||||||
if (isTariffV2(st)) {
|
|
||||||
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
|
||||||
}
|
|
||||||
// V1: the bare ladder becomes the default card body; no tiers.
|
|
||||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm.
|
|
||||||
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "dailyCapMinor"> {
|
|
||||||
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
|
||||||
if (p.mode === "stepped") {
|
|
||||||
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
|
|
||||||
const steps: TariffStep[] = p.steps.map((s) => ({
|
|
||||||
uptoMin: Math.round(Number(s.hours || "0") * 60),
|
|
||||||
totalMinor: toMinor(s.total),
|
|
||||||
}));
|
|
||||||
return { steps };
|
|
||||||
}
|
|
||||||
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
|
||||||
const last = p.blocks.length - 1;
|
|
||||||
let cum = 0;
|
|
||||||
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
|
|
||||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
|
||||||
cum += Math.round(Number(b.hours || "0") * 60);
|
|
||||||
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
|
|
||||||
});
|
|
||||||
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function tierToCard(tr: TierForm): TariffCard {
|
|
||||||
const window: TariffCard["window"] = {};
|
|
||||||
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
|
|
||||||
if (tr.fromHour && tr.toHour) {
|
|
||||||
window.fromHour = tr.fromHour;
|
|
||||||
window.toHour = tr.toHour;
|
|
||||||
}
|
|
||||||
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
|
|
||||||
if (tr.dateTo) window.dateTo = tr.dateTo;
|
|
||||||
const card: TariffCard = {
|
|
||||||
name: tr.name.trim() || "tier",
|
|
||||||
priority: Math.round(Number(tr.priority || "0")),
|
|
||||||
...pricingToCardBody(tr.pricing),
|
|
||||||
};
|
|
||||||
if (tr.category.trim()) card.category = tr.category.trim();
|
|
||||||
if (Object.keys(window).length > 0) card.window = window;
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toStructure(f: FormState): TariffStructure {
|
|
||||||
const common = {
|
|
||||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
|
||||||
incrementMin: Math.round(Number(f.incrementMin)),
|
|
||||||
lostTicketMinor: toMinor(f.lostTicket),
|
|
||||||
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
|
||||||
overstay: "reprice" as const,
|
|
||||||
};
|
|
||||||
const baseBody = pricingToCardBody(f.base);
|
|
||||||
|
|
||||||
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
|
||||||
// tiers gets exactly today's shape; the server leaves it untouched).
|
|
||||||
if (f.tiers.length === 0) {
|
|
||||||
if (f.base.mode === "stepped") {
|
|
||||||
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
|
|
||||||
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
|
|
||||||
}
|
|
||||||
if (f.base.mode === "flat") {
|
|
||||||
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
|
||||||
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
|
||||||
}
|
|
||||||
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
|
|
||||||
return {
|
|
||||||
...common,
|
|
||||||
version: 2,
|
|
||||||
tz: "",
|
|
||||||
defaultCard: { name: "default", priority: 0, ...baseBody },
|
|
||||||
windowedCards: f.tiers.map(tierToCard),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TariffComposer() {
|
export function TariffComposer() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<TariffState | null>(null);
|
const [state, setState] = useState<TariffState | null>(null);
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
|
// Which published version the editor was last loaded from (sidebar highlight).
|
||||||
|
const [loadedId, setLoadedId] = useState<string | null>(null);
|
||||||
|
// Optional label for the version about to be published. Deliberately NOT prefilled
|
||||||
|
// from the active version — a tweaked card republished under last season's name
|
||||||
|
// would mislabel the history.
|
||||||
|
const [versionName, setVersionName] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
|
|
||||||
@@ -270,74 +31,30 @@ export function TariffComposer() {
|
|||||||
.then((s) => {
|
.then((s) => {
|
||||||
setState(s);
|
setState(s);
|
||||||
setForm(formFromActive(s));
|
setForm(formFromActive(s));
|
||||||
|
setLoadedId(s.active?.id ?? null);
|
||||||
})
|
})
|
||||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
function loadVersion(v: TariffVersion) {
|
||||||
setForm((f) => ({ ...f, [key]: value }));
|
setForm(formFromVersion(v.currency, v.structure));
|
||||||
}
|
setLoadedId(v.id);
|
||||||
|
setMsg(null);
|
||||||
// --- pricing-body editing (used by the default card AND each tier) ---
|
|
||||||
// `update` maps the old PricingForm to a new one; `target` selects which body:
|
|
||||||
// the base card, or tier index N.
|
|
||||||
function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
|
|
||||||
setForm((f) => {
|
|
||||||
if (target === "base") return { ...f, base: update(f.base) };
|
|
||||||
return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
|
|
||||||
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
|
||||||
}
|
|
||||||
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
|
|
||||||
function addBlock(target: "base" | number) {
|
|
||||||
updatePricing(target, (p) => {
|
|
||||||
const next = [...p.blocks];
|
|
||||||
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
|
|
||||||
return { ...p, blocks: next };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function removeBlock(target: "base" | number, i: number) {
|
|
||||||
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
|
||||||
}
|
|
||||||
// --- stepped (up-to) editing (base card only) ---
|
|
||||||
function setStep(i: number, patch: Partial<StepForm>) {
|
|
||||||
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
|
|
||||||
}
|
|
||||||
function addStep() {
|
|
||||||
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
|
|
||||||
}
|
|
||||||
function removeStep(i: number) {
|
|
||||||
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- tier editing ---
|
|
||||||
function setTier(i: number, patch: Partial<TierForm>) {
|
|
||||||
setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
|
||||||
}
|
|
||||||
function addTier() {
|
|
||||||
setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
|
||||||
}
|
|
||||||
function removeTier(i: number) {
|
|
||||||
setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
|
||||||
}
|
|
||||||
function toggleDow(i: number, d: number) {
|
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
tiers: f.tiers.map((tr, j) =>
|
|
||||||
j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish() {
|
async function publish() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
await publishTariffVersion({
|
||||||
|
currency: form.currency.trim().toUpperCase(),
|
||||||
|
structure: toStructure(form),
|
||||||
|
...(versionName.trim() ? { name: versionName.trim() } : {}),
|
||||||
|
});
|
||||||
const fresh = await fetchTariff();
|
const fresh = await fetchTariff();
|
||||||
setState(fresh);
|
setState(fresh);
|
||||||
|
setLoadedId(fresh.active?.id ?? null);
|
||||||
|
setVersionName("");
|
||||||
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text =
|
const text =
|
||||||
@@ -354,269 +71,81 @@ export function TariffComposer() {
|
|||||||
<section className="px-4 py-6">
|
<section className="px-4 py-6">
|
||||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("tariff.title")}</h2>
|
||||||
{!state?.active ? (
|
{!state?.active ? (
|
||||||
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[12px] text-term-amber">
|
<p className="mb-4 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||||
{t("tariff.noRateCard")}
|
{t("tariff.noRateCard")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="mb-4 text-[12px] text-term-muted">
|
<p className="mb-4 text-[0.75rem] text-term-muted">
|
||||||
|
{state.active.name ? `${state.active.name} — ` : ""}
|
||||||
{t("tariff.activeSince", {
|
{t("tariff.activeSince", {
|
||||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
date: formatDateTime(state.active.effectiveFrom, t),
|
||||||
count: state.versions.length,
|
count: state.versions.length,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
<div className="flex flex-col gap-4 lg:flex-row">
|
||||||
<label className="label">{t("tariff.currency")}</label>
|
<div className="min-w-0 flex-1">
|
||||||
<input className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} />
|
<TariffEditorForm form={form} onChange={setForm} />
|
||||||
<label className="label">{t("tariff.freeEntryGrace")}</label>
|
|
||||||
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
|
||||||
<label className="label">{t("tariff.billingIncrement")}</label>
|
|
||||||
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
|
||||||
<label className="label">{t("tariff.lostTicketFee")}</label>
|
|
||||||
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
|
||||||
<label className="label">{t("tariff.exitGrace")}</label>
|
|
||||||
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
<input
|
||||||
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
className="input w-64"
|
||||||
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
|
value={versionName}
|
||||||
<div className="card card-body">
|
onChange={(e) => setVersionName(e.target.value)}
|
||||||
<PricingEditor
|
placeholder={t("tariff.versionNamePh")}
|
||||||
t={t}
|
/>
|
||||||
pricing={form.base}
|
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
|
||||||
allowStepped
|
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
</button>
|
||||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
{msg && (
|
||||||
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
|
||||||
onBlock={(i, patch) => setBlock("base", i, patch)}
|
)}
|
||||||
onAddBlock={() => addBlock("base")}
|
</div>
|
||||||
onRemoveBlock={(i) => removeBlock("base", i)}
|
</div>
|
||||||
onStep={setStep}
|
|
||||||
onAddStep={addStep}
|
|
||||||
onRemoveStep={removeStep}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
|
{/* Published history — click a version to load it into the editor. Same list
|
||||||
<details className="mt-6" open={form.tiers.length > 0}>
|
the lab's sidebar shows; here it seeds the next publish. */}
|
||||||
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
|
{state && state.versions.length > 0 && (
|
||||||
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
|
<aside className="w-full shrink-0 lg:w-72">
|
||||||
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
<h3 className="mb-1 text-h6 font-semibold uppercase tracking-wider text-term-text">
|
||||||
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
{t("tariff.versionsTitle")}
|
||||||
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
</h3>
|
||||||
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[12px] text-term-red">
|
<p className="hint mb-2">{t("tariff.versionsHint")}</p>
|
||||||
{t("tariff.steppedTiersConflict")}
|
<ul className="flex flex-col gap-1">
|
||||||
</p>
|
{state.versions.map((v) => {
|
||||||
)}
|
const isActive = v.id === state.active?.id;
|
||||||
{form.tiers.map((tr, i) => (
|
return (
|
||||||
<fieldset key={i} className="card mb-3 p-4">
|
<li key={v.id}>
|
||||||
<legend className="flex items-center gap-2 px-1">
|
<button
|
||||||
<input
|
type="button"
|
||||||
className="input w-40"
|
onClick={() => loadVersion(v)}
|
||||||
value={tr.name}
|
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||||||
onChange={(e) => setTier(i, { name: e.target.value })}
|
loadedId === v.id
|
||||||
placeholder={t("tariff.tierName")}
|
? "border-term-amber bg-term-amber/10 text-term-text"
|
||||||
/>
|
: "border-term-border text-term-muted hover:text-term-text"
|
||||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
|
}`}
|
||||||
{t("tariff.remove")}
|
>
|
||||||
</button>
|
<span className="flex items-center gap-2 font-semibold">
|
||||||
</legend>
|
{v.name ?? formatDateTime(v.effectiveFrom, t)}
|
||||||
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
{isActive && (
|
||||||
<label className="label">{t("tariff.tierPriority")}</label>
|
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
|
||||||
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
|
{t("tariff.activeBadge")}
|
||||||
<label className="label">{t("tariff.tierCategory")}</label>
|
</span>
|
||||||
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
|
)}
|
||||||
<label className="label">{t("tariff.tierDays")}</label>
|
</span>
|
||||||
<span className="flex flex-wrap gap-2">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
|
||||||
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
{v.currency}
|
||||||
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
</span>
|
||||||
{t(`tariff.dow${d}`)}
|
</button>
|
||||||
</label>
|
</li>
|
||||||
))}
|
);
|
||||||
</span>
|
})}
|
||||||
<label className="label">{t("tariff.tierHours")}</label>
|
</ul>
|
||||||
<span className="inline-flex items-center gap-2">
|
</aside>
|
||||||
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
|
|
||||||
<span className="text-term-muted">–</span>
|
|
||||||
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
|
|
||||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
|
||||||
<span className="text-[11px] text-term-muted">{t("tariff.tierOvernight")}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<label className="label">{t("tariff.tierDates")}</label>
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
|
||||||
<span className="text-term-muted">–</span>
|
|
||||||
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 border-t border-term-border pt-3">
|
|
||||||
<PricingEditor
|
|
||||||
t={t}
|
|
||||||
pricing={tr.pricing}
|
|
||||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
|
||||||
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
|
||||||
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
|
|
||||||
onBlock={(bi, patch) => setBlock(i, bi, patch)}
|
|
||||||
onAddBlock={() => addBlock(i)}
|
|
||||||
onRemoveBlock={(bi) => removeBlock(i, bi)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
))}
|
|
||||||
<button type="button" className="btn btn-sm" onClick={addTier}>
|
|
||||||
{t("tariff.addTier")}
|
|
||||||
</button>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<div className="mt-6 flex items-center gap-3">
|
|
||||||
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
|
|
||||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
|
||||||
</button>
|
|
||||||
{msg && (
|
|
||||||
<span className={msg.kind === "ok" ? "text-[12px] text-term-green" : "text-[12px] text-term-red"}>{msg.text}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A reusable pricing-body editor — flat / marginal ladder / stepped (up-to). The
|
|
||||||
// stepped mode is offered only where `allowStepped` (the default card, not tiers).
|
|
||||||
function PricingEditor(props: {
|
|
||||||
t: (k: string) => string;
|
|
||||||
pricing: PricingForm;
|
|
||||||
allowStepped?: boolean;
|
|
||||||
onMode: (m: "ladder" | "flat" | "stepped") => void;
|
|
||||||
onFlat: (v: string) => void;
|
|
||||||
onCap: (v: string) => void;
|
|
||||||
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
|
||||||
onAddBlock: () => void;
|
|
||||||
onRemoveBlock: (i: number) => void;
|
|
||||||
onStep?: (i: number, patch: Partial<StepForm>) => void;
|
|
||||||
onAddStep?: () => void;
|
|
||||||
onRemoveStep?: (i: number) => void;
|
|
||||||
}) {
|
|
||||||
const { t, pricing: p } = props;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-3 flex gap-4 text-[12px]">
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
|
||||||
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
|
||||||
{t("tariff.modeLadder")}
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
|
||||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
|
||||||
{t("tariff.modeFlat")}
|
|
||||||
</label>
|
|
||||||
{props.allowStepped && (
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
|
||||||
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
|
|
||||||
{t("tariff.modeStepped")}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{p.mode === "stepped" ? (
|
|
||||||
<>
|
|
||||||
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
|
|
||||||
<table className="w-full border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left">
|
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
|
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
|
|
||||||
<th />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{p.steps.map((s, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td className="px-2 py-1">
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
|
||||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1">
|
|
||||||
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
|
|
||||||
</td>
|
|
||||||
<td className="px-2">
|
|
||||||
{p.steps.length > 1 && (
|
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
|
|
||||||
{t("tariff.remove")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="mt-3">
|
|
||||||
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
|
|
||||||
{t("tariff.addStep")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : p.mode === "flat" ? (
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
|
||||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<table className="w-full border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left">
|
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
|
|
||||||
<th />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{p.blocks.map((b, i) => {
|
|
||||||
const isTail = i === p.blocks.length - 1;
|
|
||||||
return (
|
|
||||||
<tr key={i}>
|
|
||||||
<td className="px-2 py-1">
|
|
||||||
{isTail ? (
|
|
||||||
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
|
||||||
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1">
|
|
||||||
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
|
||||||
</td>
|
|
||||||
<td className="px-2">
|
|
||||||
{!isTail && (
|
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
|
|
||||||
{t("tariff.remove")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className="mt-3 flex items-center gap-4">
|
|
||||||
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
|
|
||||||
{t("tariff.addBlock")}
|
|
||||||
</button>
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<span className="label">{t("tariff.dailyCap")}</span>
|
|
||||||
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { currencyOptions } from "./lib/currencies.js";
|
||||||
|
import {
|
||||||
|
isTariffV2,
|
||||||
|
type TariffBlock,
|
||||||
|
type TariffCard,
|
||||||
|
type TariffStep,
|
||||||
|
type TariffStructure,
|
||||||
|
type TariffState,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
// The tariff EDITOR FORM — the rate-card composer's form machinery (state shape,
|
||||||
|
// structure↔form converters, and the editing UI), extracted so two hosts can share
|
||||||
|
// it: the /setup/tariff page (edits + publishes the live card) and the Tariff Lab's
|
||||||
|
// draft modal (edits an experimental card). The host owns the FormState and the
|
||||||
|
// submit action; this module owns everything between. Amounts are entered in major
|
||||||
|
// units (e.g. euros) and converted to integer minor units on submit.
|
||||||
|
// See wiki/concepts/tariff.md.
|
||||||
|
|
||||||
|
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
||||||
|
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
||||||
|
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
||||||
|
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
||||||
|
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
||||||
|
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
||||||
|
export interface BlockForm {
|
||||||
|
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||||
|
price: string; // major units, e.g. "2.00"
|
||||||
|
}
|
||||||
|
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
|
||||||
|
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
|
||||||
|
export interface StepForm {
|
||||||
|
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
|
||||||
|
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
|
||||||
|
}
|
||||||
|
// A pricing body the form edits: a per-increment flat rate, a marginal block ladder,
|
||||||
|
// a stepped (up-to) total-by-duration table, or a whole-window package (tiers only).
|
||||||
|
export interface PricingForm {
|
||||||
|
mode: "ladder" | "flat" | "stepped" | "package";
|
||||||
|
flat: string; // major units PER INCREMENT (used when mode==="flat")
|
||||||
|
packageTotal: string; // major units for the WHOLE window occurrence (mode==="package")
|
||||||
|
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
||||||
|
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
|
||||||
|
dailyCap: string; // "" = no cap (ladder only)
|
||||||
|
}
|
||||||
|
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
||||||
|
export interface TierForm {
|
||||||
|
name: string;
|
||||||
|
priority: string;
|
||||||
|
category: string; // "" = applies to all categories
|
||||||
|
dow: number[]; // selected days 0..6; empty = every day
|
||||||
|
fromHour: string; // "" = all day
|
||||||
|
toHour: string;
|
||||||
|
dateFrom: string; // "" = unbounded
|
||||||
|
dateTo: string;
|
||||||
|
pricing: PricingForm;
|
||||||
|
}
|
||||||
|
export interface FormState {
|
||||||
|
currency: string;
|
||||||
|
gracePeriodEntryMin: string;
|
||||||
|
incrementMin: string;
|
||||||
|
lostTicket: string;
|
||||||
|
gracePeriodExitMin: string;
|
||||||
|
// The default (always-active) card — its own flat/ladder body + daily cap.
|
||||||
|
base: PricingForm;
|
||||||
|
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
||||||
|
tiers: TierForm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||||
|
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||||
|
|
||||||
|
/** Currency-plausible EXAMPLE amounts for fresh forms/rows. The old hardcoded
|
||||||
|
* "2.00 / 1.00" examples were euro-scaled — displayed under ALL they read as
|
||||||
|
* 2 lekë/hour, i.e. nonsense (operator feedback 2026-07-06). Lek amounts are
|
||||||
|
* ~100× the euro ones; USD rides with EUR. */
|
||||||
|
function examples(currency: string): { hi: string; lo: string; stepSmall: string; stepBig: string; lost: string } {
|
||||||
|
return currency.trim().toUpperCase() === "ALL"
|
||||||
|
? { hi: "200.00", lo: "100.00", stepSmall: "200.00", stepBig: "500.00", lost: "2000.00" }
|
||||||
|
: { hi: "2.00", lo: "1.00", stepSmall: "2.00", stepBig: "5.00", lost: "20.00" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySteps(currency: string): StepForm[] {
|
||||||
|
const ex = examples(currency);
|
||||||
|
return [
|
||||||
|
{ hours: "1", total: ex.stepSmall },
|
||||||
|
{ hours: "3", total: ex.stepBig },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function emptyLadder(currency: string): PricingForm {
|
||||||
|
const ex = examples(currency);
|
||||||
|
return {
|
||||||
|
mode: "ladder",
|
||||||
|
flat: "0.00",
|
||||||
|
packageTotal: "0.00",
|
||||||
|
dailyCap: "",
|
||||||
|
blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }],
|
||||||
|
steps: emptySteps(currency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function emptyTier(currency: string): TierForm {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
priority: "10",
|
||||||
|
category: "",
|
||||||
|
dow: [],
|
||||||
|
fromHour: "",
|
||||||
|
toHour: "",
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
pricing: { ...emptyLadder(currency), blocks: [{ hours: "", price: examples(currency).lo }] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyForm(): FormState {
|
||||||
|
const currency = "ALL"; // the site's currency — examples scale with it
|
||||||
|
return {
|
||||||
|
currency,
|
||||||
|
gracePeriodEntryMin: "15",
|
||||||
|
incrementMin: "60",
|
||||||
|
lostTicket: examples(currency).lost,
|
||||||
|
gracePeriodExitMin: "15",
|
||||||
|
base: emptyLadder(currency),
|
||||||
|
tiers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
|
||||||
|
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
|
||||||
|
// load (shown as their own band).
|
||||||
|
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
||||||
|
let prev = 0;
|
||||||
|
return blocks.map((b) => {
|
||||||
|
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
||||||
|
const hours = (b.uptoMin - prev) / 60;
|
||||||
|
prev = b.uptoMin;
|
||||||
|
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
|
||||||
|
function stepsToForm(steps: TariffStep[]): StepForm[] {
|
||||||
|
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
|
||||||
|
// or window package).
|
||||||
|
function pricingFromCard(
|
||||||
|
c: {
|
||||||
|
flatMinor?: number;
|
||||||
|
blocks?: TariffBlock[];
|
||||||
|
steps?: TariffStep[];
|
||||||
|
packageMinor?: number;
|
||||||
|
dailyCapMinor?: number | null;
|
||||||
|
},
|
||||||
|
currency: string,
|
||||||
|
): PricingForm {
|
||||||
|
if (c.steps != null && c.steps.length > 0) {
|
||||||
|
return { ...emptyLadder(currency), mode: "stepped", steps: stepsToForm(c.steps) };
|
||||||
|
}
|
||||||
|
if (c.packageMinor != null) {
|
||||||
|
return { ...emptyLadder(currency), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
||||||
|
}
|
||||||
|
if (c.flatMinor != null) {
|
||||||
|
return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...emptyLadder(currency),
|
||||||
|
mode: "ladder",
|
||||||
|
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||||
|
blocks: blocksToForm(c.blocks ?? []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierFromCard(c: TariffCard, currency: string): TierForm {
|
||||||
|
const w = c.window ?? {};
|
||||||
|
return {
|
||||||
|
name: c.name,
|
||||||
|
priority: String(c.priority),
|
||||||
|
category: c.category ?? "",
|
||||||
|
dow: w.dow ? [...w.dow] : [],
|
||||||
|
fromHour: w.fromHour ?? "",
|
||||||
|
toHour: w.toHour ?? "",
|
||||||
|
dateFrom: w.dateFrom ?? "",
|
||||||
|
dateTo: w.dateTo ?? "",
|
||||||
|
pricing: pricingFromCard(c, currency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored (currency, structure) pair → the editable form. Used to load the active
|
||||||
|
* version into the composer page and a saved draft into the lab modal. */
|
||||||
|
export function formFromVersion(currency: string, st: TariffStructure): FormState {
|
||||||
|
const common = {
|
||||||
|
currency,
|
||||||
|
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||||
|
incrementMin: String(st.incrementMin),
|
||||||
|
lostTicket: toMajor(st.lostTicketMinor),
|
||||||
|
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||||
|
};
|
||||||
|
if (isTariffV2(st)) {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
base: pricingFromCard(st.defaultCard, currency),
|
||||||
|
tiers: (st.windowedCards ?? []).map((c) => tierFromCard(c, currency)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// V1: the bare ladder becomes the default card body; no tiers.
|
||||||
|
return { ...common, base: pricingFromCard(st, currency), tiers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formFromActive(s: TariffState): FormState {
|
||||||
|
return s.active ? formFromVersion(s.active.currency, s.active.structure) : emptyForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a tariff card's pricing body (flat XOR ladder XOR stepped XOR package) from a PricingForm.
|
||||||
|
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "packageMinor" | "dailyCapMinor"> {
|
||||||
|
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
||||||
|
if (p.mode === "package") return { packageMinor: toMinor(p.packageTotal) };
|
||||||
|
if (p.mode === "stepped") {
|
||||||
|
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
|
||||||
|
const steps: TariffStep[] = p.steps.map((s) => ({
|
||||||
|
uptoMin: Math.round(Number(s.hours || "0") * 60),
|
||||||
|
totalMinor: toMinor(s.total),
|
||||||
|
}));
|
||||||
|
return { steps };
|
||||||
|
}
|
||||||
|
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
||||||
|
const last = p.blocks.length - 1;
|
||||||
|
let cum = 0;
|
||||||
|
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
|
||||||
|
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||||
|
cum += Math.round(Number(b.hours || "0") * 60);
|
||||||
|
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
|
||||||
|
});
|
||||||
|
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierToCard(tr: TierForm): TariffCard {
|
||||||
|
const window: TariffCard["window"] = {};
|
||||||
|
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
|
||||||
|
if (tr.fromHour && tr.toHour) {
|
||||||
|
window.fromHour = tr.fromHour;
|
||||||
|
window.toHour = tr.toHour;
|
||||||
|
}
|
||||||
|
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
|
||||||
|
if (tr.dateTo) window.dateTo = tr.dateTo;
|
||||||
|
const card: TariffCard = {
|
||||||
|
name: tr.name.trim() || "tier",
|
||||||
|
priority: Math.round(Number(tr.priority || "0")),
|
||||||
|
...pricingToCardBody(tr.pricing),
|
||||||
|
};
|
||||||
|
if (tr.category.trim()) card.category = tr.category.trim();
|
||||||
|
if (Object.keys(window).length > 0) card.window = window;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toStructure(f: FormState): TariffStructure {
|
||||||
|
const common = {
|
||||||
|
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||||
|
incrementMin: Math.round(Number(f.incrementMin)),
|
||||||
|
lostTicketMinor: toMinor(f.lostTicket),
|
||||||
|
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||||
|
overstay: "reprice" as const,
|
||||||
|
};
|
||||||
|
const baseBody = pricingToCardBody(f.base);
|
||||||
|
|
||||||
|
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
||||||
|
// tiers gets exactly today's shape; the server leaves it untouched).
|
||||||
|
if (f.tiers.length === 0) {
|
||||||
|
if (f.base.mode === "stepped") {
|
||||||
|
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
|
||||||
|
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
|
||||||
|
}
|
||||||
|
if (f.base.mode === "flat") {
|
||||||
|
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
||||||
|
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
||||||
|
}
|
||||||
|
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
version: 2,
|
||||||
|
tz: "",
|
||||||
|
defaultCard: { name: "default", priority: 0, ...baseBody },
|
||||||
|
windowedCards: f.tiers.map(tierToCard),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full rate-card editing UI (shared settings + default card + tiers). The host
|
||||||
|
* owns the FormState; every edit flows through `onChange` as a functional update. */
|
||||||
|
export function TariffEditorForm({
|
||||||
|
form,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
form: FormState;
|
||||||
|
onChange: (update: (f: FormState) => FormState) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// The billing unit all flat/ladder prices are entered in (labels reflect it live).
|
||||||
|
const inc = Math.max(1, Math.round(Number(form.incrementMin)) || 60);
|
||||||
|
|
||||||
|
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||||
|
onChange((f) => ({ ...f, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pricing-body editing (used by the default card AND each tier) ---
|
||||||
|
// `update` maps the old PricingForm to a new one; `target` selects which body:
|
||||||
|
// the base card, or tier index N.
|
||||||
|
function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
|
||||||
|
onChange((f) => {
|
||||||
|
if (target === "base") return { ...f, base: update(f.base) };
|
||||||
|
return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
|
||||||
|
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||||
|
}
|
||||||
|
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
|
||||||
|
function addBlock(target: "base" | number) {
|
||||||
|
updatePricing(target, (p) => {
|
||||||
|
const next = [...p.blocks];
|
||||||
|
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
|
||||||
|
return { ...p, blocks: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function removeBlock(target: "base" | number, i: number) {
|
||||||
|
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
||||||
|
}
|
||||||
|
// --- stepped (up-to) editing (base card only) ---
|
||||||
|
function setStep(i: number, patch: Partial<StepForm>) {
|
||||||
|
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
|
||||||
|
}
|
||||||
|
function addStep() {
|
||||||
|
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
|
||||||
|
}
|
||||||
|
function removeStep(i: number) {
|
||||||
|
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tier editing ---
|
||||||
|
function setTier(i: number, patch: Partial<TierForm>) {
|
||||||
|
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||||
|
}
|
||||||
|
function addTier() {
|
||||||
|
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier(f.currency)] }));
|
||||||
|
}
|
||||||
|
function removeTier(i: number) {
|
||||||
|
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
||||||
|
}
|
||||||
|
function toggleDow(i: number, d: number) {
|
||||||
|
onChange((f) => ({
|
||||||
|
...f,
|
||||||
|
tiers: f.tiers.map((tr, j) =>
|
||||||
|
j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||||
|
<label className="label">{t("tariff.currency")}</label>
|
||||||
|
<select className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
|
||||||
|
{currencyOptions(form.currency).map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="label">{t("tariff.freeEntryGrace")}</label>
|
||||||
|
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||||
|
<label className="label">{t("tariff.billingIncrement")}</label>
|
||||||
|
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||||
|
<label className="label">{t("tariff.lostTicketFee")}</label>
|
||||||
|
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||||
|
<label className="label">{t("tariff.exitGrace")}</label>
|
||||||
|
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The increment is the UNIT every flat/ladder price is charged in. At 60 the
|
||||||
|
form reads naturally as per-hour; any other value silently redefines every
|
||||||
|
price below, so shout it (the 60→10 "six charges per hour" trap). */}
|
||||||
|
{inc !== 60 && (
|
||||||
|
<p className="mt-2 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||||
|
{t("tariff.incrementWarning", { min: inc })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||||
|
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||||
|
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
||||||
|
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
|
||||||
|
<div className="card card-body">
|
||||||
|
<PricingEditor
|
||||||
|
t={t}
|
||||||
|
pricing={form.base}
|
||||||
|
incrementMin={inc}
|
||||||
|
allowStepped
|
||||||
|
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||||
|
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||||
|
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
||||||
|
onBlock={(i, patch) => setBlock("base", i, patch)}
|
||||||
|
onAddBlock={() => addBlock("base")}
|
||||||
|
onRemoveBlock={(i) => removeBlock("base", i)}
|
||||||
|
onStep={setStep}
|
||||||
|
onAddStep={addStep}
|
||||||
|
onRemoveStep={removeStep}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
|
||||||
|
<details className="mt-6" open={form.tiers.length > 0}>
|
||||||
|
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
|
||||||
|
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
|
||||||
|
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
||||||
|
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
||||||
|
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
||||||
|
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
|
||||||
|
{t("tariff.steppedTiersConflict")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{form.tiers.map((tr, i) => (
|
||||||
|
<fieldset key={i} className="card mb-3 p-4">
|
||||||
|
<legend className="flex items-center gap-2 px-1">
|
||||||
|
<input
|
||||||
|
className="input w-40"
|
||||||
|
value={tr.name}
|
||||||
|
onChange={(e) => setTier(i, { name: e.target.value })}
|
||||||
|
placeholder={t("tariff.tierName")}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
|
||||||
|
{t("tariff.remove")}
|
||||||
|
</button>
|
||||||
|
</legend>
|
||||||
|
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||||
|
<label className="label">{t("tariff.tierPriority")}</label>
|
||||||
|
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
|
||||||
|
<label className="label">{t("tariff.tierCategory")}</label>
|
||||||
|
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
|
||||||
|
<label className="label">{t("tariff.tierDays")}</label>
|
||||||
|
<span className="flex flex-wrap gap-2">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||||
|
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||||
|
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||||
|
{t(`tariff.dow${d}`)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<label className="label">{t("tariff.tierHours")}</label>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
|
||||||
|
<span className="text-term-muted">–</span>
|
||||||
|
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
|
||||||
|
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||||
|
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<label className="label">{t("tariff.tierDates")}</label>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
||||||
|
<span className="text-term-muted">–</span>
|
||||||
|
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 border-t border-term-border pt-3">
|
||||||
|
<PricingEditor
|
||||||
|
t={t}
|
||||||
|
pricing={tr.pricing}
|
||||||
|
incrementMin={inc}
|
||||||
|
allowPackage
|
||||||
|
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||||
|
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
||||||
|
onPackage={(packageTotal) => updatePricing(i, (p) => ({ ...p, packageTotal }))}
|
||||||
|
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
|
||||||
|
onBlock={(bi, patch) => setBlock(i, bi, patch)}
|
||||||
|
onAddBlock={() => addBlock(i)}
|
||||||
|
onRemoveBlock={(bi) => removeBlock(i, bi)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-sm" onClick={addTier}>
|
||||||
|
{t("tariff.addTier")}
|
||||||
|
</button>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reusable pricing-body editor — flat (per increment) / marginal ladder / stepped
|
||||||
|
// (up-to) / window package. The stepped mode is offered only where `allowStepped`
|
||||||
|
// (the default card); the package mode only where `allowPackage` (tier cards — the
|
||||||
|
// engine needs a window to be an occurrence of).
|
||||||
|
function PricingEditor(props: {
|
||||||
|
t: (k: string, opts?: Record<string, unknown>) => string;
|
||||||
|
pricing: PricingForm;
|
||||||
|
/** Current billing increment (minutes) — every flat/ladder price is PER this unit,
|
||||||
|
* so the price labels state it explicitly instead of a vague "per increment". */
|
||||||
|
incrementMin: number;
|
||||||
|
allowStepped?: boolean;
|
||||||
|
allowPackage?: boolean;
|
||||||
|
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
|
||||||
|
onFlat: (v: string) => void;
|
||||||
|
onPackage?: (v: string) => void;
|
||||||
|
onCap: (v: string) => void;
|
||||||
|
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
||||||
|
onAddBlock: () => void;
|
||||||
|
onRemoveBlock: (i: number) => void;
|
||||||
|
onStep?: (i: number, patch: Partial<StepForm>) => void;
|
||||||
|
onAddStep?: () => void;
|
||||||
|
onRemoveStep?: (i: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t, pricing: p } = props;
|
||||||
|
/** "= N / orë" equivalence for a per-increment price (only shown when the tick
|
||||||
|
* isn't an hour — at 60 the price already IS the hourly price). */
|
||||||
|
const perHour = (major: string): string | null => {
|
||||||
|
if (props.incrementMin === 60) return null;
|
||||||
|
const v = Number(major);
|
||||||
|
if (!Number.isFinite(v) || v <= 0) return null;
|
||||||
|
return t("tariff.perHourEquiv", { amount: ((v * 60) / props.incrementMin).toFixed(2) });
|
||||||
|
};
|
||||||
|
const unitLabel =
|
||||||
|
props.incrementMin === 60 ? t("tariff.pricePerHour") : t("tariff.pricePerN", { min: props.incrementMin });
|
||||||
|
const flatLabel =
|
||||||
|
props.incrementMin === 60 ? t("tariff.modeFlat") : t("tariff.modeFlatN", { min: props.incrementMin });
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
|
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||||
|
{t("tariff.modeLadder")}
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
|
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||||
|
{flatLabel}
|
||||||
|
</label>
|
||||||
|
{props.allowStepped && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
|
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
|
||||||
|
{t("tariff.modeStepped")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{props.allowPackage && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
|
<input type="radio" className="accent-term-amber" checked={p.mode === "package"} onChange={() => props.onMode("package")} />
|
||||||
|
{t("tariff.modePackage")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.mode === "package" ? (
|
||||||
|
<div>
|
||||||
|
<p className="hint mb-2">{t("tariff.packageHint")}</p>
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<span className="label">{t("tariff.packageTotal")}</span>
|
||||||
|
<input className="input w-28" value={p.packageTotal} onChange={(e) => props.onPackage?.(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : p.mode === "stepped" ? (
|
||||||
|
<>
|
||||||
|
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
|
||||||
|
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{p.steps.map((s, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||||
|
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2">
|
||||||
|
{p.steps.length > 1 && (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
|
||||||
|
{t("tariff.remove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-3">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
|
||||||
|
{t("tariff.addStep")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : p.mode === "flat" ? (
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<span className="label">{unitLabel}</span>
|
||||||
|
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
||||||
|
{perHour(p.flat) && <span className="text-[0.6875rem] text-term-muted">{perHour(p.flat)}</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
||||||
|
<th className="label px-2 pb-1 font-normal">{unitLabel}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{p.blocks.map((b, i) => {
|
||||||
|
const isTail = i === p.blocks.length - 1;
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
{isTail ? (
|
||||||
|
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||||
|
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
||||||
|
{perHour(b.price) && <span className="text-[0.6875rem] text-term-muted">{perHour(b.price)}</span>}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2">
|
||||||
|
{!isTail && (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
|
||||||
|
{t("tariff.remove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-3 flex items-center gap-4">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
|
||||||
|
{t("tariff.addBlock")}
|
||||||
|
</button>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className="label">{t("tariff.dailyCap")}</span>
|
||||||
|
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user