Compare commits
28 Commits
v0.1.0
..
ea304bbfd1
| Author | SHA1 | Date | |
|---|---|---|---|
| ea304bbfd1 | |||
| 2aa1045ddc | |||
| acde3bba5b | |||
| 9a13528611 | |||
| 6f88026d3e | |||
| c481c1e788 | |||
| 3a7c3fae11 | |||
| 55d6242c7d | |||
| a9ccf9e20c | |||
| 23d6379be8 | |||
| db9c3e0e31 | |||
| d86bffa500 | |||
| 9c05f86c86 | |||
| 54e691a4c9 | |||
| 52862db8ad | |||
| 8fa66c9911 | |||
| 70e1e9939f | |||
| 5c6a21e2c3 | |||
| 969bf2b191 | |||
| 7d67934a10 | |||
| 56904422af | |||
| 8bcdea9e4a | |||
| 7804285dec | |||
| 4a7029cea6 | |||
| 7317042e8d | |||
| 439b11d16d | |||
| 276b048fa9 | |||
| faa3265e49 |
@@ -75,6 +75,27 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Sync tauri.conf.json version to the git tag
|
||||||
|
# tauri.conf.json's own "version" field is what Tauri bakes into the
|
||||||
|
# bundle filename, the app's internal version, AND the updater's
|
||||||
|
# "current vs. new" comparison — it is NOT derived from the git tag
|
||||||
|
# automatically. Hit in v0.1.1: the tag was bumped but this file
|
||||||
|
# wasn't, so the signed binary + its .sig were still built (and
|
||||||
|
# named) as 0.1.0 while latest.json (built from TAG below) claimed
|
||||||
|
# 0.1.1 — the updater found the "update", downloaded a file whose
|
||||||
|
# signature didn't match what the manifest claimed to sign, and
|
||||||
|
# silently failed (a separate bug in desktop-updater.ts's error
|
||||||
|
# handling made this invisible — also fixed). Patch it here so the
|
||||||
|
# checked-in value is only ever a placeholder for local dev builds;
|
||||||
|
# a real release's version is always driven by the tag.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" apps/desktop/src-tauri/tauri.conf.json
|
||||||
|
grep '"version"' apps/desktop/src-tauri/tauri.conf.json
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
|
||||||
- name: Build + sign desktop bundle
|
- name: Build + sign desktop bundle
|
||||||
env:
|
env:
|
||||||
# Updater signing key (Gitea repo/org secrets). Without these the
|
# Updater signing key (Gitea repo/org secrets). Without these the
|
||||||
@@ -108,8 +129,23 @@ jobs:
|
|||||||
# 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}. The URL points at the MIRROR
|
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||||
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
||||||
# appliances actually reach; see the workflow header for why. Adjust the
|
# appliances actually reach; see the workflow header for why.
|
||||||
# platform keys you actually ship.
|
#
|
||||||
|
# ONE ENTRY PER INSTALLER TYPE — this is what made every in-app update
|
||||||
|
# v0.1.0→v0.1.6 fail. tauri-plugin-updater looks up
|
||||||
|
# `{os}-{arch}-{installer}` FIRST (linux-x86_64-deb / -rpm / -appimage,
|
||||||
|
# from the running app's detected bundle type) and only then the bare
|
||||||
|
# `linux-x86_64`. The booths run the .deb, and the manifest used to
|
||||||
|
# carry ONLY `linux-x86_64` → the AppImage. So a .deb install found the
|
||||||
|
# "update", downloaded the AppImage, verified its signature fine, then
|
||||||
|
# handed the bytes to install_deb(), which checks they're a .deb
|
||||||
|
# (infer::archive::is_deb) and bails with InvalidUpdaterFormat — after
|
||||||
|
# the download, before any relaunch, with the error swallowed client-
|
||||||
|
# side until v0.1.6. Now each installer gets its own signed asset; the
|
||||||
|
# bare key stays for an AppImage install. .deb/.rpm updates run
|
||||||
|
# `pkexec dpkg -i` / `rpm -U`, so the operator sees a polkit password
|
||||||
|
# prompt — intended: updating a root-installed package IS an admin
|
||||||
|
# action on this box (see wiki/decisions/desktop-shell-tauri.md).
|
||||||
env:
|
env:
|
||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
MIRROR_REPO: mca/public_releases
|
MIRROR_REPO: mca/public_releases
|
||||||
@@ -117,22 +153,41 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
ASSET_BASE="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest"
|
||||||
SIG=$(cat "dist/${APPIMAGE}.sig")
|
cat > /tmp/latest.js <<'JS'
|
||||||
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}"
|
const fs = require("fs");
|
||||||
cat > dist/latest.json <<JSON
|
const [version, tag, base] = process.argv.slice(2);
|
||||||
{
|
const files = fs.readdirSync("dist");
|
||||||
"version": "${VERSION}",
|
const pick = (ext) => files.find((f) => f.endsWith(ext));
|
||||||
"notes": "Parking System ${TAG}",
|
const entry = (f) => ({
|
||||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
signature: fs.readFileSync(`dist/${f}.sig`, "utf8").trim(),
|
||||||
"platforms": {
|
url: `${base}/${f}`,
|
||||||
"linux-x86_64": {
|
});
|
||||||
"signature": "${SIG}",
|
const deb = pick(".deb"), rpm = pick(".rpm"), appimage = pick(".AppImage");
|
||||||
"url": "${ASSET_URL}"
|
if (!deb || !appimage) {
|
||||||
}
|
console.error(`missing bundle in dist/: deb=${deb} appimage=${appimage}`);
|
||||||
}
|
process.exit(1);
|
||||||
}
|
}
|
||||||
JSON
|
const platforms = {
|
||||||
|
"linux-x86_64-deb": entry(deb),
|
||||||
|
...(rpm ? { "linux-x86_64-rpm": entry(rpm) } : {}),
|
||||||
|
"linux-x86_64": entry(appimage),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
"dist/latest.json",
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version,
|
||||||
|
notes: `Parking System ${tag}`,
|
||||||
|
pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
||||||
|
platforms,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + "\n",
|
||||||
|
);
|
||||||
|
JS
|
||||||
|
node /tmp/latest.js "${VERSION}" "${TAG}" "${ASSET_BASE}"
|
||||||
echo "latest.json:"; cat dist/latest.json
|
echo "latest.json:"; cat dist/latest.json
|
||||||
|
|
||||||
- name: Create release + upload assets (Gitea API)
|
- name: Create release + upload assets (Gitea API)
|
||||||
|
|||||||
@@ -28,3 +28,8 @@ dist/
|
|||||||
graphify-out/
|
graphify-out/
|
||||||
parking.sqlite*.bak-*
|
parking.sqlite*.bak-*
|
||||||
questions.txt
|
questions.txt
|
||||||
|
|
||||||
|
# session planning files (planning-with-files skill)
|
||||||
|
task_plan.md
|
||||||
|
findings.md
|
||||||
|
progress.md
|
||||||
|
|||||||
@@ -44,6 +44,35 @@ see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The upd
|
|||||||
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
|
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
|
||||||
committed.
|
committed.
|
||||||
|
|
||||||
|
**The manifest carries one entry per installer type** (`linux-x86_64-deb`, `linux-x86_64-rpm`,
|
||||||
|
and bare `linux-x86_64` for AppImage). The updater picks the entry matching how the running app
|
||||||
|
was installed — a `.deb` install will only ever accept a signed `.deb`. Booths run the `.deb`,
|
||||||
|
so an in-app update ends in a **polkit password prompt** (`pkexec dpkg -i`): that is expected,
|
||||||
|
and it is the right gate — the package lives in `/usr/bin`, root-owned, and the operator is not
|
||||||
|
supposed to be able to replace it silently. Cancel the prompt and the app keeps running the old
|
||||||
|
version; the failure is logged to the server's Logs viewer.
|
||||||
|
|
||||||
|
## Release gate — run the REAL bundle locally before tagging
|
||||||
|
|
||||||
|
`tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle
|
||||||
|
loads it from `tauri://localhost`, a *secure* custom-scheme origin — and every desktop-only bug
|
||||||
|
found in the field on 2026-09-03/04 (relative-URL DOMException, mixed content, missing WS
|
||||||
|
`Origin`, the reqwest-vs-webview cookie split, the WS handshake that can't carry the cookie)
|
||||||
|
depends on that difference. **Dev mode cannot reproduce any of them**, so "works in `tauri dev`"
|
||||||
|
carries no information about a release. Before pushing a `vX.Y.Z` tag:
|
||||||
|
|
||||||
|
1. `pnpm --filter @parking/server dev` (local backend; `.env` must have `COOKIE_SECURE=0` and
|
||||||
|
`tauri://localhost` in `WS_ALLOWED_ORIGINS`).
|
||||||
|
2. `pnpm --filter @parking/desktop bundle` and run the produced AppImage from
|
||||||
|
`src-tauri/target/release/bundle/appimage/` (WSLg is enough).
|
||||||
|
3. On the ConnectScreen enter `127.0.0.1:3000`, **Test** must say reachable, then **Save**.
|
||||||
|
4. Log in. The booth header must show **LIVE** (not "JASHTË LINJË") within a few seconds.
|
||||||
|
5. Perform one mutation (e.g. change your UI language) — it must succeed (proves CSRF).
|
||||||
|
6. Open Setup → Logs and confirm a `frontend`-sourced row from this desktop session exists
|
||||||
|
(proves the desktop log channel; historically it was silently 403'd).
|
||||||
|
|
||||||
|
Only then tag. If a release still fails in the field, the gap is in this list — fix the list.
|
||||||
|
|
||||||
## Not here (deliberately)
|
## Not here (deliberately)
|
||||||
|
|
||||||
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
|
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
|
||||||
|
|||||||
Generated
+578
-8
@@ -318,6 +318,23 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -346,10 +363,39 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
"time",
|
"time",
|
||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie_store"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||||
|
dependencies = [
|
||||||
|
"cookie",
|
||||||
|
"document-features",
|
||||||
|
"idna",
|
||||||
|
"log",
|
||||||
|
"publicsuffix",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -373,7 +419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -386,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -399,6 +445,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -506,6 +561,18 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-url"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dbus"
|
name = "dbus"
|
||||||
version = "0.9.11"
|
version = "0.9.11"
|
||||||
@@ -635,6 +702,15 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dom_query"
|
name = "dom_query"
|
||||||
version = "0.27.0"
|
version = "0.27.0"
|
||||||
@@ -721,6 +797,15 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1034,8 +1119,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1057,8 +1144,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1209,6 +1299,25 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1298,6 +1407,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1321,6 +1431,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots 1.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1341,9 +1452,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1744,6 +1857,12 @@ version = "0.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1759,6 +1878,12 @@ version = "0.4.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -2178,8 +2303,11 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tauri-plugin-websocket",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2330,6 +2458,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "precomputed-hash"
|
name = "precomputed-hash"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2398,6 +2535,22 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psl-types"
|
||||||
|
version = "2.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "publicsuffix"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||||
|
dependencies = [
|
||||||
|
"idna",
|
||||||
|
"psl-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
@@ -2407,6 +2560,62 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.10.2",
|
||||||
|
"rand_pcg",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -2428,6 +2637,61 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2503,6 +2767,49 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"cookie",
|
||||||
|
"cookie_store",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-core",
|
||||||
|
"h2",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots 1.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@@ -2616,6 +2923,7 @@ version = "1.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2625,7 +2933,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"jni 0.22.4",
|
"jni 0.22.4",
|
||||||
"log",
|
"log",
|
||||||
@@ -2663,6 +2971,12 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -2745,7 +3059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
@@ -2885,6 +3199,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.21.0"
|
version = "3.21.0"
|
||||||
@@ -2948,6 +3274,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -2955,7 +3292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3137,6 +3474,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sync_wrapper"
|
name = "sync_wrapper"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -3157,6 +3505,27 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3178,7 +3547,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
@@ -3268,7 +3637,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -3367,6 +3736,54 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"log",
|
||||||
|
"objc2-foundation",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml 1.1.2+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-http"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7241a0c762649be8fba7dd4cc84684d0e409f26b335a978ef4dd5fe78da74ce6"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cookie_store",
|
||||||
|
"data-url",
|
||||||
|
"http",
|
||||||
|
"regex",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
"urlpattern",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-process"
|
name = "tauri-plugin-process"
|
||||||
version = "2.3.1"
|
version = "2.3.1"
|
||||||
@@ -3377,6 +3794,22 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-store"
|
||||||
|
version = "2.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6708afbe549f176b712066e71648ba8fafba20789453718260c7ca356733cb0c"
|
||||||
|
dependencies = [
|
||||||
|
"dunce",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-updater"
|
name = "tauri-plugin-updater"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -3393,7 +3826,7 @@ dependencies = [
|
|||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"osakit",
|
"osakit",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"rustls",
|
"rustls",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3410,6 +3843,26 @@ dependencies = [
|
|||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-websocket"
|
||||||
|
version = "2.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca243c7f0bf935cd81123e07f82188ccb919b19fbfc74518b947eedc4619bbb"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"log",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"rustls",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -3639,9 +4092,21 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -3652,6 +4117,22 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tungstenite",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -3837,9 +4318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
@@ -3877,6 +4370,24 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -4141,6 +4652,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -4206,6 +4727,24 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.26.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||||
|
dependencies = [
|
||||||
|
"webpki-roots 1.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -4391,6 +4930,17 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows-result 0.4.1",
|
||||||
|
"windows-strings 0.5.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -4820,6 +5370,26 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.56"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.56"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ serde_json = "1"
|
|||||||
# Auto-update: prompt the operator, download a signed update, relaunch.
|
# Auto-update: prompt the operator, download a signed update, relaunch.
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
|
# HTTP client for the SPA's API/WS calls to the local Fastify server. The window
|
||||||
|
# runs at tauri://localhost, which WebKitGTK treats as a secure origin — a plain
|
||||||
|
# http://127.0.0.1:3000 fetch() from inside it is blocked as mixed content (a
|
||||||
|
# long-standing WebKit limitation, not fixable via CSP). Routing through this
|
||||||
|
# plugin sends the request via Tauri's Rust side instead of the webview's own
|
||||||
|
# fetch, sidestepping the browser mixed-content check entirely.
|
||||||
|
tauri-plugin-http = "2"
|
||||||
|
# Same mixed-content problem as above, but for the live-feed WebSocket
|
||||||
|
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS
|
||||||
|
# are separate browser checks, so this needs its own plugin.
|
||||||
|
tauri-plugin-websocket = "2"
|
||||||
|
# Persists the operator-configured backend URL (host:port of the Fastify
|
||||||
|
# server this install talks to) across restarts. Read before any API call —
|
||||||
|
# see apps/web/src/lib/backend-config.ts.
|
||||||
|
tauri-plugin-store = "2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
||||||
|
|||||||
@@ -6,6 +6,18 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
"process:default"
|
"process:default",
|
||||||
|
"websocket:default",
|
||||||
|
"store:default",
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"//": "Backend address is operator-configured at runtime (backend-config.ts) so the exact host:port can't be allow-listed at build time. Wildcarded to any host — the CSP forces ALL backend traffic through this plugin (see tauri.conf.json), so this scope is the real boundary; a compromised/malicious page still can't reach anything the operator hasn't pointed the app at, since the app only ever calls the one configured origin. All 4 forms needed: a known Tauri scope-matching quirk drops http://*:PORT unless both bare and :* variants are listed.",
|
||||||
|
"allow": [
|
||||||
|
{ "url": "http://*" },
|
||||||
|
{ "url": "https://*" },
|
||||||
|
{ "url": "http://*:*" },
|
||||||
|
{ "url": "https://*:*" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
// Intentionally minimal: build the default Tauri app and run it. The window
|
// Intentionally minimal: build the default Tauri app and run it. The window
|
||||||
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
|
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
|
||||||
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
|
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
|
||||||
// the backend over HTTP to the local Fastify server, NOT through Tauri IPC. This
|
// the backend over HTTP to a Fastify server (address operator-configured at
|
||||||
// keeps the shell a thin presentation wrapper with a deny-by-default native
|
// runtime, not baked in — see apps/web/src/lib/backend-config.ts), NOT through
|
||||||
// surface (see wiki/decisions/desktop-shell-tauri.md).
|
// Tauri IPC. This keeps the shell a thin presentation wrapper with a
|
||||||
|
// deny-by-default native surface (see wiki/decisions/desktop-shell-tauri.md).
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -16,6 +17,16 @@ pub fn run() {
|
|||||||
// endpoint + signing pubkey live in tauri.conf.json.
|
// endpoint + signing pubkey live in tauri.conf.json.
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
|
// Routes the SPA's fetch()/WS calls to the operator-configured Fastify
|
||||||
|
// server through Tauri's native HTTP client — see the Cargo.toml
|
||||||
|
// comment on why the webview's own fetch() can't reach it directly.
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
|
||||||
|
// above, but WS needs its own plugin (separate browser check).
|
||||||
|
.plugin(tauri_plugin_websocket::init())
|
||||||
|
// Persists the operator-configured backend URL across restarts (JSON
|
||||||
|
// file in the app's config dir) — see backend-config.ts.
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running the Parking System desktop shell");
|
.expect("error while running the Parking System desktop shell");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000"
|
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -77,3 +77,13 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
|
|||||||
# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay.
|
# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay.
|
||||||
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR)
|
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR)
|
||||||
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
|
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
|
||||||
|
|
||||||
|
# Venue modules --------------------------------------------------------------
|
||||||
|
# Comma-separated ids of the modules this site is ENTITLED to (a vendor/deployment
|
||||||
|
# decision — set in the Komodo stack env, never by a site role). The site admin then
|
||||||
|
# ACTIVATES within this set in Setup → Site; effective = entitled ∩ activated. Unset or
|
||||||
|
# blank = every registered module (parking,validation,carwash) — a DEV convenience. In
|
||||||
|
# Docker, docker-compose.yml forwards it with a default of parking,validation, so a booth
|
||||||
|
# is never entitled to a module its Komodo stack env does not name. Required modules
|
||||||
|
# (parking) are always on. See wiki/decisions/venue-modules.md.
|
||||||
|
#MODULES_ENTITLED=parking,validation
|
||||||
|
|||||||
+36
-3
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { eq, rolePermissions, type Db } from "@parking/db";
|
import { eq, rolePermissions, users, type Db } from "@parking/db";
|
||||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||||
|
|
||||||
// Local JWT auth helpers — fully local, no external identity provider
|
// Local JWT auth helpers — fully local, no external identity provider
|
||||||
@@ -143,10 +143,41 @@ export function initAuth(db: Db): void {
|
|||||||
permsCache.clear();
|
permsCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the permission cache. Call after ANY write to roles / role_permissions
|
/** Clear the permission + role caches. Call after ANY write to roles / role_permissions
|
||||||
* (or a user's roleId) so the change takes effect on the next request. */
|
* or to a user's roleId / deletion, so the change takes effect on the next request. */
|
||||||
export function bumpPermsCache(): void {
|
export function bumpPermsCache(): void {
|
||||||
permsCache.clear();
|
permsCache.clear();
|
||||||
|
roleCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** userId → CURRENT roleId, cached until bumpPermsCache(). */
|
||||||
|
const roleCache = new Map<string, string | null>();
|
||||||
|
|
||||||
|
/** The user's CURRENT role. The token pins the roleId that was current at LOGIN; an
|
||||||
|
* admin reassigning a user's role (or deleting the user) must take effect on the next
|
||||||
|
* request exactly like editing a role does — otherwise the reassigned user keeps the
|
||||||
|
* old role's rights until they log out (found 2026-09-05: a user moved to a new
|
||||||
|
* wash role kept 403ing on the new role's permissions). null = the user is gone. */
|
||||||
|
export function currentRoleId(sub: string): string | null {
|
||||||
|
if (!authDb) throw new Error("auth not initialised (call initAuth)");
|
||||||
|
const hit = roleCache.get(sub);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const row = authDb
|
||||||
|
.select({ roleId: users.roleId, deletedAt: users.deletedAt })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, sub))
|
||||||
|
.get();
|
||||||
|
const roleId = row && row.deletedAt == null ? row.roleId : null;
|
||||||
|
roleCache.set(sub, roleId);
|
||||||
|
return roleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After jwtVerify: replace the token's pinned roleId with the user's current one, or
|
||||||
|
* end the session if the user no longer exists. */
|
||||||
|
function refreshRole(req: FastifyRequest): void {
|
||||||
|
const roleId = currentRoleId(req.user.sub);
|
||||||
|
if (roleId === null) throw Object.assign(new Error("session no longer valid"), { statusCode: 401 });
|
||||||
|
if (roleId !== req.user.roleId) req.user.roleId = roleId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The permission set for a role id, cached. `admin` is always the full set. */
|
/** The permission set for a role id, cached. `admin` is always the full set. */
|
||||||
@@ -184,6 +215,7 @@ export function requirePermission(...required: Permission[]) {
|
|||||||
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||||
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
||||||
assertCsrf(req);
|
assertCsrf(req);
|
||||||
|
refreshRole(req);
|
||||||
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
|
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
@@ -201,4 +233,5 @@ export async function requireAuth(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await req.jwtVerify();
|
await req.jwtVerify();
|
||||||
assertCsrf(req);
|
assertCsrf(req);
|
||||||
|
refreshRole(req);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "./server.js";
|
||||||
|
import { seedUser, login } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// Venue modules — entitled ∩ activated, enforced server-side (wiki/decisions/
|
||||||
|
// venue-modules.md). Boots the real app over an in-memory DB and drives it with
|
||||||
|
// app.inject, like routes.test.ts.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
const savedEnv = process.env.MODULES_ENTITLED;
|
||||||
|
|
||||||
|
async function boot(): Promise<void> {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
delete process.env.MODULES_ENTITLED;
|
||||||
|
await boot();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
if (savedEnv === undefined) delete process.env.MODULES_ENTITLED;
|
||||||
|
else process.env.MODULES_ENTITLED = savedEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function admin() {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
return login(app, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("defaults (no env, nothing activated)", () => {
|
||||||
|
it("every registered module is entitled, activated and effective; /me carries the set", async () => {
|
||||||
|
const { cookie } = await admin();
|
||||||
|
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||||
|
expect(cfg.statusCode).toBe(200);
|
||||||
|
const body = cfg.json();
|
||||||
|
expect(body.modulesEntitled).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
expect(body.modulesActivated).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
expect(body.modules).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().modules).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
|
||||||
|
// A module route answers normally while the module is on.
|
||||||
|
const programs = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
|
||||||
|
expect(programs.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("activation (site admin)", () => {
|
||||||
|
it("deactivating validation 403s its routes with module_disabled, signs a config_change, and is reversible", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
expect(put.json().modules).toEqual(["parking"]);
|
||||||
|
expect(put.json().modulesActivated).toEqual(["parking"]);
|
||||||
|
|
||||||
|
// The merchant scan routes are the module → 403; the PROGRAM routes are core (the
|
||||||
|
// discount engine serves Car Wash too) → still 200 with validation off.
|
||||||
|
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
|
||||||
|
expect(off.statusCode).toBe(403);
|
||||||
|
expect(off.json().code).toBe("module_disabled");
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
|
||||||
|
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().modules).toEqual(["parking"]);
|
||||||
|
|
||||||
|
// The flip is on the signed ledger, attributed.
|
||||||
|
const events = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||||
|
expect(events.statusCode).toBe(200);
|
||||||
|
const list = (events.json().events ?? events.json()) as Array<{ type: string; payload: Record<string, unknown> }>;
|
||||||
|
const flip = list.find((e) => e.type === "config_change" && e.payload?.setting === "modules.validation");
|
||||||
|
expect(flip).toBeTruthy();
|
||||||
|
expect(flip!.payload).toMatchObject({ value: false, prev: true, operator: "boss" });
|
||||||
|
|
||||||
|
// Nothing was deleted: re-enable and the route is back.
|
||||||
|
const back = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "validation"] },
|
||||||
|
});
|
||||||
|
expect(back.json().modules).toEqual(["parking", "validation"]);
|
||||||
|
const on = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
|
||||||
|
expect(on.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("required modules cannot be deactivated (parking is always included)", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: [] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
expect(put.json().modules).toEqual(["parking"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown ids with 400", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "bar"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carwash runs without the validation module (the discount engine is core)", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "carwash"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
expect(put.json().modules).toEqual(["parking", "carwash"]);
|
||||||
|
// The wash's sponsorship program is still composable and readable.
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie } })).statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dependency rule: a module cannot be on while a module it depends on is off", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
// Every non-required module depends on parking, and parking is required — so the rule
|
||||||
|
// is exercised through the effective-set helper directly.
|
||||||
|
const shared = await import("@parking/shared");
|
||||||
|
expect(shared.resolveModuleActivation(["parking", "validation", "carwash"], ["carwash"])).toMatchObject({ ok: true });
|
||||||
|
expect(shared.effectiveModules(["parking", "carwash"], ["parking", "carwash"])).toEqual(["parking", "carwash"]);
|
||||||
|
expect(cookie && csrf).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a no-op resave signs nothing", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const before = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||||
|
const countBefore = ((before.json().events ?? before.json()) as unknown[]).length;
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "validation", "carwash"] },
|
||||||
|
});
|
||||||
|
const after = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||||
|
expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("entitlement (vendor env)", () => {
|
||||||
|
it("MODULES_ENTITLED=parking: validation is neither offered nor activatable, and its routes 403", async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
process.env.MODULES_ENTITLED = "parking";
|
||||||
|
await boot();
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
|
||||||
|
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||||
|
expect(cfg.json().modulesEntitled).toEqual(["parking"]);
|
||||||
|
expect(cfg.json().modules).toEqual(["parking"]);
|
||||||
|
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "validation"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(400);
|
||||||
|
expect(put.json().error).toMatch(/not entitled/);
|
||||||
|
|
||||||
|
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
|
||||||
|
expect(off.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("required modules are entitled even when the env omits them; unknown ids are ignored", async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
process.env.MODULES_ENTITLED = "validation,bogus";
|
||||||
|
await boot();
|
||||||
|
const { cookie } = await admin();
|
||||||
|
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||||
|
expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]);
|
||||||
|
expect(cfg.json().modules).toEqual(["parking", "validation"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permissions matrix helpers (venue-modules.md §Permissions matrix)", async () => {
|
||||||
|
const shared = await import("@parking/shared");
|
||||||
|
it("each till is guarded by its own module's permissions", () => {
|
||||||
|
expect(shared.tillGuards("booth")).toEqual({ read: "shift:read", shift: "shift:create", cash: "drawer:create" });
|
||||||
|
expect(shared.tillGuards("carwash")).toEqual({ read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" });
|
||||||
|
const wash = new Set(["carwash:read", "carwash:cash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p))).toEqual(["carwash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p), "shift")).toEqual(["carwash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => p === "carwash:read", "shift")).toEqual([]);
|
||||||
|
// Module off → its till is not even addressable.
|
||||||
|
expect(shared.tillsFor(["parking"], () => true)).toEqual(["booth"]);
|
||||||
|
});
|
||||||
|
it("the live feed admits by watch permission and filters ledger events by their module", () => {
|
||||||
|
expect(shared.watchPermissions(["parking", "validation", "carwash"])).toEqual(
|
||||||
|
expect.arrayContaining(["event:read", "session:read", "device:read", "carwash:read"]),
|
||||||
|
);
|
||||||
|
expect(shared.watchPermissions(["parking", "validation", "carwash"])).not.toContain("report:read");
|
||||||
|
expect(shared.watchPermissions(["parking"])).not.toContain("carwash:read");
|
||||||
|
expect(shared.feedPermissionFor("carwash_payment")).toBe("carwash:read");
|
||||||
|
expect(shared.feedPermissionFor("payment")).toBe("event:read");
|
||||||
|
expect(shared.feedPermissionFor("validation")).toBe("event:read");
|
||||||
|
});
|
||||||
|
it("every job's permissions exist in the grid", () => {
|
||||||
|
for (const m of shared.MODULES) for (const j of m.jobs) for (const p of j.permissions) expect(shared.PERMISSIONS).toContain(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
effectiveModules,
|
||||||
|
isModuleId,
|
||||||
|
isTillId,
|
||||||
|
parseEntitledModules,
|
||||||
|
tillGuards,
|
||||||
|
tillsFor,
|
||||||
|
tillsOf,
|
||||||
|
type ModuleId,
|
||||||
|
type TillGuards,
|
||||||
|
type TillId,
|
||||||
|
} from "@parking/shared";
|
||||||
|
import { requireAuth, roleHasPermissions } from "./auth.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
/** Set by requireTill(): the till this request addresses (already authorized). */
|
||||||
|
till?: TillId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Venue modules — the server side of "entitled ∩ activated" (registry + rules live in
|
||||||
|
// @parking/shared; design in wiki/decisions/venue-modules.md).
|
||||||
|
//
|
||||||
|
// entitled MODULES_ENTITLED env (vendor, Komodo stack) — unset = everything.
|
||||||
|
// activated site_config.modules_json (site admin, Setup → Site) — null = everything
|
||||||
|
// entitled.
|
||||||
|
// effective what requireModule() enforces and what /api/auth/me + /api/site-config
|
||||||
|
// hand the SPA so it can hide nav. The web only HIDES; this file ENFORCES.
|
||||||
|
//
|
||||||
|
// Both inputs are re-read per request: one env read and one single-row SELECT on the
|
||||||
|
// site_config singleton — cheap, and it means a change takes effect on the next request
|
||||||
|
// with no cache to invalidate (the same reason the presence-bypass flags aren't cached).
|
||||||
|
|
||||||
|
/** The modules this deployment is entitled to. Unknown ids in the env are ignored
|
||||||
|
* (logged once at boot by registerModules). */
|
||||||
|
export function entitledModules(): ModuleId[] {
|
||||||
|
return parseEntitledModules(process.env.MODULES_ENTITLED).entitled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the persisted activation list off a site_config row. null = never set. A
|
||||||
|
* corrupt/unknown value is treated as "never set" rather than locking modules off. */
|
||||||
|
export function activatedModulesOf(row: { modulesJson?: string | null } | undefined): ModuleId[] | null {
|
||||||
|
const raw = row?.modulesJson;
|
||||||
|
if (raw == null) return null;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
return parsed.filter(isModuleId);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The effective set for this site right now. */
|
||||||
|
export function effectiveModulesFor(db: Db): ModuleId[] {
|
||||||
|
const row = db.select({ modulesJson: siteConfig.modulesJson }).from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return effectiveModules(entitledModules(), activatedModulesOf(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tills available at this site right now: the booth, plus each effective
|
||||||
|
* money-taking module's own till (registry order). */
|
||||||
|
export function effectiveTillsFor(db: Db): TillId[] {
|
||||||
|
return tillsOf(effectiveModulesFor(db));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tills a role may SEE (default) or WORK (`shift` / `cash`) here: the effective
|
||||||
|
* tills whose module guard the role holds (each desk's money is guarded by that desk's
|
||||||
|
* own permissions — venue-modules.md §"Permissions matrix"). */
|
||||||
|
export function tillsReadableBy(db: Db, roleId: string, kind: keyof TillGuards = "read"): TillId[] {
|
||||||
|
return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p]), kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** preHandler factory for the shift/drawer routes: authenticate, parse the `till`
|
||||||
|
* (query on GET, body on POST; absent = booth; 400 `bad_till` when unknown or its
|
||||||
|
* module is off), then require the role to hold THAT TILL's guard for `kind` (403
|
||||||
|
* `till_forbidden`). The authorized till lands on `req.till`. The permission is thus
|
||||||
|
* resolved from the till, never fixed: the booth checks `shift:read`/`shift:create`/
|
||||||
|
* `drawer:create`, the wash `carwash:read`/`carwash:cash`. */
|
||||||
|
export function requireTill(db: Db, kind: keyof TillGuards, from: "query" | "body") {
|
||||||
|
return async (req: FastifyRequest, reply: FastifyReply): Promise<void | FastifyReply> => {
|
||||||
|
await requireAuth(req, reply);
|
||||||
|
const raw = from === "query" ? (req.query as { till?: unknown } | undefined)?.till : (req.body as { till?: unknown } | undefined)?.till;
|
||||||
|
const till = parseTill(db, raw);
|
||||||
|
if (!till) {
|
||||||
|
await reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
if (!roleHasPermissions(req.user.roleId, [tillGuards(till)[kind]])) {
|
||||||
|
await reply
|
||||||
|
.code(403)
|
||||||
|
.send({ error: `your role cannot ${kind === "read" ? "see" : "work"} the ${till} till`, code: "till_forbidden", till });
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
req.till = till;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a till from a query/body value. Absent/blank = the booth. Unknown, or a till
|
||||||
|
* whose module is not effective here, → null (the caller answers 400). */
|
||||||
|
export function parseTill(db: Db, raw: unknown): TillId | null {
|
||||||
|
if (raw == null || raw === "") return "booth";
|
||||||
|
if (!isTillId(raw)) return null;
|
||||||
|
return effectiveTillsFor(db).includes(raw) ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** preHandler: reject the call when `id` is not effective at this site. Compose it
|
||||||
|
* BEFORE requirePermission in a preHandler array so a disabled module answers the
|
||||||
|
* same way for every role — 403 with code "module_disabled" — and never reaches
|
||||||
|
* the permission/CSRF path. */
|
||||||
|
export function requireModule(db: Db, id: ModuleId) {
|
||||||
|
return async (_req: FastifyRequest, _reply: FastifyReply): Promise<void> => {
|
||||||
|
if (!effectiveModulesFor(db).includes(id)) {
|
||||||
|
throw Object.assign(new Error(`module disabled: ${id}`), { statusCode: 403, code: "module_disabled" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
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 { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
|
||||||
|
|
||||||
|
// Car Wash module, end to end over the real app (wiki/decisions/venue-modules.md):
|
||||||
|
// settings → intake against an open parking session → done applies the sponsorship
|
||||||
|
// validation → bay payment settles the parking session at zero (what the exit reader
|
||||||
|
// checks) / booth payment carries the wash as a charge line → module off = 403.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
delete process.env.MODULES_ENTITLED;
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
type Auth = { cookie: string; csrf: string };
|
||||||
|
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||||
|
|
||||||
|
async function admin(): Promise<Auth> {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
return login(app, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An open transient session that has been parked long enough to owe money. */
|
||||||
|
async function openSession(identity: string, enteredMinutesAgo = 90): Promise<void> {
|
||||||
|
await makeLog(db).append({
|
||||||
|
type: "vehicle_entry",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
occurredAt: minutesAgo(enteredMinutesAgo),
|
||||||
|
payload: { sessionRef: identity, category: "default" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedSettings(a: Auth) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||||
|
payload: {
|
||||||
|
categories: [{ name: "Car" }, { name: "SUV" }],
|
||||||
|
services: [{ name: "Standard" }, { name: "Inside" }],
|
||||||
|
prices: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const s = res.json();
|
||||||
|
const car = s.categories.find((c: { name: string }) => c.name === "Car").id;
|
||||||
|
const suv = s.categories.find((c: { name: string }) => c.name === "SUV").id;
|
||||||
|
const std = s.services.find((c: { name: string }) => c.name === "Standard").id;
|
||||||
|
const inside = s.services.find((c: { name: string }) => c.name === "Inside").id;
|
||||||
|
const priced = await app.inject({
|
||||||
|
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||||
|
payload: {
|
||||||
|
categories: s.categories, services: s.services,
|
||||||
|
prices: [
|
||||||
|
{ categoryId: car, serviceId: std, priceMinor: 50000 },
|
||||||
|
{ categoryId: suv, serviceId: std, priceMinor: 70000 },
|
||||||
|
{ categoryId: car, serviceId: inside, priceMinor: 30000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(priced.statusCode).toBe(200);
|
||||||
|
expect(priced.json().prices).toHaveLength(3);
|
||||||
|
return { car, suv, std, inside };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flip the site's wash-payment policy (Setup → Car wash). */
|
||||||
|
async function setPayAt(a: Auth, payAt: "booth" | "bay") {
|
||||||
|
const res = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().payAt).toBe(payAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedSponsorship(a: Auth, mode: "comp" | "percent" | "doneTolerance" | "washPrice" = "comp", minutes: number | null = null) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||||
|
payload: { name: "Lavazh", mode, percent: mode === "percent" ? 50 : null, minutes, active: true, userIds: [] },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBeLessThan(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function events(a: Auth) {
|
||||||
|
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
||||||
|
return (r.json().events ?? r.json()) as Array<{ id: string; type: string; identity: string | null; payload: Record<string, unknown> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
it("round-trips categories, services and the price matrix; signs a config_change; unknown pairs are refused", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
const get = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } });
|
||||||
|
expect(get.json().categories.map((c: { name: string }) => c.name)).toEqual(["Car", "SUV"]);
|
||||||
|
expect(get.json().prices.find((p: { categoryId: string; serviceId: string }) => p.categoryId === ids.suv && p.serviceId === ids.std).priceMinor).toBe(70000);
|
||||||
|
const bad = await app.inject({
|
||||||
|
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||||
|
payload: { prices: [{ categoryId: "nope", serviceId: ids.std, priceMinor: 1 }] },
|
||||||
|
});
|
||||||
|
expect(bad.statusCode).toBe(400);
|
||||||
|
const flips = (await events(a)).filter((e) => e.type === "config_change" && e.payload.setting === "carwash.settings");
|
||||||
|
expect(flips.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("orders", () => {
|
||||||
|
it("intake needs an open session and a priced pair; the queue is oldest-first", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
const noSession = await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-NONE", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
expect(noSession.statusCode).toBe(404);
|
||||||
|
|
||||||
|
await openSession("T-1");
|
||||||
|
const noPrice = await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.inside },
|
||||||
|
});
|
||||||
|
expect(noPrice.statusCode).toBe(409);
|
||||||
|
expect(noPrice.json().code).toBe("no_price");
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(201);
|
||||||
|
expect(created.json()).toMatchObject({ identity: "T-1", categoryName: "SUV", serviceName: "Standard", priceMinor: 70000, payAt: "booth", status: "open", closed: false });
|
||||||
|
|
||||||
|
await openSession("T-2");
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-2", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||||
|
expect(queue.json().orders.map((o: { identity: string }) => o.identity)).toEqual(["T-1", "T-2"]);
|
||||||
|
|
||||||
|
const chain = (await events(a)).filter((e) => e.type === "carwash_order");
|
||||||
|
expect(chain).toHaveLength(2);
|
||||||
|
expect(chain[0]!.payload).toMatchObject({ action: "created", operator: "boss" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pay at BOOTH: the wash rides the parking quote as a charge line and is marked paid by the booth payment", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-B");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-B", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
|
||||||
|
const look = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||||
|
const s = look.json();
|
||||||
|
expect(s.chargeLines).toHaveLength(1);
|
||||||
|
expect(s.chargeLines[0]).toMatchObject({ module: "carwash", ref: order.id, amountMinor: 50000 });
|
||||||
|
expect(s.chargesMinor).toBe(50000);
|
||||||
|
expect(s.amountMinor).toBeGreaterThan(50000); // parking fee + the wash
|
||||||
|
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||||
|
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(a), payload: { identity: "T-B", tender: "cash" } });
|
||||||
|
expect(pay.statusCode).toBeLessThan(300);
|
||||||
|
|
||||||
|
const payment = (await events(a)).find((e) => e.type === "payment" && e.identity === "T-B")!;
|
||||||
|
expect(payment.payload.chargesMinor).toBe(50000);
|
||||||
|
expect((payment.payload.chargeLines as unknown[]).length).toBe(1);
|
||||||
|
expect(payment.payload.amountMinor).toBe((payment.payload.parkingMinor as number) + 50000);
|
||||||
|
|
||||||
|
const recent = await app.inject({ method: "GET", url: "/api/carwash/orders?scope=recent", headers: { cookie: a.cookie } });
|
||||||
|
const o = recent.json().orders.find((x: { id: string }) => x.id === order.id);
|
||||||
|
expect(o.paidAt).toBeTruthy();
|
||||||
|
expect(o.paymentEventId).toBeUndefined(); // not exposed on the view
|
||||||
|
expect(o.tender).toBe("cash");
|
||||||
|
// A second lookup no longer carries the line (it's settled).
|
||||||
|
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||||
|
expect(again.json().chargeLines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "comp");
|
||||||
|
await openSession("T-Y");
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-Y", categoryId: ids.suv, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
|
||||||
|
// Bay money needs an open CARWASH shift — the booth's shift does not count (tills).
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||||
|
const noShift = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||||
|
expect(noShift.statusCode).toBe(409);
|
||||||
|
expect(noShift.json()).toMatchObject({ code: "no_shift", till: "carwash" });
|
||||||
|
const openWash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||||
|
expect(openWash.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const done = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
expect(done.statusCode).toBe(200);
|
||||||
|
expect(done.json().status).toBe("done");
|
||||||
|
expect(done.json().validationEventId).toBeTruthy();
|
||||||
|
// Sponsorship applied → the parking quote is now zero-due (comp), but NOT yet paid.
|
||||||
|
const mid = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||||
|
expect(mid.json().amountMinor).toBe(0);
|
||||||
|
expect(mid.json().paidAt).toBeNull();
|
||||||
|
|
||||||
|
const paid = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "card" } });
|
||||||
|
expect(paid.statusCode).toBe(200);
|
||||||
|
expect(paid.json().closed).toBe(true);
|
||||||
|
|
||||||
|
const evs = await events(a);
|
||||||
|
const bay = evs.find((e) => e.type === "carwash_payment")!;
|
||||||
|
expect(bay.payload).toMatchObject({ orderId: order.id, amountMinor: 70000, tender: "card", operator: "boss", till: "carwash" });
|
||||||
|
// The wash Z-report carries the bay money; the booth's carries none of it.
|
||||||
|
const washZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a), payload: { till: "carwash" } })).json();
|
||||||
|
expect(washZ).toMatchObject({ till: "carwash", cardTotalMinor: 70000, cashTotalMinor: 0, paymentCount: 1 });
|
||||||
|
const boothZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||||||
|
expect(boothZ.till).toBe("booth");
|
||||||
|
expect(boothZ.cardTotalMinor).toBe(0);
|
||||||
|
expect(boothZ.paymentCount).toBe(1); // the $0 parking settlement is booth money
|
||||||
|
// The $0 parking payment exists → the exit reader's paid+grace check passes.
|
||||||
|
const parkingPay = evs.find((e) => e.type === "payment" && e.identity === "T-Y")!;
|
||||||
|
expect(parkingPay).toBeTruthy();
|
||||||
|
expect(parkingPay.payload.amountMinor).toBe(0);
|
||||||
|
const after = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||||
|
expect(after.json().paidAt).toBeTruthy();
|
||||||
|
expect(after.json().withinGrace).toBe(true);
|
||||||
|
|
||||||
|
// The queue is empty (done + paid = closed).
|
||||||
|
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||||
|
expect(queue.json().orders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pay at BAY with a PARTIAL sponsorship leaves the remainder for the booth (no $0 payment)", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "percent");
|
||||||
|
await openSession("T-P");
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-P", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const s = (await app.inject({ method: "GET", url: "/api/session/T-P", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(s.paidAt).toBeNull();
|
||||||
|
expect(s.amountMinor).toBeGreaterThan(0);
|
||||||
|
expect(s.discountMinor).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("void takes back a live sponsorship; a paid order cannot be voided", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "comp");
|
||||||
|
await openSession("T-V");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-V", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const before = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(before.validationLines).toHaveLength(1);
|
||||||
|
|
||||||
|
const voided = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/void`, headers: hdrs(a), payload: { reason: "customer left" } });
|
||||||
|
expect(voided.statusCode).toBe(200);
|
||||||
|
expect(voided.json().status).toBe("void");
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.validationLines).toEqual([]);
|
||||||
|
expect(after.chargeLines).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wash-only discount modes", () => {
|
||||||
|
it("doneTolerance credits only the WASH WINDOW (+ tolerance), never the parking before the order", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
// 100.00 per 60-min increment, no entry grace; parked 95 min → 2 increments.
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "doneTolerance", 15);
|
||||||
|
await openSession("T-D", 95);
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-D", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
const before = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(before.amountMinor).toBe(20000);
|
||||||
|
// Done right away: the wash window is ~0 min, so the credit is just the tolerance.
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-D")!;
|
||||||
|
expect(v.payload.mode).toBe("timeCredit");
|
||||||
|
expect(v.payload.programMode).toBe("doneTolerance");
|
||||||
|
expect(v.payload.minutes as number).toBeGreaterThanOrEqual(15);
|
||||||
|
expect(v.payload.minutes as number).toBeLessThanOrEqual(17);
|
||||||
|
// 95 − ~15 min still spans 2 increments → the long stay is NOT comped away.
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.amountMinor).toBe(20000);
|
||||||
|
expect(after.discountMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doneTolerance with a tolerance that covers the whole stay does comp it (the credit is real)", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "doneTolerance", 120);
|
||||||
|
await openSession("T-D2", 95);
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-D2", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-D2", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.amountMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("washPrice: the wash price comes off the parking fee, floored at zero", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
// 1000.00/h, parked 95 min → 2 increments = 200000 owed. Car·Standard wash = 50000.
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 100000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "washPrice");
|
||||||
|
await openSession("T-W", 95);
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-W", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
const before = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.discountMinor).toBe(50000);
|
||||||
|
expect(after.amountMinor).toBe(before.amountMinor - 50000);
|
||||||
|
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-W")!;
|
||||||
|
expect(v.payload).toMatchObject({ mode: "fixed", programMode: "washPrice", amountMinor: 50000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a merchant scan cannot apply a wash-only program", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
await seedSponsorship(a, "washPrice");
|
||||||
|
// Bind the admin to it so the binding check passes and the MODE check is what refuses.
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||||
|
payload: { name: "Lavazh", mode: "washPrice", active: true, userIds: [(await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: a.cookie } })).json().id] },
|
||||||
|
});
|
||||||
|
await openSession("T-M");
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(a), payload: { identity: "T-M", programId: "carwash" } });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toMatch(/car wash order/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module gate", () => {
|
||||||
|
it("with carwash deactivated every route 403s and the booth quote carries no wash lines", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-G");
|
||||||
|
await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-G", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
const off = await app.inject({ method: "PUT", url: "/api/site-config", headers: hdrs(a), payload: { modules: ["parking", "validation"] } });
|
||||||
|
expect(off.json().modules).toEqual(["parking", "validation"]);
|
||||||
|
const q = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||||
|
expect(q.statusCode).toBe(403);
|
||||||
|
expect(q.json().code).toBe("module_disabled");
|
||||||
|
const look = (await app.inject({ method: "GET", url: "/api/session/T-G", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(look.chargeLines).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("where the money is taken is a SITE setting", () => {
|
||||||
|
it("defaults to the booth, persists, signs a config_change, and freezes on each order", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json().payAt).toBe("booth");
|
||||||
|
await openSession("T-S1");
|
||||||
|
const o1 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S1", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||||
|
expect(o1.payAt).toBe("booth");
|
||||||
|
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const cfg = (await events(a)).find((e) => e.type === "config_change" && e.payload.setting === "carwash.payAt")!;
|
||||||
|
expect(cfg.payload).toMatchObject({ value: "bay", prev: "booth", operator: "boss" });
|
||||||
|
await openSession("T-S2");
|
||||||
|
const o2 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||||
|
expect(o2.payAt).toBe("bay");
|
||||||
|
expect(o1.payAt).toBe("booth"); // earlier order keeps the policy it was created under
|
||||||
|
|
||||||
|
// A stale client insisting on the other place is refused, never silently overridden.
|
||||||
|
const stale = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std, payAt: "booth" } });
|
||||||
|
expect(stale.statusCode).toBe(409);
|
||||||
|
expect(stale.json().code).toBe("pay_at_policy");
|
||||||
|
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt: "pocket" } });
|
||||||
|
expect(bad.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tills are gated by the module permission", () => {
|
||||||
|
it("a wash-only role works the carwash till and never the booth's; a booth role the reverse", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
await seedSettings(a);
|
||||||
|
// The wash-operator JOB: no shift:* / drawer:* at all — the wash till is guarded by
|
||||||
|
// carwash:read / carwash:cash (venue-modules.md §"Permissions matrix").
|
||||||
|
const washer = await seedUser(db, {
|
||||||
|
username: "lavazhier", roleId: "washer",
|
||||||
|
permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
|
||||||
|
});
|
||||||
|
const w = await login(app, washer.username, washer.password);
|
||||||
|
// What the UI offers: only the wash till.
|
||||||
|
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
|
||||||
|
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
|
||||||
|
// The booth's shift is refused outright (the role holds no shift:*).
|
||||||
|
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
|
||||||
|
expect(booth.statusCode).toBe(403);
|
||||||
|
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
|
||||||
|
const boothState = await app.inject({ method: "GET", url: "/api/shift/current", headers: { cookie: w.cookie } });
|
||||||
|
expect(boothState.statusCode).toBe(403);
|
||||||
|
const boothCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100 } });
|
||||||
|
expect(boothCash.statusCode).toBe(403);
|
||||||
|
// The wash till works.
|
||||||
|
const wash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w), payload: { till: "carwash" } });
|
||||||
|
expect(wash.statusCode).toBe(200);
|
||||||
|
expect(wash.json().till).toBe("carwash");
|
||||||
|
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
|
||||||
|
expect(washCash.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// A wash user who may look (carwash:read) but not work the till (no carwash:cash)
|
||||||
|
// sees the state and gets canWork=false; opening is refused.
|
||||||
|
const looker = await seedUser(db, { username: "looker", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||||
|
const l = await login(app, looker.username, looker.password);
|
||||||
|
const lookTills = (await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: l.cookie } })).json();
|
||||||
|
expect(lookTills.tills).toMatchObject([{ till: "carwash", canWork: false }]);
|
||||||
|
expect((await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(l), payload: { till: "carwash" } })).statusCode).toBe(403);
|
||||||
|
|
||||||
|
// A booth operator (shift:*, no carwash:*) cannot touch the wash till.
|
||||||
|
const booth1 = await seedUser(db, {
|
||||||
|
username: "boothie", roleId: "booth-op",
|
||||||
|
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
|
||||||
|
});
|
||||||
|
const b = await login(app, booth1.username, booth1.password);
|
||||||
|
const noWash = await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(b), payload: { till: "carwash" } });
|
||||||
|
expect(noWash.statusCode).toBe(403);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a role reassignment takes effect without re-login", () => {
|
||||||
|
it("a user moved from a look-only role to the wash-operator role can create an order on the next request", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-R");
|
||||||
|
const looker = await seedUser(db, { username: "moved", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||||
|
// Materialise the target role (seedUser creates the role rows; the user itself is a throwaway).
|
||||||
|
await seedUser(db, { username: "throwaway", roleId: "wash-op", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] });
|
||||||
|
const l = await login(app, looker.username, looker.password);
|
||||||
|
const before = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||||
|
expect(before.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie: a.cookie } })).json();
|
||||||
|
const id = list.users.find((u: { username: string }) => u.username === "moved").id;
|
||||||
|
const moved = await app.inject({ method: "PUT", url: `/api/users/${id}`, headers: hdrs(a), payload: { roleId: "wash-op" } });
|
||||||
|
expect(moved.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// Same cookie, no re-login: the token's pinned role is refreshed per request.
|
||||||
|
const after = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||||
|
expect(after.statusCode).toBe(201);
|
||||||
|
const me = (await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: l.cookie } })).json();
|
||||||
|
expect(me.roleId).toBe("wash-op");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ServerModule } from "../index.js";
|
||||||
|
import { carwashRoutes } from "./routes.js";
|
||||||
|
import { CarwashService } from "./service.js";
|
||||||
|
|
||||||
|
// Car Wash — the pilot venue module (wiki/decisions/venue-modules.md). Everything the
|
||||||
|
// module is lives in this folder: its service (master data, the order queue, the bay
|
||||||
|
// payment, the parking sponsorship + settlement), its routes, and the booth charge
|
||||||
|
// provider it registers with the core's PayStation. The core knows it only through the
|
||||||
|
// registry line in ../index.ts and the manifest in @parking/shared.
|
||||||
|
export const carwashModule: ServerModule = {
|
||||||
|
id: "carwash",
|
||||||
|
async register(app, deps) {
|
||||||
|
const service = new CarwashService(deps, app.log);
|
||||||
|
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
|
||||||
|
// the core calls back after the payment is signed so the order is marked paid.
|
||||||
|
deps.payStation.registerChargeProvider(service.chargeProvider());
|
||||||
|
await carwashRoutes(app, deps, service);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
|
import type { Tender } from "@parking/shared";
|
||||||
|
import { requirePermission } from "../../auth.js";
|
||||||
|
import { requireModule } from "../../modules.js";
|
||||||
|
import { NoShiftOpenError } from "../../shift-service.js";
|
||||||
|
import type { ServerModuleDeps } from "../index.js";
|
||||||
|
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
|
||||||
|
|
||||||
|
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
|
||||||
|
// FIRST (403 module_disabled), then a permission:
|
||||||
|
// settings (master data) site:read / site:update — the site admin's job
|
||||||
|
// queue / ticket lookup carwash:read — the wash desk
|
||||||
|
// intake carwash:create
|
||||||
|
// done / bay payment / void carwash:update
|
||||||
|
// The sponsorship PROGRAM itself is a validation program row (id "carwash") and is
|
||||||
|
// composed through the existing /api/validation/programs/:id route (site:update).
|
||||||
|
|
||||||
|
function sendError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||||
|
if (err instanceof CarwashError) {
|
||||||
|
return reply.code(err.status).send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
||||||
|
}
|
||||||
|
if (err instanceof NoShiftOpenError) {
|
||||||
|
// The bay takes money on the CARWASH till: the wash operator's own shift must be
|
||||||
|
// open (the booth's does not count). The desk shows its shift control on this code.
|
||||||
|
return reply.code(409).send({ error: err.message, code: "no_shift", till: err.till });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService): Promise<void> {
|
||||||
|
const moduleOn = requireModule(deps.db, "carwash");
|
||||||
|
const settingsRead = [moduleOn, requirePermission("site:read")];
|
||||||
|
const settingsWrite = [moduleOn, requirePermission("site:update")];
|
||||||
|
const read = [moduleOn, requirePermission("carwash:read")];
|
||||||
|
const create = [moduleOn, requirePermission("carwash:create")];
|
||||||
|
const update = [moduleOn, requirePermission("carwash:update")];
|
||||||
|
|
||||||
|
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
|
||||||
|
|
||||||
|
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.saveSettings(req.body ?? {}, req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { identity: string } }>("/api/carwash/session/:identity", { preHandler: read }, async (req) =>
|
||||||
|
service.lookup(req.params.identity),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Querystring: { scope?: string; limit?: string } }>("/api/carwash/orders", { preHandler: read }, async (req) => {
|
||||||
|
if (req.query.scope === "recent") return { orders: service.recentOrders(Number(req.query.limit) || 100) };
|
||||||
|
return { orders: service.openOrders() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: { identity?: string; categoryId?: string; serviceId?: string; payAt?: string } }>(
|
||||||
|
"/api/carwash/orders",
|
||||||
|
{ preHandler: create },
|
||||||
|
async (req, reply) => {
|
||||||
|
const b = req.body ?? {};
|
||||||
|
// payAt is a SITE setting now; the desk no longer sends it. Accept it only when it
|
||||||
|
// matches (the service refuses a mismatch) so a stale client cannot pick the till.
|
||||||
|
if (b.payAt !== undefined && !isPayAt(b.payAt)) return reply.code(400).send({ error: "payAt must be booth|bay" });
|
||||||
|
try {
|
||||||
|
const order = await service.createOrder({
|
||||||
|
identity: String(b.identity ?? ""),
|
||||||
|
categoryId: String(b.categoryId ?? ""),
|
||||||
|
serviceId: String(b.serviceId ?? ""),
|
||||||
|
...(b.payAt !== undefined ? { payAt: b.payAt } : {}),
|
||||||
|
actor: req.user.username,
|
||||||
|
});
|
||||||
|
return reply.code(201).send(order);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>("/api/carwash/orders/:id/done", { preHandler: update }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.markDone(req.params.id, req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { tender?: Tender } }>(
|
||||||
|
"/api/carwash/orders/:id/pay",
|
||||||
|
{ preHandler: update },
|
||||||
|
async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.payAtBay(req.params.id, (req.body?.tender ?? "cash") as Tender, req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { reason?: string } }>(
|
||||||
|
"/api/carwash/orders/:id/void",
|
||||||
|
{ preHandler: update },
|
||||||
|
async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.voidOrder(req.params.id, String(req.body?.reason ?? "").trim(), req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
carwashCategories,
|
||||||
|
carwashConfig,
|
||||||
|
carwashOrders,
|
||||||
|
carwashPrices,
|
||||||
|
carwashServices,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
type CarwashOrderRow,
|
||||||
|
type Db,
|
||||||
|
} from "@parking/db";
|
||||||
|
import {
|
||||||
|
CARWASH_PAY_AT,
|
||||||
|
CARWASH_PAY_AT_DEFAULT,
|
||||||
|
CARWASH_PROGRAM_ID,
|
||||||
|
type CarWashPayAt,
|
||||||
|
type CarwashOrderView,
|
||||||
|
type CarwashSettingsView,
|
||||||
|
type ChargeLine,
|
||||||
|
type Tender,
|
||||||
|
type TillId,
|
||||||
|
} from "@parking/shared";
|
||||||
|
import type { EventLog } from "../../event-log.js";
|
||||||
|
import { effectiveModulesFor } from "../../modules.js";
|
||||||
|
import type { ChargeProvider, PayStation } from "../../pay-station.js";
|
||||||
|
import type { ShiftService } from "../../shift-service.js";
|
||||||
|
import { applyValidation, liveValidations } from "../../validations.js";
|
||||||
|
import type { ServerModuleDeps } from "../index.js";
|
||||||
|
|
||||||
|
// Car Wash — the module's whole behaviour (wiki/decisions/venue-modules.md, "Car Wash —
|
||||||
|
// the pilot module" + "v1 answers"). Master data is mutable rows; every order freezes
|
||||||
|
// what it sold (names + price) and signs its life onto the ledger; money at the bay is
|
||||||
|
// a signed `carwash_payment`; money at the booth rides the parking `payment` as a
|
||||||
|
// charge line (ChargeProvider below). The parking sponsorship is the site's "carwash"
|
||||||
|
// VALIDATION program, applied through the shared applyValidation() when a wash is done
|
||||||
|
// — the wash never touches parking code, it talks to the core through ServerModuleDeps.
|
||||||
|
|
||||||
|
/** A refusal the route maps to an HTTP status. */
|
||||||
|
/** The till bay money lands on — declared by the module manifest (MODULES). */
|
||||||
|
const CARWASH_TILL: TillId = "carwash";
|
||||||
|
|
||||||
|
export class CarwashError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: 400 | 404 | 409,
|
||||||
|
message: string,
|
||||||
|
readonly code?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CarwashError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsBody {
|
||||||
|
categories?: { id?: string; name?: string; active?: boolean }[];
|
||||||
|
services?: { id?: string; name?: string; active?: boolean }[];
|
||||||
|
prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[];
|
||||||
|
/** Where wash money is taken at this site (site-level policy). */
|
||||||
|
payAt?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderInput {
|
||||||
|
identity: string;
|
||||||
|
categoryId: string;
|
||||||
|
serviceId: string;
|
||||||
|
/** Optional — the SITE policy decides; a stale client that sends a different value
|
||||||
|
* is refused (409 pay_at_policy) rather than silently overridden. */
|
||||||
|
payAt?: CarWashPayAt;
|
||||||
|
actor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketLookup {
|
||||||
|
identity: string;
|
||||||
|
found: boolean;
|
||||||
|
open: boolean;
|
||||||
|
subscription: boolean;
|
||||||
|
plate: string | null;
|
||||||
|
enteredAt: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
orders: CarwashOrderView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
|
|
||||||
|
/** Stable slug for a new master-data row: from the name, else a random id. */
|
||||||
|
function slugify(name: string): string {
|
||||||
|
const s = name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 40);
|
||||||
|
return s || randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CarwashService {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #log: EventLog;
|
||||||
|
readonly #pay: PayStation;
|
||||||
|
readonly #shift: ShiftService;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
|
||||||
|
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger) {
|
||||||
|
this.#db = deps.db;
|
||||||
|
this.#log = deps.eventLog;
|
||||||
|
this.#pay = deps.payStation;
|
||||||
|
this.#shift = deps.shiftService;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
#enabled(): boolean {
|
||||||
|
return effectiveModulesFor(this.#db).includes("carwash");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Settings (master data) -------------------------------------------------
|
||||||
|
|
||||||
|
settings(): CarwashSettingsView {
|
||||||
|
const categories = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashCategories)
|
||||||
|
.where(isNull(carwashCategories.deletedAt))
|
||||||
|
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
|
||||||
|
.all()
|
||||||
|
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
|
||||||
|
const services = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashServices)
|
||||||
|
.where(isNull(carwashServices.deletedAt))
|
||||||
|
.orderBy(asc(carwashServices.sortOrder), asc(carwashServices.name))
|
||||||
|
.all()
|
||||||
|
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
|
||||||
|
const live = new Set([...categories.map((c) => c.id), ...services.map((s) => s.id)]);
|
||||||
|
const prices = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashPrices)
|
||||||
|
.all()
|
||||||
|
.filter((p) => live.has(p.categoryId) && live.has(p.serviceId))
|
||||||
|
.map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor }));
|
||||||
|
return { categories, services, prices, currency: this.#currency(), payAt: this.payAt() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */
|
||||||
|
payAt(): CarWashPayAt {
|
||||||
|
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
|
||||||
|
return row?.payAt ?? CARWASH_PAY_AT_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The site's currency = the active tariff's (the wash is priced in the same money
|
||||||
|
* the booth takes). null when no tariff is published yet. */
|
||||||
|
#currency(): string | null {
|
||||||
|
try {
|
||||||
|
// Any open session's quote carries it; without one, fall back to the tariff table.
|
||||||
|
const row = this.#db.select().from(carwashOrders).orderBy(desc(carwashOrders.createdAt)).limit(1).get();
|
||||||
|
if (row) return row.currency;
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
return this.#pay.activeCurrency();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full-replacement save of the three lists. Rows missing from the body are
|
||||||
|
* soft-deleted (orders already reference names + prices by value, so nothing
|
||||||
|
* historical changes). Signs one config_change. */
|
||||||
|
async saveSettings(body: SettingsBody, actor: string): Promise<CarwashSettingsView> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const upsertList = (
|
||||||
|
table: typeof carwashCategories | typeof carwashServices,
|
||||||
|
items: { id?: string; name?: string; active?: boolean }[] | undefined,
|
||||||
|
label: string,
|
||||||
|
): string[] => {
|
||||||
|
if (items === undefined) {
|
||||||
|
return this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all().map((r) => r.id);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(items)) throw new CarwashError(400, `${label} must be an array`);
|
||||||
|
const keep: string[] = [];
|
||||||
|
let sort = 0;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const it of items) {
|
||||||
|
const name = String(it?.name ?? "").trim();
|
||||||
|
if (!name) throw new CarwashError(400, `${label}: every item needs a name`);
|
||||||
|
let id = typeof it.id === "string" && it.id.trim() ? it.id.trim() : slugify(name);
|
||||||
|
if (!ID_RE.test(id)) throw new CarwashError(400, `${label}: bad id "${id}"`);
|
||||||
|
// Two new items slugging to the same id → disambiguate rather than merge.
|
||||||
|
while (seen.has(id)) id = `${id}-${sort}`;
|
||||||
|
seen.add(id);
|
||||||
|
const active = it.active !== false;
|
||||||
|
const existing = this.#db.select().from(table).where(eq(table.id, id)).get();
|
||||||
|
if (existing) {
|
||||||
|
this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null }).where(eq(table.id, id)).run();
|
||||||
|
} else {
|
||||||
|
this.#db.insert(table).values({ id, name, sortOrder: sort, active }).run();
|
||||||
|
}
|
||||||
|
keep.push(id);
|
||||||
|
sort += 1;
|
||||||
|
}
|
||||||
|
const live = this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all();
|
||||||
|
for (const r of live) {
|
||||||
|
if (!keep.includes(r.id)) {
|
||||||
|
this.#db.update(table).set({ deletedAt: now, deletedBy: actor }).where(eq(table.id, r.id)).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keep;
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryIds = upsertList(carwashCategories, body.categories, "categories");
|
||||||
|
const serviceIds = upsertList(carwashServices, body.services, "services");
|
||||||
|
|
||||||
|
if (body.prices !== undefined) {
|
||||||
|
if (!Array.isArray(body.prices)) throw new CarwashError(400, "prices must be an array");
|
||||||
|
const rows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
|
||||||
|
for (const p of body.prices) {
|
||||||
|
const categoryId = String(p?.categoryId ?? "");
|
||||||
|
const serviceId = String(p?.serviceId ?? "");
|
||||||
|
const priceMinor = p?.priceMinor;
|
||||||
|
if (!categoryIds.includes(categoryId)) throw new CarwashError(400, `prices: unknown category "${categoryId}"`);
|
||||||
|
if (!serviceIds.includes(serviceId)) throw new CarwashError(400, `prices: unknown service "${serviceId}"`);
|
||||||
|
if (!Number.isInteger(priceMinor) || (priceMinor as number) < 0) {
|
||||||
|
throw new CarwashError(400, "prices: priceMinor must be a non-negative integer");
|
||||||
|
}
|
||||||
|
rows.push({ categoryId, serviceId, priceMinor: priceMinor as number });
|
||||||
|
}
|
||||||
|
this.#db.delete(carwashPrices).run();
|
||||||
|
for (const r of rows) this.#db.insert(carwashPrices).values(r).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.#log.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: "module:carwash",
|
||||||
|
payload: {
|
||||||
|
setting: "carwash.settings",
|
||||||
|
value: { categories: categoryIds.length, services: serviceIds.length, prices: body.prices?.length ?? null },
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Where the money is taken — a site policy, signed on its own when it flips (it
|
||||||
|
// decides which till the cash lands on and whether the booth barrier or the exit
|
||||||
|
// reader releases the car; fraud-relevant, so it is attributed like other config).
|
||||||
|
if (body.payAt !== undefined) {
|
||||||
|
if (!isPayAt(body.payAt)) throw new CarwashError(400, "payAt must be booth|bay");
|
||||||
|
const prev = this.payAt();
|
||||||
|
if (body.payAt !== prev) {
|
||||||
|
this.#db
|
||||||
|
.insert(carwashConfig)
|
||||||
|
.values({ id: 1, payAt: body.payAt, updatedAt: now, updatedBy: actor })
|
||||||
|
.onConflictDoUpdate({ target: carwashConfig.id, set: { payAt: body.payAt, updatedAt: now, updatedBy: actor } })
|
||||||
|
.run();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: "module:carwash",
|
||||||
|
payload: { setting: "carwash.payAt", value: body.payAt, prev, operator: actor },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.settings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Orders ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
#view(r: CarwashOrderRow): CarwashOrderView {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
identity: r.identity,
|
||||||
|
plate: r.plate,
|
||||||
|
categoryId: r.categoryId,
|
||||||
|
categoryName: r.categoryName,
|
||||||
|
serviceId: r.serviceId,
|
||||||
|
serviceName: r.serviceName,
|
||||||
|
priceMinor: r.priceMinor,
|
||||||
|
currency: r.currency,
|
||||||
|
payAt: r.payAt,
|
||||||
|
status: r.status,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
createdBy: r.createdBy,
|
||||||
|
doneAt: r.doneAt,
|
||||||
|
doneBy: r.doneBy,
|
||||||
|
paidAt: r.paidAt,
|
||||||
|
paidBy: r.paidBy,
|
||||||
|
tender: (r.tender as Tender | null) ?? null,
|
||||||
|
closed: r.status === "void" || (r.status === "done" && r.paidAt != null),
|
||||||
|
validationEventId: r.validationEventId,
|
||||||
|
voidBy: r.voidBy,
|
||||||
|
voidReason: r.voidReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#row(id: string): CarwashOrderRow {
|
||||||
|
const r = this.#db.select().from(carwashOrders).where(eq(carwashOrders.id, id)).get();
|
||||||
|
if (!r) throw new CarwashError(404, "order not found");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The desk's queue: every order still needing something, oldest first. */
|
||||||
|
openOrders(): CarwashOrderView[] {
|
||||||
|
return this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashOrders)
|
||||||
|
.where(inArray(carwashOrders.status, ["open", "done"]))
|
||||||
|
.orderBy(asc(carwashOrders.createdAt))
|
||||||
|
.all()
|
||||||
|
.map((r) => this.#view(r))
|
||||||
|
.filter((o) => !o.closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recent history (closed included), newest first. */
|
||||||
|
recentOrders(limit = 100): CarwashOrderView[] {
|
||||||
|
return this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashOrders)
|
||||||
|
.orderBy(desc(carwashOrders.createdAt))
|
||||||
|
.limit(Math.min(Math.max(limit, 1), 500))
|
||||||
|
.all()
|
||||||
|
.map((r) => this.#view(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
#ordersFor(identity: string): CarwashOrderView[] {
|
||||||
|
return this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashOrders)
|
||||||
|
.where(eq(carwashOrders.identity, identity))
|
||||||
|
.orderBy(asc(carwashOrders.createdAt))
|
||||||
|
.all()
|
||||||
|
.map((r) => this.#view(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticket → session facts the desk needs (the parking ticket IS the customer). */
|
||||||
|
lookup(identity: string): TicketLookup {
|
||||||
|
const id = identity.trim();
|
||||||
|
const s = this.#pay.lookup(id);
|
||||||
|
return {
|
||||||
|
identity: id,
|
||||||
|
found: s.found,
|
||||||
|
open: s.open,
|
||||||
|
subscription: s.subscription,
|
||||||
|
plate: s.plate,
|
||||||
|
enteredAt: s.enteredAt,
|
||||||
|
currency: s.currency,
|
||||||
|
orders: this.#ordersFor(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrder(input: CreateOrderInput): Promise<CarwashOrderView> {
|
||||||
|
const identity = input.identity.trim();
|
||||||
|
if (!identity) throw new CarwashError(400, "identity (ticket) required");
|
||||||
|
|
||||||
|
const s = this.#pay.lookup(identity);
|
||||||
|
if (!s.found) throw new CarwashError(404, "no session for ticket");
|
||||||
|
if (!s.open) throw new CarwashError(409, "session is closed");
|
||||||
|
if (s.subscription) throw new CarwashError(409, "subscription sessions: order the wash with payAt=bay", "subscription");
|
||||||
|
|
||||||
|
const category = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashCategories)
|
||||||
|
.where(and(eq(carwashCategories.id, input.categoryId), isNull(carwashCategories.deletedAt)))
|
||||||
|
.get();
|
||||||
|
if (!category || !category.active) throw new CarwashError(404, "category not found or inactive");
|
||||||
|
const service = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashServices)
|
||||||
|
.where(and(eq(carwashServices.id, input.serviceId), isNull(carwashServices.deletedAt)))
|
||||||
|
.get();
|
||||||
|
if (!service || !service.active) throw new CarwashError(404, "service not found or inactive");
|
||||||
|
const price = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashPrices)
|
||||||
|
.where(and(eq(carwashPrices.categoryId, category.id), eq(carwashPrices.serviceId, service.id)))
|
||||||
|
.get();
|
||||||
|
if (!price) throw new CarwashError(409, `no price for ${category.name} · ${service.name}`, "no_price");
|
||||||
|
// The SITE decides where wash money is taken (Setup → Car wash); the order freezes
|
||||||
|
// the policy in force. A client that still sends a different value is stale.
|
||||||
|
const payAt = this.payAt();
|
||||||
|
if (input.payAt !== undefined && input.payAt !== payAt) {
|
||||||
|
throw new CarwashError(409, `this site takes wash money at the ${payAt === "bay" ? "bay" : "booth"}`, "pay_at_policy");
|
||||||
|
}
|
||||||
|
const currency = s.currency ?? this.#pay.activeCurrency();
|
||||||
|
if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff");
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const row: CarwashOrderRow = {
|
||||||
|
id: randomUUID(),
|
||||||
|
identity,
|
||||||
|
plate: s.plate,
|
||||||
|
categoryId: category.id,
|
||||||
|
categoryName: category.name,
|
||||||
|
serviceId: service.id,
|
||||||
|
serviceName: service.name,
|
||||||
|
priceMinor: price.priceMinor,
|
||||||
|
currency,
|
||||||
|
payAt,
|
||||||
|
status: "open",
|
||||||
|
createdAt: now,
|
||||||
|
createdBy: input.actor,
|
||||||
|
doneAt: null,
|
||||||
|
doneBy: null,
|
||||||
|
paidAt: null,
|
||||||
|
paidBy: null,
|
||||||
|
tender: null,
|
||||||
|
paymentEventId: null,
|
||||||
|
validationEventId: null,
|
||||||
|
voidAt: null,
|
||||||
|
voidBy: null,
|
||||||
|
voidReason: null,
|
||||||
|
};
|
||||||
|
this.#db.insert(carwashOrders).values(row).run();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "carwash_order",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
orderId: row.id,
|
||||||
|
action: "created",
|
||||||
|
categoryName: row.categoryName,
|
||||||
|
serviceName: row.serviceName,
|
||||||
|
priceMinor: row.priceMinor,
|
||||||
|
currency,
|
||||||
|
payAt: row.payAt,
|
||||||
|
operator: input.actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.#view(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wash is finished: apply the site's sponsorship program to the parking session
|
||||||
|
* (if one is configured and active), then — for a bay order already paid — settle
|
||||||
|
* the parking session so the exit reader opens. */
|
||||||
|
async markDone(id: string, actor: string): Promise<CarwashOrderView> {
|
||||||
|
const r = this.#row(id);
|
||||||
|
if (r.status === "void") throw new CarwashError(409, "order is void");
|
||||||
|
if (r.status === "done") throw new CarwashError(409, "order is already done");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
let validationEventId: string | null = null;
|
||||||
|
// Wash context for the wash-only discount modes: the WASH WINDOW in minutes — from
|
||||||
|
// the order's intake to now (= done) — and the order's frozen price. NOT the time
|
||||||
|
// since entry: a car parked for hours before it asks for a wash still pays for those
|
||||||
|
// hours (found 2026-09-05 on a long-open ticket that would have been fully comped).
|
||||||
|
// The credit lands at the start of the billed period (that is how timeCredit
|
||||||
|
// folds), so for a flat tariff the money is identical; a stepped/daily-cap tariff
|
||||||
|
// may differ by an increment. See applyValidation().
|
||||||
|
const washMinutes = Math.max(0, Math.ceil((Date.now() - Date.parse(r.createdAt)) / 60_000));
|
||||||
|
const applied = await applyValidation(this.#db, this.#log, {
|
||||||
|
programId: CARWASH_PROGRAM_ID,
|
||||||
|
identity: r.identity,
|
||||||
|
actor,
|
||||||
|
wash: { washMinutes, priceMinor: r.priceMinor },
|
||||||
|
});
|
||||||
|
if (applied.ok) validationEventId = applied.eventId;
|
||||||
|
else if (applied.status !== 404 && !/already applied/.test(applied.error)) {
|
||||||
|
// A real refusal (session closed, daily cap …) — the wash is still done; the
|
||||||
|
// customer simply gets no sponsorship. Keep it visible in the log.
|
||||||
|
this.#logger.warn(`carwash sponsorship not applied for ${r.identity}: ${applied.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#db
|
||||||
|
.update(carwashOrders)
|
||||||
|
.set({ status: "done", doneAt: now, doneBy: actor, validationEventId })
|
||||||
|
.where(eq(carwashOrders.id, id))
|
||||||
|
.run();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "carwash_order",
|
||||||
|
source: "manual",
|
||||||
|
identity: r.identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: r.identity,
|
||||||
|
orderId: id,
|
||||||
|
action: "done",
|
||||||
|
categoryName: r.categoryName,
|
||||||
|
serviceName: r.serviceName,
|
||||||
|
priceMinor: r.priceMinor,
|
||||||
|
currency: r.currency,
|
||||||
|
payAt: r.payAt,
|
||||||
|
...(validationEventId ? { validationEventId } : {}),
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const updated = this.#row(id);
|
||||||
|
if (updated.payAt === "bay" && updated.paidAt != null) await this.#settleParkingIfFree(updated, actor);
|
||||||
|
return this.#view(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Money taken AT THE BAY. Needs an open CARWASH shift (it is the wash operator's
|
||||||
|
* drawer money, never the booth's — wiki/concepts/shift.md "Tills"); signs a
|
||||||
|
* carwash_payment on that till; then, if the wash is also done, settles the
|
||||||
|
* parking session. */
|
||||||
|
async payAtBay(id: string, tender: Tender, actor: string): Promise<CarwashOrderView> {
|
||||||
|
const r = this.#row(id);
|
||||||
|
if (r.status === "void") throw new CarwashError(409, "order is void");
|
||||||
|
if (r.payAt !== "bay") throw new CarwashError(409, "this order is paid at the booth", "pay_at_booth");
|
||||||
|
if (r.paidAt != null) throw new CarwashError(409, "order is already paid");
|
||||||
|
if (tender !== "cash" && tender !== "card") throw new CarwashError(400, "tender must be cash|card");
|
||||||
|
this.#shift.requireOpenShift(CARWASH_TILL);
|
||||||
|
|
||||||
|
const ev = await this.#log.append({
|
||||||
|
type: "carwash_payment",
|
||||||
|
source: "manual",
|
||||||
|
identity: r.identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: r.identity,
|
||||||
|
orderId: id,
|
||||||
|
amountMinor: r.priceMinor,
|
||||||
|
currency: r.currency,
|
||||||
|
tender,
|
||||||
|
till: CARWASH_TILL,
|
||||||
|
categoryName: r.categoryName,
|
||||||
|
serviceName: r.serviceName,
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
this.#db
|
||||||
|
.update(carwashOrders)
|
||||||
|
.set({ paidAt: now, paidBy: actor, tender, paymentEventId: ev.id })
|
||||||
|
.where(eq(carwashOrders.id, id))
|
||||||
|
.run();
|
||||||
|
const updated = this.#row(id);
|
||||||
|
if (updated.status === "done") await this.#settleParkingIfFree(updated, actor, tender);
|
||||||
|
return this.#view(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bay-paid, done wash: if the sponsorship made the parking session zero-due, sign
|
||||||
|
* the $0 parking payment now — that is what the exit READER checks (a validation
|
||||||
|
* alone opens nothing; see exit-flow.ts). A remaining balance stays for the booth. */
|
||||||
|
async #settleParkingIfFree(r: CarwashOrderRow, actor: string, tender: Tender = "cash"): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = this.#pay.lookup(r.identity);
|
||||||
|
if (!s.open || s.subscription || s.paidAt != null) return;
|
||||||
|
const q = this.#pay.quote(r.identity);
|
||||||
|
if (q.amountMinor !== 0) return;
|
||||||
|
await this.#pay.pay(r.identity, tender);
|
||||||
|
this.#logger.info(`carwash: parking session ${r.identity} settled at zero after bay payment (by ${actor})`);
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.warn(`carwash: could not settle parking for ${r.identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async voidOrder(id: string, reason: string, actor: string): Promise<CarwashOrderView> {
|
||||||
|
const r = this.#row(id);
|
||||||
|
if (r.status === "void") throw new CarwashError(409, "order is already void");
|
||||||
|
if (r.paidAt != null) throw new CarwashError(409, "a paid order cannot be voided", "paid");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
// Take back the sponsorship if it is still live (not consumed by a payment).
|
||||||
|
if (r.validationEventId) {
|
||||||
|
const live = liveValidations(this.#db, r.identity).find((v) => v.eventId === r.validationEventId);
|
||||||
|
if (live) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "validation",
|
||||||
|
source: "manual",
|
||||||
|
identity: r.identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: r.identity,
|
||||||
|
refId: r.validationEventId,
|
||||||
|
programId: live.programId,
|
||||||
|
programLabel: live.label,
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#db
|
||||||
|
.update(carwashOrders)
|
||||||
|
.set({ status: "void", voidAt: now, voidBy: actor, voidReason: reason || null })
|
||||||
|
.where(eq(carwashOrders.id, id))
|
||||||
|
.run();
|
||||||
|
await this.#log.append({
|
||||||
|
type: "carwash_order",
|
||||||
|
source: "manual",
|
||||||
|
identity: r.identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: r.identity,
|
||||||
|
orderId: id,
|
||||||
|
action: "void",
|
||||||
|
categoryName: r.categoryName,
|
||||||
|
serviceName: r.serviceName,
|
||||||
|
priceMinor: r.priceMinor,
|
||||||
|
currency: r.currency,
|
||||||
|
payAt: r.payAt,
|
||||||
|
reason: reason || undefined,
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.#view(this.#row(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Booth settlement hook ------------------------------------------------------
|
||||||
|
|
||||||
|
/** Orders with payAt = "booth" ride the parking payment as charge lines; the core
|
||||||
|
* calls back after the payment is signed so they are marked paid. Off = no lines. */
|
||||||
|
chargeProvider(): ChargeProvider {
|
||||||
|
return {
|
||||||
|
lines: (identity) => {
|
||||||
|
if (!this.#enabled()) return [];
|
||||||
|
return this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashOrders)
|
||||||
|
.where(and(eq(carwashOrders.identity, identity), eq(carwashOrders.payAt, "booth"), isNull(carwashOrders.paidAt)))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.status !== "void")
|
||||||
|
.map((r) => ({
|
||||||
|
module: "carwash" as const,
|
||||||
|
ref: r.id,
|
||||||
|
label: `Lavazh — ${r.categoryName} · ${r.serviceName}`,
|
||||||
|
amountMinor: r.priceMinor,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onPaid: async (_identity, lines, payment) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const l of lines) {
|
||||||
|
if (l.module !== "carwash") continue;
|
||||||
|
this.#db
|
||||||
|
.update(carwashOrders)
|
||||||
|
.set({ paidAt: now, paidBy: payment.operator ?? "booth", tender: payment.tender, paymentEventId: payment.eventId })
|
||||||
|
.where(and(eq(carwashOrders.id, l.ref), isNull(carwashOrders.paidAt)))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type guard for the void body etc. */
|
||||||
|
export function isPayAt(v: unknown): v is CarWashPayAt {
|
||||||
|
return typeof v === "string" && (CARWASH_PAY_AT as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { MODULES, parseEntitledModules, type ModuleId } from "@parking/shared";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
|
import type { PayStation } from "../pay-station.js";
|
||||||
|
import type { ShiftService } from "../shift-service.js";
|
||||||
|
import { effectiveModulesFor } from "../modules.js";
|
||||||
|
import { carwashModule } from "./carwash/index.js";
|
||||||
|
import { validationModule } from "./validation/index.js";
|
||||||
|
|
||||||
|
// The server-side module registry. A module's routes live in its own folder
|
||||||
|
// (apps/server/src/modules/<id>/index.ts) and are registered by iterating
|
||||||
|
// @parking/shared's MODULES — so adding a module is one manifest entry + one folder +
|
||||||
|
// one line in SERVER_MODULES below, with nothing else in the core touched
|
||||||
|
// (wiki/decisions/venue-modules.md, "A module = a manifest + three folders").
|
||||||
|
//
|
||||||
|
// `parking` is registered in the manifest but has NO folder yet: its routes are still
|
||||||
|
// the flat list in server.ts. That is deliberate — the seam is drawn, the code moves
|
||||||
|
// across it subsystem by subsystem as each is touched, not in one big move.
|
||||||
|
|
||||||
|
/** What the core hands a module at registration. Modules reach the core ONLY through
|
||||||
|
* these (never by importing another module): the DB, the signed ledger, the booth
|
||||||
|
* settlement (to fold charges in / settle a session — PayStation.registerChargeProvider,
|
||||||
|
* quote, pay) and the shift service (money needs an open shift). */
|
||||||
|
export interface ServerModuleDeps {
|
||||||
|
db: Db;
|
||||||
|
eventLog: EventLog;
|
||||||
|
payStation: PayStation;
|
||||||
|
shiftService: ShiftService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerModule {
|
||||||
|
id: ModuleId;
|
||||||
|
register(app: FastifyInstance, deps: ServerModuleDeps): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVER_MODULES: Partial<Record<ModuleId, ServerModule>> = {
|
||||||
|
validation: validationModule,
|
||||||
|
carwash: carwashModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Register every folder-based module in registry order, then log what this site
|
||||||
|
* is entitled to / has effective, so a "why is X missing" question is answerable
|
||||||
|
* from the container log alone. */
|
||||||
|
export async function registerModules(app: FastifyInstance, deps: ServerModuleDeps): Promise<void> {
|
||||||
|
for (const manifest of MODULES) {
|
||||||
|
const impl = SERVER_MODULES[manifest.id];
|
||||||
|
if (impl) {
|
||||||
|
if (impl.id !== manifest.id) throw new Error(`module registry mismatch: ${impl.id} registered under ${manifest.id}`);
|
||||||
|
await impl.register(app, deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { entitled, unknown } = parseEntitledModules(process.env.MODULES_ENTITLED);
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
app.log.warn({ unknown }, "MODULES_ENTITLED names unknown module ids — ignored");
|
||||||
|
}
|
||||||
|
app.log.info(
|
||||||
|
{ entitled, effective: effectiveModulesFor(deps.db) },
|
||||||
|
"venue modules (entitled = MODULES_ENTITLED env; effective = entitled ∩ site activation)",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { validationRoutes } from "../../routes/validations.js";
|
||||||
|
import type { ServerModule } from "../index.js";
|
||||||
|
|
||||||
|
// Merchant-scan ticket validation as a venue module. Kept for the Bar until a Bar
|
||||||
|
// module absorbs it (wiki/decisions/venue-modules.md, decision 1). The routes
|
||||||
|
// themselves still live in routes/validations.ts (unchanged location, now guarded by
|
||||||
|
// requireModule("validation")); this folder is the registry hook.
|
||||||
|
export const validationModule: ServerModule = {
|
||||||
|
id: "validation",
|
||||||
|
async register(app, { db, eventLog }) {
|
||||||
|
await validationRoutes(app, db, eventLog);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
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, type ValidationLine } from "@parking/shared";
|
import { BOOTH_TILL, priceSession, type ChargeLine, 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";
|
||||||
@@ -29,6 +29,18 @@ export class NoTariffError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A module that folds its own charges into a booth settlement (venue-modules.md):
|
||||||
|
* `lines(identity)` returns the open charges for the session (e.g. wash orders with
|
||||||
|
* payAt = "booth"); after the `payment` is signed, `onPaid` lets the module mark them
|
||||||
|
* settled. Registered by the module at boot (registerChargeProvider) — PayStation
|
||||||
|
* never imports a module.
|
||||||
|
*/
|
||||||
|
export interface ChargeProvider {
|
||||||
|
lines(identity: string): ChargeLine[];
|
||||||
|
onPaid(identity: string, lines: ChargeLine[], payment: { eventId: string; tender: Tender; operator?: string }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Quote {
|
export interface Quote {
|
||||||
readonly identity: string;
|
readonly identity: string;
|
||||||
/** Vehicle entry time (the session's original entry; for display/audit). */
|
/** Vehicle entry time (the session's original entry; for display/audit). */
|
||||||
@@ -39,8 +51,14 @@ 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], NET of merchant validations. */
|
/** Amount owed now: the parking fee for [periodStart → now] NET of merchant
|
||||||
|
* validations, PLUS any module charge lines (a wash paid at the booth). */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
|
/** The parking-only net (amountMinor − chargesMinor). */
|
||||||
|
readonly parkingMinor: number;
|
||||||
|
/** Non-parking charges folded in by modules (see ChargeProvider). */
|
||||||
|
readonly chargeLines: ChargeLine[];
|
||||||
|
readonly chargesMinor: number;
|
||||||
/** The pre-validation fee (= amountMinor when no validations apply). */
|
/** The pre-validation fee (= amountMinor when no validations apply). */
|
||||||
readonly grossMinor: number;
|
readonly grossMinor: number;
|
||||||
/** Total the merchant validations took off (gross − net). */
|
/** Total the merchant validations took off (gross − net). */
|
||||||
@@ -133,12 +151,16 @@ export interface SessionLookup {
|
|||||||
readonly grossMinor: number | null;
|
readonly grossMinor: number | null;
|
||||||
readonly discountMinor: number | null;
|
readonly discountMinor: number | null;
|
||||||
readonly validationLines: ValidationLine[];
|
readonly validationLines: ValidationLine[];
|
||||||
|
/** Module charge lines folded into `amountMinor` (e.g. a wash paid at the booth). */
|
||||||
|
readonly chargeLines: ChargeLine[];
|
||||||
|
readonly chargesMinor: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PayStation {
|
export class PayStation {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #chargeProviders: ChargeProvider[] = [];
|
||||||
|
|
||||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -146,6 +168,30 @@ export class PayStation {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Let a module fold its charges into booth settlements (see ChargeProvider). */
|
||||||
|
registerChargeProvider(p: ChargeProvider): void {
|
||||||
|
this.#chargeProviders.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The currency of the tariff in force right now (null = none published). Modules
|
||||||
|
* price their own goods in the same money the booth takes. */
|
||||||
|
activeCurrency(): string | null {
|
||||||
|
return this.#tariffVersionFor(new Date().toISOString())?.currency ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chargeLines(identity: string): ChargeLine[] {
|
||||||
|
const out: ChargeLine[] = [];
|
||||||
|
for (const p of this.#chargeProviders) {
|
||||||
|
try {
|
||||||
|
out.push(...p.lines(identity));
|
||||||
|
} catch (err) {
|
||||||
|
// A module's fault must never block a parking settlement — log and price without it.
|
||||||
|
this.#logger.error(`charge provider failed for ${identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
|
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
|
||||||
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
|
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
|
||||||
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
|
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
|
||||||
@@ -184,11 +230,16 @@ export class PayStation {
|
|||||||
category,
|
category,
|
||||||
validations,
|
validations,
|
||||||
);
|
);
|
||||||
|
const chargeLines = this.#chargeLines(identity);
|
||||||
|
const chargesMinor = chargeLines.reduce((sum, l) => sum + l.amountMinor, 0);
|
||||||
return {
|
return {
|
||||||
identity,
|
identity,
|
||||||
enteredAt: entry.occurredAt,
|
enteredAt: entry.occurredAt,
|
||||||
periodStart: p.periodStart,
|
periodStart: p.periodStart,
|
||||||
amountMinor: p.amountMinor,
|
amountMinor: p.amountMinor + chargesMinor,
|
||||||
|
parkingMinor: p.amountMinor,
|
||||||
|
chargeLines,
|
||||||
|
chargesMinor,
|
||||||
grossMinor: p.grossMinor,
|
grossMinor: p.grossMinor,
|
||||||
discountMinor: p.discountMinor,
|
discountMinor: p.discountMinor,
|
||||||
validationLines: p.validationLines,
|
validationLines: p.validationLines,
|
||||||
@@ -246,6 +297,7 @@ export class PayStation {
|
|||||||
amountMinor,
|
amountMinor,
|
||||||
currency: subWindow.currency ?? undefined,
|
currency: subWindow.currency ?? undefined,
|
||||||
tender,
|
tender,
|
||||||
|
till: BOOTH_TILL,
|
||||||
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
|
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
|
||||||
subscriptionWindowCharge: true,
|
subscriptionWindowCharge: true,
|
||||||
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
|
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
|
||||||
@@ -258,7 +310,7 @@ export class PayStation {
|
|||||||
const q = this.quote(identity);
|
const q = this.quote(identity);
|
||||||
const amountMinor = overrideMinor ?? q.amountMinor;
|
const amountMinor = overrideMinor ?? q.amountMinor;
|
||||||
|
|
||||||
await this.#log.append({
|
const paymentEvent = await this.#log.append({
|
||||||
type: "payment",
|
type: "payment",
|
||||||
source: "manual",
|
source: "manual",
|
||||||
identity,
|
identity,
|
||||||
@@ -267,7 +319,19 @@ export class PayStation {
|
|||||||
amountMinor,
|
amountMinor,
|
||||||
currency: q.currency,
|
currency: q.currency,
|
||||||
tender,
|
tender,
|
||||||
|
// Parking money is BOOTH money (a wash paid at the booth rides along as
|
||||||
|
// chargeLines, so it is booth money too). See wiki/concepts/shift.md "Tills".
|
||||||
|
till: BOOTH_TILL,
|
||||||
tariffVersionId: q.tariffVersionId,
|
tariffVersionId: q.tariffVersionId,
|
||||||
|
// Module charges (e.g. a wash paid at the booth): frozen as lines so the
|
||||||
|
// receipt reproduces and reporting can split parking from the rest.
|
||||||
|
...(q.chargeLines.length
|
||||||
|
? {
|
||||||
|
chargeLines: q.chargeLines.map((l) => ({ ...l })),
|
||||||
|
chargesMinor: q.chargesMinor,
|
||||||
|
parkingMinor: q.parkingMinor,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
// 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,
|
||||||
@@ -294,6 +358,17 @@ export class PayStation {
|
|||||||
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Let each module mark the charge lines it contributed as settled by this payment.
|
||||||
|
if (q.chargeLines.length) {
|
||||||
|
for (const p of this.#chargeProviders) {
|
||||||
|
try {
|
||||||
|
await p.onPaid(identity, q.chargeLines, { eventId: paymentEvent.id, tender });
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`charge provider onPaid failed for ${identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
|
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
|
||||||
return { amountMinor, currency: q.currency };
|
return { amountMinor, currency: q.currency };
|
||||||
}
|
}
|
||||||
@@ -320,6 +395,7 @@ export class PayStation {
|
|||||||
withinGrace: false, graceExpiresAt: 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: [],
|
grossMinor: null, discountMinor: null, validationLines: [],
|
||||||
|
chargeLines: [], chargesMinor: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||||
@@ -360,6 +436,8 @@ export class PayStation {
|
|||||||
let grossMinor: number | null = null;
|
let grossMinor: number | null = null;
|
||||||
let discountMinor: number | null = null;
|
let discountMinor: number | null = null;
|
||||||
let validationLines: ValidationLine[] = [];
|
let validationLines: ValidationLine[] = [];
|
||||||
|
let chargeLines: ChargeLine[] = [];
|
||||||
|
let chargesMinor: number | null = null;
|
||||||
if (open && !isSubscription) {
|
if (open && !isSubscription) {
|
||||||
try {
|
try {
|
||||||
const q = this.quote(id);
|
const q = this.quote(id);
|
||||||
@@ -368,6 +446,8 @@ export class PayStation {
|
|||||||
grossMinor = q.grossMinor;
|
grossMinor = q.grossMinor;
|
||||||
discountMinor = q.discountMinor;
|
discountMinor = q.discountMinor;
|
||||||
validationLines = q.validationLines;
|
validationLines = q.validationLines;
|
||||||
|
chargeLines = q.chargeLines;
|
||||||
|
chargesMinor = q.chargesMinor;
|
||||||
} catch {
|
} catch {
|
||||||
/* no active tariff — leave null; modal shows session without a price */
|
/* no active tariff — leave null; modal shows session without a price */
|
||||||
}
|
}
|
||||||
@@ -389,6 +469,7 @@ export class PayStation {
|
|||||||
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,
|
grossMinor, discountMinor, validationLines,
|
||||||
|
chargeLines, chargesMinor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, roles, users, type Db } from "@parking/db";
|
import { eq, roles, users, type Db } from "@parking/db";
|
||||||
|
import { effectiveModulesFor } from "../modules.js";
|
||||||
import {
|
import {
|
||||||
clearAuthCookies,
|
clearAuthCookies,
|
||||||
newCsrfToken,
|
newCsrfToken,
|
||||||
@@ -65,7 +66,21 @@ function cleanProfileField(v: string | null | undefined): string | null | undefi
|
|||||||
|
|
||||||
/** The session shape the SPA bootstraps from: identity + role + its permission
|
/** The session shape the SPA bootstraps from: identity + role + its permission
|
||||||
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||||
* permissions are the source of truth. */
|
* permissions are the source of truth.
|
||||||
|
*
|
||||||
|
* `csrf`, when passed, echoes the SAME value already sent as the readable
|
||||||
|
* parking_csrf cookie — not a new secret, just a second channel to learn it.
|
||||||
|
* The desktop shell needs this: tauri-plugin-http's fetch() runs through
|
||||||
|
* Rust's reqwest, which keeps its own cookie jar separate from the webview,
|
||||||
|
* so document.cookie on the tauri://localhost page never sees a cookie set
|
||||||
|
* on a plugin-routed response (open upstream bug, tauri-apps/tauri#13045).
|
||||||
|
* The cookie itself IS still sent back to the server by reqwest on
|
||||||
|
* subsequent requests — only the *client-side read* is broken — so
|
||||||
|
* api.ts's desktop path stashes this body value in memory instead of
|
||||||
|
* reading document.cookie, and echoes it in X-CSRF-Token exactly as the
|
||||||
|
* browser path echoes the cookie. See lib/api.ts and assertCsrf() in
|
||||||
|
* ../auth.ts (unchanged — this never touches verification, only how the
|
||||||
|
* desktop client learns what to send). */
|
||||||
function sessionView(
|
function sessionView(
|
||||||
db: Db,
|
db: Db,
|
||||||
user: {
|
user: {
|
||||||
@@ -78,6 +93,7 @@ function sessionView(
|
|||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
},
|
},
|
||||||
|
csrf?: string,
|
||||||
) {
|
) {
|
||||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||||
const permissions = [...permissionsFor(user.roleId)];
|
const permissions = [...permissionsFor(user.roleId)];
|
||||||
@@ -92,6 +108,10 @@ function sessionView(
|
|||||||
fontScale: user.fontScale,
|
fontScale: user.fontScale,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
|
// Effective venue modules (entitled ∩ activated) so the SPA can hide nav/routes
|
||||||
|
// on first paint. The server still enforces via requireModule — this is display.
|
||||||
|
modules: effectiveModulesFor(db),
|
||||||
|
...(csrf ? { csrfToken: csrf } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +146,7 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
setAuthCookies(reply, token, csrf);
|
setAuthCookies(reply, token, csrf);
|
||||||
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
||||||
// read from the DB, so changing it needs no token refresh.
|
// read from the DB, so changing it needs no token refresh.
|
||||||
return sessionView(db, user);
|
return sessionView(db, user, csrf);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/logout", async (_req, reply) => {
|
app.post("/api/auth/logout", async (_req, reply) => {
|
||||||
@@ -146,7 +166,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
clearAuthCookies(reply);
|
clearAuthCookies(reply);
|
||||||
return reply.code(401).send({ error: "session no longer valid" });
|
return reply.code(401).send({ error: "session no longer valid" });
|
||||||
}
|
}
|
||||||
return sessionView(db, row);
|
// req.user.csrf is the value bound into the JWT at login (see assertCsrf in
|
||||||
|
// ../auth.ts) — same value as the cookie, re-surfaced for the desktop path.
|
||||||
|
return sessionView(db, row, req.user.csrf);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import type { Db } from "@parking/db";
|
||||||
|
import type { TillId } from "@parking/shared";
|
||||||
|
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
|
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
|
||||||
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
||||||
|
|
||||||
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
// 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
|
// 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.
|
// 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)
|
// - POST /api/drawer/movement : operator records a cash_in/cash_out on a till.
|
||||||
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
// Guard = the till's `cash` (booth drawer:create,
|
||||||
// only their own; reviewers see all + can filter status.
|
// wash carwash:cash).
|
||||||
|
// - GET /api/drawer/movements: list with review status, over the tills the role may
|
||||||
|
// read (own movements); reviewers (drawer:review) see all
|
||||||
|
// tills + all operators and can filter status.
|
||||||
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
||||||
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read)
|
// - GET /api/drawer/balance : a till's physical balance NOW (guard = the till's 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
|
// 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.
|
// judgment about the operator settled outside the app, never a cash reversal.
|
||||||
|
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); each
|
||||||
|
// desk's cash is guarded by that desk's own permissions (venue-modules.md §"Permissions
|
||||||
|
// matrix").
|
||||||
|
|
||||||
interface MovementBody {
|
interface MovementBody {
|
||||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||||
@@ -23,6 +30,8 @@ interface MovementBody {
|
|||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
/** Which drawer (default: the booth). */
|
||||||
|
till?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReviewBody {
|
interface ReviewBody {
|
||||||
@@ -36,15 +45,15 @@ interface ReviewBody {
|
|||||||
interface MovementsQuery {
|
interface MovementsQuery {
|
||||||
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
||||||
status?: MovementStatus;
|
status?: MovementStatus;
|
||||||
|
/** Filter to one till; absent = every till the role may read (reviewers: every till). */
|
||||||
|
till?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||||
const createGuard = requirePermission("drawer:create");
|
|
||||||
const reviewGuard = requirePermission("drawer:review");
|
const reviewGuard = requirePermission("drawer:review");
|
||||||
const readGuard = requirePermission("shift:read");
|
|
||||||
|
|
||||||
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
// 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) => {
|
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: requireTill(db, "cash", "body") }, async (req, reply) => {
|
||||||
const b = req.body ?? ({} as MovementBody);
|
const b = req.body ?? ({} as MovementBody);
|
||||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||||
@@ -56,6 +65,7 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
|
|||||||
amountMinor: b.amountMinor,
|
amountMinor: b.amountMinor,
|
||||||
reason: b.reason ?? "",
|
reason: b.reason ?? "",
|
||||||
currency: b.currency,
|
currency: b.currency,
|
||||||
|
till: req.till!,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||||
@@ -63,22 +73,37 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// List movements + review status. Operators are hard-scoped to their OWN movements; a
|
// List movements + review status. Operators are hard-scoped to their OWN movements on
|
||||||
// reviewer sees ALL and may filter by status (the pending review queue).
|
// the tills they may read; a reviewer sees ALL and may filter by status (the pending
|
||||||
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => {
|
// review queue).
|
||||||
|
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: requireAuth }, async (req, reply) => {
|
||||||
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
||||||
|
const readable = tillsReadableBy(db, req.user.roleId);
|
||||||
|
if (!canReview && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
|
||||||
const q = req.query ?? {};
|
const q = req.query ?? {};
|
||||||
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
||||||
const movements = shift.movementsWithStatus({
|
let tills: TillId[] | undefined = canReview ? undefined : readable;
|
||||||
operator: canReview ? undefined : req.user.username,
|
if (q.till?.trim()) {
|
||||||
status,
|
const parsed = parseTill(db, q.till.trim());
|
||||||
});
|
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||||
|
if (!canReview && !readable.includes(parsed)) {
|
||||||
|
return reply.code(403).send({ error: `your role cannot see the ${parsed} till`, code: "till_forbidden", till: parsed });
|
||||||
|
}
|
||||||
|
tills = [parsed];
|
||||||
|
}
|
||||||
|
const operator = canReview ? undefined : req.user.username;
|
||||||
|
const movements = tills
|
||||||
|
? tills.flatMap((till) => shift.movementsWithStatus({ operator, status, till })).sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
|
||||||
|
: shift.movementsWithStatus({ operator, status });
|
||||||
return { movements, scope: canReview ? "all" : "self" };
|
return { movements, scope: canReview ? "all" : "self" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// The physical drawer balance now. Same visibility as the open shift's X-report
|
// A till's 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.
|
// (the till's read guard) — a drawer is a shared till, not per-operator data.
|
||||||
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance());
|
app.get("/api/drawer/balance", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
|
||||||
|
till: req.till!,
|
||||||
|
...shift.drawerBalance(req.till!),
|
||||||
|
}));
|
||||||
|
|
||||||
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
// 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) => {
|
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("health + login", () => {
|
|||||||
it("GET /health is open", async () => {
|
it("GET /health is open", async () => {
|
||||||
const res = await app.inject({ method: "GET", url: "/health" });
|
const res = await app.inject({ method: "GET", url: "/health" });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.json()).toEqual({ status: "ok" });
|
expect(res.json()).toEqual({ status: "ok", app: "parking-system" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("login with bad credentials is rejected", async () => {
|
it("login with bad credentials is rejected", async () => {
|
||||||
@@ -142,7 +142,7 @@ describe("drawer balance (the till NOW)", () => {
|
|||||||
const { cookie } = await login(app, viewer.username, viewer.password);
|
const { cookie } = await login(app, viewer.username, viewer.password);
|
||||||
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
|
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
|
||||||
expect(ok.statusCode).toBe(200);
|
expect(ok.statusCode).toBe(200);
|
||||||
expect(ok.json()).toEqual({ balanceMinor: 0, currency: null });
|
expect(ok.json()).toEqual({ till: "booth", balanceMinor: 0, currency: null });
|
||||||
|
|
||||||
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
|
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
|
||||||
const other = await login(app, outsider.username, outsider.password);
|
const other = await login(app, outsider.username, outsider.password);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import type { Db } from "@parking/db";
|
||||||
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
|
import { tillGuards, type TillId } from "@parking/shared";
|
||||||
|
import { requireAuth, roleHasPermissions } from "../auth.js";
|
||||||
|
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
|
||||||
|
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService, type ShiftSummary } from "../shift-service.js";
|
||||||
|
|
||||||
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). */
|
||||||
@@ -8,85 +11,124 @@ interface ShiftsQuery {
|
|||||||
/** ISO window over shift START time. */
|
/** ISO window over shift START time. */
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
/** Filter to one till; absent = every till the role may read. */
|
||||||
|
till?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
// 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.
|
||||||
|
//
|
||||||
|
// TILLS + PERMISSIONS: every endpoint addresses a `till` (query on GET, body on POST;
|
||||||
|
// default booth) and its guard is resolved FROM THE TILL (requireTill): the booth's shift
|
||||||
|
// is `shift:read` / `shift:create`, the wash's is `carwash:read` / `carwash:cash` — each
|
||||||
|
// desk's money is guarded by that desk's own permissions, so a wash role holds no
|
||||||
|
// `shift:*` at all and cannot touch the booth. See venue-modules.md §"Permissions matrix".
|
||||||
|
|
||||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||||
// Reading the shift state vs. opening/closing one's own shift.
|
const statusOf = (till: TillId, me: string, roleId: string) => {
|
||||||
const readGuard = requirePermission("shift:read");
|
const open = shift.currentOpenShift(till);
|
||||||
const guard = requirePermission("shift:create");
|
|
||||||
|
|
||||||
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
|
|
||||||
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
|
|
||||||
// someone else's shift → disabled. Also returns the live drawer balance.
|
|
||||||
// - open: the open shift { startedAt, operator } or null (site-wide)
|
|
||||||
// - isMine: true iff the open shift belongs to the requesting operator
|
|
||||||
// - operator: the requesting user (for the UI's own identity)
|
|
||||||
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => {
|
|
||||||
const me = req.user.username;
|
|
||||||
const open = shift.currentOpenShift();
|
|
||||||
const heldBy = open?.identity ?? null;
|
const heldBy = open?.identity ?? null;
|
||||||
const drawer = shift.drawerBalance();
|
const drawer = shift.drawerBalance(till);
|
||||||
return {
|
return {
|
||||||
operator: me,
|
till,
|
||||||
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
||||||
isMine: open != null && heldBy === me,
|
isMine: open != null && heldBy === me,
|
||||||
|
/** May this role open/close this till's shift? (The UI offers the button only then.) */
|
||||||
|
canWork: roleHasPermissions(roleId, [tillGuards(till).shift]),
|
||||||
drawerMinor: drawer.balanceMinor,
|
drawerMinor: drawer.balanceMinor,
|
||||||
currency: drawer.currency,
|
currency: drawer.currency,
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// The shift state of ONE till (at most one shift open per till). The UI uses this
|
||||||
|
// to render a till's control: no shift → "Open"; my shift → "Close" (enabled);
|
||||||
|
// someone else's shift → disabled. Also returns the live drawer balance.
|
||||||
|
// - till: which till this describes
|
||||||
|
// - open: the open shift { startedAt, operator } or null
|
||||||
|
// - isMine: true iff the open shift belongs to the requesting operator
|
||||||
|
// - canWork: may this role open/close it
|
||||||
|
// - operator: the requesting user (for the UI's own identity)
|
||||||
|
// - tills: every till THIS ROLE may read — what the UI offers controls for
|
||||||
|
app.get("/api/shift/current", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
|
||||||
|
operator: req.user.username,
|
||||||
|
tills: tillsReadableBy(db, req.user.roleId),
|
||||||
|
...statusOf(req.till!, req.user.username, req.user.roleId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The state of every till this role may read, in one read — the shift hub lists
|
||||||
|
// each open shift and offers "start" for the idle ones it may work.
|
||||||
|
app.get("/api/shift/tills", { preHandler: requireAuth }, async (req) => {
|
||||||
|
const me = req.user.username;
|
||||||
|
return { operator: me, tills: tillsReadableBy(db, req.user.roleId).map((t) => statusOf(t, me, req.user.roleId)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
|
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
|
||||||
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
|
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
|
||||||
// as of now. Appends nothing — it's not an accountability mark, just a projection
|
// as of now. Appends nothing — it's not an accountability mark, just a projection
|
||||||
// (the Z-report at close is the signed record). 204 when no shift is open.
|
// (the Z-report at close is the signed record). 204 when no shift is open.
|
||||||
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
|
app.get("/api/shift/report", { preHandler: requireTill(db, "read", "query") }, async (req, reply) => {
|
||||||
const report = shift.currentReport();
|
const report = shift.currentReport(req.till!);
|
||||||
if (!report) return reply.code(204).send();
|
if (!report) return reply.code(204).send();
|
||||||
return report;
|
return report;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Completed shift history. SCOPED by permission:
|
// Completed shift history. SCOPED by permission:
|
||||||
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
// - a till's `read` guard (operators) → own shifts only, on the tills they may read;
|
||||||
|
// operator/from/to params ignored.
|
||||||
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||||
// `operator` and a `from`/`to` time window over each shift's START.
|
// `operator` and a `from`/`to` time window over each shift's START.
|
||||||
// This keeps one operator from reading another's takings while letting admins
|
// This keeps one operator from reading another's takings while letting admins
|
||||||
// reconcile across the site. The data is the signed shift_z_report chain.
|
// reconcile across the site. The data is the signed shift_z_report chain. Both
|
||||||
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
|
// scopes may filter by `till` (must be one the role may read).
|
||||||
|
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: requireAuth }, async (req, reply) => {
|
||||||
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
|
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
|
||||||
|
const readable = tillsReadableBy(db, req.user.roleId);
|
||||||
|
if (!canSeeAll && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
|
||||||
const q = req.query ?? {};
|
const q = req.query ?? {};
|
||||||
// Non-admins are hard-scoped to themselves regardless of any operator param.
|
// Non-admins are hard-scoped to themselves regardless of any operator param.
|
||||||
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
|
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
|
||||||
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 });
|
let tills: TillId[] = canSeeAll ? [] : readable; // [] = no till filter (admin)
|
||||||
|
if (q.till?.trim()) {
|
||||||
|
const parsed = parseTill(db, q.till.trim());
|
||||||
|
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||||
|
if (!canSeeAll && !readable.includes(parsed)) return badTill(reply, parsed);
|
||||||
|
tills = [parsed];
|
||||||
|
}
|
||||||
|
const shifts: ShiftSummary[] =
|
||||||
|
tills.length === 0
|
||||||
|
? shift.listShifts({ operator, from, to })
|
||||||
|
: tills.flatMap((till) => shift.listShifts({ operator, from, to, till })).sort((a, b) => b.index - a.index);
|
||||||
// Admins also get the distinct operator list (unfiltered) for the filter
|
// Admins also get the distinct operator list (unfiltered) for the filter
|
||||||
// dropdown — operators don't see other names, so it's scope-gated.
|
// dropdown — operators don't see other names, so it's scope-gated.
|
||||||
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() };
|
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: tillsReadableBy(db, req.user.roleId) };
|
||||||
return { shifts, scope: "self" };
|
return { shifts, scope: "self", tills: readable };
|
||||||
});
|
});
|
||||||
|
|
||||||
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||||
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
||||||
|
|
||||||
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
app.post("/api/shift/open", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
return await shift.open(req.user.username);
|
return await shift.open(req.user.username, req.till!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
||||||
return reply.code(500).send({ error: (err as Error).message });
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
|
app.post("/api/shift/close", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
return await shift.close(req.user.username);
|
return await shift.close(req.user.username, req.till!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
||||||
return reply.code(500).send({ error: (err as Error).message });
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function badTill(reply: FastifyReply, till: TillId): FastifyReply {
|
||||||
|
return reply.code(403).send({ error: `your role cannot see the ${till} till`, code: "till_forbidden", till });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
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 { MODULES, effectiveModules, isModuleId, resolveModuleActivation, type ModuleId } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
|
import { activatedModulesOf, entitledModules } from "../modules.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
|
||||||
@@ -36,6 +38,10 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
|||||||
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's
|
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's
|
||||||
* plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */
|
* plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */
|
||||||
anprEntryEnabled?: boolean;
|
anprEntryEnabled?: boolean;
|
||||||
|
/** Venue modules to ACTIVATE (full desired set). Validated against the entitlement
|
||||||
|
* and the registry's dependency rules; required modules are always included. Each
|
||||||
|
* module that actually flips signs a config_change. See wiki/decisions/venue-modules.md. */
|
||||||
|
modules?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||||
@@ -48,6 +54,13 @@ type SiteConfig = {
|
|||||||
anprEntryEnabled: boolean;
|
anprEntryEnabled: boolean;
|
||||||
bypassPresenceRadar: boolean;
|
bypassPresenceRadar: boolean;
|
||||||
bypassPresenceCamera: boolean;
|
bypassPresenceCamera: boolean;
|
||||||
|
/** Effective venue modules = entitled ∩ activated (what the server enforces). */
|
||||||
|
modules: ModuleId[];
|
||||||
|
/** What this deployment is entitled to (MODULES_ENTITLED env) — the Setup → Site
|
||||||
|
* panel offers exactly these to toggle. */
|
||||||
|
modulesEntitled: ModuleId[];
|
||||||
|
/** What the site admin has activated (null in storage = everything entitled). */
|
||||||
|
modulesActivated: ModuleId[];
|
||||||
} & Record<TextField, string | null>;
|
} & Record<TextField, string | null>;
|
||||||
|
|
||||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||||
@@ -59,11 +72,22 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
|||||||
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
||||||
bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
|
bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
|
||||||
bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
|
bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
|
||||||
|
...moduleView(row),
|
||||||
} 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moduleView(row: typeof siteConfig.$inferSelect | undefined) {
|
||||||
|
const entitled = entitledModules();
|
||||||
|
const activated = activatedModulesOf(row) ?? entitled;
|
||||||
|
return {
|
||||||
|
modules: effectiveModules(entitled, activated),
|
||||||
|
modulesEntitled: entitled,
|
||||||
|
modulesActivated: activated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Trim a text field; empty string becomes null so blank input clears it. */
|
/** Trim a text field; empty string becomes null so blank input clears it. */
|
||||||
function normText(v: unknown): string | null {
|
function normText(v: unknown): string | null {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
@@ -136,6 +160,40 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
|
||||||
|
// Venue-module activation. The body carries the full DESIRED set; the shared rules
|
||||||
|
// (required always on, must be entitled, dependencies effective) decide, and every
|
||||||
|
// module whose effective state actually flips is signed as a config_change — the
|
||||||
|
// same attribution pattern as the presence-bypass endpoint below. Disabling never
|
||||||
|
// deletes anything: tables/history/grants stay, routes 403, UI hides.
|
||||||
|
if ("modules" in body) {
|
||||||
|
const requested = body.modules;
|
||||||
|
if (!Array.isArray(requested) || !requested.every(isModuleId)) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: `modules must be an array of module ids (${MODULES.map((m) => m.id).join(", ")})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const entitled = entitledModules();
|
||||||
|
const result = resolveModuleActivation(entitled, requested);
|
||||||
|
if (!result.ok) return reply.code(400).send({ error: result.error });
|
||||||
|
const prevEffective = new Set(effectiveModules(entitled, activatedModulesOf(existing) ?? entitled));
|
||||||
|
const nextEffective = new Set(effectiveModules(entitled, result.modules));
|
||||||
|
const operator = req.user?.username ?? "unknown";
|
||||||
|
for (const m of MODULES) {
|
||||||
|
const was = prevEffective.has(m.id);
|
||||||
|
const now = nextEffective.has(m.id);
|
||||||
|
if (was !== now) {
|
||||||
|
await eventLog?.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: `module:${m.id}`,
|
||||||
|
payload: { setting: `modules.${m.id}`, value: now, prev: was, operator },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patch.modulesJson = JSON.stringify(result.modules);
|
||||||
|
}
|
||||||
|
|
||||||
const updatedAt = new Date().toISOString();
|
const updatedAt = new Date().toISOString();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||||
import { NoPrinterAvailableError } from "@parking/devices";
|
import { NoPrinterAvailableError } from "@parking/devices";
|
||||||
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
|
import { BOOTH_TILL, type SubscriptionPlan, type SubscriptionQuote, type Tender } from "@parking/shared";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
import { softDelete } from "../recycle-bin.js";
|
import { softDelete } from "../recycle-bin.js";
|
||||||
import { invalidateHolder } from "../event-enrich.js";
|
import { invalidateHolder } from "../event-enrich.js";
|
||||||
@@ -381,6 +381,7 @@ export async function subscriptionRoutes(
|
|||||||
amountMinor,
|
amountMinor,
|
||||||
currency,
|
currency,
|
||||||
tender,
|
tender,
|
||||||
|
till: BOOTH_TILL,
|
||||||
operator,
|
operator,
|
||||||
// Flags this `payment` as a subscription SALE (not a parking payment) so the
|
// Flags this `payment` as a subscription SALE (not a parking payment) so the
|
||||||
// live feed / activity log can label it distinctly. plan + periods for audit
|
// live feed / activity log can label it distinctly. plan + periods for audit
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import bcrypt from "bcrypt";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
|
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
|
||||||
import { ADMIN_ROLE_ID } from "@parking/shared";
|
import { ADMIN_ROLE_ID } from "@parking/shared";
|
||||||
import { permissionsFor, requirePermission } from "../auth.js";
|
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||||
import { softDelete } from "../recycle-bin.js";
|
import { softDelete } from "../recycle-bin.js";
|
||||||
|
|
||||||
// User management (admin). Users are created/edited at runtime here — the
|
// User management (admin). Users are created/edited at runtime here — the
|
||||||
@@ -202,6 +202,8 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return reply.code(400).send({ error: "nothing to update" });
|
return reply.code(400).send({ error: "nothing to update" });
|
||||||
}
|
}
|
||||||
db.update(users).set(next).where(eq(users.id, id)).run();
|
db.update(users).set(next).where(eq(users.id, id)).run();
|
||||||
|
// A role reassignment takes effect on the user's NEXT request (auth.ts refreshRole).
|
||||||
|
if (next.roleId) bumpPermsCache();
|
||||||
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
|
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -251,6 +253,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return reply.code(409).send({ error: "cannot delete the last admin" });
|
return reply.code(409).send({ error: "cannot delete the last admin" });
|
||||||
}
|
}
|
||||||
softDelete(db, "user", id, req.user.sub);
|
softDelete(db, "user", id, req.user.sub);
|
||||||
|
bumpPermsCache(); // their live session ends on its next request
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import {
|
|||||||
validationPrograms,
|
validationPrograms,
|
||||||
type Db,
|
type Db,
|
||||||
} from "@parking/db";
|
} from "@parking/db";
|
||||||
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared";
|
import { MERCHANT_VALIDATION_MODES, VALIDATION_MODES, type ValidationMode } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { requireModule } from "../modules.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
import { liveValidations, sessionValidations } from "../validations.js";
|
import { applyValidation, liveValidations, sessionValidations } from "../validations.js";
|
||||||
|
|
||||||
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
|
// 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
|
// customer's ticket on their own device and apply their program — all money and paper
|
||||||
@@ -63,21 +64,33 @@ function validateProgram(b: ProgramBody): string | null {
|
|||||||
if (!b.name || !String(b.name).trim()) return "name is required";
|
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";
|
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);
|
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
|
||||||
if (!intOrNull(b.minutes)) return "minutes must be a positive integer";
|
// doneTolerance's minutes is a TOLERANCE — zero is a legitimate "free until done, not a
|
||||||
|
// minute more"; every other minutes use is a positive credit.
|
||||||
|
const minutesOk = b.mode === "doneTolerance"
|
||||||
|
? b.minutes == null || (Number.isInteger(b.minutes) && (b.minutes as number) >= 0)
|
||||||
|
: intOrNull(b.minutes);
|
||||||
|
if (!minutesOk) return b.mode === "doneTolerance" ? "minutes must be a non-negative integer" : "minutes must be a positive integer";
|
||||||
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor 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 (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
|
||||||
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
|
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
|
||||||
return "percent must be 1..100";
|
return "percent must be 1..100";
|
||||||
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
|
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
|
||||||
|
if (b.mode === "doneTolerance" && b.minutes == null) return "doneTolerance needs minutes (the tolerance; 0 allowed)";
|
||||||
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
|
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
|
||||||
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
|
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
|
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
|
||||||
|
// The PROGRAM routes (compose / read discount programs) are CORE: the discount engine
|
||||||
|
// serves every module that grants a parking discount (Car Wash's "carwash" program
|
||||||
|
// rides it), so they are never behind the validation module gate — plain site:read /
|
||||||
|
// site:update. The MERCHANT routes (mine / lookup / apply / void — the scan screen)
|
||||||
|
// are the validation module itself: module gate FIRST (403 module_disabled when the
|
||||||
|
// site has validation off — see ../modules.ts), then the permission.
|
||||||
const siteRead = requirePermission("site:read");
|
const siteRead = requirePermission("site:read");
|
||||||
const siteWrite = requirePermission("site:update");
|
const siteWrite = requirePermission("site:update");
|
||||||
const applyGuard = requirePermission("validation:create");
|
const applyGuard = [requireModule(db, "validation"), requirePermission("validation:create")];
|
||||||
|
|
||||||
const liveProgram = (id: string) =>
|
const liveProgram = (id: string) =>
|
||||||
db
|
db
|
||||||
@@ -243,88 +256,21 @@ export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: E
|
|||||||
if (!boundUserIds(programId).includes(req.user.sub)) {
|
if (!boundUserIds(programId).includes(req.user.sub)) {
|
||||||
return reply.code(403).send({ error: "you are not bound to this program" });
|
return reply.code(403).send({ error: "you are not bound to this program" });
|
||||||
}
|
}
|
||||||
|
if (!MERCHANT_VALIDATION_MODES.includes(program.mode)) {
|
||||||
// Session state — an open transient (subscriptions are prepaid; nothing to discount).
|
return reply.code(400).send({ error: "this program's discount is resolved by a car wash order, not at scan" });
|
||||||
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
|
// The decision chain + the signed append live in ../validations.ts (applyValidation)
|
||||||
// appliance runs in site time).
|
// — shared with the Car Wash module, which applies its own sponsorship program with
|
||||||
if (program.maxPerDay != null) {
|
// no user binding. Only the binding check above is merchant-specific.
|
||||||
const midnight = new Date();
|
const result = await applyValidation(db, eventLog, {
|
||||||
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,
|
programId,
|
||||||
label: program.name,
|
identity,
|
||||||
mode: program.mode,
|
actor: req.user.username,
|
||||||
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
|
amountMinor: req.body?.amountMinor,
|
||||||
percent: program.mode === "percent" ? program.percent : undefined,
|
|
||||||
amountMinor,
|
|
||||||
});
|
});
|
||||||
|
if (!result.ok) return reply.code(result.status).send({ error: result.error });
|
||||||
|
return reply.code(201).send(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
|
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
|
||||||
|
|||||||
+117
-26
@@ -1,7 +1,9 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import { feedPermissionFor, watchPermissions, type LedgerEvent, type Permission } from "@parking/shared";
|
||||||
import { roleHasPermissions } from "../auth.js";
|
import { currentRoleId, requireAuth, roleHasPermissions } from "../auth.js";
|
||||||
|
import { effectiveModulesFor } from "../modules.js";
|
||||||
import {
|
import {
|
||||||
deviceEvents,
|
deviceEvents,
|
||||||
type LaneStatusEvent,
|
type LaneStatusEvent,
|
||||||
@@ -31,10 +33,61 @@ import { getOccupancy } from "../occupancy.js";
|
|||||||
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
|
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
|
||||||
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
|
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
|
||||||
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
|
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
|
||||||
|
//
|
||||||
|
// Desktop shell (Tauri) exception — the WS TICKET. The desktop app's HTTP goes
|
||||||
|
// through tauri-plugin-http (reqwest, its own cookie jar) and its WebSocket
|
||||||
|
// through tauri-plugin-websocket (bare tungstenite, NO cookie jar at all), so
|
||||||
|
// the JWT cookie set at login can never ride on the WS handshake — jwtVerify()
|
||||||
|
// would 401 every connect (found 2026-09-04: the desktop live feed reconnected
|
||||||
|
// every 10s forever). The JWT is HttpOnly and must stay out of JS, so instead
|
||||||
|
// the desktop client POSTs /api/ws/ticket (normal cookie + CSRF auth) to get a
|
||||||
|
// single-use, 30-second random ticket bound to its user, and presents it in an
|
||||||
|
// `x-ws-ticket` header on the handshake. A browser page cannot set custom
|
||||||
|
// headers on a WebSocket, so this path is unreachable from a browser and adds
|
||||||
|
// no CSWSH surface; the Origin allowlist still applies to both paths.
|
||||||
|
|
||||||
/** Permission required to watch the live feed (a read-only stream of ledger +
|
// WHO may watch, and WHAT they see (venue-modules.md §"Permissions matrix", move 3):
|
||||||
* device status). Any role granted `report:read` may watch. */
|
// a role connects if it holds ANY watch permission — the core feed/occupancy/device
|
||||||
const WATCH_PERMISSION = "report:read" as const;
|
// ones or an effective module's own (carwash:read) — and every pushed message is then
|
||||||
|
// FILTERED per role: a ledger event needs feedPermissionFor(type) (the owning module's,
|
||||||
|
// else event:read); occupancy + the plate backfill need session:read; device / printer /
|
||||||
|
// lane / radar need device:read. `report:read` is the REPORTS screen, not the socket: the
|
||||||
|
// wash desk gets a live queue without the booth's ledger, the booth a feed without reports.
|
||||||
|
type Viewer = { has: (p: Permission) => boolean };
|
||||||
|
|
||||||
|
/** Handshake header carrying a desktop WS ticket (see file header). */
|
||||||
|
const WS_TICKET_HEADER = "x-ws-ticket";
|
||||||
|
/** A ticket is only good for the connect that immediately follows its issue. */
|
||||||
|
const WS_TICKET_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
interface WsTicket {
|
||||||
|
sub: string;
|
||||||
|
roleId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outstanding tickets. Tiny (one per desktop connect attempt), in-memory only —
|
||||||
|
* a server restart invalidates them, which is fine: the client just asks for
|
||||||
|
* another on its next reconnect. */
|
||||||
|
const tickets = new Map<string, WsTicket>();
|
||||||
|
|
||||||
|
function issueWsTicket(sub: string, roleId: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, t] of tickets) {
|
||||||
|
if (t.expiresAt <= now) tickets.delete(key);
|
||||||
|
}
|
||||||
|
const ticket = randomBytes(32).toString("hex");
|
||||||
|
tickets.set(ticket, { sub, roleId, expiresAt: now + WS_TICKET_TTL_MS });
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-use: the ticket is removed whether or not it turns out to be valid. */
|
||||||
|
function consumeWsTicket(ticket: string): WsTicket | null {
|
||||||
|
const t = tickets.get(ticket);
|
||||||
|
if (!t) return null;
|
||||||
|
tickets.delete(ticket);
|
||||||
|
return t.expiresAt > Date.now() ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
|
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
|
||||||
@@ -61,18 +114,25 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
|||||||
type OutMsg =
|
type OutMsg =
|
||||||
| {
|
| {
|
||||||
kind: "hello";
|
kind: "hello";
|
||||||
occupancy: ReturnType<typeof getOccupancy>;
|
occupancy: ReturnType<typeof getOccupancy> | null;
|
||||||
devices: unknown;
|
devices: unknown;
|
||||||
lanes: LaneStatusEvent;
|
lanes: LaneStatusEvent | null;
|
||||||
radar: LanePresenceEvent;
|
radar: LanePresenceEvent | null;
|
||||||
}
|
}
|
||||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> | null }
|
||||||
| { 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: "lane-presence"; radar: LanePresenceEvent }
|
||||||
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
/** The role the WS preHandler authenticated (ticket or cookie path) — for the handler's filter. */
|
||||||
|
wsRoleId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function wsRoutes(
|
export async function wsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -80,24 +140,52 @@ export async function wsRoutes(
|
|||||||
laneStatus: LaneStatus,
|
laneStatus: LaneStatus,
|
||||||
lanePresence: LanePresence,
|
lanePresence: LanePresence,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Desktop-only: mint a WS ticket for the signed-in session (see file header).
|
||||||
|
// Ordinary cookie + CSRF auth — the desktop client CAN do that over HTTP (via
|
||||||
|
// tauri-plugin-http), it just can't carry the cookie onto the WebSocket.
|
||||||
|
app.post("/api/ws/ticket", { preHandler: requireAuth }, async (req) => ({
|
||||||
|
ticket: issueWsTicket(req.user.sub, req.user.roleId),
|
||||||
|
expiresInMs: WS_TICKET_TTL_MS,
|
||||||
|
}));
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/api/ws",
|
"/api/ws",
|
||||||
{
|
{
|
||||||
websocket: true,
|
websocket: true,
|
||||||
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
|
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN
|
||||||
// role. Reject a cross/absent origin before touching the token, so a hijack
|
// session (JWT cookie, or a desktop WS ticket) THEN role. Reject a
|
||||||
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
|
// cross/absent origin before touching either credential, so a hijack
|
||||||
|
// attempt never reaches an authenticated socket.
|
||||||
preHandler: async (req) => {
|
preHandler: async (req) => {
|
||||||
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
||||||
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
|
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
await req.jwtVerify();
|
const rawTicket = req.headers[WS_TICKET_HEADER];
|
||||||
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) {
|
const ticket = Array.isArray(rawTicket) ? rawTicket[0] : rawTicket;
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
let roleId: string;
|
||||||
|
if (ticket !== undefined) {
|
||||||
|
const t = consumeWsTicket(ticket);
|
||||||
|
if (!t) {
|
||||||
|
throw Object.assign(new Error("invalid or expired ws ticket"), { statusCode: 401 });
|
||||||
|
}
|
||||||
|
roleId = t.roleId;
|
||||||
|
} else {
|
||||||
|
await req.jwtVerify(); // reads the HttpOnly cookie (browser path)
|
||||||
|
if (!req.user) {
|
||||||
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
|
}
|
||||||
|
roleId = currentRoleId(req.user.sub) ?? "";
|
||||||
}
|
}
|
||||||
|
const may = watchPermissions(effectiveModulesFor(db)).some((p) => roleHasPermissions(roleId, [p]));
|
||||||
|
if (!may) throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
|
req.wsRoleId = roleId;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
(socket) => {
|
(socket, req) => {
|
||||||
|
const roleId = req.wsRoleId ?? req.user?.roleId ?? "";
|
||||||
|
const viewer: Viewer = { has: (p) => roleHasPermissions(roleId, [p]) };
|
||||||
|
const seesOccupancy = viewer.has("session:read");
|
||||||
|
const seesDevices = viewer.has("device:read");
|
||||||
const send = (msg: OutMsg) => {
|
const send = (msg: OutMsg) => {
|
||||||
// readyState 1 = OPEN; never throw out of an event-bus callback.
|
// readyState 1 = OPEN; never throw out of an event-bus callback.
|
||||||
if (socket.readyState === 1) {
|
if (socket.readyState === 1) {
|
||||||
@@ -111,40 +199,43 @@ 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).
|
||||||
|
// Each part of the snapshot only for a role that may see it (null otherwise).
|
||||||
send({
|
send({
|
||||||
kind: "hello",
|
kind: "hello",
|
||||||
occupancy: getOccupancy(db),
|
occupancy: seesOccupancy ? getOccupancy(db) : null,
|
||||||
devices: deviceMonitor.snapshot(),
|
devices: seesDevices ? deviceMonitor.snapshot() : null,
|
||||||
lanes: laneStatus.snapshot(),
|
lanes: seesDevices ? laneStatus.snapshot() : null,
|
||||||
radar: lanePresence.snapshot(),
|
radar: seesDevices ? lanePresence.snapshot() : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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.
|
||||||
const offLedger = deviceEvents.onLedger((event) => {
|
const offLedger = deviceEvents.onLedger((event) => {
|
||||||
|
// Per-role filter: the event type's feed permission (module's own, else event:read).
|
||||||
|
if (!viewer.has(feedPermissionFor((event as { type: LedgerEvent["type"] }).type))) return;
|
||||||
// Enrich with read-time display fields (subscriber name) before fan-out.
|
// Enrich with read-time display fields (subscriber name) before fan-out.
|
||||||
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
|
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
|
||||||
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) });
|
send({ kind: "ledger", event: enriched, occupancy: seesOccupancy ? getOccupancy(db) : null });
|
||||||
});
|
});
|
||||||
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
||||||
send({ kind: "printer-status", event });
|
if (seesDevices) send({ kind: "printer-status", event });
|
||||||
});
|
});
|
||||||
// Unified device status (all categories) for the booth footer — pushed on
|
// Unified device status (all categories) for the booth footer — pushed on
|
||||||
// change; the initial set rode the hello above.
|
// change; the initial set rode the hello above.
|
||||||
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
||||||
send({ kind: "device-status", event });
|
if (seesDevices) send({ kind: "device-status", event });
|
||||||
});
|
});
|
||||||
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
|
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
|
||||||
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
||||||
send({ kind: "lane-status", lanes });
|
if (seesDevices) send({ kind: "lane-status", lanes });
|
||||||
});
|
});
|
||||||
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
||||||
const offPresence = deviceEvents.onLanePresence((radar) => {
|
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||||
send({ kind: "lane-presence", radar });
|
if (seesDevices) send({ kind: "lane-presence", radar });
|
||||||
});
|
});
|
||||||
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
|
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
|
||||||
const offPlate = deviceEvents.onPlateRecognized((plate) => {
|
const offPlate = deviceEvents.onPlateRecognized((plate) => {
|
||||||
send({ kind: "plate-recognized", plate });
|
if (seesOccupancy) send({ kind: "plate-recognized", plate });
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("close", () => {
|
socket.on("close", () => {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import { shiftRoutes } from "./routes/shift.js";
|
|||||||
import { drawerRoutes } from "./routes/drawer.js";
|
import { drawerRoutes } from "./routes/drawer.js";
|
||||||
import { entryRoutes } from "./routes/entry.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 { registerModules } from "./modules/index.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";
|
||||||
@@ -106,7 +106,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/health", async () => ({ status: "ok" }));
|
// Unauthenticated liveness probe. `app` lets a client (the desktop ConnectScreen
|
||||||
|
// test — apps/web/src/lib/backend-config.ts) tell THIS server apart from any
|
||||||
|
// other service that happens to answer on the address the operator typed.
|
||||||
|
app.get("/health", async () => ({ status: "ok", app: "parking-system" }));
|
||||||
|
|
||||||
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||||
await authRoutes(app, db);
|
await authRoutes(app, db);
|
||||||
@@ -283,9 +286,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
await subscriptionPlanRoutes(app, db);
|
await subscriptionPlanRoutes(app, db);
|
||||||
|
|
||||||
// Shift open/close (shiftService constructed above).
|
// Shift open/close (shiftService constructed above).
|
||||||
await shiftRoutes(app, shiftService);
|
await shiftRoutes(app, db, shiftService);
|
||||||
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
||||||
await drawerRoutes(app, shiftService);
|
await drawerRoutes(app, db, shiftService);
|
||||||
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
|
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
|
||||||
await entryRoutes(app, entryFlow, laneStatus, shiftService);
|
await entryRoutes(app, entryFlow, laneStatus, shiftService);
|
||||||
|
|
||||||
@@ -293,10 +296,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// 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, eventLog);
|
await siteRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Merchant validations (bar / lavazh): setup panel config + the merchant user's
|
// Venue modules (wiki/decisions/venue-modules.md): folder-based modules register
|
||||||
// scan-and-apply. The booth settlement folds the applied validations into its
|
// here by iterating the shared registry — today that is `validation` (merchant
|
||||||
// quote (pay-station.ts). See wiki/concepts/validation-discounts.md.
|
// validations for the Bar; the booth settlement folds applied validations into its
|
||||||
await validationRoutes(app, db, eventLog);
|
// quote, pay-station.ts). `parking` is in the registry too but its routes are still
|
||||||
|
// the flat list above; they move behind the seam subsystem by subsystem.
|
||||||
|
await registerModules(app, { db, eventLog, payStation, shiftService });
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -242,3 +242,93 @@ describe("close signs a Z-report; listShifts reads it back", () => {
|
|||||||
expect(shift.listOperators()).toEqual(["alice", "bob"]);
|
expect(shift.listOperators()).toEqual(["alice", "bob"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("tills: one shift per till, one drawer per till", () => {
|
||||||
|
/** A bay payment as the Car Wash module signs it (till = carwash). */
|
||||||
|
async function bayPayment(amountMinor: number, tender: "cash" | "card" = "cash") {
|
||||||
|
await log.append({
|
||||||
|
type: "carwash_payment", source: "manual", identity: "T",
|
||||||
|
payload: { sessionRef: "T", orderId: "o1", amountMinor, currency: "ALL", tender, till: "carwash" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("the booth and the carwash till can both be open at once, by different operators", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await expect(shift.open("wanda", "carwash")).resolves.toMatchObject({ till: "carwash" });
|
||||||
|
expect(shift.currentOpenShift()?.identity).toBe("alice");
|
||||||
|
expect(shift.currentOpenShift("carwash")?.identity).toBe("wanda");
|
||||||
|
// Each till keeps its own single-open rule.
|
||||||
|
await expect(shift.open("bob", "carwash")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||||
|
await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requireOpenShift is per till: a booth shift does not cover the bay", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
expect(() => shift.requireOpenShift("carwash")).toThrow(NoShiftOpenError);
|
||||||
|
await shift.open("wanda", "carwash");
|
||||||
|
expect(shift.requireOpenShift("carwash").identity).toBe("wanda");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("money folds into ITS till only: bay cash is the wash operator's, not the booth's", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.open("wanda", "carwash");
|
||||||
|
await payment(10000); // booth (payment events carry till=booth or nothing)
|
||||||
|
await bayPayment(70000);
|
||||||
|
await bayPayment(20000, "card");
|
||||||
|
|
||||||
|
const booth = shift.currentReport()!;
|
||||||
|
expect(booth.till).toBe("booth");
|
||||||
|
expect(booth.cashTotalMinor).toBe(10000);
|
||||||
|
expect(booth.paymentCount).toBe(1);
|
||||||
|
expect(booth.expectedDrawerMinor).toBe(10000);
|
||||||
|
|
||||||
|
const wash = shift.currentReport("carwash")!;
|
||||||
|
expect(wash.till).toBe("carwash");
|
||||||
|
expect(wash.cashTotalMinor).toBe(70000);
|
||||||
|
expect(wash.cardTotalMinor).toBe(20000);
|
||||||
|
expect(wash.paymentCount).toBe(2);
|
||||||
|
expect(wash.expectedDrawerMinor).toBe(70000);
|
||||||
|
|
||||||
|
expect(shift.drawerBalance().balanceMinor).toBe(10000);
|
||||||
|
expect(shift.drawerBalance("carwash").balanceMinor).toBe(70000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("vouchers name their till; each till's expected drawer carries forward on its own", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.open("wanda", "carwash");
|
||||||
|
await shift.recordVoucher({ type: "cash_in", operator: "wanda", amountMinor: 5000, reason: "float", till: "carwash" });
|
||||||
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float" });
|
||||||
|
await bayPayment(70000);
|
||||||
|
expect(shift.movementsWithStatus({ till: "carwash" }).map((m) => m.amountMinor)).toEqual([5000]);
|
||||||
|
|
||||||
|
const washZ = await shift.close("wanda", "carwash");
|
||||||
|
expect(washZ).toMatchObject({ till: "carwash", cashAddedMinor: 5000, cashTotalMinor: 70000, expectedDrawerMinor: 75000 });
|
||||||
|
const boothZ = await shift.close("alice");
|
||||||
|
expect(boothZ).toMatchObject({ till: "booth", cashAddedMinor: 100000, cashTotalMinor: 0, expectedDrawerMinor: 100000 });
|
||||||
|
|
||||||
|
// Next shift on each till inherits that till's drawer only.
|
||||||
|
expect((await shift.open("wanda", "carwash")).openingFloatMinor).toBe(75000);
|
||||||
|
expect((await shift.open("bob")).openingFloatMinor).toBe(100000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close is per till: closing the booth never closes the wash desk", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.open("alice", "carwash");
|
||||||
|
await shift.close("alice");
|
||||||
|
expect(shift.currentOpenShift()).toBeNull();
|
||||||
|
expect(shift.currentOpenShift("carwash")?.identity).toBe("alice");
|
||||||
|
await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("history lists both tills, filterable; pre-till reports read as booth", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.open("wanda", "carwash");
|
||||||
|
await shift.close("wanda", "carwash");
|
||||||
|
await shift.close("alice");
|
||||||
|
const all = shift.listShifts();
|
||||||
|
expect(all.map((s) => s.till).sort()).toEqual(["booth", "carwash"]);
|
||||||
|
expect(shift.listShifts({ till: "carwash" }).map((s) => s.operator)).toEqual(["wanda"]);
|
||||||
|
expect(shift.listShifts({ till: "booth" }).map((s) => s.operator)).toEqual(["alice"]);
|
||||||
|
expect(shift.listOperators("carwash")).toEqual(["wanda"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
|
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
|
||||||
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
|
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
|
||||||
import type { LedgerPayload } from "@parking/shared";
|
import { BOOTH_TILL, tillOf, type LedgerPayload, type TillId } 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";
|
||||||
|
|
||||||
@@ -8,33 +8,46 @@ import type { EventLog } from "./event-log.js";
|
|||||||
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
|
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
|
||||||
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
|
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
|
||||||
// `payment` events taken during the shift by tender and print a Z-report.
|
// `payment` events taken during the shift by tender and print a Z-report.
|
||||||
|
//
|
||||||
|
// TILLS (2026-09-05): a shift is opened ON A TILL — the booth, or a money-taking
|
||||||
|
// module's own desk (Car Wash → "carwash"). One shift may be open PER TILL, each with
|
||||||
|
// its own operator, opening float, expected drawer and Z-report. Every money event
|
||||||
|
// names its till (`payload.till`; absent = booth, which is what every pre-till event
|
||||||
|
// is), and every fold in this file filters by it. Every public method takes the till,
|
||||||
|
// defaulting to the booth so the parking paths read as they always did.
|
||||||
// See wiki/concepts/shift.md.
|
// See wiki/concepts/shift.md.
|
||||||
|
|
||||||
export class ShiftAlreadyOpenError extends Error {
|
export class ShiftAlreadyOpenError extends Error {
|
||||||
/** The operator who currently holds the open shift (may be someone else). */
|
/** The operator who currently holds the open shift (may be someone else). */
|
||||||
readonly heldBy: string;
|
readonly heldBy: string;
|
||||||
constructor(operator: string, heldBy: string) {
|
constructor(operator: string, heldBy: string, till: TillId = BOOTH_TILL) {
|
||||||
super(
|
super(
|
||||||
heldBy === operator
|
heldBy === operator
|
||||||
? `operator ${operator} already has an open shift`
|
? `operator ${operator} already has an open ${till} shift`
|
||||||
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
|
: `another operator (${heldBy}) has an open ${till} shift; only one shift may be open per till`,
|
||||||
);
|
);
|
||||||
this.name = "ShiftAlreadyOpenError";
|
this.name = "ShiftAlreadyOpenError";
|
||||||
this.heldBy = heldBy;
|
this.heldBy = heldBy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class NoOpenShiftError extends Error {
|
export class NoOpenShiftError extends Error {
|
||||||
constructor(operator: string) {
|
constructor(operator: string, till: TillId = BOOTH_TILL) {
|
||||||
super(`operator ${operator} has no open shift`);
|
super(`operator ${operator} has no open ${till} shift`);
|
||||||
this.name = "NoOpenShiftError";
|
this.name = "NoOpenShiftError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** Thrown by the booth money path when NO shift is open site-wide — an operator
|
/** Thrown by a money path when NO shift is open on its till — an operator must open
|
||||||
* must open a shift before any payment/exit can be attributed to a shift. */
|
* a shift there before any payment/exit can be attributed to one. */
|
||||||
export class NoShiftOpenError extends Error {
|
export class NoShiftOpenError extends Error {
|
||||||
constructor() {
|
readonly till: TillId;
|
||||||
super("no shift is open — open a shift before processing tickets");
|
constructor(till: TillId = BOOTH_TILL) {
|
||||||
|
super(
|
||||||
|
till === BOOTH_TILL
|
||||||
|
? "no shift is open — open a shift before processing tickets"
|
||||||
|
: `no ${till} shift is open — open one before taking money there`,
|
||||||
|
);
|
||||||
this.name = "NoShiftOpenError";
|
this.name = "NoShiftOpenError";
|
||||||
|
this.till = till;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +57,8 @@ export class NoShiftOpenError extends Error {
|
|||||||
export interface ShiftSummary {
|
export interface ShiftSummary {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
|
/** The till this shift reconciled (booth for every pre-till report). */
|
||||||
|
readonly till: TillId;
|
||||||
readonly operator: string;
|
readonly operator: string;
|
||||||
readonly startedAt: string;
|
readonly startedAt: string;
|
||||||
readonly endedAt: string;
|
readonly endedAt: string;
|
||||||
@@ -63,6 +78,7 @@ export interface ShiftSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ShiftReport {
|
export interface ShiftReport {
|
||||||
|
readonly till: TillId;
|
||||||
readonly operator: string;
|
readonly operator: string;
|
||||||
readonly startedAt: string;
|
readonly startedAt: string;
|
||||||
readonly endedAt: string;
|
readonly endedAt: string;
|
||||||
@@ -102,6 +118,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
|
|||||||
export interface DrawerMovement {
|
export interface DrawerMovement {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly type: "cash_in" | "cash_out";
|
readonly type: "cash_in" | "cash_out";
|
||||||
|
/** Which drawer the cash moved in/out of. */
|
||||||
|
readonly till: TillId;
|
||||||
/** Positive magnitude; direction is the `type`. */
|
/** Positive magnitude; direction is the `type`. */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
@@ -122,6 +140,9 @@ export class InvalidCashMovementError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */
|
||||||
|
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" };
|
||||||
|
|
||||||
export class ShiftService {
|
export class ShiftService {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -133,41 +154,42 @@ export class ShiftService {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current physical drawer balance (cash payments + cash_movements, by time). For
|
/** Current physical drawer balance of a till (cash payments + cash_movements, by
|
||||||
* the UI to show "inherited / in the drawer now". */
|
* time). For the UI to show "inherited / in the drawer now". */
|
||||||
drawerBalance(): { balanceMinor: number; currency: string | null } {
|
drawerBalance(till: TillId = BOOTH_TILL): { balanceMinor: number; currency: string | null } {
|
||||||
return this.#drawerBalanceAt(new Date().toISOString());
|
return this.#drawerBalanceAt(new Date().toISOString(), till);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
/** The shift-boundary events (shift_open / shift_z_report) of ONE till, chain order. */
|
||||||
openShiftFor(operator: string) {
|
#shiftEvents(till: TillId) {
|
||||||
// Scan shift events for this operator; the shift is open if the most recent
|
return this.#db
|
||||||
// shift event for them is a `shift_open` (not yet closed by a z_report).
|
|
||||||
const rows = this.#db
|
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(eq(ledgerEvents.identity, operator))
|
.where(inArray(ledgerEvents.type, ["shift_open", "shift_z_report"]))
|
||||||
.orderBy(ledgerEvents.index)
|
.orderBy(ledgerEvents.index)
|
||||||
.all()
|
.all()
|
||||||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
.filter((r) => tillOf(r.payload as LedgerPayload | null) === till);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is there an open shift for this operator on this till? Returns the open
|
||||||
|
* `shift_open` row or null. */
|
||||||
|
openShiftFor(operator: string, till: TillId = BOOTH_TILL) {
|
||||||
|
// The shift is open if the operator's most recent shift event on the till is a
|
||||||
|
// `shift_open` (not yet closed by a z_report).
|
||||||
|
const rows = this.#shiftEvents(till).filter((r) => r.identity === operator);
|
||||||
const last = rows[rows.length - 1];
|
const last = rows[rows.length - 1];
|
||||||
return last && last.type === "shift_open" ? last : null;
|
return last && last.type === "shift_open" ? last : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
|
* The SINGLE open shift of a till, or null. A shift is the till's accountability
|
||||||
* period: at most ONE may be open at a time (so booth takings are unambiguously
|
* period: at most ONE may be open per till at a time (so its takings are
|
||||||
* attributed to one operator). It's open iff the most recent shift event on the
|
* unambiguously attributed to one operator). It's open iff the till's most recent
|
||||||
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
|
* shift event is a `shift_open` (the matching `shift_z_report` hasn't been appended
|
||||||
* appended yet). Returns that row so callers can read its operator/startedAt.
|
* yet). Returns that row so callers can read its operator/startedAt.
|
||||||
*/
|
*/
|
||||||
currentOpenShift() {
|
currentOpenShift(till: TillId = BOOTH_TILL) {
|
||||||
const rows = this.#db
|
const rows = this.#shiftEvents(till);
|
||||||
.select()
|
|
||||||
.from(ledgerEvents)
|
|
||||||
.orderBy(ledgerEvents.index)
|
|
||||||
.all()
|
|
||||||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
|
||||||
const last = rows[rows.length - 1];
|
const last = rows[rows.length - 1];
|
||||||
return last && last.type === "shift_open" ? last : null;
|
return last && last.type === "shift_open" ? last : null;
|
||||||
}
|
}
|
||||||
@@ -186,24 +208,25 @@ export class ShiftService {
|
|||||||
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
|
* 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).
|
* for an operator that exists (the filter is an exact username match).
|
||||||
*/
|
*/
|
||||||
listOperators(): string[] {
|
listOperators(till?: TillId): string[] {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(eq(ledgerEvents.type, "shift_z_report"))
|
.where(inArray(ledgerEvents.type, ["shift_z_report", "shift_open"]))
|
||||||
.all();
|
.all()
|
||||||
|
.filter((r) => till == null || tillOf(r.payload as LedgerPayload | null) === till);
|
||||||
|
// Every operator with a closed report, plus the holder of each open shift (an
|
||||||
|
// open shift is the last shift_open on its till — but any shift_open's operator
|
||||||
|
// has or had a shift, which is all the dropdown needs).
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
|
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
|
||||||
if (op) names.add(op);
|
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));
|
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; till?: TillId } = {}): ShiftSummary[] {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
@@ -232,12 +255,15 @@ export class ShiftService {
|
|||||||
};
|
};
|
||||||
const operator = pl.operator ?? r.identity ?? "?";
|
const operator = pl.operator ?? r.identity ?? "?";
|
||||||
const startedAt = pl.startedAt ?? r.occurredAt;
|
const startedAt = pl.startedAt ?? r.occurredAt;
|
||||||
|
const till = tillOf(pl);
|
||||||
|
if (opts.till && till !== opts.till) continue;
|
||||||
if (opts.operator && operator !== opts.operator) continue;
|
if (opts.operator && operator !== opts.operator) continue;
|
||||||
if (opts.from && startedAt < opts.from) continue;
|
if (opts.from && startedAt < opts.from) continue;
|
||||||
if (opts.to && startedAt > opts.to) continue;
|
if (opts.to && startedAt > opts.to) continue;
|
||||||
out.push({
|
out.push({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
index: r.index,
|
index: r.index,
|
||||||
|
till,
|
||||||
operator,
|
operator,
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt: pl.endedAt ?? r.occurredAt,
|
endedAt: pl.endedAt ?? r.occurredAt,
|
||||||
@@ -267,10 +293,10 @@ export class ShiftService {
|
|||||||
return out.reverse();
|
return out.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Require an open shift for the booth money path; returns it or throws. */
|
/** Require an open shift on a till for its money path; returns it or throws. */
|
||||||
requireOpenShift() {
|
requireOpenShift(till: TillId = BOOTH_TILL) {
|
||||||
const open = this.currentOpenShift();
|
const open = this.currentOpenShift(till);
|
||||||
if (!open) throw new NoShiftOpenError();
|
if (!open) throw new NoShiftOpenError(till);
|
||||||
return open;
|
return open;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,9 +309,10 @@ export class ShiftService {
|
|||||||
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
|
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
|
||||||
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
|
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
|
||||||
* removal) — historical chain events that still fold in unchanged.
|
* removal) — historical chain events that still fold in unchanged.
|
||||||
* This is what carries across shifts.
|
* This is what carries across shifts. ONE till: every money event is filtered by
|
||||||
|
* `tillOf(payload)` (absent = booth).
|
||||||
*/
|
*/
|
||||||
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
|
#drawerBalanceAt(at: string, till: TillId): { balanceMinor: number; currency: string | null } {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
@@ -295,16 +322,20 @@ export class ShiftService {
|
|||||||
(r) =>
|
(r) =>
|
||||||
r.occurredAt <= at &&
|
r.occurredAt <= at &&
|
||||||
(r.type === "payment" ||
|
(r.type === "payment" ||
|
||||||
|
// Car Wash module: money taken at the bay (cash adds to the drawer, card
|
||||||
|
// never does — same tender rule as a parking payment).
|
||||||
|
r.type === "carwash_payment" ||
|
||||||
r.type === "cash_in" ||
|
r.type === "cash_in" ||
|
||||||
r.type === "cash_out" ||
|
r.type === "cash_out" ||
|
||||||
r.type === "cash_movement"),
|
r.type === "cash_movement") &&
|
||||||
|
tillOf(r.payload as LedgerPayload | null) === till,
|
||||||
);
|
);
|
||||||
let balanceMinor = 0;
|
let balanceMinor = 0;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
if (r.type === "payment") {
|
if (r.type === "payment" || r.type === "carwash_payment") {
|
||||||
// Only CASH enters the till; card settles to the bank.
|
// Only CASH enters the till; card settles to the bank.
|
||||||
if (pl.tender !== "card") balanceMinor += amt;
|
if (pl.tender !== "card") balanceMinor += amt;
|
||||||
} else if (r.type === "cash_in") {
|
} else if (r.type === "cash_in") {
|
||||||
@@ -347,8 +378,11 @@ export class ShiftService {
|
|||||||
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 }> {
|
/** Which drawer the cash moved in/out of (default: the booth). */
|
||||||
|
till?: TillId;
|
||||||
|
}): Promise<{ type: "cash_in" | "cash_out"; till: TillId; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||||
const { type, operator, reason } = args;
|
const { type, operator, reason } = args;
|
||||||
|
const till = args.till ?? BOOTH_TILL;
|
||||||
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)");
|
||||||
}
|
}
|
||||||
@@ -365,15 +399,16 @@ export class ShiftService {
|
|||||||
...(args.currency ? { currency: args.currency } : {}),
|
...(args.currency ? { currency: args.currency } : {}),
|
||||||
operator,
|
operator,
|
||||||
voucherNo,
|
voucherNo,
|
||||||
|
till,
|
||||||
},
|
},
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
});
|
});
|
||||||
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
const { balanceMinor, currency } = this.#drawerBalanceAt(now, till);
|
||||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now });
|
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now, till });
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
`${type} ${voucherNo} ${amountMinor} by ${operator} on ${till} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||||
);
|
);
|
||||||
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
return { type, till, amountMinor, voucherNo, balanceMinor, printed };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -431,7 +466,7 @@ export class ShiftService {
|
|||||||
* review queue. `operator` (optional) scopes to one operator's movements (an operator
|
* 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.
|
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
|
||||||
*/
|
*/
|
||||||
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] {
|
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus; till?: TillId }): DrawerMovement[] {
|
||||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
// Latest review decision per movement id.
|
// Latest review decision per movement id.
|
||||||
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||||||
@@ -452,12 +487,15 @@ export class ShiftService {
|
|||||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||||
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
|
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
|
||||||
if (filter?.operator && operator !== filter.operator) continue;
|
if (filter?.operator && operator !== filter.operator) continue;
|
||||||
|
const till = tillOf(pl);
|
||||||
|
if (filter?.till && till !== filter.till) continue;
|
||||||
const review = reviewByRef.get(r.id);
|
const review = reviewByRef.get(r.id);
|
||||||
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
|
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
|
||||||
if (filter?.status && status !== filter.status) continue;
|
if (filter?.status && status !== filter.status) continue;
|
||||||
out.push({
|
out.push({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
|
till,
|
||||||
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
|
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
|
||||||
currency: pl.currency ?? null,
|
currency: pl.currency ?? null,
|
||||||
reason: pl.reason ?? null,
|
reason: pl.reason ?? null,
|
||||||
@@ -474,27 +512,28 @@ export class ShiftService {
|
|||||||
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
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 on a till (explicit start). The opening float is
|
||||||
* inherited from the chain = the drawer balance at the start instant. */
|
* auto-inherited from the chain = that till's drawer balance at the start instant. */
|
||||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
async open(operator: string, till: TillId = BOOTH_TILL): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
|
||||||
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
|
// Single-open-per-till invariant: refuse if a shift is open ON THIS TILL — whether
|
||||||
// operator's own (double-open) or another operator's (handover not done). Only
|
// this operator's own (double-open) or another operator's (handover not done).
|
||||||
// one accountability period at a time.
|
// One accountability period per drawer at a time. (Another till's shift is
|
||||||
const current = this.currentOpenShift();
|
// independent: the booth and the wash desk run side by side.)
|
||||||
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
|
const current = this.currentOpenShift(till);
|
||||||
|
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator, till);
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt, till);
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "shift_open",
|
type: "shift_open",
|
||||||
source: "manual",
|
source: "manual",
|
||||||
identity: operator, // the shift's operator; `identity` keys the shift to them
|
identity: operator, // the shift's operator; `identity` keys the shift to them
|
||||||
// Record the inherited opening float on the shift_open so it's reproducible
|
// Record the inherited opening float on the shift_open so it's reproducible
|
||||||
// and the next operator's handover figure is fixed in the chain.
|
// and the next operator's handover figure is fixed in the chain.
|
||||||
payload: { operator, openingFloatMinor },
|
payload: { operator, openingFloatMinor, till },
|
||||||
occurredAt: startedAt,
|
occurredAt: startedAt,
|
||||||
});
|
});
|
||||||
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
this.#logger.info(`${till} shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||||||
return { startedAt, openingFloatMinor };
|
return { startedAt, till, openingFloatMinor };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -510,15 +549,22 @@ export class ShiftService {
|
|||||||
): Omit<ShiftReport, "printed"> {
|
): Omit<ShiftReport, "printed"> {
|
||||||
const operator = open.identity ?? "?";
|
const operator = open.identity ?? "?";
|
||||||
const startedAt = open.occurredAt;
|
const startedAt = open.occurredAt;
|
||||||
|
const till = tillOf(open.payload as LedgerPayload | null);
|
||||||
|
|
||||||
// All payments taken in [startedAt, asOf], summed by tender. Payment time =
|
// All payments taken ON THIS TILL in [startedAt, asOf], summed by tender. Payment
|
||||||
// the operator who handled the money (decision: sum by payment time).
|
// time = the operator who handled the money (decision: sum by payment time).
|
||||||
const payments = this.#db
|
const payments = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(eq(ledgerEvents.type, "payment"))
|
// Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the
|
||||||
|
// parking payment's amount already, as chargeLines). Both fold into the cash/card
|
||||||
|
// tender totals so the expected drawer is right; a separate wash bucket on the
|
||||||
|
// Z-report is a follow-up (venue-modules.md).
|
||||||
|
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
|
||||||
.all()
|
.all()
|
||||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
|
.filter(
|
||||||
|
(r) => r.occurredAt >= startedAt && r.occurredAt <= asOf && tillOf(r.payload as LedgerPayload | null) === till,
|
||||||
|
);
|
||||||
|
|
||||||
let cashTotalMinor = 0;
|
let cashTotalMinor = 0;
|
||||||
let cardTotalMinor = 0;
|
let cardTotalMinor = 0;
|
||||||
@@ -557,7 +603,7 @@ export class ShiftService {
|
|||||||
const openingFloatMinor =
|
const openingFloatMinor =
|
||||||
typeof openPl.openingFloatMinor === "number"
|
typeof openPl.openingFloatMinor === "number"
|
||||||
? openPl.openingFloatMinor
|
? openPl.openingFloatMinor
|
||||||
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
: this.#drawerBalanceAt(startedAt, till).balanceMinor;
|
||||||
|
|
||||||
// Drawer movements within the window, split into added (+) and removed (−).
|
// Drawer movements within the window, split into added (+) and removed (−).
|
||||||
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
|
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
|
||||||
@@ -570,7 +616,8 @@ export class ShiftService {
|
|||||||
(r) =>
|
(r) =>
|
||||||
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
||||||
r.occurredAt >= startedAt &&
|
r.occurredAt >= startedAt &&
|
||||||
r.occurredAt <= asOf,
|
r.occurredAt <= asOf &&
|
||||||
|
tillOf(r.payload as LedgerPayload | null) === till,
|
||||||
);
|
);
|
||||||
let cashAddedMinor = 0;
|
let cashAddedMinor = 0;
|
||||||
let cashRemovedMinor = 0;
|
let cashRemovedMinor = 0;
|
||||||
@@ -589,6 +636,7 @@ export class ShiftService {
|
|||||||
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
till,
|
||||||
operator,
|
operator,
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt: asOf,
|
endedAt: asOf,
|
||||||
@@ -615,17 +663,18 @@ export class ShiftService {
|
|||||||
* projection the Z-report prints, so the operator sees exactly what their close
|
* projection the Z-report prints, so the operator sees exactly what their close
|
||||||
* will show. See wiki/concepts/shift.md.
|
* will show. See wiki/concepts/shift.md.
|
||||||
*/
|
*/
|
||||||
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
|
currentReport(till: TillId = BOOTH_TILL): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
|
||||||
const open = this.currentOpenShift();
|
const open = this.currentOpenShift(till);
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
const asOf = new Date().toISOString();
|
const asOf = new Date().toISOString();
|
||||||
return { ...this.#summariseWindow(open, asOf), asOf };
|
return { ...this.#summariseWindow(open, asOf), asOf };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
/** Close the operator's open shift on a till: sum its payments in the window, sign +
|
||||||
async close(operator: string): Promise<ShiftReport> {
|
* print the Z-report. */
|
||||||
const open = this.openShiftFor(operator);
|
async close(operator: string, till: TillId = BOOTH_TILL): Promise<ShiftReport> {
|
||||||
if (!open) throw new NoOpenShiftError(operator);
|
const open = this.openShiftFor(operator, till);
|
||||||
|
if (!open) throw new NoOpenShiftError(operator, till);
|
||||||
const endedAt = new Date().toISOString();
|
const endedAt = new Date().toISOString();
|
||||||
|
|
||||||
const report = this.#summariseWindow(open, endedAt);
|
const report = this.#summariseWindow(open, endedAt);
|
||||||
@@ -652,6 +701,7 @@ export class ShiftService {
|
|||||||
identity: operator,
|
identity: operator,
|
||||||
payload: {
|
payload: {
|
||||||
operator,
|
operator,
|
||||||
|
till,
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt,
|
endedAt,
|
||||||
cashTotalMinor,
|
cashTotalMinor,
|
||||||
@@ -673,7 +723,7 @@ export class ShiftService {
|
|||||||
const printed = await this.#printZReport(report);
|
const printed = await this.#printZReport(report);
|
||||||
|
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
|
`${till} shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
|
||||||
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||||||
);
|
);
|
||||||
return { ...report, printed };
|
return { ...report, printed };
|
||||||
@@ -692,6 +742,9 @@ export class ShiftService {
|
|||||||
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
|
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
|
||||||
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
|
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
|
||||||
const lines = [
|
const lines = [
|
||||||
|
// Which drawer this report reconciles — only printed off the booth, so booth
|
||||||
|
// slips stay byte-identical to before tills existed.
|
||||||
|
...(r.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[r.till]}`] : []),
|
||||||
`Operatori: ${r.operator}`,
|
`Operatori: ${r.operator}`,
|
||||||
`Nga: ${zStamp(r.startedAt)}`,
|
`Nga: ${zStamp(r.startedAt)}`,
|
||||||
`Deri: ${zStamp(r.endedAt)}`,
|
`Deri: ${zStamp(r.endedAt)}`,
|
||||||
@@ -737,6 +790,7 @@ export class ShiftService {
|
|||||||
operator: string;
|
operator: string;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
at: string;
|
at: string;
|
||||||
|
till: TillId;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const printer = await this.#boothPrinter();
|
const printer = await this.#boothPrinter();
|
||||||
if (!printer) {
|
if (!printer) {
|
||||||
@@ -748,6 +802,7 @@ export class ShiftService {
|
|||||||
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
|
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
|
||||||
const lines = [
|
const lines = [
|
||||||
`Mandat Nr.: ${v.voucherNo}`,
|
`Mandat Nr.: ${v.voucherNo}`,
|
||||||
|
...(v.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[v.till]}`] : []),
|
||||||
`Data: ${zStamp(v.at)}`,
|
`Data: ${zStamp(v.at)}`,
|
||||||
"",
|
"",
|
||||||
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq, ledgerEvents, type Db } from "@parking/db";
|
import { eq, ledgerEvents, type Db, and, isNull, validationPrograms } from "@parking/db";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
import type { SessionValidation, ValidationMode } from "@parking/shared";
|
import type { SessionValidation, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
|
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
|
||||||
@@ -86,3 +87,170 @@ export function sessionValidations(db: Db, identity: string): AppliedValidation[
|
|||||||
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
|
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
|
||||||
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
|
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Apply (shared by the merchant route and the Car Wash module) ----------------
|
||||||
|
|
||||||
|
export interface ApplyValidationInput {
|
||||||
|
programId: string;
|
||||||
|
identity: string;
|
||||||
|
/** Username recorded as the applying operator. */
|
||||||
|
actor: string;
|
||||||
|
/** fixed mode only: the amount the operator grants (minor units, ≤ maxAmountMinor). */
|
||||||
|
amountMinor?: number;
|
||||||
|
/** Car Wash context — required by the wash-only modes (doneTolerance / washPrice), which
|
||||||
|
* are RESOLVED here into a plain timeCredit / fixed event the pricing fold already
|
||||||
|
* understands: `washMinutes` = the wash window (order intake → done), NOT the whole
|
||||||
|
* stay; `priceMinor` = the wash price. */
|
||||||
|
wash?: { washMinutes: number; priceMinor: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApplyValidationResult =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
eventId: string;
|
||||||
|
programId: string;
|
||||||
|
label: string;
|
||||||
|
mode: string;
|
||||||
|
minutes?: number | null;
|
||||||
|
percent?: number | null;
|
||||||
|
amountMinor?: number;
|
||||||
|
}
|
||||||
|
| { ok: false; status: 400 | 404 | 409; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a validation program to an open transient session and append the signed
|
||||||
|
* `validation` event with the RESOLVED values. The decision chain, in order: program
|
||||||
|
* live + active → open TRANSIENT session → not already carrying a live application of
|
||||||
|
* this program → per-day cap → fixed-amount bounds. The merchant route adds its own
|
||||||
|
* program↔user BINDING check before calling this; a module applying its own program
|
||||||
|
* (Car Wash sponsorship) has no binding — the actor is attributed on the event instead.
|
||||||
|
* Returns a result object rather than throwing so each caller maps to its own HTTP
|
||||||
|
* shape. See wiki/concepts/validation-discounts.md.
|
||||||
|
*/
|
||||||
|
export async function applyValidation(
|
||||||
|
db: Db,
|
||||||
|
eventLog: EventLog,
|
||||||
|
input: ApplyValidationInput,
|
||||||
|
): Promise<ApplyValidationResult> {
|
||||||
|
const { programId, identity, actor } = input;
|
||||||
|
const program = db
|
||||||
|
.select()
|
||||||
|
.from(validationPrograms)
|
||||||
|
.where(and(eq(validationPrograms.id, programId), isNull(validationPrograms.deletedAt)))
|
||||||
|
.get();
|
||||||
|
if (!program || !program.active) return { ok: false, status: 404, error: "program not found or inactive" };
|
||||||
|
|
||||||
|
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 { ok: false, status: 404, error: "no session for ticket" };
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
if (entryPl.permit === true || entryPl.permitId != null) {
|
||||||
|
return { ok: false, status: 409, error: "subscription sessions cannot be validated" };
|
||||||
|
}
|
||||||
|
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
|
||||||
|
return { ok: false, status: 409, error: "session is closed" };
|
||||||
|
}
|
||||||
|
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
|
||||||
|
return { ok: false, status: 409, 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 { ok: false, status: 409, error: "daily cap reached for this program" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the program into the event's (mode, minutes/percent/amount). The wash-only
|
||||||
|
// modes become the plain modes the pricing fold knows; `programMode` keeps the original
|
||||||
|
// on the signed event for audit.
|
||||||
|
let mode: "comp" | "timeCredit" | "fixed" | "percent";
|
||||||
|
let minutes: number | undefined;
|
||||||
|
let percent: number | undefined;
|
||||||
|
let amountMinor: number | undefined;
|
||||||
|
switch (program.mode) {
|
||||||
|
case "comp":
|
||||||
|
mode = "comp";
|
||||||
|
break;
|
||||||
|
case "timeCredit":
|
||||||
|
mode = "timeCredit";
|
||||||
|
minutes = program.minutes ?? undefined;
|
||||||
|
break;
|
||||||
|
case "percent":
|
||||||
|
mode = "percent";
|
||||||
|
percent = program.percent ?? undefined;
|
||||||
|
break;
|
||||||
|
case "fixed": {
|
||||||
|
const a = input.amountMinor;
|
||||||
|
if (a == null || !Number.isInteger(a) || a <= 0) {
|
||||||
|
return { ok: false, status: 400, error: "amountMinor (positive integer) required for this program" };
|
||||||
|
}
|
||||||
|
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
|
||||||
|
return { ok: false, status: 400, error: `amount exceeds the program cap (${program.maxAmountMinor})` };
|
||||||
|
}
|
||||||
|
mode = "fixed";
|
||||||
|
amountMinor = a;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "doneTolerance": {
|
||||||
|
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (done time)" };
|
||||||
|
mode = "timeCredit";
|
||||||
|
minutes = Math.max(0, input.wash.washMinutes) + Math.max(0, program.minutes ?? 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "washPrice": {
|
||||||
|
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (price)" };
|
||||||
|
mode = "fixed";
|
||||||
|
amountMinor = Math.max(0, input.wash.priceMinor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { ok: false, status: 400, error: `unknown program mode ${String(program.mode)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ev = await eventLog.append({
|
||||||
|
type: "validation",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
programId,
|
||||||
|
programLabel: program.name,
|
||||||
|
mode,
|
||||||
|
...(program.mode !== mode ? { programMode: program.mode } : {}),
|
||||||
|
...(minutes != null ? { minutes } : {}),
|
||||||
|
...(percent != null ? { percent } : {}),
|
||||||
|
...(amountMinor != null ? { amountMinor } : {}),
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
eventId: ev.id,
|
||||||
|
programId,
|
||||||
|
label: program.name,
|
||||||
|
mode,
|
||||||
|
minutes,
|
||||||
|
percent,
|
||||||
|
amountMinor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,12 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.15",
|
"@radix-ui/react-tabs": "^1.1.15",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-store": "^2.4.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
"@tauri-apps/plugin-websocket": "^2.3.0",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
|
|||||||
+41
-3
@@ -3,27 +3,52 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { RouterProvider } from "@tanstack/react-router";
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
import { fetchMe, type SessionUser } from "./api.js";
|
import { fetchMe, type SessionUser } from "./api.js";
|
||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
|
import { ConnectScreen } from "./ConnectScreen.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, applyFontScale } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { router } from "./router.js";
|
import { router } from "./router.js";
|
||||||
|
import { initApiBase, inTauri } from "./lib/origin.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
|
||||||
// off to TanStack Router inside the QueryClient provider. The router renders the
|
// off to TanStack Router inside the QueryClient provider. The router renders the
|
||||||
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
||||||
// the signed-in user flows into the router context for role-based route guards.
|
// the signed-in user flows into the router context for role-based route guards.
|
||||||
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
||||||
|
//
|
||||||
|
// Desktop shell only: BEFORE any of that, the backend origin itself must be
|
||||||
|
// known — the same installer is used at every booth (see lib/origin.ts /
|
||||||
|
// backend-config.ts), so on first launch (or after the operator clears it)
|
||||||
|
// there is no server to call fetchMe() against yet. ConnectScreen gates that;
|
||||||
|
// a browser build always has a same-origin backend, so `needsConnect` is
|
||||||
|
// always false there and this is skipped entirely.
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [user, setUser] = useState<SessionUser | null>(null);
|
const [user, setUser] = useState<SessionUser | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [needsConnect, setNeedsConnect] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMe()
|
initApiBase().then((saved) => {
|
||||||
.then(setUser)
|
if (inTauri() && !saved) {
|
||||||
.finally(() => setLoading(false));
|
setNeedsConnect(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchMe()
|
||||||
|
.then(setUser)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Route-context consumers (RootLayout's nav, route beforeLoad guards) only re-read
|
||||||
|
// the router context on navigation — NOT when this `user` state changes. So after
|
||||||
|
// any session refresh (login, profile edit, a venue-module flip in Setup → Site)
|
||||||
|
// re-validate the current matches once React has committed the new context.
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) void router.invalidate();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
||||||
// resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults
|
// resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults
|
||||||
// before auth resolves; on logout, fall back so the Login screen is consistent.
|
// before auth resolves; on logout, fall back so the Login screen is consistent.
|
||||||
@@ -41,6 +66,19 @@ export function App() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
||||||
}
|
}
|
||||||
|
if (needsConnect) {
|
||||||
|
return (
|
||||||
|
<ConnectScreen
|
||||||
|
onConnected={() => {
|
||||||
|
setNeedsConnect(false);
|
||||||
|
setLoading(true);
|
||||||
|
fetchMe()
|
||||||
|
.then(setUser)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -407,6 +407,23 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Module charges folded into the settlement (e.g. a car wash ordered
|
||||||
|
with "pay at booth") — one "+" line each; the Total below includes
|
||||||
|
them. See wiki/decisions/venue-modules.md. */}
|
||||||
|
{!isSubscription &&
|
||||||
|
(s.chargeLines ?? []).length > 0 &&
|
||||||
|
s.currency != null && (
|
||||||
|
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||||
|
<div className="text-term-muted">{t("booth.charges")}</div>
|
||||||
|
{(s.chargeLines ?? []).map((c, i) => (
|
||||||
|
<div key={i} className="flex justify-between text-term-text">
|
||||||
|
<span>{c.label}</span>
|
||||||
|
<span className="tabular-nums">+{formatMoney(c.amountMinor, 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. */}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { setApiBase } from "./lib/origin.js";
|
||||||
|
|
||||||
|
// Desktop-only gate shown BEFORE Login whenever no backend has been
|
||||||
|
// configured yet (first launch of a generic .deb/.AppImage install, or after
|
||||||
|
// the operator clears it from Settings). Same installer works at any booth —
|
||||||
|
// see backend-config.ts for why this can't be a build-time value.
|
||||||
|
//
|
||||||
|
// backend-config.ts is imported dynamically (not at module top-level) purely
|
||||||
|
// to keep bundling consistent with origin.ts/router.tsx's other Tauri-only
|
||||||
|
// imports — this component itself only ever renders inside Tauri anyway, so
|
||||||
|
// it's not a functional requirement, just avoids an INEFFECTIVE_DYNAMIC_IMPORT
|
||||||
|
// warning from Vite (a static import here would defeat those other dynamic
|
||||||
|
// imports' chunk-splitting intent).
|
||||||
|
|
||||||
|
function normalizeHost(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
return /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectScreen({ onConnected }: { onConnected: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [host, setHost] = useState("");
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [result, setResult] = useState<"ok" | "unreachable" | "bad_response" | null>(null);
|
||||||
|
const [detail, setDetail] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const url = normalizeHost(host);
|
||||||
|
const canSubmit = url.length > 0 && !testing && !saving;
|
||||||
|
|
||||||
|
async function handleTest(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setTesting(true);
|
||||||
|
setResult(null);
|
||||||
|
setDetail(undefined);
|
||||||
|
try {
|
||||||
|
const { testBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
const check = await testBackendUrl(url);
|
||||||
|
setResult(check.ok ? "ok" : (check.reason ?? "unreachable"));
|
||||||
|
setDetail(check.detail);
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const { saveBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
await saveBackendUrl(url);
|
||||||
|
setApiBase(url);
|
||||||
|
onConnected();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
|
||||||
|
<form onSubmit={handleTest} className="card w-full max-w-sm p-6">
|
||||||
|
<h1 className="mb-1 text-h5 font-semibold uppercase tracking-widest text-term-amber">
|
||||||
|
{t("connect.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mb-5 text-[0.75rem] text-term-muted">{t("connect.hint")}</p>
|
||||||
|
|
||||||
|
<div className="field mb-3">
|
||||||
|
<label className="label">{t("connect.serverAddress")}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => {
|
||||||
|
setHost(e.target.value);
|
||||||
|
setResult(null);
|
||||||
|
}}
|
||||||
|
placeholder="192.168.1.50:3000"
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result === "ok" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-green">{t("connect.testOk")}</p>
|
||||||
|
)}
|
||||||
|
{result === "unreachable" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-red">
|
||||||
|
{t("connect.testUnreachable")}
|
||||||
|
{detail ? ` (${detail})` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{result === "bad_response" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-red">{t("connect.testBadResponse")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" className="btn flex-1" disabled={!canSubmit}>
|
||||||
|
{testing ? t("connect.testing") : t("connect.test")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary flex-1"
|
||||||
|
disabled={!canSubmit || result !== "ok"}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{saving ? t("connect.saving") : t("connect.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,24 +7,30 @@ import {
|
|||||||
fetchEvents,
|
fetchEvents,
|
||||||
fetchShift,
|
fetchShift,
|
||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
|
fetchShiftTills,
|
||||||
fetchShifts,
|
fetchShifts,
|
||||||
recordDrawerMovement,
|
recordDrawerMovement,
|
||||||
reviewDrawerMovement,
|
reviewDrawerMovement,
|
||||||
type DrawerMovement,
|
type DrawerMovement,
|
||||||
type MovementStatus,
|
type MovementStatus,
|
||||||
|
type SessionUser,
|
||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
|
type TillId,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { shiftKey } from "./lib/use-shift.js";
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import { tillGuards, tillOf, type LedgerEvent } from "@parking/shared";
|
||||||
|
import { can } from "./api.js";
|
||||||
|
|
||||||
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
|
// 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
|
// 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
|
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
|
||||||
// activity (every cash payment and voucher, live), the movement record/review flow
|
// 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
|
// (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
|
// chain. TILLS (2026-09-05): there is one drawer PER TILL (booth, wash desk); the hub
|
||||||
// wiki/concepts/shift.md.
|
// shows one till at a time — a switch appears when the site has more than one — and
|
||||||
|
// every panel below is scoped to it. See wiki/concepts/shift.md "Tills".
|
||||||
|
|
||||||
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
||||||
|
|
||||||
@@ -50,9 +56,17 @@ function StatusBadge({ status }: { status: MovementStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
export function DrawerManager({ user, canReview }: { user: SessionUser | null; canReview: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
// Which tills this role may READ (each desk's drawer is guarded by that desk's own
|
||||||
|
// permissions) — the first one is the default view.
|
||||||
|
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
|
||||||
|
const tills: TillId[] = status.data?.tills.map((x) => x.till) ?? [];
|
||||||
|
const [chosen, setChosen] = useState<TillId | null>(null);
|
||||||
|
const till = chosen && tills.includes(chosen) ? chosen : (tills[0] ?? "booth");
|
||||||
|
// Recording on a till needs that till's `cash` guard (booth drawer:create, wash carwash:cash).
|
||||||
|
const canCreate = can(user, tillGuards(till).cash);
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||||
// A voucher moves the open shift's added/removed figures too (the X-report).
|
// A voucher moves the open shift's added/removed figures too (the X-report).
|
||||||
@@ -61,17 +75,28 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
|
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
|
||||||
|
{/* Till switch — only when there is more than one drawer to look at. */}
|
||||||
|
{tills.length > 1 && (
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
{tills.map((x) => (
|
||||||
|
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setChosen(x)}>
|
||||||
|
{t(`till.${x}Long`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Row 1: the till NOW + the record form. */}
|
{/* Row 1: the till NOW + the record form. */}
|
||||||
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
|
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
|
||||||
<StatePanel />
|
<StatePanel till={till} />
|
||||||
{canCreate && <RecordPanel onDone={refresh} />}
|
{canCreate && <RecordPanel till={till} onDone={refresh} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
|
{/* 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">
|
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
|
||||||
<TodayPanel />
|
<TodayPanel till={till} />
|
||||||
<MovementsPanel canReview={canReview} onChanged={refresh} />
|
<MovementsPanel till={till} canReview={canReview} onChanged={refresh} />
|
||||||
<ShiftHistoryPanel />
|
<ShiftHistoryPanel till={till} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -81,13 +106,13 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
// Balance from the chain + the open shift's running X-report breakdown, so the big
|
// 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.
|
// number is always explainable: float + cash takings + in − out = expected = balance.
|
||||||
|
|
||||||
function StatePanel() {
|
function StatePanel({ till }: { till: TillId }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 });
|
const balance = useQuery({ queryKey: ["drawer", "balance", till], queryFn: () => fetchDrawerBalance(till), refetchInterval: 10_000 });
|
||||||
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
|
const status = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
|
||||||
const report = useQuery({
|
const report = useQuery({
|
||||||
queryKey: ["shift", "xreport"],
|
queryKey: ["shift", "xreport", till],
|
||||||
queryFn: fetchShiftReport,
|
queryFn: () => fetchShiftReport(till),
|
||||||
enabled: status.data?.open != null,
|
enabled: status.data?.open != null,
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
@@ -149,7 +174,7 @@ function StatePanel() {
|
|||||||
// Every drawer-touching event since local midnight: cash payments (the current
|
// Every drawer-touching event since local midnight: cash payments (the current
|
||||||
// shift's incomings, live) + vouchers. Card payments never enter the till.
|
// shift's incomings, live) + vouchers. Card payments never enter the till.
|
||||||
|
|
||||||
function TodayPanel() {
|
function TodayPanel({ till }: { till: TillId }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ["drawer", "today"],
|
queryKey: ["drawer", "today"],
|
||||||
@@ -157,9 +182,12 @@ function TodayPanel() {
|
|||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// This till's drawer-touching events only (a bay payment is wash-till money; a
|
||||||
|
// parking payment is booth money — tillOf() is the one shared rule).
|
||||||
const rows = (q.data?.events ?? []).filter((e) => {
|
const rows = (q.data?.events ?? []).filter((e) => {
|
||||||
|
if (tillOf(e.payload) !== till) return false;
|
||||||
if (e.type === "cash_in" || e.type === "cash_out") return true;
|
if (e.type === "cash_in" || e.type === "cash_out") return true;
|
||||||
if (e.type !== "payment") return false;
|
if (e.type !== "payment" && e.type !== "carwash_payment") return false;
|
||||||
return (e.payload as { tender?: string } | null)?.tender !== "card";
|
return (e.payload as { tender?: string } | null)?.tender !== "card";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,7 +199,7 @@ function TodayPanel() {
|
|||||||
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
|
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
|
||||||
const amt = pl.amountMinor ?? 0;
|
const amt = pl.amountMinor ?? 0;
|
||||||
if (pl.currency) cur = pl.currency;
|
if (pl.currency) cur = pl.currency;
|
||||||
if (e.type === "payment") {
|
if (e.type === "payment" || e.type === "carwash_payment") {
|
||||||
cashIn += amt;
|
cashIn += amt;
|
||||||
payments++;
|
payments++;
|
||||||
} else {
|
} else {
|
||||||
@@ -225,7 +253,7 @@ function TodayRow({ e }: { e: LedgerEvent }) {
|
|||||||
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
|
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
|
||||||
const time = formatClock(e.occurredAt);
|
const time = formatClock(e.occurredAt);
|
||||||
const label =
|
const label =
|
||||||
e.type === "payment"
|
e.type === "payment" || e.type === "carwash_payment"
|
||||||
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
||||||
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
|
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
|
||||||
return (
|
return (
|
||||||
@@ -243,13 +271,13 @@ function TodayRow({ e }: { e: LedgerEvent }) {
|
|||||||
|
|
||||||
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
|
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
|
||||||
|
|
||||||
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) {
|
function MovementsPanel({ till, canReview, onChanged }: { till: TillId; canReview: boolean; onChanged: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Reviewers can filter the list (the pending queue); operators always see their own, all.
|
// Reviewers can filter the list (the pending queue); operators always see their own, all.
|
||||||
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ["drawer", "movements", canReview ? statusFilter : ""],
|
queryKey: ["drawer", "movements", canReview ? statusFilter : "", till],
|
||||||
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined),
|
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined, till),
|
||||||
});
|
});
|
||||||
const movements = q.data?.movements ?? [];
|
const movements = q.data?.movements ?? [];
|
||||||
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
||||||
@@ -316,9 +344,9 @@ function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChange
|
|||||||
// --- Closed shifts, drawer-focused -------------------------------------------
|
// --- Closed shifts, drawer-focused -------------------------------------------
|
||||||
// Scope follows /api/shifts: operators see their own, admins all.
|
// Scope follows /api/shifts: operators see their own, admins all.
|
||||||
|
|
||||||
function ShiftHistoryPanel() {
|
function ShiftHistoryPanel({ till }: { till: TillId }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() });
|
const q = useQuery({ queryKey: ["shifts", "drawer-history", till], queryFn: () => fetchShifts({ till }) });
|
||||||
const shifts = (q.data?.shifts ?? []).slice(0, 50);
|
const shifts = (q.data?.shifts ?? []).slice(0, 50);
|
||||||
const showOperator = q.data?.scope === "all";
|
const showOperator = q.data?.scope === "all";
|
||||||
|
|
||||||
@@ -377,14 +405,14 @@ function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: b
|
|||||||
|
|
||||||
// --- Record form (unchanged from the pre-redesign feature) ------------------
|
// --- Record form (unchanged from the pre-redesign feature) ------------------
|
||||||
|
|
||||||
function RecordPanel({ onDone }: { onDone: () => void }) {
|
function RecordPanel({ till, onDone }: { till: TillId; onDone: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||||
const record = useMutation({
|
const record = useMutation({
|
||||||
mutationFn: (type: "cash_in" | "cash_out") =>
|
mutationFn: (type: "cash_in" | "cash_out") =>
|
||||||
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }),
|
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim(), till }),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
|
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
|
||||||
setAmount("");
|
setAmount("");
|
||||||
|
|||||||
@@ -13,12 +13,20 @@ import {
|
|||||||
type SessionUser,
|
type SessionUser,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { MODULES, tillsFor, type JobPreset, type ModuleId, type TillId } from "@parking/shared";
|
||||||
|
|
||||||
// Role management (admin). Compose a role from the permission grid (a checkbox
|
// Role management (admin). Compose a role from the permission grid (a checkbox
|
||||||
// matrix of resource × action) and name it; users are then assigned a role. The
|
// matrix of resource × action) and name it; users are then assigned a role. The
|
||||||
// built-in `admin` role is shown read-only/locked (it always has every permission
|
// built-in `admin` role is shown read-only/locked (it always has every permission
|
||||||
// and can't be edited or deleted). The server enforces the same. See
|
// and can't be edited or deleted). The server enforces the same. See
|
||||||
// @parking/shared PERMISSIONS.
|
// @parking/shared PERMISSIONS.
|
||||||
|
//
|
||||||
|
// JOBS (venue-modules.md §"Permissions matrix", move 2): each EFFECTIVE module brings
|
||||||
|
// named permission bundles ("Booth operator", "Wash operator", "Merchant") offered as
|
||||||
|
// one-click chips above the grid — a chip adds/removes its bundle; the grid stays the
|
||||||
|
// fine-tune + enforcement layer. The editor LINTS the result (warnings, never blocks):
|
||||||
|
// "mixes desks" (may open more than one till) and "partial job" (holds a module's read
|
||||||
|
// permission but not the rest of its job — a desk that can look but not act).
|
||||||
|
|
||||||
/** Group "resource:action" permissions by resource for the grid rows. */
|
/** Group "resource:action" permissions by resource for the grid rows. */
|
||||||
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
|
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
|
||||||
@@ -75,6 +83,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
|||||||
<RoleEditor
|
<RoleEditor
|
||||||
role={editing === "new" ? null : editing}
|
role={editing === "new" ? null : editing}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
|
effective={(user?.modules ?? []) as ModuleId[]}
|
||||||
onCancel={() => setEditing(null)}
|
onCancel={() => setEditing(null)}
|
||||||
onSubmit={async (v) => {
|
onSubmit={async (v) => {
|
||||||
try {
|
try {
|
||||||
@@ -124,11 +133,37 @@ async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown)
|
|||||||
try { await deleteRole(id); ok(); } catch (e) { onError(e); }
|
try { await deleteRole(id); ok(); } catch (e) { onError(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The jobs the composer offers: every effective module's, in registry order. */
|
||||||
|
function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] {
|
||||||
|
return MODULES.filter((m) => effective.includes(m.id)).flatMap((m) => m.jobs.map((job) => ({ module: m.id, job })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composer lints — warnings about what the admin just composed. */
|
||||||
|
function lintRole(perms: Set<Permission>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] {
|
||||||
|
const has = (p: Permission) => perms.has(p);
|
||||||
|
const out: { key: string; vars?: Record<string, string> }[] = [];
|
||||||
|
// Mixes desks: may OPEN more than one till.
|
||||||
|
const workable: TillId[] = tillsFor(effective, has, "shift");
|
||||||
|
if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } });
|
||||||
|
// Partial job: holds a module's till-read (or a job's first permission) but not the
|
||||||
|
// rest of that job's OWN-resource permissions (a booth job also carries core
|
||||||
|
// permissions a supervisor legitimately leaves out — those don't count).
|
||||||
|
for (const { module, job } of jobsFor(effective)) {
|
||||||
|
const m = MODULES.find((x) => x.id === module)!;
|
||||||
|
const anchor = m.tillGuards?.read ?? job.permissions[0];
|
||||||
|
if (!anchor || !has(anchor)) continue;
|
||||||
|
const own = job.permissions.filter((p) => !has(p) && m.resources.some((r) => p.startsWith(`${r}:`)));
|
||||||
|
if (own.length > 0) out.push({ key: "roles.lintPartialJob", vars: { job: job.id, missing: own.join(", ") } });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function RoleEditor({
|
function RoleEditor({
|
||||||
role, grouped, onCancel, onSubmit,
|
role, grouped, effective, onCancel, onSubmit,
|
||||||
}: {
|
}: {
|
||||||
role: ManagedRole | null;
|
role: ManagedRole | null;
|
||||||
grouped: Record<string, Permission[]>;
|
grouped: Record<string, Permission[]>;
|
||||||
|
effective: readonly ModuleId[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
|
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -141,6 +176,16 @@ function RoleEditor({
|
|||||||
next.has(p) ? next.delete(p) : next.add(p);
|
next.has(p) ? next.delete(p) : next.add(p);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
const jobs = useMemo(() => jobsFor(effective), [effective]);
|
||||||
|
const jobOn = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
|
||||||
|
const toggleJob = (job: JobPreset) =>
|
||||||
|
setPerms((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (job.permissions.every((p) => prev.has(p))) for (const p of job.permissions) next.delete(p);
|
||||||
|
else for (const p of job.permissions) next.add(p);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const lints = useMemo(() => lintRole(perms, effective), [perms, effective]);
|
||||||
|
|
||||||
const valid = name.trim().length > 0;
|
const valid = name.trim().length > 0;
|
||||||
|
|
||||||
@@ -151,6 +196,34 @@ function RoleEditor({
|
|||||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="label">{t("roles.jobs")}</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{jobs.map(({ module, job }) => (
|
||||||
|
<button
|
||||||
|
key={job.id}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${jobOn(job) ? "btn-primary" : ""}`}
|
||||||
|
title={job.permissions.join(", ")}
|
||||||
|
onClick={() => toggleJob(job)}
|
||||||
|
>
|
||||||
|
{t(`jobs.${job.id}`)} <span className="opacity-60">· {t(`modules.name.${module}`)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="hint">{t("roles.jobsHint")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lints.length > 0 && (
|
||||||
|
<div className="mb-3 rounded-term border border-term-amber/60 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||||
|
{lints.map((l) => (
|
||||||
|
<div key={l.key + JSON.stringify(l.vars)}>{t(l.key, l.vars)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="label">{t("roles.permissions")}</div>
|
<div className="label">{t("roles.permissions")}</div>
|
||||||
<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]) => (
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
|
import { qk } from "./lib/query.js";
|
||||||
|
import { useShift } from "./lib/use-shift.js";
|
||||||
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift control for ONE TILL — the till's single-open shift expressed as one button:
|
||||||
|
* - no shift open → "Open shift" (enabled; opens this operator's shift on the till)
|
||||||
|
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
|
||||||
|
* - another's shift open → disabled, labelled with who holds it (you can neither
|
||||||
|
* open yours nor close theirs until they hand over).
|
||||||
|
* The header renders it for the booth; the wash desk renders it for the carwash till
|
||||||
|
* (its labels then name the till, so the two are never confused). On open/close it
|
||||||
|
* invalidates the shift status, the per-shift log, and occupancy.
|
||||||
|
* See wiki/concepts/shift.md "Tills".
|
||||||
|
*/
|
||||||
|
export function ShiftButton({ till = "booth" }: { till?: TillId }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { status, isOpen, isMine, blockedByOther, heldBy } = useShift(till);
|
||||||
|
// The till's `shift` guard (booth shift:create / wash carwash:cash). A role that may
|
||||||
|
// only LOOK sees the state text, never the button; the server refuses the same.
|
||||||
|
const canWork = status?.canWork ?? false;
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
// Closing a shift signs the Z-report and is irreversible, so the button never
|
||||||
|
// closes directly (a stray click would end the shift) — it opens a confirm modal that
|
||||||
|
// shows the live X-report first. Opening a shift has no such risk → immediate.
|
||||||
|
const [confirmingClose, setConfirmingClose] = useState(false);
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
if (isMine) {
|
||||||
|
setConfirmingClose(true);
|
||||||
|
} else {
|
||||||
|
void act("open");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function act(kind: "open" | "close") {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
if (kind === "open") await openShift(till);
|
||||||
|
else await closeShift(till);
|
||||||
|
// The shift boundary moves: refresh status, the per-shift log window, drawer.
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["shifts"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The booth keeps its historical wording; any other till names itself.
|
||||||
|
const tillName = t(`till.${till}`);
|
||||||
|
const label = blockedByOther
|
||||||
|
? till === "booth"
|
||||||
|
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
|
||||||
|
: t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })
|
||||||
|
: isMine
|
||||||
|
? till === "booth"
|
||||||
|
? t("shift.headerClose")
|
||||||
|
: t("shift.tillClose", { till: tillName })
|
||||||
|
: till === "booth"
|
||||||
|
? t("shift.headerOpen")
|
||||||
|
: t("shift.tillOpen", { till: tillName });
|
||||||
|
const tone = blockedByOther
|
||||||
|
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
|
||||||
|
: isMine
|
||||||
|
? "border-term-red text-term-red hover:bg-term-red/10"
|
||||||
|
: "border-term-green text-term-green hover:bg-term-green/10";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{canWork && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || blockedByOther}
|
||||||
|
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!canWork && isOpen && (
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
|
{till === "booth" ? t("shift.headerHeldByShort", { operator: heldBy ?? "?" }) : t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isOpen && (
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||||
|
{till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
||||||
|
{confirmingClose && (
|
||||||
|
<CloseShiftConfirm
|
||||||
|
till={till}
|
||||||
|
busy={busy}
|
||||||
|
onCancel={() => setConfirmingClose(false)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await act("close");
|
||||||
|
setConfirmingClose(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm-before-close modal for the shift button. Fetches the till's live X-report so
|
||||||
|
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
|
||||||
|
* expected drawer before committing the irreversible Z-report. */
|
||||||
|
function CloseShiftConfirm({
|
||||||
|
till,
|
||||||
|
busy,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
till: TillId;
|
||||||
|
busy: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm", till], queryFn: () => fetchShiftReport(till) });
|
||||||
|
const x = q.data;
|
||||||
|
const cur = x?.currency ?? null;
|
||||||
|
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open onClose={onCancel} title={till === "booth" ? t("shift.endShift") : t("shift.tillClose", { till: t(`till.${till}`) })} width="max-w-md">
|
||||||
|
<div className="text-[0.8125rem] tabular-nums">
|
||||||
|
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||||
|
{!x ? (
|
||||||
|
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
|
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||||
|
<span />
|
||||||
|
{/* Split by source — only meaningful on the booth (a wash till has no
|
||||||
|
tickets or subscriptions; its takings are the bay payments). */}
|
||||||
|
{till === "booth" && (
|
||||||
|
<>
|
||||||
|
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||||||
|
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
||||||
|
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
||||||
|
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
||||||
|
<span />
|
||||||
|
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||||
|
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||||
|
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
||||||
|
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||||
|
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||||
|
<span />
|
||||||
|
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
|
||||||
|
{t("subs.cancel")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
||||||
|
{busy ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Spinner /> {t("shift.ending")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("shift.endShift")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||||
|
<span
|
||||||
|
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
closeShift,
|
closeShift,
|
||||||
fetchEvents,
|
fetchEvents,
|
||||||
fetchShift,
|
|
||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
|
fetchShiftTills,
|
||||||
fetchShifts,
|
fetchShifts,
|
||||||
openShift,
|
openShift,
|
||||||
type ShiftReport,
|
type ShiftReport,
|
||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
type SessionUser,
|
type SessionUser,
|
||||||
|
type TillId,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
|
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
@@ -24,7 +25,9 @@ import type { LedgerEvent } from "@parking/shared";
|
|||||||
// selected shift's signed activity log (every ledger event in its window). The current
|
// selected shift's signed activity log (every ledger event in its window). The current
|
||||||
// shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far),
|
// shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far),
|
||||||
// each opening a modal. Scope is enforced SERVER-SIDE: an operator sees only their own;
|
// each opening a modal. Scope is enforced SERVER-SIDE: an operator sees only their own;
|
||||||
// an admin (shift:cash) sees all. See wiki/concepts/shift.md.
|
// an admin (shift:cash) sees all. TILLS: a shift belongs to a till (booth / wash desk);
|
||||||
|
// every open shift (one per till) lists on top, cards carry a till badge when the site
|
||||||
|
// has more than one, and the list can be filtered by till. See wiki/concepts/shift.md.
|
||||||
|
|
||||||
function money(minor: number, currency: string | null): string {
|
function money(minor: number, currency: string | null): string {
|
||||||
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
||||||
@@ -47,28 +50,36 @@ function presetRange(p: Preset): { from: string; to: string } | null {
|
|||||||
return { from: iso(from), to: iso(now) };
|
return { from: iso(from), to: iso(now) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The CURRENT (open) shift, synthesized from the X-report so it lists alongside closed
|
type CurrentShift = ShiftSummary & { open: true; isMine: boolean };
|
||||||
* shifts. `id` is a sentinel; `open` marks it for the badge + the action pane. null when
|
|
||||||
* no shift is open (or not visible to the requester). */
|
/** The CURRENT (open) shifts — one per till at most — each synthesized from its till's
|
||||||
function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; isMine: boolean; refetch: () => void } {
|
* X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open`
|
||||||
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
|
* marks it for the badge + the action pane. Also returns every till the site has, so
|
||||||
const report = useQuery({
|
* the hub can offer "start shift" per till and show badges only when there are two. */
|
||||||
queryKey: ["shift", "xreport"],
|
function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; workable: TillId[]; refetch: () => void } {
|
||||||
queryFn: fetchShiftReport,
|
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
|
||||||
enabled: status.data?.open != null,
|
const openTills = (status.data?.tills ?? []).filter((t) => t.open != null);
|
||||||
|
// One X-report per open till (the key carries the till list so a newly opened
|
||||||
|
// shift refetches).
|
||||||
|
const reports = useQuery({
|
||||||
|
queryKey: ["shift", "xreport", "hub", openTills.map((t) => t.till).join(",")],
|
||||||
|
queryFn: async () => Promise.all(openTills.map((t) => fetchShiftReport(t.till))),
|
||||||
|
enabled: openTills.length > 0,
|
||||||
});
|
});
|
||||||
const refetch = () => {
|
const refetch = () => {
|
||||||
void status.refetch();
|
void status.refetch();
|
||||||
void report.refetch();
|
void reports.refetch();
|
||||||
};
|
};
|
||||||
if (!status.data?.open || !report.data) return { current: null, isMine: status.data?.isMine ?? false, refetch };
|
const tills = status.data?.tills.map((t) => t.till) ?? [];
|
||||||
const x = report.data;
|
const workable = status.data?.tills.filter((t) => t.canWork).map((t) => t.till) ?? [];
|
||||||
return {
|
const current: CurrentShift[] = [];
|
||||||
isMine: status.data.isMine,
|
openTills.forEach((t, i) => {
|
||||||
refetch,
|
const x = reports.data?.[i];
|
||||||
current: {
|
if (!x) return;
|
||||||
id: "__current__",
|
current.push({
|
||||||
|
id: `__current__${t.till}`,
|
||||||
index: Number.MAX_SAFE_INTEGER,
|
index: Number.MAX_SAFE_INTEGER,
|
||||||
|
till: x.till,
|
||||||
operator: x.operator,
|
operator: x.operator,
|
||||||
startedAt: x.startedAt,
|
startedAt: x.startedAt,
|
||||||
endedAt: x.asOf,
|
endedAt: x.asOf,
|
||||||
@@ -85,8 +96,10 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
|
|||||||
cashRemovedMinor: x.cashRemovedMinor,
|
cashRemovedMinor: x.cashRemovedMinor,
|
||||||
expectedDrawerMinor: x.expectedDrawerMinor,
|
expectedDrawerMinor: x.expectedDrawerMinor,
|
||||||
open: true,
|
open: true,
|
||||||
},
|
isMine: t.isMine,
|
||||||
};
|
});
|
||||||
|
});
|
||||||
|
return { current, tills, workable, refetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
||||||
@@ -96,14 +109,17 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
const [customFrom, setCustomFrom] = useState("");
|
const [customFrom, setCustomFrom] = useState("");
|
||||||
const [customTo, setCustomTo] = useState("");
|
const [customTo, setCustomTo] = useState("");
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [tillFilter, setTillFilter] = useState<TillId | "">("");
|
||||||
|
|
||||||
const { current, isMine, refetch: refetchCurrent } = useCurrentShift();
|
const { current, tills, workable, refetch: refetchCurrent } = useCurrentShifts();
|
||||||
|
const multiTill = tills.length > 1;
|
||||||
|
|
||||||
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
|
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
|
||||||
const applied = {
|
const applied = {
|
||||||
operator: operator.trim() || undefined,
|
operator: operator.trim() || undefined,
|
||||||
from: range?.from ? new Date(`${range.from}T00:00:00`).toISOString() : undefined,
|
from: range?.from ? new Date(`${range.from}T00:00:00`).toISOString() : undefined,
|
||||||
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
||||||
|
till: tillFilter || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// keepPreviousData: every filter change makes a NEW query key; without it the
|
// keepPreviousData: every filter change makes a NEW query key; without it the
|
||||||
@@ -118,16 +134,23 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
const closed = q.data?.shifts ?? [];
|
const closed = q.data?.shifts ?? [];
|
||||||
const operators = q.data?.operators ?? [];
|
const operators = q.data?.operators ?? [];
|
||||||
|
|
||||||
// The current/open shift sits at the TOP of the list (when present + visible to me).
|
// The current/open shifts sit at the TOP of the list (those visible to me: mine, or
|
||||||
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
// all for an admin), honouring the till filter.
|
||||||
|
const visibleCurrent = current.filter((c) => (c.isMine || isAdmin) && (!tillFilter || c.till === tillFilter));
|
||||||
|
const list: (ShiftSummary & { open?: boolean; isMine?: boolean })[] = [...visibleCurrent, ...closed];
|
||||||
const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null;
|
const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null;
|
||||||
|
const currentIds = visibleCurrent.map((c) => c.id).join(",");
|
||||||
|
|
||||||
// Default the selection to the current shift (if any), else the newest closed one.
|
// Default the selection to the current shift (if any), else the newest closed one.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (list.length === 0) setSelectedId(null);
|
if (list.length === 0) setSelectedId(null);
|
||||||
else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id);
|
else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [q.data, current?.id]);
|
}, [q.data, currentIds]);
|
||||||
|
|
||||||
|
// Tills this role may WORK with no open shift → offer "start" for each.
|
||||||
|
const openOn = new Set(current.map((c) => c.till));
|
||||||
|
const startable = workable.filter((x) => !openOn.has(x));
|
||||||
|
|
||||||
function refreshAll() {
|
function refreshAll() {
|
||||||
void q.refetch();
|
void q.refetch();
|
||||||
@@ -145,9 +168,13 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
||||||
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
{/* No shift open → the only action is to start one (gated on shift:create). */}
|
{/* A till with no open shift → the action is to start one (gated on shift:create). */}
|
||||||
{canManage && !current && (
|
{canManage && startable.length > 0 && (
|
||||||
<StartShiftButton onDone={refreshAll} />
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
{startable.map((x) => (
|
||||||
|
<StartShiftButton key={x} till={x} named={multiTill} onDone={refreshAll} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,6 +202,16 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{multiTill && (
|
||||||
|
<div className="field">
|
||||||
|
<select className="input w-40" value={tillFilter} onChange={(e) => setTillFilter(e.target.value as TillId | "")}>
|
||||||
|
<option value="">{t("till.all")}</option>
|
||||||
|
{tills.map((x) => (
|
||||||
|
<option key={x} value={x}>{t(`till.${x}Long`)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
{/* <span className="label">{t("shifts.operator")}</span> */}
|
{/* <span className="label">{t("shifts.operator")}</span> */}
|
||||||
@@ -202,7 +239,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] 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} showTill={multiTill} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,8 +248,9 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
<ShiftActivityLog
|
<ShiftActivityLog
|
||||||
shift={selected}
|
shift={selected}
|
||||||
isCurrent={!!selected.open}
|
isCurrent={!!selected.open}
|
||||||
isMine={isMine}
|
isMine={!!selected.isMine}
|
||||||
showOperator={isAdmin}
|
showOperator={isAdmin}
|
||||||
|
showTill={multiTill}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
onChanged={refreshAll}
|
onChanged={refreshAll}
|
||||||
/>
|
/>
|
||||||
@@ -225,7 +263,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StartShiftButton({ onDone }: { onDone: () => void }) {
|
function StartShiftButton({ till, named, onDone }: { till: TillId; named: boolean; onDone: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
@@ -233,7 +271,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
await openShift();
|
await openShift(till);
|
||||||
onDone();
|
onDone();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
@@ -249,6 +287,8 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
|||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Spinner /> {t("shift.starting")}
|
<Spinner /> {t("shift.starting")}
|
||||||
</span>
|
</span>
|
||||||
|
) : named ? (
|
||||||
|
t("shift.tillOpen", { till: t(`till.${till}`) })
|
||||||
) : (
|
) : (
|
||||||
t("shift.startShift")
|
t("shift.startShift")
|
||||||
)}
|
)}
|
||||||
@@ -257,7 +297,13 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; open: boolean; selected: boolean; onClick: () => void }) {
|
/** Which drawer a shift reconciled — shown only when the site has more than one. */
|
||||||
|
function TillBadge({ till }: { till: TillId }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <span className="rounded border border-term-cyan/60 px-1 text-[0.625rem] uppercase tracking-wider text-term-cyan">{t(`till.${till}`)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; showTill: boolean; open: boolean; selected: boolean; onClick: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const cur = s.currency;
|
const cur = s.currency;
|
||||||
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
||||||
@@ -270,6 +316,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
|||||||
<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-[0.625rem] 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>}
|
||||||
|
{showTill && <TillBadge till={s.till} />}
|
||||||
{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>
|
||||||
@@ -290,6 +337,7 @@ function ShiftActivityLog({
|
|||||||
isCurrent,
|
isCurrent,
|
||||||
isMine,
|
isMine,
|
||||||
showOperator,
|
showOperator,
|
||||||
|
showTill,
|
||||||
canManage,
|
canManage,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
@@ -297,6 +345,7 @@ function ShiftActivityLog({
|
|||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
isMine: boolean;
|
isMine: boolean;
|
||||||
showOperator: boolean;
|
showOperator: boolean;
|
||||||
|
showTill: boolean;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -322,6 +371,7 @@ function ShiftActivityLog({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
|
<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-[0.625rem] 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>}
|
||||||
|
{showTill && <TillBadge till={shift.till} />}
|
||||||
{showOperator && `${shift.operator} · `}
|
{showOperator && `${shift.operator} · `}
|
||||||
{formatRelativeDateTime(shift.startedAt, t)}
|
{formatRelativeDateTime(shift.startedAt, t)}
|
||||||
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
||||||
@@ -358,7 +408,7 @@ 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 === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
{modal === "takings" && <TakingsModal till={shift.till} onClose={() => setModal(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -376,7 +426,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
setReport(await closeShift());
|
setReport(await closeShift(shift.till));
|
||||||
onDone();
|
onDone();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
@@ -447,9 +497,9 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TakingsModal({ onClose }: { onClose: () => void }) {
|
function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
const q = useQuery({ queryKey: ["shift", "xreport", "modal", till], queryFn: () => fetchShiftReport(till) });
|
||||||
const x = q.data;
|
const x = q.data;
|
||||||
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">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
|
fetchMe,
|
||||||
fetchOccupancy,
|
fetchOccupancy,
|
||||||
fetchSiteConfig,
|
fetchSiteConfig,
|
||||||
fetchValidationPrograms,
|
fetchValidationPrograms,
|
||||||
@@ -10,7 +12,9 @@ import {
|
|||||||
type SiteConfig,
|
type SiteConfig,
|
||||||
type ValidationProgramView,
|
type ValidationProgramView,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js";
|
import { STATIONS, ValidationStationsPanel, defaultProgram, stationLabelKey, type StationId } from "./ValidationSetup.js";
|
||||||
|
import { MODULES, type ModuleId } from "@parking/shared";
|
||||||
|
import type { RouterContext } from "./router.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.
|
||||||
@@ -42,23 +46,43 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
// station's `active` (persisted at once — each flip signs a config_change); the
|
// station's `active` (persisted at once — each flip signs a config_change); the
|
||||||
// right-column panel edits the enabled stations. See validation-discounts.md.
|
// right-column panel edits the enabled stations. See validation-discounts.md.
|
||||||
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
|
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
|
||||||
|
// Venue modules: what this site is entitled to (vendor-set), what the admin has
|
||||||
|
// activated, and the effective set. Toggling persists at once (the server signs a
|
||||||
|
// config_change per module that flips and validates dependencies). See
|
||||||
|
// wiki/decisions/venue-modules.md.
|
||||||
|
const [mods, setMods] = useState<{ entitled: ModuleId[]; activated: ModuleId[]; effective: ModuleId[] } | null>(null);
|
||||||
|
const [modMsg, setModMsg] = useState<string | null>(null);
|
||||||
|
const moduleOn = (id: ModuleId) => mods?.effective.includes(id) ?? false;
|
||||||
|
// The header nav gates module entries on the SESSION's module set (/api/auth/me),
|
||||||
|
// so a flip here must refresh the session too or the nav stays stale until reload
|
||||||
|
// (App re-validates the router whenever `user` changes).
|
||||||
|
const { setUser } = useRouteContext({ strict: false }) as RouterContext;
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
fetchOccupancy().then(setOcc).catch(() => {});
|
fetchOccupancy().then(setOcc).catch(() => {});
|
||||||
}
|
}
|
||||||
|
/** The validation programs are a module route — only ask for them while the
|
||||||
|
* module is effective (the server 403s otherwise, which would land in app_logs
|
||||||
|
* as a failed request every time an admin opens this page). */
|
||||||
|
function loadPrograms(effective: ModuleId[]) {
|
||||||
|
if (!canEdit || !effective.includes("validation")) {
|
||||||
|
setPrograms([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchValidationPrograms()
|
||||||
|
.then((r) => setPrograms(r.programs))
|
||||||
|
.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));
|
||||||
setExitVoucherDefault(c.exitVoucherDefault);
|
setExitVoucherDefault(c.exitVoucherDefault);
|
||||||
setReserveSubs(c.reserveSubscriberSpots);
|
setReserveSubs(c.reserveSubscriberSpots);
|
||||||
setAnprEntry(c.anprEntryEnabled);
|
setAnprEntry(c.anprEntryEnabled);
|
||||||
|
setMods({ entitled: c.modulesEntitled, activated: c.modulesActivated, effective: c.modules });
|
||||||
|
loadPrograms(c.modules);
|
||||||
const m: Record<string, string> = {};
|
const m: Record<string, string> = {};
|
||||||
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
||||||
setMeta(m);
|
setMeta(m);
|
||||||
@@ -73,7 +97,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
const existing = programs.find((p) => p.id === id);
|
const existing = programs.find((p) => p.id === id);
|
||||||
const body = existing
|
const body = existing
|
||||||
? { ...existing, active }
|
? { ...existing, active }
|
||||||
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active };
|
: { ...defaultProgram(id, t(stationLabelKey(id))), active };
|
||||||
try {
|
try {
|
||||||
const saved = await saveValidationProgram(id, body);
|
const saved = await saveValidationProgram(id, body);
|
||||||
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
|
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
|
||||||
@@ -82,6 +106,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Flip a module: send the full desired activation set; the server decides
|
||||||
|
* (required always on, must be entitled, dependencies) and echoes the result. */
|
||||||
|
async function toggleModule(id: ModuleId, on: boolean) {
|
||||||
|
if (!mods) return;
|
||||||
|
setModMsg(null);
|
||||||
|
const next = on ? [...new Set([...mods.activated, id])] : mods.activated.filter((m) => m !== id);
|
||||||
|
try {
|
||||||
|
const c = await saveSiteConfig({ modules: next });
|
||||||
|
setMods({ entitled: c.modulesEntitled, activated: c.modulesActivated, effective: c.modules });
|
||||||
|
loadPrograms(c.modules);
|
||||||
|
const me = await fetchMe();
|
||||||
|
if (me) setUser(me);
|
||||||
|
} catch (e) {
|
||||||
|
setModMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
const raw = capInput.trim();
|
const raw = capInput.trim();
|
||||||
@@ -164,22 +205,53 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] 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")}
|
{t("modules.sectionTitle")}
|
||||||
</div>
|
</div>
|
||||||
<span className="hint -mt-2">{t("val.sectionHint")}</span>
|
<span className="hint -mt-2">{t("modules.sectionHint")}</span>
|
||||||
<div className="flex gap-6">
|
<div className="grid gap-1.5">
|
||||||
{STATIONS.map((id) => (
|
{MODULES.filter((m) => mods?.entitled.includes(m.id)).map((m) => (
|
||||||
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
<label key={m.id} className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="mt-0.5 accent-term-amber"
|
||||||
checked={programs.find((p) => p.id === id)?.active ?? false}
|
checked={moduleOn(m.id)}
|
||||||
onChange={(e) => toggleStation(id, e.target.checked)}
|
disabled={m.required || !mods}
|
||||||
|
onChange={(e) => toggleModule(m.id, e.target.checked)}
|
||||||
/>
|
/>
|
||||||
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
<span>
|
||||||
|
{t(`modules.name.${m.id}`)}
|
||||||
|
{m.required && <span className="hint block">{t("modules.required")}</span>}
|
||||||
|
{m.dependsOn.length > 0 && (
|
||||||
|
<span className="hint block">
|
||||||
|
{t("modules.requires", { deps: m.dependsOn.map((d) => t(`modules.name.${d}`)).join(", ") })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
{modMsg && <span className="text-[0.75rem] text-term-red">{modMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{moduleOn("validation") && (
|
||||||
|
<>
|
||||||
|
<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(stationLabelKey(id))}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] 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("site.parkDetails")}
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +283,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
{canEdit && (
|
{canEdit && moduleOn("validation") && (
|
||||||
<ValidationStationsPanel
|
<ValidationStationsPanel
|
||||||
programs={programs}
|
programs={programs}
|
||||||
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { MERCHANT_VALIDATION_MODES } from "@parking/shared";
|
||||||
import {
|
import {
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
saveValidationProgram,
|
saveValidationProgram,
|
||||||
@@ -15,12 +16,22 @@ import {
|
|||||||
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
|
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
|
||||||
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
|
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
/** The two well-known stations the checkboxes toggle. */
|
/** The well-known merchant stations the checkboxes toggle. Was `["bar", "lavazh"]`;
|
||||||
export const STATIONS = ["bar", "lavazh"] as const;
|
* the Lavazh (car-wash) station was retired 2026-09-05 — the Car Wash module
|
||||||
|
* sponsors parking through its own order flow instead (wiki/decisions/
|
||||||
|
* venue-modules.md). Existing `lavazh` program rows are untouched data; the server
|
||||||
|
* accepts any kebab slug, so they simply no longer have a checkbox. */
|
||||||
|
export const STATIONS = ["bar"] as const;
|
||||||
export type StationId = (typeof STATIONS)[number];
|
export type StationId = (typeof STATIONS)[number];
|
||||||
|
|
||||||
|
/** i18n label for a station's checkbox / tab. */
|
||||||
|
const STATION_LABEL_KEY: Record<StationId, string> = { bar: "val.enableBar" };
|
||||||
|
export function stationLabelKey(id: StationId): string {
|
||||||
|
return STATION_LABEL_KEY[id];
|
||||||
|
}
|
||||||
|
|
||||||
/** A blank program draft for a station enabled for the first time. */
|
/** A blank program draft for a station enabled for the first time. */
|
||||||
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
|
export function defaultProgram(id: string, label: string): Omit<ValidationProgramView, "id"> {
|
||||||
return {
|
return {
|
||||||
name: label,
|
name: label,
|
||||||
mode: "comp",
|
mode: "comp",
|
||||||
@@ -46,13 +57,36 @@ const toInt = (s: string): number | null => {
|
|||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isInteger(n) && n > 0 ? n : null;
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
};
|
};
|
||||||
|
/** Like toInt but 0 is valid (a tolerance of "not a minute more"). */
|
||||||
|
const toNonNeg = (s: string): number | null => {
|
||||||
|
const v = s.trim();
|
||||||
|
if (v === "") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isInteger(n) && n >= 0 ? n : null;
|
||||||
|
};
|
||||||
|
const MODE_LABEL_KEY: Record<ValidationMode, string> = {
|
||||||
|
comp: "val.modeComp",
|
||||||
|
timeCredit: "val.modeTimeCredit",
|
||||||
|
fixed: "val.modeFixed",
|
||||||
|
percent: "val.modePercent",
|
||||||
|
doneTolerance: "val.modeDoneTolerance",
|
||||||
|
washPrice: "val.modeWashPrice",
|
||||||
|
};
|
||||||
|
|
||||||
function StationForm({
|
/** One validation program's editor. Also reused by the Car Wash module for its
|
||||||
|
* sponsorship program (`hideUsers`: that program is applied by the wash flow, not by
|
||||||
|
* bound merchant users). */
|
||||||
|
export function StationForm({
|
||||||
program,
|
program,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
hideUsers = false,
|
||||||
|
modes = MERCHANT_VALIDATION_MODES,
|
||||||
}: {
|
}: {
|
||||||
program: ValidationProgramView;
|
program: ValidationProgramView;
|
||||||
onSaved: (p: ValidationProgramView) => void;
|
onSaved: (p: ValidationProgramView) => void;
|
||||||
|
hideUsers?: boolean;
|
||||||
|
/** Which discount modes to offer (merchant stations vs the car wash differ). */
|
||||||
|
modes?: readonly ValidationMode[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState(program.name);
|
const [name, setName] = useState(program.name);
|
||||||
@@ -86,6 +120,7 @@ function StationForm({
|
|||||||
const valid = useMemo(() => {
|
const valid = useMemo(() => {
|
||||||
if (!name.trim()) return false;
|
if (!name.trim()) return false;
|
||||||
if (mode === "timeCredit") return toInt(minutes) != null;
|
if (mode === "timeCredit") return toInt(minutes) != null;
|
||||||
|
if (mode === "doneTolerance") return toNonNeg(minutes) != null;
|
||||||
if (mode === "percent") {
|
if (mode === "percent") {
|
||||||
const p = toInt(percent);
|
const p = toInt(percent);
|
||||||
return p != null && p <= 100;
|
return p != null && p <= 100;
|
||||||
@@ -100,7 +135,7 @@ function StationForm({
|
|||||||
const saved = await saveValidationProgram(program.id, {
|
const saved = await saveValidationProgram(program.id, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
mode,
|
mode,
|
||||||
minutes: mode === "timeCredit" ? toInt(minutes) : null,
|
minutes: mode === "timeCredit" ? toInt(minutes) : mode === "doneTolerance" ? toNonNeg(minutes) : null,
|
||||||
percent: mode === "percent" ? toInt(percent) : null,
|
percent: mode === "percent" ? toInt(percent) : null,
|
||||||
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
|
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
|
||||||
maxPerDay: toInt(maxPerDay),
|
maxPerDay: toInt(maxPerDay),
|
||||||
@@ -130,11 +165,12 @@ function StationForm({
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("val.mode")}</span>
|
<span className="label">{t("val.mode")}</span>
|
||||||
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
|
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
|
||||||
<option value="comp">{t("val.modeComp")}</option>
|
{modes.map((m) => (
|
||||||
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
|
<option key={m} value={m}>{t(MODE_LABEL_KEY[m])}</option>
|
||||||
<option value="fixed">{t("val.modeFixed")}</option>
|
))}
|
||||||
<option value="percent">{t("val.modePercent")}</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
{mode === "doneTolerance" && <span className="hint">{t("val.modeDoneToleranceHint")}</span>}
|
||||||
|
{mode === "washPrice" && <span className="hint">{t("val.modeWashPriceHint")}</span>}
|
||||||
</div>
|
</div>
|
||||||
{mode === "timeCredit" && (
|
{mode === "timeCredit" && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -142,6 +178,12 @@ function StationForm({
|
|||||||
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
|
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{mode === "doneTolerance" && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.toleranceMinutes")}</span>
|
||||||
|
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="15" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode === "percent" && (
|
{mode === "percent" && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("val.percent")}</span>
|
<span className="label">{t("val.percent")}</span>
|
||||||
@@ -158,6 +200,7 @@ function StationForm({
|
|||||||
<span className="label">{t("val.maxPerDay")}</span>
|
<span className="label">{t("val.maxPerDay")}</span>
|
||||||
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
|
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
{!hideUsers && (
|
||||||
<div>
|
<div>
|
||||||
<div className="label">{t("val.users")}</div>
|
<div className="label">{t("val.users")}</div>
|
||||||
<span className="hint block">{t("val.usersHint")}</span>
|
<span className="hint block">{t("val.usersHint")}</span>
|
||||||
@@ -182,6 +225,7 @@ function StationForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
|
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
|
||||||
{t("site.save")}
|
{t("site.save")}
|
||||||
@@ -220,7 +264,7 @@ export function ValidationStationsPanel({
|
|||||||
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
|
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
|
||||||
onClick={() => setTab(p.id)}
|
onClick={() => setTab(p.id)}
|
||||||
>
|
>
|
||||||
{t(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
{t(stationLabelKey(p.id as StationId))}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+115
-33
@@ -1,13 +1,22 @@
|
|||||||
// Thin API client for the operator/admin UI.
|
// Thin API client for the operator/admin UI.
|
||||||
//
|
//
|
||||||
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
|
// Auth is cookie-based: the JWT lives in an HttpOnly cookie sent automatically
|
||||||
// automatically (credentials: 'include'). For mutations we echo the readable
|
// (credentials: 'include'). For mutations we echo the readable CSRF cookie
|
||||||
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
|
// back in the X-CSRF-Token header (double-submit). See
|
||||||
// wiki/entities/local-jwt-auth.md.
|
// wiki/entities/local-jwt-auth.md.
|
||||||
|
//
|
||||||
|
// Desktop shell exception: document.cookie can't see the CSRF cookie there
|
||||||
|
// (reqwest's separate jar — see lib/desktop-csrf.ts), so the server also
|
||||||
|
// echoes the token in the login/me response BODY (sessionView's csrfToken —
|
||||||
|
// see routes/auth.ts) and setSessionUser() (called wherever a SessionUser is
|
||||||
|
// received) stashes it via setDesktopCsrfToken(). The browser path is
|
||||||
|
// untouched — it still reads document.cookie.
|
||||||
|
|
||||||
|
import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js";
|
||||||
import { logFailedRequest } from "./lib/logger.js";
|
import { logFailedRequest } from "./lib/logger.js";
|
||||||
import { apiUrl } from "./lib/origin.js";
|
import { apiUrl, platformFetch } from "./lib/origin.js";
|
||||||
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
|
import { inTauri } from "./lib/tauri-env.js";
|
||||||
|
import type { AppLogRecord, ChargeLine, ModuleId, TillId, ValidationLine, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
const CSRF_COOKIE = "parking_csrf";
|
const CSRF_COOKIE = "parking_csrf";
|
||||||
const CSRF_HEADER = "X-CSRF-Token";
|
const CSRF_HEADER = "X-CSRF-Token";
|
||||||
@@ -17,6 +26,13 @@ function readCookie(name: string): string | null {
|
|||||||
return m ? decodeURIComponent(m[1]!) : null;
|
return m ? decodeURIComponent(m[1]!) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update the desktop CSRF stash. Called wherever a SessionUser is received
|
||||||
|
* (login, fetchMe). No-op / cheap in the browser (the value just goes
|
||||||
|
* unused there — reads still come from document.cookie). */
|
||||||
|
function setSessionUser(user: SessionUser): void {
|
||||||
|
if (user.csrfToken) setDesktopCsrfToken(user.csrfToken);
|
||||||
|
}
|
||||||
|
|
||||||
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
|
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
|
||||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const method = (init.method ?? "GET").toUpperCase();
|
const method = (init.method ?? "GET").toUpperCase();
|
||||||
@@ -25,10 +41,10 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
headers.set("content-type", "application/json");
|
headers.set("content-type", "application/json");
|
||||||
}
|
}
|
||||||
if (method !== "GET" && method !== "HEAD") {
|
if (method !== "GET" && method !== "HEAD") {
|
||||||
const csrf = readCookie(CSRF_COOKIE);
|
const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE);
|
||||||
if (csrf) headers.set(CSRF_HEADER, csrf);
|
if (csrf) headers.set(CSRF_HEADER, csrf);
|
||||||
}
|
}
|
||||||
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
||||||
const error = msg.error ?? `${path}: ${res.status}`;
|
const error = msg.error ?? `${path}: ${res.status}`;
|
||||||
@@ -82,6 +98,13 @@ export interface SessionUser {
|
|||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
/** Optional contact email (profile metadata); null if unset. */
|
/** Optional contact email (profile metadata); null if unset. */
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
/** Effective venue modules at this site (entitled ∩ activated) — what the SPA may
|
||||||
|
* SHOW; the server enforces. See lib/modules.ts. */
|
||||||
|
modules: ModuleId[];
|
||||||
|
/** Desktop-only: the CSRF token also echoed via the (JS-unreadable, on
|
||||||
|
* desktop) parking_csrf cookie — see the file header. Absent/unused in the
|
||||||
|
* browser build, which reads the cookie directly instead. */
|
||||||
|
csrfToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this session grant the permission? Central authz check for the SPA. */
|
/** Does this session grant the permission? Central authz check for the SPA. */
|
||||||
@@ -89,15 +112,28 @@ export function can(user: SessionUser | null, perm: Permission): boolean {
|
|||||||
return !!user && user.permissions.includes(perm);
|
return !!user && user.permissions.includes(perm);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login(username: string, password: string): Promise<SessionUser> {
|
export async function login(username: string, password: string): Promise<SessionUser> {
|
||||||
return apiFetch<SessionUser>("/api/auth/login", {
|
const user = await apiFetch<SessionUser>("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
setSessionUser(user);
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout(): Promise<{ ok: boolean }> {
|
export async function logout(): Promise<{ ok: boolean }> {
|
||||||
return apiFetch("/api/auth/logout", { method: "POST" });
|
const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
|
||||||
|
setDesktopCsrfToken(null);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop only: mint a single-use, short-lived ticket that authenticates the
|
||||||
|
* live-feed WebSocket handshake in place of the session cookie — the native WS
|
||||||
|
* plugin has no cookie jar, so the cookie can never ride along (see
|
||||||
|
* routes/ws.ts and lib/platform-ws.ts). Normal cookie + CSRF auth on the way in. */
|
||||||
|
export async function fetchWsTicket(): Promise<string> {
|
||||||
|
const { ticket } = await apiFetch<{ ticket: string }>("/api/ws/ticket", { method: "POST" });
|
||||||
|
return ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist the current user's UI language preference (restored on next login). */
|
/** Persist the current user's UI language preference (restored on next login). */
|
||||||
@@ -146,7 +182,9 @@ export function changeMyPassword(
|
|||||||
/** Returns the current user, or null if not authenticated. */
|
/** Returns the current user, or null if not authenticated. */
|
||||||
export async function fetchMe(): Promise<SessionUser | null> {
|
export async function fetchMe(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<SessionUser>("/api/auth/me");
|
const user = await apiFetch<SessionUser>("/api/auth/me");
|
||||||
|
setSessionUser(user);
|
||||||
|
return user;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
||||||
throw e;
|
throw e;
|
||||||
@@ -1024,18 +1062,32 @@ export function deleteSubscription(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Shifts ---------------------------------------------------------------
|
// --- Shifts ---------------------------------------------------------------
|
||||||
|
// A shift is opened ON A TILL (booth | carwash …): one open shift per till, each with
|
||||||
|
// its own drawer and Z-report. Every call below takes the till, defaulting to the
|
||||||
|
// booth. See wiki/concepts/shift.md "Tills".
|
||||||
|
|
||||||
export interface ShiftStatus {
|
export type { TillId };
|
||||||
/** The requesting (logged-in) operator. */
|
|
||||||
operator: string;
|
/** One till's shift state (at most one shift open per till). */
|
||||||
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
|
export interface TillShiftStatus {
|
||||||
|
till: TillId;
|
||||||
|
/** The till's open shift (startedAt + whose), or null if none open. */
|
||||||
open: { startedAt: string; operator: string | null } | null;
|
open: { startedAt: string; operator: string | null } | null;
|
||||||
/** True iff the open shift belongs to the requesting operator (can close it). */
|
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||||||
isMine: boolean;
|
isMine: boolean;
|
||||||
/** Live physical drawer balance (cash payments + cash movements). */
|
/** May this role open/close this till's shift (its module's `shift` guard)? */
|
||||||
|
canWork: boolean;
|
||||||
|
/** Live physical drawer balance of this till (cash payments + cash movements). */
|
||||||
drawerMinor: number;
|
drawerMinor: number;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShiftStatus extends TillShiftStatus {
|
||||||
|
/** The requesting (logged-in) operator. */
|
||||||
|
operator: string;
|
||||||
|
/** Every till addressable at this site (the booth + effective modules' tills). */
|
||||||
|
tills: TillId[];
|
||||||
|
}
|
||||||
/** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales +
|
/** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales +
|
||||||
* out-of-window charges). Cash+card combined; the per-tender totals stay separate for
|
* out-of-window charges). Cash+card combined; the per-tender totals stay separate for
|
||||||
* the drawer. Shared by the X-report, the close Z-report, and the history summary. */
|
* the drawer. Shared by the X-report, the close Z-report, and the history summary. */
|
||||||
@@ -1047,6 +1099,7 @@ export interface ShiftSourceSplit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ShiftReport extends ShiftSourceSplit {
|
export interface ShiftReport extends ShiftSourceSplit {
|
||||||
|
till: TillId;
|
||||||
operator: string;
|
operator: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string;
|
endedAt: string;
|
||||||
@@ -1062,20 +1115,27 @@ export interface ShiftReport extends ShiftSourceSplit {
|
|||||||
printed: boolean;
|
printed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchShift(): Promise<ShiftStatus> {
|
const tillQs = (till?: TillId) => (till && till !== "booth" ? `?till=${till}` : "");
|
||||||
return apiFetch("/api/shift/current");
|
|
||||||
|
export function fetchShift(till: TillId = "booth"): Promise<ShiftStatus> {
|
||||||
|
return apiFetch(`/api/shift/current${tillQs(till)}`);
|
||||||
}
|
}
|
||||||
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
/** Every till's shift state in one read (the shift hub lists each open shift). */
|
||||||
return apiFetch("/api/shift/open", { method: "POST" });
|
export function fetchShiftTills(): Promise<{ operator: string; tills: TillShiftStatus[] }> {
|
||||||
|
return apiFetch("/api/shift/tills");
|
||||||
}
|
}
|
||||||
export function closeShift(): Promise<ShiftReport> {
|
export function openShift(till: TillId = "booth"): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
|
||||||
return apiFetch("/api/shift/close", { method: "POST" });
|
return apiFetch("/api/shift/open", { method: "POST", body: JSON.stringify({ till }) });
|
||||||
|
}
|
||||||
|
export function closeShift(till: TillId = "booth"): Promise<ShiftReport> {
|
||||||
|
return apiFetch("/api/shift/close", { method: "POST", body: JSON.stringify({ till }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
|
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
|
||||||
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
||||||
* snapshot instant. */
|
* snapshot instant. */
|
||||||
export interface XReport extends ShiftSourceSplit {
|
export interface XReport extends ShiftSourceSplit {
|
||||||
|
till: TillId;
|
||||||
operator: string;
|
operator: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string; // = asOf
|
endedAt: string; // = asOf
|
||||||
@@ -1091,8 +1151,8 @@ export interface XReport extends ShiftSourceSplit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
|
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
|
||||||
export async function fetchShiftReport(): Promise<XReport | null> {
|
export async function fetchShiftReport(till: TillId = "booth"): Promise<XReport | null> {
|
||||||
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
return (await apiFetch<XReport | undefined>(`/api/shift/report${tillQs(till)}`)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
||||||
@@ -1106,6 +1166,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
|
|||||||
export interface DrawerMovement {
|
export interface DrawerMovement {
|
||||||
id: string;
|
id: string;
|
||||||
type: "cash_in" | "cash_out";
|
type: "cash_in" | "cash_out";
|
||||||
|
/** Which drawer the cash moved in/out of. */
|
||||||
|
till: TillId;
|
||||||
/** Positive magnitude; direction is the type. */
|
/** Positive magnitude; direction is the type. */
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
@@ -1127,8 +1189,11 @@ export function recordDrawerMovement(args: {
|
|||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
/** Which drawer (default: the booth). */
|
||||||
|
till?: TillId;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
type: "cash_in" | "cash_out";
|
type: "cash_in" | "cash_out";
|
||||||
|
till: TillId;
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
voucherNo: string;
|
voucherNo: string;
|
||||||
balanceMinor: number;
|
balanceMinor: number;
|
||||||
@@ -1139,18 +1204,21 @@ export function recordDrawerMovement(args: {
|
|||||||
|
|
||||||
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
|
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
|
||||||
* and may filter by status (the pending review queue). */
|
* and may filter by status (the pending review queue). */
|
||||||
export function fetchDrawerMovements(status?: MovementStatus): Promise<{
|
export function fetchDrawerMovements(status?: MovementStatus, till?: TillId): Promise<{
|
||||||
movements: DrawerMovement[];
|
movements: DrawerMovement[];
|
||||||
scope: "all" | "self";
|
scope: "all" | "self";
|
||||||
}> {
|
}> {
|
||||||
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
const qs = new URLSearchParams();
|
||||||
return apiFetch(`/api/drawer/movements${qs}`);
|
if (status) qs.set("status", status);
|
||||||
|
if (till) qs.set("till", till);
|
||||||
|
const q = qs.toString();
|
||||||
|
return apiFetch(`/api/drawer/movements${q ? `?${q}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The physical drawer balance NOW (cash payments + vouchers over the whole chain —
|
/** A till's physical drawer balance NOW (cash payments + vouchers over the whole chain
|
||||||
* the amount that carries across shifts). */
|
* — the amount that carries across that till's shifts). */
|
||||||
export function fetchDrawerBalance(): Promise<{ balanceMinor: number; currency: string | null }> {
|
export function fetchDrawerBalance(till: TillId = "booth"): Promise<{ till: TillId; balanceMinor: number; currency: string | null }> {
|
||||||
return apiFetch("/api/drawer/balance");
|
return apiFetch(`/api/drawer/balance${tillQs(till)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
||||||
@@ -1166,6 +1234,8 @@ export function reviewDrawerMovement(args: {
|
|||||||
export interface ShiftSummary extends ShiftSourceSplit {
|
export interface ShiftSummary extends ShiftSourceSplit {
|
||||||
id: string;
|
id: string;
|
||||||
index: number;
|
index: number;
|
||||||
|
/** The till this shift reconciled. */
|
||||||
|
till: TillId;
|
||||||
operator: string;
|
operator: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string;
|
endedAt: string;
|
||||||
@@ -1183,16 +1253,19 @@ export interface ShiftSummary extends ShiftSourceSplit {
|
|||||||
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
|
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
|
||||||
* filtered by operator + a from/to window over the shift start. `scope` echoes
|
* filtered by operator + a from/to window over the shift start. `scope` echoes
|
||||||
* which the server applied, so the UI can show/hide the filter. */
|
* which the server applied, so the UI can show/hide the filter. */
|
||||||
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
export function fetchShifts(params: { operator?: string; from?: string; to?: string; till?: TillId } = {}): Promise<{
|
||||||
shifts: ShiftSummary[];
|
shifts: ShiftSummary[];
|
||||||
scope: "all" | "self";
|
scope: "all" | "self";
|
||||||
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
|
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
|
||||||
operators?: string[];
|
operators?: string[];
|
||||||
|
/** Every till addressable at this site — more than one → show the till filter/badges. */
|
||||||
|
tills: TillId[];
|
||||||
}> {
|
}> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.operator) qs.set("operator", params.operator);
|
if (params.operator) qs.set("operator", params.operator);
|
||||||
if (params.from) qs.set("from", params.from);
|
if (params.from) qs.set("from", params.from);
|
||||||
if (params.to) qs.set("to", params.to);
|
if (params.to) qs.set("to", params.to);
|
||||||
|
if (params.till) qs.set("till", params.till);
|
||||||
const q = qs.toString();
|
const q = qs.toString();
|
||||||
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
|
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
|
||||||
}
|
}
|
||||||
@@ -1222,6 +1295,12 @@ export interface SiteConfig {
|
|||||||
bypassPresenceRadar: boolean;
|
bypassPresenceRadar: boolean;
|
||||||
/** Entry presence-gate bypass: drop camera detection as an entry-button requirement. */
|
/** Entry presence-gate bypass: drop camera detection as an entry-button requirement. */
|
||||||
bypassPresenceCamera: boolean;
|
bypassPresenceCamera: boolean;
|
||||||
|
/** Effective venue modules (entitled ∩ activated). */
|
||||||
|
modules: ModuleId[];
|
||||||
|
/** What this deployment is entitled to (vendor-set) — the toggles offered in Setup. */
|
||||||
|
modulesEntitled: ModuleId[];
|
||||||
|
/** What the site admin has activated. Send the full desired set via saveSiteConfig. */
|
||||||
|
modulesActivated: ModuleId[];
|
||||||
parkName: string | null;
|
parkName: string | null;
|
||||||
operatorName: string | null;
|
operatorName: string | null;
|
||||||
/** NIUS — Albanian tax/identification number. */
|
/** NIUS — Albanian tax/identification number. */
|
||||||
@@ -1315,6 +1394,9 @@ export interface SessionLookup {
|
|||||||
grossMinor: number | null;
|
grossMinor: number | null;
|
||||||
discountMinor: number | null;
|
discountMinor: number | null;
|
||||||
validationLines: ValidationLine[];
|
validationLines: ValidationLine[];
|
||||||
|
/** Module charges folded into `amountMinor` (e.g. a car wash paid at the booth). */
|
||||||
|
chargeLines: ChargeLine[];
|
||||||
|
chargesMinor: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Desktop-only: the operator-configured backend origin (host:port of the
|
||||||
|
// Fastify server this install talks to), persisted across restarts.
|
||||||
|
//
|
||||||
|
// The desktop shell is a generic .deb/.AppImage — it is NOT built for one
|
||||||
|
// specific booth, so the backend address can't be baked in at build time
|
||||||
|
// (that was the old VITE_API_BASE approach; a rebuild was needed to point the
|
||||||
|
// same installer at a different appliance). Instead the operator enters it
|
||||||
|
// once in the ConnectScreen (shown before login whenever nothing usable is
|
||||||
|
// stored yet) and it's saved to a JSON file in the OS config dir via
|
||||||
|
// tauri-plugin-store, read back on every launch before any API call.
|
||||||
|
//
|
||||||
|
// Browser build: this module is never reached (inTauri() gates every call
|
||||||
|
// site — see origin.ts), so there is no browser equivalent or fallback here.
|
||||||
|
|
||||||
|
import type { Store } from "@tauri-apps/plugin-store";
|
||||||
|
|
||||||
|
const STORE_FILE = "backend-config.json";
|
||||||
|
const KEY = "backendUrl";
|
||||||
|
|
||||||
|
let storeHandle: Store | null = null;
|
||||||
|
async function getStore(): Promise<Store> {
|
||||||
|
if (!storeHandle) {
|
||||||
|
const { load } = await import("@tauri-apps/plugin-store");
|
||||||
|
storeHandle = await load(STORE_FILE, { autoSave: true });
|
||||||
|
}
|
||||||
|
return storeHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The saved backend origin (no trailing slash), or null if never configured.
|
||||||
|
* Desktop only — throws if called from a browser build. */
|
||||||
|
export async function loadBackendUrl(): Promise<string | null> {
|
||||||
|
const store = await getStore();
|
||||||
|
const v = await store.get<string>(KEY);
|
||||||
|
return typeof v === "string" && v.length > 0 ? v.replace(/\/$/, "") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a new backend origin (validated + reachable — call testBackendUrl
|
||||||
|
* first). Takes effect immediately for future platformFetch/wsUrl calls. */
|
||||||
|
export async function saveBackendUrl(url: string): Promise<void> {
|
||||||
|
const store = await getStore();
|
||||||
|
await store.set(KEY, url.replace(/\/$/, ""));
|
||||||
|
await store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the saved backend (forces the ConnectScreen back up next launch). */
|
||||||
|
export async function clearBackendUrl(): Promise<void> {
|
||||||
|
const store = await getStore();
|
||||||
|
await store.delete(KEY);
|
||||||
|
await store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendCheck {
|
||||||
|
ok: boolean;
|
||||||
|
/** "unreachable" (network/DNS/refused) | "bad_response" (reachable, not our API). */
|
||||||
|
reason?: "unreachable" | "bad_response";
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe a candidate origin via GET /health — the server's one unauthenticated
|
||||||
|
* route (server.ts), which answers `{status:"ok", app:"parking-system"}`. We
|
||||||
|
* require BOTH a 2xx and that `app` value: the previous probe hit an
|
||||||
|
* auth-guarded route and accepted 401/403 as "ours", which any password-
|
||||||
|
* protected service on the LAN would also have passed. Uses the same
|
||||||
|
* tauri-plugin-http path platformFetch does (raw fetch from the webview can't
|
||||||
|
* reach an arbitrary LAN host — mixed content, see origin.ts). */
|
||||||
|
export async function testBackendUrl(url: string): Promise<BackendCheck> {
|
||||||
|
const origin = url.replace(/\/$/, "");
|
||||||
|
try {
|
||||||
|
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
|
||||||
|
const res = await tauriFetch(`${origin}/health`, {
|
||||||
|
method: "GET",
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return { ok: false, reason: "bad_response", detail: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
const body = (await res.json().catch(() => null)) as { app?: unknown } | null;
|
||||||
|
if (body?.app !== "parking-system") {
|
||||||
|
return { ok: false, reason: "bad_response", detail: "unexpected /health body" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "unreachable",
|
||||||
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Desktop-only in-memory CSRF token stash.
|
||||||
|
//
|
||||||
|
// tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its OWN
|
||||||
|
// cookie jar separate from the webview — document.cookie on tauri://localhost
|
||||||
|
// never sees the parking_csrf cookie the server sets (open upstream bug,
|
||||||
|
// tauri-apps/tauri#13045). The cookie IS still sent to the server by reqwest;
|
||||||
|
// only the client-side READ is broken. So the server echoes the same value in
|
||||||
|
// the login / me response body (sessionView's csrfToken, routes/auth.ts) and
|
||||||
|
// the desktop client keeps it here, echoing THIS in X-CSRF-Token instead of
|
||||||
|
// reading document.cookie.
|
||||||
|
//
|
||||||
|
// One module, no imports, so BOTH echo sites can share it without a cycle:
|
||||||
|
// api.ts (sets it, uses it for apiFetch mutations) and logger.ts (uses it for
|
||||||
|
// the /api/logs flush — which api.ts imports, so it can't import api.ts back).
|
||||||
|
// Never persisted: a fresh launch re-learns it via login or /api/auth/me.
|
||||||
|
|
||||||
|
let token: string | null = null;
|
||||||
|
|
||||||
|
export function setDesktopCsrfToken(value: string | null): void {
|
||||||
|
token = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesktopCsrfToken(): string | null {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
@@ -9,11 +9,18 @@
|
|||||||
// and never tries to resolve the Tauri APIs. Offline-first: a failed check (no
|
// and never tries to resolve the Tauri APIs. Offline-first: a failed check (no
|
||||||
// network — the appliance is usually offline) is swallowed; updates only happen
|
// network — the appliance is usually offline) is swallowed; updates only happen
|
||||||
// when someone has brought the box online (e.g. a phone hotspot) on purpose.
|
// when someone has brought the box online (e.g. a phone hotspot) on purpose.
|
||||||
|
//
|
||||||
|
// A release build's console.error is invisible with no way to attach devtools
|
||||||
|
// in the field (kiosk mode blocks the context menu; this WebKitGTK build's
|
||||||
|
// remote inspector doesn't answer standard discovery endpoints either — both
|
||||||
|
// confirmed dead ends 2026-09-03). logClient() ships straight to the
|
||||||
|
// server-side app_logs store regardless of the client's console-forward log
|
||||||
|
// level (that gate is meant for noisy console chatter, not this), so a real
|
||||||
|
// post-accept install failure is visible via wiki/concepts/app-logs.md /
|
||||||
|
// LogsViewer.tsx without needing a terminal or devtools at all.
|
||||||
|
|
||||||
/** True when running inside the Tauri webview (not a normal browser). */
|
import { logClient } from "./logger.js";
|
||||||
function inTauri(): boolean {
|
import { inTauri } from "./tauri-env.js";
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdatePrompt {
|
export interface UpdatePrompt {
|
||||||
/** Newer version string offered by the server. */
|
/** Newer version string offered by the server. */
|
||||||
@@ -41,11 +48,39 @@ export async function checkForDesktopUpdate(
|
|||||||
|
|
||||||
// Download + install the signed update (signature verified against the
|
// Download + install the signed update (signature verified against the
|
||||||
// pubkey in tauri.conf.json), then relaunch into the new version.
|
// pubkey in tauri.conf.json), then relaunch into the new version.
|
||||||
await update.downloadAndInstall();
|
try {
|
||||||
|
await update.downloadAndInstall((progress) => {
|
||||||
|
logClient({
|
||||||
|
level: "info",
|
||||||
|
message: `desktop update download progress: ${progress.event}`,
|
||||||
|
context: { kind: "desktop_update_progress", version: update.version, event: progress.event },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// A real update WAS found and accepted — this is a genuine install
|
||||||
|
// failure (bad signature, corrupted download, disk/permission issue),
|
||||||
|
// not "offline". Surface it instead of silently reverting to the old
|
||||||
|
// version with no explanation.
|
||||||
|
logClient({
|
||||||
|
level: "error",
|
||||||
|
message: `desktop update download/install failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
stack: err instanceof Error ? err.stack : undefined,
|
||||||
|
context: { kind: "desktop_update_install_failed", version: update.version },
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||||
await relaunch();
|
await relaunch();
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Offline / endpoint unreachable / no update server yet → ignore. The app
|
// Offline / endpoint unreachable / no update server yet → ignore. The app
|
||||||
// keeps running on the current version; checking again next launch.
|
// keeps running on the current version; checking again next launch. Still
|
||||||
|
// log it (info, not error — this path is expected/normal far more often
|
||||||
|
// than it's a real problem) so a real install failure (rethrown above,
|
||||||
|
// logged as error) isn't lost among routine offline checks.
|
||||||
|
logClient({
|
||||||
|
level: "info",
|
||||||
|
message: `desktop update check/apply skipped: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
context: { kind: "desktop_update_skipped" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,24 @@ export function formatMoney(amountMinor: number, currency: string): string {
|
|||||||
export function formatDuration(fromIso: string, toIso: string): string {
|
export function formatDuration(fromIso: string, toIso: string): string {
|
||||||
const ms = Date.parse(toIso) - Date.parse(fromIso);
|
const ms = Date.parse(toIso) - Date.parse(fromIso);
|
||||||
if (!Number.isFinite(ms) || ms < 0) return "—";
|
if (!Number.isFinite(ms) || ms < 0) return "—";
|
||||||
const mins = Math.floor(ms / 60_000);
|
return formatMinutesLong(Math.floor(ms / 60_000));
|
||||||
const h = Math.floor(mins / 60);
|
}
|
||||||
|
|
||||||
|
/** "Xy Xd Xh Xm" with the leading zero units dropped — a stay of 1797h reads as
|
||||||
|
* "74d 21h 23m", not a wall of hours (a stale/forgotten ticket is a real case on a
|
||||||
|
* booth; the number should still be readable at a glance). Years only past 365 days. */
|
||||||
|
export function formatMinutesLong(totalMinutes: number): string {
|
||||||
|
const mins = Math.max(0, Math.floor(totalMinutes));
|
||||||
|
const y = Math.floor(mins / (365 * 24 * 60));
|
||||||
|
const d = Math.floor((mins % (365 * 24 * 60)) / (24 * 60));
|
||||||
|
const h = Math.floor((mins % (24 * 60)) / 60);
|
||||||
const m = mins % 60;
|
const m = mins % 60;
|
||||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
const parts: string[] = [];
|
||||||
|
if (y > 0) parts.push(`${y}y`);
|
||||||
|
if (y > 0 || d > 0) parts.push(`${d}d`);
|
||||||
|
if (y > 0 || d > 0 || h > 0) parts.push(`${h}h`);
|
||||||
|
parts.push(`${m}m`);
|
||||||
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
|
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
|
||||||
@@ -41,9 +55,7 @@ export function formatCountdown(untilIso: string | null, nowMs: number = Date.no
|
|||||||
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
||||||
export function formatMinutes(mins: number): string {
|
export function formatMinutes(mins: number): string {
|
||||||
if (!Number.isFinite(mins) || mins < 0) return "—";
|
if (!Number.isFinite(mins) || mins < 0) return "—";
|
||||||
const m = Math.round(mins);
|
return formatMinutesLong(Math.round(mins));
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
||||||
|
|||||||
+118
-3
@@ -42,11 +42,96 @@ export const en: Catalog = {
|
|||||||
signIn: "Sign in",
|
signIn: "Sign in",
|
||||||
signingIn: "Signing in…",
|
signingIn: "Signing in…",
|
||||||
},
|
},
|
||||||
|
connect: {
|
||||||
|
title: "Connect to server",
|
||||||
|
hint: "Enter the address of the parking system server for this booth.",
|
||||||
|
serverAddress: "Server address",
|
||||||
|
test: "Test",
|
||||||
|
testing: "Testing…",
|
||||||
|
save: "Save & continue",
|
||||||
|
saving: "Saving…",
|
||||||
|
testOk: "Reachable — this looks like a Parking System server.",
|
||||||
|
testUnreachable: "Could not reach this address.",
|
||||||
|
testBadResponse: "Reachable, but this doesn't look like a Parking System server.",
|
||||||
|
changeServer: "Change server",
|
||||||
|
changeServerConfirm: "This signs you out and asks for a new server address on next launch. Continue?",
|
||||||
|
},
|
||||||
|
modules: {
|
||||||
|
sectionTitle: "Modules",
|
||||||
|
sectionHint: "Optional parts of the system this site uses. What can be switched on here is decided at deployment; switching one off hides it and refuses its actions — nothing is deleted.",
|
||||||
|
required: "Always on.",
|
||||||
|
requires: "Requires: {{deps}}",
|
||||||
|
name: {
|
||||||
|
parking: "Parking",
|
||||||
|
validation: "Merchant validations (Bar)",
|
||||||
|
carwash: "Car wash",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wash: {
|
||||||
|
tillTitle: "Wash till",
|
||||||
|
tillHint: "Money taken at the bay is recorded on the wash till — open your wash shift first. The booth's shift does not cover it.",
|
||||||
|
tillOtherHint: "{{operator}} holds the wash shift; only they can take money at the bay.",
|
||||||
|
drawerNow: "Wash drawer now",
|
||||||
|
intake: "New wash",
|
||||||
|
ticketPh: "Parking ticket (scan or type)",
|
||||||
|
lookup: "Look up",
|
||||||
|
notFound: "No session for this ticket.",
|
||||||
|
closed: "This session is already closed.",
|
||||||
|
ticket: "Ticket",
|
||||||
|
plate: "Plate",
|
||||||
|
enteredAt: "Entered",
|
||||||
|
alreadyOpen: "This ticket already has an open wash order.",
|
||||||
|
category: "Vehicle category",
|
||||||
|
service: "Service",
|
||||||
|
price: "Price",
|
||||||
|
noPrice: "no price set for this pair",
|
||||||
|
payAt: "Payment",
|
||||||
|
payAtBooth: "At the booth",
|
||||||
|
payAtBay: "At the bay",
|
||||||
|
payAtBoothHint: "Added to the parking settlement; the exit barrier opens after the booth payment.",
|
||||||
|
payAtBayHint: "You take the money here; the customer leaves by scanning the ticket at the exit reader (the parking sponsorship must cover the fee).",
|
||||||
|
create: "Create order",
|
||||||
|
created: "Order created.",
|
||||||
|
queue: "Open orders",
|
||||||
|
empty: "Nothing to wash.",
|
||||||
|
time: "Time",
|
||||||
|
what: "Wash",
|
||||||
|
status: "Status",
|
||||||
|
statusOpen: "in progress",
|
||||||
|
statusDone: "done",
|
||||||
|
paid: "paid",
|
||||||
|
unpaid: "unpaid",
|
||||||
|
done: "Done",
|
||||||
|
payCash: "Paid cash",
|
||||||
|
payCard: "Paid card",
|
||||||
|
void: "Void",
|
||||||
|
voidReason: "Reason",
|
||||||
|
categories: "Vehicle categories",
|
||||||
|
services: "Services",
|
||||||
|
prices: "Prices",
|
||||||
|
pricesHint: "One price per category × service. Leave a cell blank to make that pair unsellable.",
|
||||||
|
addCategory: "category",
|
||||||
|
addService: "service",
|
||||||
|
active: "active",
|
||||||
|
save: "Save",
|
||||||
|
saved: "Saved.",
|
||||||
|
finished: "Finished",
|
||||||
|
finishedEmpty: "No finished washes yet.",
|
||||||
|
voided: "voided",
|
||||||
|
by: "By",
|
||||||
|
cash: "cash",
|
||||||
|
card: "card",
|
||||||
|
sponsorship: "Parking discount",
|
||||||
|
sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.",
|
||||||
|
sponsorshipLabel: "Car wash",
|
||||||
|
},
|
||||||
update: {
|
update: {
|
||||||
available: "Update available",
|
available: "Update available",
|
||||||
prompt: "Version {{version}} is available. Install now and restart?",
|
prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)",
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
wash: "Car wash",
|
||||||
|
carwash: "Car wash",
|
||||||
booth: "Booth",
|
booth: "Booth",
|
||||||
shift: "Shift",
|
shift: "Shift",
|
||||||
setup: "Setup",
|
setup: "Setup",
|
||||||
@@ -232,6 +317,9 @@ export const en: Catalog = {
|
|||||||
evtCashReview: "REVIEW",
|
evtCashReview: "REVIEW",
|
||||||
evtConfigChange: "CONFIG",
|
evtConfigChange: "CONFIG",
|
||||||
evtValidation: "VALIDATION",
|
evtValidation: "VALIDATION",
|
||||||
|
evtCarwashOrder: "CAR WASH",
|
||||||
|
evtCarwashPayment: "WASH PAYMENT",
|
||||||
|
charges: "Extra charges",
|
||||||
decision: { authorize: "authorized", deny: "denied" },
|
decision: { authorize: "authorized", deny: "denied" },
|
||||||
evtAnomaly: "ANOMALY",
|
evtAnomaly: "ANOMALY",
|
||||||
evtRefused: "REFUSED",
|
evtRefused: "REFUSED",
|
||||||
@@ -742,9 +830,8 @@ export const en: Catalog = {
|
|||||||
val: {
|
val: {
|
||||||
// /setup/site
|
// /setup/site
|
||||||
sectionTitle: "Merchant validations",
|
sectionTitle: "Merchant validations",
|
||||||
sectionHint: "An in-park merchant (bar / car-wash) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.",
|
sectionHint: "An in-park merchant (the bar) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.",
|
||||||
enableBar: "Bar",
|
enableBar: "Bar",
|
||||||
enableLavazh: "Car wash",
|
|
||||||
labelName: "Receipt label",
|
labelName: "Receipt label",
|
||||||
labelNamePh: "e.g. Car wash — first hour free",
|
labelNamePh: "e.g. Car wash — first hour free",
|
||||||
mode: "Discount type",
|
mode: "Discount type",
|
||||||
@@ -752,6 +839,11 @@ export const en: Catalog = {
|
|||||||
modeTimeCredit: "First minutes free",
|
modeTimeCredit: "First minutes free",
|
||||||
modeFixed: "Amount off (typed at scan)",
|
modeFixed: "Amount off (typed at scan)",
|
||||||
modePercent: "Percent off",
|
modePercent: "Percent off",
|
||||||
|
modeDoneTolerance: "Free while the wash runs (+ tolerance)",
|
||||||
|
modeDoneToleranceHint: "The time from the wash order to \"done\", plus the tolerance minutes, comes off the parking. Time parked before the order and after the tolerance is charged at the tariff.",
|
||||||
|
modeWashPrice: "Wash price off the parking fee",
|
||||||
|
modeWashPriceHint: "The parking fee minus the wash price; never below zero.",
|
||||||
|
toleranceMinutes: "Tolerance after done (minutes)",
|
||||||
minutes: "Free minutes",
|
minutes: "Free minutes",
|
||||||
percent: "Percent (%)",
|
percent: "Percent (%)",
|
||||||
maxAmount: "Cap per validation",
|
maxAmount: "Cap per validation",
|
||||||
@@ -819,6 +911,17 @@ export const en: Catalog = {
|
|||||||
permCount_other: "{{count}} permissions",
|
permCount_other: "{{count}} permissions",
|
||||||
userCount_one: "{{count}} user",
|
userCount_one: "{{count}} user",
|
||||||
userCount_other: "{{count}} users",
|
userCount_other: "{{count}} users",
|
||||||
|
// Jobs — one-click permission bundles each module brings; the grid stays the fine-tune.
|
||||||
|
jobs: "Jobs",
|
||||||
|
jobsHint: "A job adds its permissions in one click; fine-tune below. Tap it again to remove them.",
|
||||||
|
lintMixedTills: "This role can open more than one till ({{tills}}) — one person, two drawers. Intended?",
|
||||||
|
lintPartialJob: "Partial \"{{job}}\": missing {{missing}} — this desk can look but not act.",
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
"booth-operator": "Booth operator",
|
||||||
|
"booth-supervisor": "Booth supervisor",
|
||||||
|
merchant: "Merchant (validation)",
|
||||||
|
"wash-operator": "Wash operator",
|
||||||
},
|
},
|
||||||
shift: {
|
shift: {
|
||||||
label: "Shift:",
|
label: "Shift:",
|
||||||
@@ -880,6 +983,18 @@ export const en: Catalog = {
|
|||||||
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
|
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
|
||||||
openNow: "Open shift now",
|
openNow: "Open shift now",
|
||||||
opening: "Opening…",
|
opening: "Opening…",
|
||||||
|
// Tills — a shift belongs to a drawer (booth / wash desk), not the site.
|
||||||
|
tillOpen: "Open {{till}} shift",
|
||||||
|
tillClose: "Close {{till}} shift",
|
||||||
|
tillHeldByShort: "{{till}}: {{operator}}",
|
||||||
|
tillNoShift: "No {{till}} shift",
|
||||||
|
},
|
||||||
|
till: {
|
||||||
|
booth: "Booth",
|
||||||
|
carwash: "Wash",
|
||||||
|
boothLong: "Booth till",
|
||||||
|
carwashLong: "Wash till",
|
||||||
|
all: "All tills",
|
||||||
},
|
},
|
||||||
shifts: {
|
shifts: {
|
||||||
title: "Shift history",
|
title: "Shift history",
|
||||||
|
|||||||
+118
-3
@@ -45,11 +45,96 @@ export const sq = {
|
|||||||
signIn: "Hyr",
|
signIn: "Hyr",
|
||||||
signingIn: "Duke hyrë…",
|
signingIn: "Duke hyrë…",
|
||||||
},
|
},
|
||||||
|
connect: {
|
||||||
|
title: "Lidhu me serverin",
|
||||||
|
hint: "Vendos adresën e serverit të sistemit të parkimit për këtë kabinë.",
|
||||||
|
serverAddress: "Adresa e serverit",
|
||||||
|
test: "Testo",
|
||||||
|
testing: "Duke testuar…",
|
||||||
|
save: "Ruaj & vazhdo",
|
||||||
|
saving: "Duke ruajtur…",
|
||||||
|
testOk: "I arritshëm — duket si server i Sistemit të Parkimit.",
|
||||||
|
testUnreachable: "Nuk u arrit kjo adresë.",
|
||||||
|
testBadResponse: "I arritshëm, por nuk duket si server i Sistemit të Parkimit.",
|
||||||
|
changeServer: "Ndrysho serverin",
|
||||||
|
changeServerConfirm: "Kjo do t'ju dalë nga sesioni dhe do kërkojë adresë të re serveri në hapjen tjetër. Vazhdo?",
|
||||||
|
},
|
||||||
|
modules: {
|
||||||
|
sectionTitle: "Modulet",
|
||||||
|
sectionHint: "Pjesët opsionale të sistemit që përdor ky park. Çfarë mund të aktivizohet këtu vendoset gjatë instalimit; çaktivizimi e fsheh modulin dhe refuzon veprimet e tij — asgjë nuk fshihet.",
|
||||||
|
required: "Gjithmonë aktiv.",
|
||||||
|
requires: "Kërkon: {{deps}}",
|
||||||
|
name: {
|
||||||
|
parking: "Parkimi",
|
||||||
|
validation: "Validime tregtare (Bar)",
|
||||||
|
carwash: "Lavazh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wash: {
|
||||||
|
tillTitle: "Arka e lavazhit",
|
||||||
|
tillHint: "Paratë e marra te lavazhi regjistrohen në arkën e lavazhit — hap fillimisht turnin e lavazhit. Turni i kabinës nuk vlen.",
|
||||||
|
tillOtherHint: "{{operator}} e ka turnin e lavazhit; vetëm ai mund të marrë para te lavazhi.",
|
||||||
|
drawerNow: "Arka e lavazhit tani",
|
||||||
|
intake: "Lavazh i ri",
|
||||||
|
ticketPh: "Bileta e parkimit (skano ose shkruaj)",
|
||||||
|
lookup: "Kërko",
|
||||||
|
notFound: "Nuk ka sesion për këtë biletë.",
|
||||||
|
closed: "Ky sesion është mbyllur.",
|
||||||
|
ticket: "Bileta",
|
||||||
|
plate: "Targa",
|
||||||
|
enteredAt: "Hyri",
|
||||||
|
alreadyOpen: "Kjo biletë ka tashmë një porosi lavazhi të hapur.",
|
||||||
|
category: "Kategoria e mjetit",
|
||||||
|
service: "Shërbimi",
|
||||||
|
price: "Çmimi",
|
||||||
|
noPrice: "nuk ka çmim për këtë kombinim",
|
||||||
|
payAt: "Pagesa",
|
||||||
|
payAtBooth: "Në kabinë",
|
||||||
|
payAtBay: "Në lavazh",
|
||||||
|
payAtBoothHint: "Shtohet në llogarinë e parkimit; barriera e daljes hapet pas pagesës në kabinë.",
|
||||||
|
payAtBayHint: "Paratë merren këtu; klienti del duke skanuar biletën te lexuesi i daljes (sponsorizimi i parkimit duhet ta mbulojë tarifën).",
|
||||||
|
create: "Krijo porosinë",
|
||||||
|
created: "Porosia u krijua.",
|
||||||
|
queue: "Porositë e hapura",
|
||||||
|
empty: "Asgjë për të larë.",
|
||||||
|
time: "Ora",
|
||||||
|
what: "Lavazhi",
|
||||||
|
status: "Statusi",
|
||||||
|
statusOpen: "në proces",
|
||||||
|
statusDone: "mbaroi",
|
||||||
|
paid: "paguar",
|
||||||
|
unpaid: "papaguar",
|
||||||
|
done: "Mbaroi",
|
||||||
|
payCash: "Paguar cash",
|
||||||
|
payCard: "Paguar me kartë",
|
||||||
|
void: "Anulo",
|
||||||
|
voidReason: "Arsyeja",
|
||||||
|
categories: "Kategoritë e mjeteve",
|
||||||
|
services: "Shërbimet",
|
||||||
|
prices: "Çmimet",
|
||||||
|
pricesHint: "Një çmim për çdo kategori × shërbim. Lëre bosh një qelizë që ai kombinim të mos shitet.",
|
||||||
|
addCategory: "kategori",
|
||||||
|
addService: "shërbim",
|
||||||
|
active: "aktiv",
|
||||||
|
save: "Ruaj",
|
||||||
|
saved: "U ruajt.",
|
||||||
|
finished: "Të mbaruara",
|
||||||
|
finishedEmpty: "Ende asnjë lavazh i mbaruar.",
|
||||||
|
voided: "anuluar",
|
||||||
|
by: "Nga",
|
||||||
|
cash: "cash",
|
||||||
|
card: "kartë",
|
||||||
|
sponsorship: "Zbritje parkimi",
|
||||||
|
sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.",
|
||||||
|
sponsorshipLabel: "Lavazh",
|
||||||
|
},
|
||||||
update: {
|
update: {
|
||||||
available: "Përditësim i disponueshëm",
|
available: "Përditësim i disponueshëm",
|
||||||
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?",
|
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis? (Instalimi kërkon fjalëkalimin e administratorit.)",
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
wash: "Lavazh",
|
||||||
|
carwash: "Lavazh",
|
||||||
booth: "Kabina",
|
booth: "Kabina",
|
||||||
shift: "Turni",
|
shift: "Turni",
|
||||||
setup: "Konfigurimi",
|
setup: "Konfigurimi",
|
||||||
@@ -237,6 +322,9 @@ export const sq = {
|
|||||||
evtCashReview: "SHQYRTIM",
|
evtCashReview: "SHQYRTIM",
|
||||||
evtConfigChange: "KONFIG",
|
evtConfigChange: "KONFIG",
|
||||||
evtValidation: "VALIDIM",
|
evtValidation: "VALIDIM",
|
||||||
|
evtCarwashOrder: "LAVAZH",
|
||||||
|
evtCarwashPayment: "PAGESË LAVAZHI",
|
||||||
|
charges: "Shtesa",
|
||||||
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||||
evtAnomaly: "ANOMALI",
|
evtAnomaly: "ANOMALI",
|
||||||
evtRefused: "REFUZUAR",
|
evtRefused: "REFUZUAR",
|
||||||
@@ -755,9 +843,8 @@ export const sq = {
|
|||||||
val: {
|
val: {
|
||||||
// /setup/site
|
// /setup/site
|
||||||
sectionTitle: "Validime tregtare",
|
sectionTitle: "Validime tregtare",
|
||||||
sectionHint: "Shërbime të tjera brenda parkut (bar / lavazh) skanojnë biletën e hyrjes dhe bëjnë zbritje — pagesa dhe fatura bëhen në kabinë.",
|
sectionHint: "Bari brenda parkut skanon biletën e hyrjes dhe bën zbritje — pagesa dhe fatura bëhen në kabinë.",
|
||||||
enableBar: "Bar",
|
enableBar: "Bar",
|
||||||
enableLavazh: "Lavazh",
|
|
||||||
labelName: "Etiketa në faturë",
|
labelName: "Etiketa në faturë",
|
||||||
labelNamePh: "p.sh. Lavazh — 1 orë falas",
|
labelNamePh: "p.sh. Lavazh — 1 orë falas",
|
||||||
mode: "Lloji i zbritjes",
|
mode: "Lloji i zbritjes",
|
||||||
@@ -765,6 +852,11 @@ export const sq = {
|
|||||||
modeTimeCredit: "Minutat e para falas",
|
modeTimeCredit: "Minutat e para falas",
|
||||||
modeFixed: "Zbritje shume (shkruhet në skanim)",
|
modeFixed: "Zbritje shume (shkruhet në skanim)",
|
||||||
modePercent: "Zbritje në përqindje",
|
modePercent: "Zbritje në përqindje",
|
||||||
|
modeDoneTolerance: "Falas gjatë lavazhit (+ tolerancë)",
|
||||||
|
modeDoneToleranceHint: "Koha nga porosia e lavazhit deri te \"mbaroi\", plus minutat e tolerancës, zbritet nga parkimi. Koha e parkuar para porosisë dhe pas tolerancës paguhet sipas tarifës.",
|
||||||
|
modeWashPrice: "Çmimi i lavazhit zbritet nga parkimi",
|
||||||
|
modeWashPriceHint: "Tarifa e parkimit minus çmimin e lavazhit; asnjëherë nën zero.",
|
||||||
|
toleranceMinutes: "Toleranca pas mbarimit (minuta)",
|
||||||
minutes: "Minuta falas",
|
minutes: "Minuta falas",
|
||||||
percent: "Përqindja (%)",
|
percent: "Përqindja (%)",
|
||||||
maxAmount: "Tavani i zbritjes për validim",
|
maxAmount: "Tavani i zbritjes për validim",
|
||||||
@@ -833,6 +925,17 @@ export const sq = {
|
|||||||
permCount_other: "{{count}} leje",
|
permCount_other: "{{count}} leje",
|
||||||
userCount_one: "{{count}} përdorues",
|
userCount_one: "{{count}} përdorues",
|
||||||
userCount_other: "{{count}} përdorues",
|
userCount_other: "{{count}} përdorues",
|
||||||
|
// Punët — pako lejesh që sjell çdo modul; rrjeta poshtë mbetet për rregullim të imët.
|
||||||
|
jobs: "Punët",
|
||||||
|
jobsHint: "Një punë shton lejet e saj me një klik; rregulloji poshtë. Kliko sërish për t'i hequr.",
|
||||||
|
lintMixedTills: "Ky rol mund të hapë më shumë se një arkë ({{tills}}) — një person, dy arka. E qëllimshme?",
|
||||||
|
lintPartialJob: "\"{{job}}\" e pjesshme: mungojnë {{missing}} — kjo tavolinë sheh, por nuk vepron.",
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
"booth-operator": "Operator kabine",
|
||||||
|
"booth-supervisor": "Përgjegjës kabine",
|
||||||
|
merchant: "Tregtar (validime)",
|
||||||
|
"wash-operator": "Operator lavazhi",
|
||||||
},
|
},
|
||||||
shift: {
|
shift: {
|
||||||
label: "Turni:",
|
label: "Turni:",
|
||||||
@@ -894,6 +997,18 @@ export const sq = {
|
|||||||
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
|
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
|
||||||
openNow: "Hap turnin tani",
|
openNow: "Hap turnin tani",
|
||||||
opening: "Duke hapur…",
|
opening: "Duke hapur…",
|
||||||
|
// Arkat — turni i përket një arke (kabina / lavazhi), jo gjithë sitit.
|
||||||
|
tillOpen: "Hap turnin e {{till}}",
|
||||||
|
tillClose: "Mbyll turnin e {{till}}",
|
||||||
|
tillHeldByShort: "{{till}}: {{operator}}",
|
||||||
|
tillNoShift: "Pa turn {{till}}",
|
||||||
|
},
|
||||||
|
till: {
|
||||||
|
booth: "kabinës",
|
||||||
|
carwash: "lavazhit",
|
||||||
|
boothLong: "Arka e kabinës",
|
||||||
|
carwashLong: "Arka e lavazhit",
|
||||||
|
all: "Të gjitha arkat",
|
||||||
},
|
},
|
||||||
shifts: {
|
shifts: {
|
||||||
title: "Historiku i turneve",
|
title: "Historiku i turneve",
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
// high-signal sources (failed requests, uncaught errors) are always captured.
|
// high-signal sources (failed requests, uncaught errors) are always captured.
|
||||||
|
|
||||||
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
||||||
|
import { getDesktopCsrfToken } from "./desktop-csrf.js";
|
||||||
|
import { apiUrl, platformFetch } from "./origin.js";
|
||||||
|
import { inTauri } from "./tauri-env.js";
|
||||||
|
|
||||||
const ENDPOINT = "/api/logs";
|
const ENDPOINT = "/api/logs";
|
||||||
const FLUSH_MS = 4000;
|
const FLUSH_MS = 4000;
|
||||||
@@ -74,9 +77,13 @@ async function flush(): Promise<void> {
|
|||||||
flushing = true;
|
flushing = true;
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||||
const csrf = readCookie(CSRF_COOKIE);
|
// /api/logs is behind requireAuth → assertCsrf on POST. On desktop the
|
||||||
|
// cookie is unreadable (see desktop-csrf.ts) — without this branch every
|
||||||
|
// desktop flush 403'd and was dropped here, silently, by design (found
|
||||||
|
// 2026-09-04: no desktop client log had EVER reached app_logs).
|
||||||
|
const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE);
|
||||||
if (csrf) headers[CSRF_HEADER] = csrf;
|
if (csrf) headers[CSRF_HEADER] = csrf;
|
||||||
await fetch(ENDPOINT, {
|
await platformFetch(apiUrl(ENDPOINT), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -90,7 +97,10 @@ async function flush(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). */
|
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). Browser
|
||||||
|
* only — sendBeacon is a native browser API with no Tauri-HTTP-plugin equivalent,
|
||||||
|
* so this drops silently in the desktop shell (unload is rare there; the regular
|
||||||
|
* 4s-interval flush above covers the common case). */
|
||||||
function flushBeacon(): void {
|
function flushBeacon(): void {
|
||||||
if (queue.length === 0) return;
|
if (queue.length === 0) return;
|
||||||
const entries = queue.splice(0, queue.length);
|
const entries = queue.splice(0, queue.length);
|
||||||
@@ -99,7 +109,7 @@ function flushBeacon(): void {
|
|||||||
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
|
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
|
||||||
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
|
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
|
||||||
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
||||||
navigator.sendBeacon(ENDPOINT, blob);
|
navigator.sendBeacon(apiUrl(ENDPOINT), blob);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { AnyRoute } from "@tanstack/react-router";
|
||||||
|
import { watchPermissions, type ModuleId } from "@parking/shared";
|
||||||
|
import { can, type Permission, type SessionUser } from "../api.js";
|
||||||
|
import type { rootRoute } from "../router.js";
|
||||||
|
|
||||||
|
/** The app's root route (type only — a runtime import here would be a cycle). */
|
||||||
|
export type RootRoute = typeof rootRoute;
|
||||||
|
|
||||||
|
// Venue modules — the web side. The server ENFORCES the effective set
|
||||||
|
// (requireModule); this file only decides what to SHOW. A module's nav entries and
|
||||||
|
// routes live in its own folder (apps/web/src/modules/<id>/index.tsx) and are
|
||||||
|
// discovered through WEB_MODULES below, so router.tsx never names a module's screens.
|
||||||
|
// See wiki/decisions/venue-modules.md.
|
||||||
|
|
||||||
|
/** Is the module effective for this session? `modules` comes from /api/auth/me
|
||||||
|
* (entitled ∩ activated); a server too old to send it hides every module rather
|
||||||
|
* than showing something it would 403 — fail closed on the display side too. */
|
||||||
|
export function moduleOn(user: SessionUser | null, id: ModuleId): boolean {
|
||||||
|
return !!user && Array.isArray(user.modules) && user.modules.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** May this role open the live WebSocket at all? Any watch permission (core event/
|
||||||
|
* session/device read, or an effective module's own feed permission). The server
|
||||||
|
* admits by the same rule and then filters what it pushes. NOT report:read. */
|
||||||
|
export function canWatchFeed(user: SessionUser | null): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
const effective = Array.isArray(user.modules) ? user.modules : [];
|
||||||
|
return watchPermissions(effective).some((p) => can(user, p));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebModuleNav {
|
||||||
|
to: string;
|
||||||
|
/** i18n key for the header label. */
|
||||||
|
labelKey: string;
|
||||||
|
/** Shown only if the role holds this permission (and the module is on). */
|
||||||
|
perm: Permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebModule {
|
||||||
|
id: ModuleId;
|
||||||
|
/** Header nav entries, in display order. */
|
||||||
|
nav: readonly WebModuleNav[];
|
||||||
|
/** Where a user whose role has NO booth (`session:read`) lands after login, if the
|
||||||
|
* module is on and the role holds `perm` — e.g. the wash desk for a wash operator,
|
||||||
|
* the scan screen for a merchant. First match in WEB_MODULES order wins. */
|
||||||
|
landing?: WebModuleNav;
|
||||||
|
/** Build this module's routes under the given root. Called once at router
|
||||||
|
* assembly; each route's own beforeLoad must gate on moduleOn + permission. */
|
||||||
|
routes(root: RootRoute): AnyRoute[];
|
||||||
|
/** Setup tabs (under /setup), if the module has admin configuration. */
|
||||||
|
setupNav?: readonly WebModuleNav[];
|
||||||
|
/** Build this module's routes under the /setup layout route. */
|
||||||
|
setupRoutes?(setup: AnyRoute): AnyRoute[];
|
||||||
|
}
|
||||||
@@ -3,16 +3,49 @@
|
|||||||
// In a browser (dev via the Vite proxy, or prod where Fastify serves the built
|
// In a browser (dev via the Vite proxy, or prod where Fastify serves the built
|
||||||
// SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so
|
// SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so
|
||||||
// nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled
|
// nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled
|
||||||
// SPA from `tauri://localhost`, which has no backend and no proxy; there we set
|
// SPA from `tauri://localhost`, which has no backend and no proxy; there the
|
||||||
// VITE_API_BASE to the appliance's Fastify origin (e.g. http://127.0.0.1:3000)
|
// operator enters the appliance's Fastify origin (e.g. http://192.168.1.50:3000)
|
||||||
// at build time so /api and the live WS feed resolve to the real server.
|
// once in the ConnectScreen and it's persisted via tauri-plugin-store (see
|
||||||
|
// backend-config.ts) — a RUNTIME value, not a build-time one, since the same
|
||||||
|
// installer is used across every booth and the backend can move (new box, new
|
||||||
|
// IP) without a rebuild. main.tsx calls initApiBase() before the app mounts.
|
||||||
//
|
//
|
||||||
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
|
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
|
||||||
// WebSocket both read it, so the web app and the desktop shell stay identical
|
// WebSocket both read it, so the web app and the desktop shell stay identical
|
||||||
// except for this one build-time value.
|
// except for this one runtime value.
|
||||||
|
//
|
||||||
|
// platformFetch(): WebKitGTK treats tauri://localhost as a SECURE origin, so a
|
||||||
|
// plain http://192.168.1.50:3000 fetch() from inside it is blocked as mixed
|
||||||
|
// content (a WebKit limitation — CSP's connect-src does NOT override this;
|
||||||
|
// found 2026-09-03 as "Load failed" on every desktop request). Inside Tauri we
|
||||||
|
// dynamically import @tauri-apps/plugin-http's fetch, which routes the request
|
||||||
|
// through Tauri's native side instead of the webview's own fetch, sidestepping
|
||||||
|
// the check entirely. Browser build never imports the plugin (dynamic import,
|
||||||
|
// same pattern as desktop-updater.ts).
|
||||||
|
|
||||||
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative. */
|
import { inTauri } from "./tauri-env.js";
|
||||||
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
|
|
||||||
|
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative
|
||||||
|
* (browser) or not-yet-configured (desktop, before the ConnectScreen runs). */
|
||||||
|
export let API_BASE: string = "";
|
||||||
|
|
||||||
|
/** Desktop only: load the persisted backend URL (if any) before the app
|
||||||
|
* mounts, so the very first fetchMe() call already has the right origin.
|
||||||
|
* No-op in the browser. Returns the loaded value (null = not configured yet,
|
||||||
|
* meaning main.tsx should show the ConnectScreen instead of the normal app). */
|
||||||
|
export async function initApiBase(): Promise<string | null> {
|
||||||
|
if (!inTauri()) return null;
|
||||||
|
const { loadBackendUrl } = await import("./backend-config.js");
|
||||||
|
const saved = await loadBackendUrl();
|
||||||
|
if (saved) API_BASE = saved;
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop only: change the backend origin at runtime (after the operator
|
||||||
|
* saves a new one in Settings) without requiring a full app restart. */
|
||||||
|
export function setApiBase(url: string): void {
|
||||||
|
API_BASE = url.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */
|
/** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */
|
||||||
export function apiUrl(path: string): string {
|
export function apiUrl(path: string): string {
|
||||||
@@ -28,3 +61,19 @@ export function wsUrl(path: string): string {
|
|||||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
return `${proto}//${window.location.host}${path}`;
|
return `${proto}//${window.location.host}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { inTauri };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
|
||||||
|
* shell (see the file header for why the webview's own fetch can't reach
|
||||||
|
* the local backend). Same signature as the global fetch; a plain pass-
|
||||||
|
* through in the browser.
|
||||||
|
*/
|
||||||
|
export async function platformFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||||
|
if (inTauri()) {
|
||||||
|
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
|
||||||
|
return tauriFetch(input, init);
|
||||||
|
}
|
||||||
|
return fetch(input, init);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Desktop-only WebSocket adapter.
|
||||||
|
//
|
||||||
|
// WebKitGTK treats tauri://localhost as a SECURE origin, so a plain
|
||||||
|
// ws://127.0.0.1:3000 connection from inside it is blocked as mixed content —
|
||||||
|
// same root cause as the HTTP fetch() issue (see origin.ts's platformFetch),
|
||||||
|
// but WS is a separate browser check with its own plugin
|
||||||
|
// (@tauri-apps/plugin-websocket), which routes the connection through Tauri's
|
||||||
|
// native side instead of the webview's own WebSocket.
|
||||||
|
//
|
||||||
|
// That plugin's API is async/listener-based, not the synchronous
|
||||||
|
// onopen/onmessage/onclose event surface use-live-feed.ts is written against
|
||||||
|
// (and has already been hardened for — reconnect backoff, StrictMode
|
||||||
|
// double-invoke, cleanup). Rather than rewrite that hook around a different
|
||||||
|
// API shape, this adapter presents the same native-WebSocket-like interface
|
||||||
|
// use-live-feed.ts already expects, so that hook needs no changes at all.
|
||||||
|
//
|
||||||
|
// Browser build: plain pass-through to the real WebSocket (this file's
|
||||||
|
// createPlatformSocket is only called from inside inTauri() callers).
|
||||||
|
|
||||||
|
import { fetchWsTicket } from "../api.js";
|
||||||
|
import { logClient } from "./logger.js";
|
||||||
|
import { inTauri } from "./tauri-env.js";
|
||||||
|
|
||||||
|
/** Rate-limit the "connect failed" log: use-live-feed reconnects every ≤10s
|
||||||
|
* forever, and each attempt is a fresh adapter, so without this an outage
|
||||||
|
* would write six near-identical app_logs rows a minute. */
|
||||||
|
const CONNECT_FAIL_LOG_INTERVAL_MS = 60_000;
|
||||||
|
let lastConnectFailLogAt = 0;
|
||||||
|
|
||||||
|
export interface PlatformSocket {
|
||||||
|
onopen: (() => void) | null;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null;
|
||||||
|
onclose: (() => void) | null;
|
||||||
|
onerror: (() => void) | null;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NativeSocketAdapter implements PlatformSocket {
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
#sock: WebSocket;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.#sock = new WebSocket(url);
|
||||||
|
this.#sock.onopen = () => this.onopen?.();
|
||||||
|
this.#sock.onmessage = (ev) => this.onmessage?.({ data: ev.data as string });
|
||||||
|
this.#sock.onclose = () => this.onclose?.();
|
||||||
|
this.#sock.onerror = () => this.onerror?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.#sock.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TauriSocketAdapter implements PlatformSocket {
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
#closed = false;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
#conn: any = null;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
void this.#connect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #connect(url: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket");
|
||||||
|
if (this.#closed) return; // close() called before connect resolved
|
||||||
|
// The native WS plugin is a bare tungstenite client: no page context AND
|
||||||
|
// no cookie jar. Two consequences, both handled via explicit headers:
|
||||||
|
// - Origin: nothing auto-attaches `Origin: tauri://localhost` the way a
|
||||||
|
// browser WebSocket would, and routes/ws.ts's anti-CSWSH check rejects a
|
||||||
|
// missing/mismatched Origin — so set it to match WS_ALLOWED_ORIGINS.
|
||||||
|
// - Session: the HttpOnly JWT cookie lives in tauri-plugin-http's reqwest
|
||||||
|
// jar and can't ride on this handshake, so jwtVerify() would 401 every
|
||||||
|
// connect (the 2026-09-04 "reconnects every 10s forever" bug). Instead,
|
||||||
|
// mint a single-use ticket over normal HTTP auth and present it in the
|
||||||
|
// x-ws-ticket header (see routes/ws.ts).
|
||||||
|
const ticket = await fetchWsTicket();
|
||||||
|
if (this.#closed) return;
|
||||||
|
const conn = await TauriWebSocket.connect(url, {
|
||||||
|
headers: { Origin: "tauri://localhost", "x-ws-ticket": ticket },
|
||||||
|
});
|
||||||
|
if (this.#closed) {
|
||||||
|
void conn.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#conn = conn;
|
||||||
|
conn.addListener((msg: { type: string; data: unknown }) => {
|
||||||
|
if (msg.type === "Text") {
|
||||||
|
this.onmessage?.({ data: msg.data as string });
|
||||||
|
} else if (msg.type === "Close") {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
// Binary/Ping/Pong: the server protocol here is text-JSON only (see
|
||||||
|
// routes/ws.ts) — nothing else is expected.
|
||||||
|
});
|
||||||
|
this.onopen?.();
|
||||||
|
} catch (err) {
|
||||||
|
// logClient, not console.error: console output only reaches app_logs at
|
||||||
|
// debug/trace level, which is how the ticket-less 401 stayed invisible
|
||||||
|
// for a full day. A closed-before-connect race isn't a failure.
|
||||||
|
if (!this.#closed && Date.now() - lastConnectFailLogAt > CONNECT_FAIL_LOG_INTERVAL_MS) {
|
||||||
|
lastConnectFailLogAt = Date.now();
|
||||||
|
logClient({
|
||||||
|
level: "error",
|
||||||
|
message: `desktop live-feed connect failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
context: { kind: "desktop_ws_connect_failed", url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.onerror?.();
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.#closed = true;
|
||||||
|
void this.#conn?.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a live-feed socket, routed through the Tauri WebSocket plugin inside the
|
||||||
|
* desktop shell (mixed-content workaround), or the native WebSocket in a browser. */
|
||||||
|
export function createPlatformSocket(url: string): PlatformSocket {
|
||||||
|
return inTauri() ? new TauriSocketAdapter(url) : new NativeSocketAdapter(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** True when running inside the Tauri webview (not a normal browser). Single
|
||||||
|
* source for this check — origin.ts, platform-ws.ts, desktop-updater.ts, and
|
||||||
|
* backend-config.ts all gate their Tauri-only code paths on it. */
|
||||||
|
export function inTauri(): boolean {
|
||||||
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
|||||||
import { qk } from "./query.js";
|
import { qk } from "./query.js";
|
||||||
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
||||||
import { wsUrl } from "./origin.js";
|
import { wsUrl } from "./origin.js";
|
||||||
|
import { createPlatformSocket, type PlatformSocket } from "./platform-ws.js";
|
||||||
|
|
||||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||||
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
|
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
|
||||||
@@ -14,8 +15,9 @@ import { wsUrl } from "./origin.js";
|
|||||||
|
|
||||||
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||||
type WsMessage =
|
type WsMessage =
|
||||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
|
// Parts a role may not see arrive as null (the server filters per role — ws.ts).
|
||||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
| { kind: "hello"; occupancy: Occupancy | null; devices: DeviceStatus[] | null; lanes: LaneStatus | null; radar: LanePresence | null }
|
||||||
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy | null }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: DeviceStatus }
|
| { kind: "device-status"; event: DeviceStatus }
|
||||||
| { kind: "lane-status"; lanes: LaneStatus }
|
| { kind: "lane-status"; lanes: LaneStatus }
|
||||||
@@ -36,7 +38,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
|||||||
useLiveStore();
|
useLiveStore();
|
||||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
// double-invoke and unmount.
|
// double-invoke and unmount.
|
||||||
const sockRef = useRef<WebSocket | null>(null);
|
const sockRef = useRef<PlatformSocket | null>(null);
|
||||||
const retryRef = useRef(0);
|
const retryRef = useRef(0);
|
||||||
const closedRef = useRef(false);
|
const closedRef = useRef(false);
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
|||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (closedRef.current) return;
|
if (closedRef.current) return;
|
||||||
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
||||||
const sock = new WebSocket(wsUrl("/api/ws"));
|
const sock = createPlatformSocket(wsUrl("/api/ws"));
|
||||||
sockRef.current = sock;
|
sockRef.current = sock;
|
||||||
|
|
||||||
sock.onopen = () => {
|
sock.onopen = () => {
|
||||||
@@ -66,7 +68,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
|||||||
return; // ignore malformed frames
|
return; // ignore malformed frames
|
||||||
}
|
}
|
||||||
if (msg.kind === "hello") {
|
if (msg.kind === "hello") {
|
||||||
setOccupancy(msg.occupancy);
|
if (msg.occupancy) setOccupancy(msg.occupancy);
|
||||||
// Initial device-status snapshot for the footer.
|
// Initial device-status snapshot for the footer.
|
||||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||||
if (msg.lanes) setLanes(msg.lanes);
|
if (msg.lanes) setLanes(msg.lanes);
|
||||||
@@ -83,7 +85,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
|||||||
patchPlate(msg.plate.identity, msg.plate.plate);
|
patchPlate(msg.plate.identity, msg.plate.plate);
|
||||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||||
} else if (msg.kind === "ledger") {
|
} else if (msg.kind === "ledger") {
|
||||||
setOccupancy(msg.occupancy);
|
if (msg.occupancy) setOccupancy(msg.occupancy);
|
||||||
pushEvent(msg.event);
|
pushEvent(msg.event);
|
||||||
// Keep Query authoritative: the durable event list, occupancy totals,
|
// Keep Query authoritative: the durable event list, occupancy totals,
|
||||||
// and active-sessions list refetch on the next read instead of trusting
|
// and active-sessions list refetch on the next read instead of trusting
|
||||||
@@ -98,7 +100,8 @@ export function useLiveFeed(enabled: boolean = true): void {
|
|||||||
msg.event.type === "shift_z_report" ||
|
msg.event.type === "shift_z_report" ||
|
||||||
msg.event.type === "cash_movement" ||
|
msg.event.type === "cash_movement" ||
|
||||||
msg.event.type === "cash_in" ||
|
msg.event.type === "cash_in" ||
|
||||||
msg.event.type === "cash_out"
|
msg.event.type === "cash_out" ||
|
||||||
|
msg.event.type === "carwash_payment"
|
||||||
) {
|
) {
|
||||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchShift, type ShiftStatus } from "../api.js";
|
import { fetchShift, type ShiftStatus, type TillId } from "../api.js";
|
||||||
import { qk } from "./query.js";
|
import { qk } from "./query.js";
|
||||||
|
|
||||||
// Shared shift status for the whole app — the header control, the booth screen's
|
// Shared shift status for the whole app — the header control, the booth screen's
|
||||||
// per-shift log scope, and the pay/exit modal's gate all read this one Query so
|
// per-shift log scope, and the pay/exit modal's gate all read this one Query so
|
||||||
// they never disagree about whether a shift is open and whose it is. A shift is a
|
// they never disagree about whether a shift is open and whose it is. A shift is a
|
||||||
// SITE-WIDE single-open accountability period (at most one open at a time). The WS
|
// per-TILL single-open accountability period (at most one open per till). The
|
||||||
// invalidates qk.shift on shift_open/shift_z_report/cash_movement, so this stays
|
// default till is the booth; the wash desk reads its own (`useShift("carwash")`).
|
||||||
// live without polling. See wiki/concepts/shift.md.
|
// The WS invalidates qk.shift (a prefix, so every till) on shift_open/shift_z_report/
|
||||||
|
// cash movements, so this stays live without polling. See wiki/concepts/shift.md.
|
||||||
|
|
||||||
export interface ShiftState {
|
export interface ShiftState {
|
||||||
/** Raw status from the server (null while loading / on error). */
|
/** Raw status from the server (null while loading / on error). */
|
||||||
status: ShiftStatus | undefined;
|
status: ShiftStatus | undefined;
|
||||||
/** Is ANY shift open site-wide? */
|
/** Is a shift open on this till? */
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
|
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
|
||||||
isMine: boolean;
|
isMine: boolean;
|
||||||
@@ -25,8 +26,12 @@ export interface ShiftState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useShift(): ShiftState {
|
/** Query key of one till's shift status — under the qk.shift prefix so the WS
|
||||||
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift });
|
* invalidation reaches every till. */
|
||||||
|
export const shiftKey = (till: TillId) => [...qk.shift, "current", till] as const;
|
||||||
|
|
||||||
|
export function useShift(till: TillId = "booth"): ShiftState {
|
||||||
|
const q = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
|
||||||
const s = q.data;
|
const s = q.data;
|
||||||
const isOpen = s?.open != null;
|
const isOpen = s?.open != null;
|
||||||
const isMine = s?.isMine ?? false;
|
const isMine = s?.isMine ?? false;
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, type CarWashPayAt } from "@parking/shared";
|
||||||
|
import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
|
||||||
|
import { StationForm, defaultProgram } from "../../ValidationSetup.js";
|
||||||
|
import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js";
|
||||||
|
|
||||||
|
// Setup → Car wash: the master data (vehicle categories, services, the category ×
|
||||||
|
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
|
||||||
|
// validation program (id "carwash"), composed with the same editor the merchant
|
||||||
|
// programs use. See wiki/decisions/venue-modules.md ("v1 answers").
|
||||||
|
|
||||||
|
type Item = { id?: string; name: string; active: boolean };
|
||||||
|
|
||||||
|
const fromMinor = (v: number | undefined): string => (v == null ? "" : (v / 100).toFixed(2).replace(/\.00$/, ""));
|
||||||
|
const toMinor = (s: string): number | null => {
|
||||||
|
const v = s.trim();
|
||||||
|
if (v === "") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ListEditor({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
onChange,
|
||||||
|
addLabel,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: Item[];
|
||||||
|
onChange: (items: Item[]) => void;
|
||||||
|
addLabel: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div>
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<div key={it.id ?? `new-${i}`} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={it.name}
|
||||||
|
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1 text-[0.75rem] text-term-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={it.active}
|
||||||
|
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, active: e.target.checked } : x)))}
|
||||||
|
/>
|
||||||
|
{t("wash.active")}
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => onChange(items.filter((_, j) => j !== i))}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
|
||||||
|
+ {addLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
|
||||||
|
const [categories, setCategories] = useState<Item[]>([]);
|
||||||
|
const [services, setServices] = useState<Item[]>([]);
|
||||||
|
/** Price inputs keyed "categoryId|serviceId" (major units as typed). New rows have no
|
||||||
|
* id yet, so the matrix keys use the row INDEX until saved. */
|
||||||
|
const [prices, setPrices] = useState<Record<string, string>>({});
|
||||||
|
/** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */
|
||||||
|
const [payAt, setPayAt] = useState<CarWashPayAt>("booth");
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [program, setProgram] = useState<ValidationProgramView | null>(null);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
fetchCarwashSettings()
|
||||||
|
.then((s) => {
|
||||||
|
setSettings(s);
|
||||||
|
setCategories(s.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
|
||||||
|
setServices(s.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
|
||||||
|
const p: Record<string, string> = {};
|
||||||
|
for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
|
||||||
|
setPrices(p);
|
||||||
|
setPayAt(s.payAt);
|
||||||
|
})
|
||||||
|
.catch((e) => setMsg((e as Error).message));
|
||||||
|
fetchValidationPrograms()
|
||||||
|
.then((r) => {
|
||||||
|
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
|
||||||
|
setProgram(existing ?? { id: CARWASH_PROGRAM_ID, ...defaultProgram(CARWASH_PROGRAM_ID, t("wash.sponsorshipLabel")) });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
useEffect(load, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const keyOf = (c: Item, ci: number, s: Item, si: number) => `${c.id ?? `#${ci}`}|${s.id ?? `#${si}`}`;
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const listBody = {
|
||||||
|
categories: categories.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
|
||||||
|
services: services.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
|
||||||
|
};
|
||||||
|
// New rows have no id until the server assigns one, and the price matrix is keyed
|
||||||
|
// by ids — so save the lists first, map each new row to the id that came back (the
|
||||||
|
// server returns rows in the order sent), then save the prices in a second call.
|
||||||
|
// One button, two requests; the user just sees "Saved."
|
||||||
|
let cats = categories;
|
||||||
|
let svcs = services;
|
||||||
|
if (categories.some((c) => !c.id) || services.some((s) => !s.id)) {
|
||||||
|
// Keep only the prices whose rows survive this save (a removed row's prices
|
||||||
|
// would be refused as unknown ids).
|
||||||
|
const keepC = new Set(categories.map((c) => c.id).filter(Boolean));
|
||||||
|
const keepS = new Set(services.map((s) => s.id).filter(Boolean));
|
||||||
|
const first = await saveCarwashSettings({
|
||||||
|
...listBody,
|
||||||
|
prices: (settings?.prices ?? []).filter((p) => keepC.has(p.categoryId) && keepS.has(p.serviceId)),
|
||||||
|
});
|
||||||
|
cats = categories.map((c, i) => ({ ...c, id: c.id ?? first.categories[i]?.id }));
|
||||||
|
svcs = services.map((s, i) => ({ ...s, id: s.id ?? first.services[i]?.id }));
|
||||||
|
}
|
||||||
|
const priceRows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
|
||||||
|
categories.forEach((c, ci) =>
|
||||||
|
services.forEach((s, si) => {
|
||||||
|
const v = toMinor(prices[keyOf(c, ci, s, si)] ?? "");
|
||||||
|
const cid = cats[ci]?.id;
|
||||||
|
const sid = svcs[si]?.id;
|
||||||
|
if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const saved = await saveCarwashSettings({
|
||||||
|
categories: cats.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
|
||||||
|
services: svcs.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
|
||||||
|
prices: priceRows,
|
||||||
|
payAt,
|
||||||
|
});
|
||||||
|
setSettings(saved);
|
||||||
|
setPayAt(saved.payAt);
|
||||||
|
setCategories(saved.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
|
||||||
|
setServices(saved.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
|
||||||
|
const p: Record<string, string> = {};
|
||||||
|
for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
|
||||||
|
setPrices(p);
|
||||||
|
setMsg(t("wash.saved"));
|
||||||
|
} catch (e) {
|
||||||
|
setMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = settings?.currency ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||||
|
<section className="card w-full max-w-2xl p-4">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} />
|
||||||
|
<ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
{t("wash.prices")} {currency && <span className="normal-case tracking-normal">({currency})</span>}
|
||||||
|
</div>
|
||||||
|
<span className="hint">{t("wash.pricesHint")}</span>
|
||||||
|
{categories.length > 0 && services.length > 0 && (
|
||||||
|
<div className="mt-2 overflow-x-auto">
|
||||||
|
<table className="text-[0.75rem]">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 pr-3 text-left text-term-muted"></th>
|
||||||
|
{services.map((s, si) => (
|
||||||
|
<th key={s.id ?? `#${si}`} className="py-1 pr-3 text-left">{s.name || "…"}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categories.map((c, ci) => (
|
||||||
|
<tr key={c.id ?? `#${ci}`}>
|
||||||
|
<td className="py-1 pr-3 font-semibold">{c.name || "…"}</td>
|
||||||
|
{services.map((s, si) => {
|
||||||
|
const k = keyOf(c, ci, s, si);
|
||||||
|
return (
|
||||||
|
<td key={k} className="py-1 pr-3">
|
||||||
|
<input
|
||||||
|
className="input w-24 text-right tabular-nums"
|
||||||
|
value={prices[k] ?? ""}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setPrices((p) => ({ ...p, [k]: e.target.value }))}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("wash.payAt")}</span>
|
||||||
|
<div className="flex gap-4 text-[0.75rem]">
|
||||||
|
{CARWASH_PAY_AT.map((v) => (
|
||||||
|
<label key={v} className="flex items-center gap-1.5">
|
||||||
|
<input type="radio" name="carwash-payAt" className="accent-term-amber" checked={payAt === v} disabled={!canEdit} onChange={() => setPayAt(v)} />
|
||||||
|
{t(v === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("wash.save")}</button>
|
||||||
|
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{canEdit && program && (
|
||||||
|
<section className="card w-full max-w-md p-4">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.sponsorship")}</div>
|
||||||
|
<span className="hint">{t("wash.sponsorshipHint")}</span>
|
||||||
|
<div className="mt-2">
|
||||||
|
<StationForm program={program} onSaved={setProgram} hideUsers modes={CARWASH_VALIDATION_MODES} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Tender } from "@parking/shared";
|
||||||
|
import { formatMoney } from "../../lib/format.js";
|
||||||
|
import { useShift } from "../../lib/use-shift.js";
|
||||||
|
import { ShiftButton } from "../../ShiftControl.js";
|
||||||
|
import {
|
||||||
|
createCarwashOrder,
|
||||||
|
fetchCarwashOrders,
|
||||||
|
fetchCarwashSettings,
|
||||||
|
lookupCarwashTicket,
|
||||||
|
markCarwashDone,
|
||||||
|
payCarwashAtBay,
|
||||||
|
voidCarwashOrder,
|
||||||
|
type CarwashOrderView,
|
||||||
|
type CarwashSettingsView,
|
||||||
|
type CarwashTicketLookup,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
// The wash desk (/wash): intake a wash against a parking ticket (category × service →
|
||||||
|
// price, where the money is taken), then work the queue — a plain list of open orders,
|
||||||
|
// oldest first: Done / Pay at bay / Void. Bay money lands on the WASH TILL: the desk
|
||||||
|
// carries that till's own shift control, and the pay buttons are gated on the wash
|
||||||
|
// operator's shift (the booth's shift does not cover the bay — the two drawers
|
||||||
|
// reconcile separately). See wiki/decisions/venue-modules.md + shift.md "Tills".
|
||||||
|
|
||||||
|
const QK = ["carwash", "orders"] as const;
|
||||||
|
|
||||||
|
function timeOf(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WashDesk() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
|
||||||
|
const [ticket, setTicket] = useState("");
|
||||||
|
const [lookup, setLookup] = useState<CarwashTicketLookup | null>(null);
|
||||||
|
const [categoryId, setCategoryId] = useState("");
|
||||||
|
const [serviceId, setServiceId] = useState("");
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [voiding, setVoiding] = useState<{ id: string; reason: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCarwashSettings().then(setSettings).catch((e) => setMsg((e as Error).message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const orders = useQuery({
|
||||||
|
queryKey: QK,
|
||||||
|
queryFn: () => fetchCarwashOrders("open"),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
// Finished washes (done + paid, or voided) — the most recent ones, newest first, so
|
||||||
|
// the desk can answer "did we wash that car?" without leaving the screen.
|
||||||
|
const finished = useQuery({
|
||||||
|
queryKey: [...QK, "recent"],
|
||||||
|
queryFn: () => fetchCarwashOrders("recent"),
|
||||||
|
refetchInterval: 15000,
|
||||||
|
select: (r) => r.orders.filter((o) => o.closed).slice(0, 50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = useMemo(() => (settings?.categories ?? []).filter((c) => c.active), [settings]);
|
||||||
|
const services = useMemo(() => (settings?.services ?? []).filter((s) => s.active), [settings]);
|
||||||
|
const price = useMemo(
|
||||||
|
() => settings?.prices.find((p) => p.categoryId === categoryId && p.serviceId === serviceId) ?? null,
|
||||||
|
[settings, categoryId, serviceId],
|
||||||
|
);
|
||||||
|
const currency = settings?.currency ?? lookup?.currency ?? null;
|
||||||
|
|
||||||
|
async function doLookup(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setMsg(null);
|
||||||
|
if (!ticket.trim()) return;
|
||||||
|
try {
|
||||||
|
setLookup(await lookupCarwashTicket(ticket));
|
||||||
|
} catch (err) {
|
||||||
|
setMsg((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: QK }); // also matches [...QK, "recent"]
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createCarwashOrder({ identity: lookup!.identity, categoryId, serviceId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setMsg(t("wash.created"));
|
||||||
|
setLookup(null);
|
||||||
|
setTicket("");
|
||||||
|
void invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => setMsg((e as Error).message),
|
||||||
|
});
|
||||||
|
const done = useMutation({
|
||||||
|
mutationFn: (id: string) => markCarwashDone(id),
|
||||||
|
onSuccess: () => void invalidate(),
|
||||||
|
onError: (e) => setMsg((e as Error).message),
|
||||||
|
});
|
||||||
|
const pay = useMutation({
|
||||||
|
mutationFn: ({ id, tender }: { id: string; tender: Tender }) => payCarwashAtBay(id, tender),
|
||||||
|
onSuccess: () => void invalidate(),
|
||||||
|
onError: (e) => setMsg((e as Error).message),
|
||||||
|
});
|
||||||
|
const voidIt = useMutation({
|
||||||
|
mutationFn: ({ id, reason }: { id: string; reason: string }) => voidCarwashOrder(id, reason),
|
||||||
|
onSuccess: () => {
|
||||||
|
setVoiding(null);
|
||||||
|
void invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => setMsg((e as Error).message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const canCreate =
|
||||||
|
lookup?.found && lookup.open && !!categoryId && !!serviceId && price != null && !create.isPending;
|
||||||
|
|
||||||
|
// The wash till's shift: money at the bay is only takeable while MY wash shift is open.
|
||||||
|
const washShift = useShift("carwash");
|
||||||
|
const canTakeMoney = washShift.isMine;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||||
|
<section className="flex w-full flex-wrap items-center gap-3 rounded-term border border-term-border bg-term-panel-2 px-4 py-2">
|
||||||
|
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.tillTitle")}</span>
|
||||||
|
<ShiftButton till="carwash" />
|
||||||
|
{washShift.status && (
|
||||||
|
<span className="text-[0.75rem] tabular-nums text-term-muted">
|
||||||
|
{t("wash.drawerNow")}{" "}
|
||||||
|
<span className="font-semibold text-term-text">
|
||||||
|
{formatMoney(washShift.status.drawerMinor, washShift.status.currency ?? currency ?? "")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="basis-full text-[0.6875rem] text-term-muted">
|
||||||
|
{washShift.blockedByOther ? t("wash.tillOtherHint", { operator: washShift.heldBy ?? "?" }) : t("wash.tillHint")}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card w-full max-w-md p-4">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.intake")}</div>
|
||||||
|
<form onSubmit={doLookup} className="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={ticket}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTicket(e.target.value);
|
||||||
|
setLookup(null);
|
||||||
|
}}
|
||||||
|
placeholder={t("wash.ticketPh")}
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-sm" disabled={!ticket.trim()}>
|
||||||
|
{t("wash.lookup")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{lookup && !lookup.found && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.notFound")}</p>}
|
||||||
|
{lookup?.found && !lookup.open && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.closed")}</p>}
|
||||||
|
{lookup?.found && lookup.open && (
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-term-muted">{t("wash.ticket")}</span>
|
||||||
|
<span className="font-mono">{lookup.identity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-term-muted">{t("wash.plate")}</span>
|
||||||
|
<span className="font-mono">{lookup.plate ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
{lookup.enteredAt && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-term-muted">{t("wash.enteredAt")}</span>
|
||||||
|
<span className="tabular-nums">{timeOf(lookup.enteredAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lookup.orders.filter((o) => !o.closed).length > 0 && (
|
||||||
|
<div className="mt-1 text-term-amber">{t("wash.alreadyOpen")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("wash.category")}</span>
|
||||||
|
<select className="select" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("wash.service")}</span>
|
||||||
|
<select className="select" value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{services.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[0.8125rem]">
|
||||||
|
<span className="text-term-muted">{t("wash.price")}</span>
|
||||||
|
<span className="font-semibold tabular-nums">
|
||||||
|
{price && currency ? formatMoney(price.priceMinor, currency) : categoryId && serviceId ? t("wash.noPrice") : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Where the money is taken is the SITE's setting (Setup → Car wash), shown
|
||||||
|
here so the operator knows what this order will do — never chosen per order. */}
|
||||||
|
<div className="flex items-center justify-between text-[0.75rem]">
|
||||||
|
<span className="text-term-muted">{t("wash.payAt")}</span>
|
||||||
|
<span>{settings ? t(settings.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay") : "—"}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={!canCreate} onClick={() => create.mutate()}>
|
||||||
|
{t("wash.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && <p className="mt-3 text-[0.75rem] text-term-muted">{msg}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card w-full max-w-3xl p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.queue")}</div>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void orders.refetch()}>↻</button>
|
||||||
|
</div>
|
||||||
|
{(orders.data?.orders ?? []).length === 0 ? (
|
||||||
|
<p className="mt-3 text-[0.75rem] text-term-muted">{t("wash.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 overflow-x-auto">
|
||||||
|
<table className="w-full text-[0.75rem]">
|
||||||
|
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 pr-3">{t("wash.time")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.ticket")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.plate")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.what")}</th>
|
||||||
|
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.payAt")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.status")}</th>
|
||||||
|
<th className="py-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(orders.data?.orders ?? []).map((o: CarwashOrderView) => (
|
||||||
|
<tr key={o.id} className="border-t border-term-border/50">
|
||||||
|
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.createdAt)}</td>
|
||||||
|
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
|
||||||
|
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
|
||||||
|
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
|
||||||
|
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
|
||||||
|
<td className="py-1.5 pr-3">
|
||||||
|
<span className={o.status === "done" ? "text-term-green" : "text-term-amber"}>
|
||||||
|
{t(o.status === "done" ? "wash.statusDone" : "wash.statusOpen")}
|
||||||
|
</span>
|
||||||
|
<span className="text-term-muted"> · </span>
|
||||||
|
<span className={o.paidAt ? "text-term-green" : "text-term-muted"}>
|
||||||
|
{t(o.paidAt ? "wash.paid" : "wash.unpaid")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5">
|
||||||
|
<div className="flex flex-wrap justify-end gap-1">
|
||||||
|
{o.status === "open" && (
|
||||||
|
<button type="button" className="btn btn-sm btn-primary" disabled={done.isPending} onClick={() => done.mutate(o.id)}>
|
||||||
|
{t("wash.done")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{o.payAt === "bay" && !o.paidAt && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "cash" })}>
|
||||||
|
{t("wash.payCash")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "card" })}>
|
||||||
|
{t("wash.payCard")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!o.paidAt && (
|
||||||
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setVoiding({ id: o.id, reason: "" })}>
|
||||||
|
{t("wash.void")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{voiding?.id === o.id && (
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={voiding.reason}
|
||||||
|
placeholder={t("wash.voidReason")}
|
||||||
|
onChange={(e) => setVoiding({ id: o.id, reason: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-sm btn-danger" disabled={voidIt.isPending} onClick={() => voidIt.mutate(voiding)}>
|
||||||
|
{t("wash.void")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setVoiding(null)}>
|
||||||
|
{t("subs.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.finished")}</div>
|
||||||
|
{(finished.data ?? []).length === 0 ? (
|
||||||
|
<p className="mt-2 text-[0.75rem] text-term-muted">{t("wash.finishedEmpty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 overflow-x-auto">
|
||||||
|
<table className="w-full text-[0.75rem]">
|
||||||
|
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 pr-3">{t("wash.time")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.ticket")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.plate")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.what")}</th>
|
||||||
|
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.payAt")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.status")}</th>
|
||||||
|
<th className="py-1">{t("wash.by")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-term-muted">
|
||||||
|
{(finished.data ?? []).map((o: CarwashOrderView) => (
|
||||||
|
<tr key={o.id} className="border-t border-term-border/50">
|
||||||
|
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.doneAt ?? o.paidAt ?? o.createdAt)}</td>
|
||||||
|
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
|
||||||
|
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
|
||||||
|
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
|
||||||
|
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
|
||||||
|
<td className="py-1.5 pr-3">
|
||||||
|
{o.status === "void" ? (
|
||||||
|
<span className="text-term-red">{t("wash.voided")}{o.voidReason ? ` · ${o.voidReason}` : ""}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-term-green">
|
||||||
|
{t("wash.statusDone")} · {t("wash.paid")}{o.tender ? ` (${t(o.tender === "card" ? "wash.card" : "wash.cash")})` : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5">{o.status === "void" ? o.voidBy ?? "" : o.paidBy ?? o.doneBy ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { CarWashPayAt, CarwashOrderView, CarwashSettingsView, Tender } from "@parking/shared";
|
||||||
|
import { apiFetch } from "../../api.js";
|
||||||
|
|
||||||
|
// The Car Wash module's API client — module-local so apps/web/src/api.ts (the core
|
||||||
|
// client) never learns about wash endpoints. Shapes come from @parking/shared.
|
||||||
|
|
||||||
|
export type { CarWashPayAt, CarwashOrderView, CarwashSettingsView };
|
||||||
|
|
||||||
|
export interface CarwashTicketLookup {
|
||||||
|
identity: string;
|
||||||
|
found: boolean;
|
||||||
|
open: boolean;
|
||||||
|
subscription: boolean;
|
||||||
|
plate: string | null;
|
||||||
|
enteredAt: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
orders: CarwashOrderView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarwashSettingsBody {
|
||||||
|
categories?: { id?: string; name: string; active?: boolean }[];
|
||||||
|
services?: { id?: string; name: string; active?: boolean }[];
|
||||||
|
prices?: { categoryId: string; serviceId: string; priceMinor: number }[];
|
||||||
|
/** Where wash money is taken at this site (site-level; the desk no longer asks). */
|
||||||
|
payAt?: CarWashPayAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
|
||||||
|
return apiFetch("/api/carwash/settings");
|
||||||
|
}
|
||||||
|
export function saveCarwashSettings(body: CarwashSettingsBody): Promise<CarwashSettingsView> {
|
||||||
|
return apiFetch("/api/carwash/settings", { method: "PUT", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function lookupCarwashTicket(identity: string): Promise<CarwashTicketLookup> {
|
||||||
|
return apiFetch(`/api/carwash/session/${encodeURIComponent(identity.trim())}`);
|
||||||
|
}
|
||||||
|
export function fetchCarwashOrders(scope: "open" | "recent" = "open"): Promise<{ orders: CarwashOrderView[] }> {
|
||||||
|
return apiFetch(`/api/carwash/orders?scope=${scope}`);
|
||||||
|
}
|
||||||
|
export function createCarwashOrder(body: {
|
||||||
|
identity: string;
|
||||||
|
categoryId: string;
|
||||||
|
serviceId: string;
|
||||||
|
}): Promise<CarwashOrderView> {
|
||||||
|
return apiFetch("/api/carwash/orders", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function markCarwashDone(id: string): Promise<CarwashOrderView> {
|
||||||
|
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/done`, { method: "POST" });
|
||||||
|
}
|
||||||
|
export function payCarwashAtBay(id: string, tender: Tender): Promise<CarwashOrderView> {
|
||||||
|
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/pay`, { method: "POST", body: JSON.stringify({ tender }) });
|
||||||
|
}
|
||||||
|
export function voidCarwashOrder(id: string, reason: string): Promise<CarwashOrderView> {
|
||||||
|
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/void`, { method: "POST", body: JSON.stringify({ reason }) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createRoute, redirect, useRouteContext } from "@tanstack/react-router";
|
||||||
|
import type { AnyRoute } from "@tanstack/react-router";
|
||||||
|
import { can } from "../../api.js";
|
||||||
|
import { moduleOn, type RootRoute, type WebModule } from "../../lib/modules.js";
|
||||||
|
import type { RouterContext } from "../../router.js";
|
||||||
|
import { CarWashSetup } from "./CarWashSetup.js";
|
||||||
|
import { WashDesk } from "./WashDesk.js";
|
||||||
|
|
||||||
|
// Car Wash — the pilot venue module, web side (wiki/decisions/venue-modules.md).
|
||||||
|
// Two screens: the wash desk (/wash, carwash:read) and Setup → Car wash
|
||||||
|
// (/setup/carwash, site:read; editing needs site:update). Both gate on the module
|
||||||
|
// being effective at this site AND the permission; the server enforces the same.
|
||||||
|
|
||||||
|
function gate(perm: string) {
|
||||||
|
return ({ context }: { context: unknown }) => {
|
||||||
|
const ctx = context as RouterContext;
|
||||||
|
// Bounce to the landing resolver, never straight to the booth (a wash-only role
|
||||||
|
// has no booth to land on).
|
||||||
|
if (!moduleOn(ctx.user, "carwash") || !can(ctx.user, perm)) throw redirect({ to: "/" });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const carwashModule: WebModule = {
|
||||||
|
id: "carwash",
|
||||||
|
nav: [{ to: "/wash", labelKey: "nav.wash", perm: "carwash:read" }],
|
||||||
|
landing: { to: "/wash", labelKey: "nav.wash", perm: "carwash:read" },
|
||||||
|
routes(root: RootRoute) {
|
||||||
|
const washRoute = createRoute({
|
||||||
|
getParentRoute: () => root,
|
||||||
|
path: "/wash",
|
||||||
|
beforeLoad: gate("carwash:read"),
|
||||||
|
component: WashDesk,
|
||||||
|
});
|
||||||
|
return [washRoute];
|
||||||
|
},
|
||||||
|
setupNav: [{ to: "/setup/carwash", labelKey: "nav.carwash", perm: "site:read" }],
|
||||||
|
setupRoutes(setup: AnyRoute) {
|
||||||
|
const setupCarwashRoute = createRoute({
|
||||||
|
getParentRoute: () => setup,
|
||||||
|
path: "/carwash",
|
||||||
|
beforeLoad: gate("site:read"),
|
||||||
|
component: function CarWashSetupRoute() {
|
||||||
|
const { user } = useRouteContext({ strict: false }) as RouterContext;
|
||||||
|
return <CarWashSetup canEdit={can(user, "site:update")} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return [setupCarwashRoute];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { WebModule } from "../lib/modules.js";
|
||||||
|
import { carwashModule } from "./carwash/index.js";
|
||||||
|
import { validationModule } from "./validation/index.js";
|
||||||
|
|
||||||
|
// The web-side module registry, in display order. Adding a module = its folder here
|
||||||
|
// + one entry below (+ the manifest in @parking/shared). router.tsx spreads these
|
||||||
|
// into the nav and the route tree and never names a module's screens itself.
|
||||||
|
// `parking` has no folder yet — its screens are still declared directly in
|
||||||
|
// router.tsx; they move behind this seam subsystem by subsystem.
|
||||||
|
export const WEB_MODULES: readonly WebModule[] = [validationModule, carwashModule];
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createRoute, redirect, useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { can } from "../../api.js";
|
||||||
|
import { moduleOn, type RootRoute, type WebModule } from "../../lib/modules.js";
|
||||||
|
import type { RouterContext } from "../../router.js";
|
||||||
|
import { ValidateScreen } from "../../ValidateScreen.js";
|
||||||
|
|
||||||
|
// Merchant-scan ticket validation as a venue module (kept for the Bar —
|
||||||
|
// wiki/decisions/venue-modules.md, decision 1). The merchant (bar) scan-and-validate
|
||||||
|
// screen is usually the ONLY page a merchant user's role can reach. The server
|
||||||
|
// enforces module-on + the program↔user binding on apply; the gates here are
|
||||||
|
// defence in depth / display. See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
export const validationModule: WebModule = {
|
||||||
|
id: "validation",
|
||||||
|
nav: [{ to: "/validate", labelKey: "nav.validate", perm: "validation:create" }],
|
||||||
|
landing: { to: "/validate", labelKey: "nav.validate", perm: "validation:create" },
|
||||||
|
routes(root: RootRoute) {
|
||||||
|
const validateRoute = createRoute({
|
||||||
|
getParentRoute: () => root,
|
||||||
|
path: "/validate",
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
const ctx = context as RouterContext;
|
||||||
|
if (!moduleOn(ctx.user, "validation") || !can(ctx.user, "validation:create")) {
|
||||||
|
throw redirect({ to: "/" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: function ValidateRoute() {
|
||||||
|
const { user } = useRouteContext({ strict: false }) as RouterContext;
|
||||||
|
if (!user) return null;
|
||||||
|
return <ValidateScreen user={user} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return [validateRoute];
|
||||||
|
},
|
||||||
|
};
|
||||||
+137
-216
@@ -6,17 +6,14 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
redirect,
|
redirect,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||||
import {
|
import {
|
||||||
can,
|
can,
|
||||||
closeShift,
|
|
||||||
fetchShiftReport,
|
|
||||||
fetchVersion,
|
fetchVersion,
|
||||||
logout,
|
logout,
|
||||||
openShift,
|
|
||||||
setLanguagePref,
|
setLanguagePref,
|
||||||
setThemePref,
|
setThemePref,
|
||||||
setFontScalePref,
|
setFontScalePref,
|
||||||
@@ -24,14 +21,15 @@ import {
|
|||||||
FONT_SCALE_MAX,
|
FONT_SCALE_MAX,
|
||||||
FONT_SCALE_STEP,
|
FONT_SCALE_STEP,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { qk, queryClient } from "./lib/query.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { Spinner } from "./ui/Spinner.js";
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { inTauri } from "./lib/origin.js";
|
||||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||||
|
import { ShiftButton } from "./ShiftControl.js";
|
||||||
import { StatusDot } from "./ui/StatusDot.js";
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
import { BoothScreen } from "./BoothScreen.js";
|
import { BoothScreen } from "./BoothScreen.js";
|
||||||
import { SetupWizard } from "./SetupWizard.js";
|
import { SetupWizard } from "./SetupWizard.js";
|
||||||
@@ -44,10 +42,11 @@ import { UsersManager } from "./UsersManager.js";
|
|||||||
import { RolesManager } from "./RolesManager.js";
|
import { RolesManager } from "./RolesManager.js";
|
||||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||||
import { DrawerManager } from "./DrawerManager.js";
|
import { DrawerManager } from "./DrawerManager.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
|
||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
import { BackupSettings } from "./BackupSettings.js";
|
import { BackupSettings } from "./BackupSettings.js";
|
||||||
import { ValidateScreen } from "./ValidateScreen.js";
|
import { WEB_MODULES } from "./modules/index.js";
|
||||||
|
import { canWatchFeed, moduleOn } from "./lib/modules.js";
|
||||||
|
import { TILL_IDS, tillGuards } from "@parking/shared";
|
||||||
import { RecycleBin } from "./RecycleBin.js";
|
import { RecycleBin } from "./RecycleBin.js";
|
||||||
import { Profile } from "./Profile.js";
|
import { Profile } from "./Profile.js";
|
||||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||||
@@ -106,6 +105,78 @@ function VersionBadge() {
|
|||||||
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
|
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The installed Tauri app's own "vX.Y.Z" (from tauri.conf.json, synced to the git tag by
|
||||||
|
* release.yml — see wiki/decisions/desktop-shell-tauri.md) — the client's version, distinct
|
||||||
|
* from VersionBadge's SERVER build. No-op / renders nothing in a browser (there's no Tauri
|
||||||
|
* API to call). Was invisible before this: an operator had no way to tell which desktop
|
||||||
|
* build was actually installed short of reading the update-available prompt. */
|
||||||
|
function DesktopVersionBadge() {
|
||||||
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inTauri()) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void import("@tauri-apps/api/app").then(({ getVersion }) =>
|
||||||
|
getVersion().then((v) => {
|
||||||
|
if (!cancelled) setVersion(v);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
if (!version) return null;
|
||||||
|
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">app v{version}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop-only "change which server this install talks to" control. No-op /
|
||||||
|
* renders nothing in a browser (the concept doesn't apply — same-origin).
|
||||||
|
* Simplest correct action: clear the saved backend URL and reload, which
|
||||||
|
* drops the app back to ConnectScreen (see App.tsx) to re-enter it — this
|
||||||
|
* mirrors clearing the session (logout → back to Login), not an inline
|
||||||
|
* editor, since repointing the app is a rare, deliberate admin action. */
|
||||||
|
function DesktopServerButton() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
if (!inTauri()) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm ml-2"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
>
|
||||||
|
{t("connect.changeServer")}
|
||||||
|
</button>
|
||||||
|
{confirming && (
|
||||||
|
<Modal open onClose={() => setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm">
|
||||||
|
<div className="text-[0.8125rem]">
|
||||||
|
<p className="text-term-muted">{t("connect.changeServerConfirm")}</p>
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setConfirming(false)} disabled={busy}>
|
||||||
|
{t("subs.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
const { clearBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
await clearBackendUrl();
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? <Spinner /> : t("connect.changeServer")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
||||||
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
||||||
* deep links and the back button work and a denied tab redirects to the booth. */
|
* deep links and the back button work and a denied tab redirects to the booth. */
|
||||||
@@ -124,7 +195,15 @@ function SetupLayout() {
|
|||||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||||
|
{/* Venue-module setup tabs (e.g. Car wash) — module on AND permission. */}
|
||||||
|
{WEB_MODULES.flatMap((m) =>
|
||||||
|
(m.setupNav ?? [])
|
||||||
|
.filter((n) => moduleOn(user, m.id) && show(n.perm))
|
||||||
|
.map((n) => <SetupTab key={n.to} to={n.to} label={t(n.labelKey)} />),
|
||||||
|
)}
|
||||||
{show("site:read") && <VersionBadge />}
|
{show("site:read") && <VersionBadge />}
|
||||||
|
<DesktopVersionBadge />
|
||||||
|
<DesktopServerButton />
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
@@ -287,176 +366,7 @@ function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: Se
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Header shift control lives in ShiftControl.tsx (shared with the wash desk, per till).
|
||||||
* Header shift control — the site-wide single-open shift expressed as one button:
|
|
||||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
|
||||||
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
|
|
||||||
* - another's shift open → disabled, labelled with who holds it (you can neither
|
|
||||||
* open yours nor close theirs until they hand over).
|
|
||||||
* On open/close it invalidates the shift status, the per-shift log, and occupancy.
|
|
||||||
*/
|
|
||||||
function ShiftButton() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
|
||||||
// Closing a shift signs the Z-report and is irreversible, so the header button never
|
|
||||||
// closes directly (a stray click would end the shift) — it opens a confirm modal that
|
|
||||||
// shows the live X-report first. Opening a shift has no such risk → immediate.
|
|
||||||
const [confirmingClose, setConfirmingClose] = useState(false);
|
|
||||||
|
|
||||||
function onClick() {
|
|
||||||
if (isMine) {
|
|
||||||
setConfirmingClose(true);
|
|
||||||
} else {
|
|
||||||
void act("open");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function act(kind: "open" | "close") {
|
|
||||||
setBusy(true);
|
|
||||||
setErr(null);
|
|
||||||
try {
|
|
||||||
if (kind === "open") await openShift();
|
|
||||||
else await closeShift();
|
|
||||||
// The shift boundary moves: refresh status, the per-shift log window, drawer.
|
|
||||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
|
||||||
void qc.invalidateQueries({ queryKey: qk.events });
|
|
||||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
|
||||||
} catch (e) {
|
|
||||||
setErr((e as Error).message);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disabled when another operator holds the shift (can't open or close).
|
|
||||||
const label = blockedByOther
|
|
||||||
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
|
|
||||||
: isMine
|
|
||||||
? t("shift.headerClose")
|
|
||||||
: t("shift.headerOpen");
|
|
||||||
const tone = blockedByOther
|
|
||||||
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
|
|
||||||
: isMine
|
|
||||||
? "border-term-red text-term-red hover:bg-term-red/10"
|
|
||||||
: "border-term-green text-term-green hover:bg-term-green/10";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || blockedByOther}
|
|
||||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
|
||||||
onClick={onClick}
|
|
||||||
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
|
|
||||||
>
|
|
||||||
{busy ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
label
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{!isOpen && (
|
|
||||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
|
||||||
)}
|
|
||||||
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
|
||||||
{confirmingClose && (
|
|
||||||
<CloseShiftConfirm
|
|
||||||
busy={busy}
|
|
||||||
onCancel={() => setConfirmingClose(false)}
|
|
||||||
onConfirm={async () => {
|
|
||||||
await act("close");
|
|
||||||
setConfirmingClose(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Confirm-before-close modal for the header shift button. Fetches the live X-report so
|
|
||||||
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
|
|
||||||
* expected drawer before committing the irreversible Z-report. */
|
|
||||||
function CloseShiftConfirm({
|
|
||||||
busy,
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
}: {
|
|
||||||
busy: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm"], queryFn: fetchShiftReport });
|
|
||||||
const x = q.data;
|
|
||||||
const cur = x?.currency ?? null;
|
|
||||||
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
|
|
||||||
<div className="text-[0.8125rem] tabular-nums">
|
|
||||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
|
||||||
{!x ? (
|
|
||||||
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
|
||||||
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
|
|
||||||
<span />
|
|
||||||
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
|
|
||||||
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
|
||||||
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
|
||||||
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
|
||||||
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
|
||||||
<span />
|
|
||||||
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
|
||||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
|
||||||
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
|
||||||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
|
||||||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
|
||||||
<span />
|
|
||||||
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="mt-3 flex justify-end gap-2">
|
|
||||||
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
|
|
||||||
{t("subs.cancel")}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
|
||||||
{busy ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Spinner /> {t("shift.ending")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
t("shift.endShift")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
|
||||||
<span
|
|
||||||
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const { user, setUser } = rootRoute.useRouteContext();
|
const { user, setUser } = rootRoute.useRouteContext();
|
||||||
@@ -465,12 +375,13 @@ function RootLayout() {
|
|||||||
// the permission its screen needs (the route guards enforce the same server-side).
|
// the permission its screen needs (the route guards enforce the same server-side).
|
||||||
const show = (perm: Permission) => can(user, perm);
|
const show = (perm: Permission) => can(user, perm);
|
||||||
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
||||||
// for roles the server would accept (routes/ws.ts gates on report:read). A
|
// for roles the server would accept (routes/ws.ts admits any WATCH permission:
|
||||||
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
|
// event/session/device read, or an effective module's own feed permission — and
|
||||||
// on backoff forever and spam the server log. Same rule for the widgets that feed
|
// then filters what it pushes per role). A merchant validator holds none and must
|
||||||
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
|
// not even attempt it: the 403'd upgrade would reconnect on backoff forever and
|
||||||
// DeviceFooter → device:read).
|
// spam the server log. Same rule for the widgets that feed off it (StatusDot) or
|
||||||
const canWatch = show("report:read");
|
// make their own gated calls (ShiftButton → shift:read, DeviceFooter → device:read).
|
||||||
|
const canWatch = canWatchFeed(user);
|
||||||
useLiveFeed(canWatch);
|
useLiveFeed(canWatch);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -480,9 +391,14 @@ function RootLayout() {
|
|||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
|
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
|
||||||
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
|
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
|
||||||
{/* The merchant's (bar/lavazh) scan-and-validate screen. Their typical role
|
{/* Venue-module nav entries (e.g. the Bar merchant's scan-and-validate screen,
|
||||||
grants ONLY validation:create, so this is often their whole nav. */}
|
often that role's whole nav): shown iff the module is effective at this
|
||||||
{show("validation:create") && <NavLink to="/validate" label={t("nav.validate")} />}
|
site AND the role holds the entry's permission. See lib/modules.ts. */}
|
||||||
|
{WEB_MODULES.flatMap((m) =>
|
||||||
|
m.nav
|
||||||
|
.filter((n) => moduleOn(user, m.id) && show(n.perm))
|
||||||
|
.map((n) => <NavLink key={n.to} to={n.to} label={t(n.labelKey)} />),
|
||||||
|
)}
|
||||||
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||||||
user can do either. See wiki/concepts/shift.md. */}
|
user can do either. See wiki/concepts/shift.md. */}
|
||||||
{(show("drawer:create") || show("drawer:review")) && (
|
{(show("drawer:create") || show("drawer:review")) && (
|
||||||
@@ -508,6 +424,9 @@ function RootLayout() {
|
|||||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
{/* The header button is the BOOTH till's, guarded by the booth's own
|
||||||
|
shift:read (a wash role holds no shift:* at all and has its own control on
|
||||||
|
the wash desk). The server resolves the same guard from the till. */}
|
||||||
{user && show("shift:read") && <ShiftButton />}
|
{user && show("shift:read") && <ShiftButton />}
|
||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
@@ -549,33 +468,33 @@ const indexRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/",
|
path: "/",
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
// A merchant-only user (validation:create without the booth's session:read)
|
// Landing = the first screen this role can actually use. The booth for anyone
|
||||||
// lands on their scan-and-validate screen; everyone else on the booth.
|
// with the booth's permission; otherwise the first venue-module landing the role
|
||||||
if (can(context.user, "validation:create") && !can(context.user, "session:read")) {
|
// holds (wash desk for a wash operator, scan screen for a merchant); otherwise
|
||||||
throw redirect({ to: "/validate" });
|
// the shift hub; otherwise the profile. Every guard that bounces sends people
|
||||||
}
|
// HERE (never straight to the booth) so a booth-less role never dead-ends.
|
||||||
throw redirect({ to: "/booth" });
|
throw redirect({ to: landingFor(context.user) });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function landingFor(user: SessionUser | null): string {
|
||||||
|
if (can(user, "session:read")) return "/booth";
|
||||||
|
for (const m of WEB_MODULES) {
|
||||||
|
if (m.landing && moduleOn(user, m.id) && can(user, m.landing.perm)) return m.landing.to;
|
||||||
|
}
|
||||||
|
if (can(user, "shift:read")) return "/shifts";
|
||||||
|
return "/profile";
|
||||||
|
}
|
||||||
|
|
||||||
const boothRoute = createRoute({
|
const boothRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/booth",
|
path: "/booth",
|
||||||
component: BoothScreen,
|
// The booth is the parking operator's screen; a role without session:read (a wash
|
||||||
});
|
// operator, a merchant) goes to its own landing instead of a screen that 403s.
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
// The merchant (bar/lavazh) scan-and-validate screen — usually the ONLY page a
|
if (!can(context.user, "session:read")) throw redirect({ to: "/" });
|
||||||
// merchant user's role can reach. The server enforces the program↔user binding on
|
|
||||||
// apply; this gate is defence in depth. See wiki/concepts/validation-discounts.md.
|
|
||||||
const validateRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/validate",
|
|
||||||
beforeLoad: ({ context }) => requirePerm("validation:create")(context),
|
|
||||||
component: function ValidateRoute() {
|
|
||||||
const { user } = rootRoute.useRouteContext();
|
|
||||||
if (!user) return null;
|
|
||||||
return <ValidateScreen user={user} />;
|
|
||||||
},
|
},
|
||||||
|
component: BoothScreen,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
||||||
@@ -644,17 +563,16 @@ const drawerRoute = createRoute({
|
|||||||
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
||||||
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
|
// Anyone who may read a till's drawer, record on one, or review — the component
|
||||||
throw redirect({ to: "/booth" });
|
// shows the right view per till. (canWatchFeed-style: any of the till guards.)
|
||||||
}
|
const u = context.user;
|
||||||
|
const anyTill = TILL_IDS.some((t) => can(u, tillGuards(t).read) || can(u, tillGuards(t).cash));
|
||||||
|
if (!anyTill && !can(u, "drawer:review")) throw redirect({ to: "/" });
|
||||||
},
|
},
|
||||||
component: function DrawerRoute() {
|
component: function DrawerRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
return (
|
return (
|
||||||
<DrawerManager
|
<DrawerManager user={user} canReview={can(user, "drawer:review")} />
|
||||||
canCreate={can(user, "drawer:create")}
|
|
||||||
canReview={can(user, "drawer:review")}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -823,7 +741,9 @@ const profileRoute = createRoute({
|
|||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
boothRoute,
|
boothRoute,
|
||||||
validateRoute,
|
// Venue-module routes (e.g. /validate) — each module gates its own routes on
|
||||||
|
// moduleOn + permission. See modules/index.ts.
|
||||||
|
...WEB_MODULES.flatMap((m) => m.routes(rootRoute)),
|
||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
profileRoute,
|
profileRoute,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
@@ -842,6 +762,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
recycleBinRoute,
|
recycleBinRoute,
|
||||||
logsRoute,
|
logsRoute,
|
||||||
backupRoute,
|
backupRoute,
|
||||||
|
...WEB_MODULES.flatMap((m) => m.setupRoutes?.(setupRoute) ?? []),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
|||||||
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||||
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
||||||
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
|
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
|
||||||
|
carwash_order: { labelKey: "booth.evtCarwashOrder", color: "text-term-cyan" },
|
||||||
|
carwash_payment: { labelKey: "booth.evtCarwashPayment", color: "text-term-cyan" },
|
||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ services:
|
|||||||
# The booth WS live feed checks the browser Origin — must list the address operators
|
# The booth WS live feed checks the browser Origin — must list the address operators
|
||||||
# actually hit (e.g. http://<booth-ip>:3000), or the live feed is rejected.
|
# actually hit (e.g. http://<booth-ip>:3000), or the live feed is rejected.
|
||||||
WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-}
|
WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-}
|
||||||
|
# Venue modules this site is ENTITLED to (vendor decision, per stack in Komodo; the site
|
||||||
|
# admin activates within this set in Setup → Site). Only variables listed HERE reach the
|
||||||
|
# container — a value in the Komodo stack env alone does nothing (found 2026-09-06: every
|
||||||
|
# booth had Car Wash on). Default = what booths had before modules existed; the server
|
||||||
|
# treats a BLANK value as "every module", so never set it to "" on a booth.
|
||||||
|
MODULES_ENTITLED: ${MODULES_ENTITLED:-parking,validation}
|
||||||
volumes:
|
volumes:
|
||||||
- parking-data:/data
|
- parking-data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
+17
-4
@@ -49,10 +49,16 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
|||||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
# exists as the pointer; we deploy the sha, not the mover.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-28bd838
|
TAG=stage-8fa66c9
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
|
# Venue modules this site is ENTITLED to (vendor decision; the site admin activates within
|
||||||
|
# this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md.
|
||||||
|
MODULES_ENTITLED=parking,validation
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
WS_ALLOWED_ORIGINS=
|
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||||
|
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||||
|
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
|
||||||
|
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
|
||||||
JWT_SECRET=[[park_buzi_jwt_secret]]
|
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||||
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||||
BACKUP_KEY=[[park_buzi_backup_key]]
|
BACKUP_KEY=[[park_buzi_backup_key]]
|
||||||
@@ -79,10 +85,17 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
|||||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
# exists as the pointer; we deploy the sha, not the mover.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-28bd838
|
TAG=stage-2aa1045
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
|
# Venue modules this site is ENTITLED to (vendor decision; the site admin activates within
|
||||||
|
# this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md.
|
||||||
|
# park-2 pilots the Car Wash module (2026-09-05).
|
||||||
|
MODULES_ENTITLED=parking,carwash
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
WS_ALLOWED_ORIGINS=
|
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||||
|
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||||
|
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
|
||||||
|
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
|
||||||
JWT_SECRET=[[park_2_jwt_secret]]
|
JWT_SECRET=[[park_2_jwt_secret]]
|
||||||
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
||||||
BACKUP_KEY=[[park_2_backup_key]]
|
BACKUP_KEY=[[park_2_backup_key]]
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Venue modules: which optional modules the site admin has ACTIVATED (JSON array of
|
||||||
|
-- module ids, e.g. ["parking","validation"]). null = never set → everything the site is
|
||||||
|
-- entitled to (MODULES_ENTITLED env). Effective set = entitled ∩ activated, computed server-
|
||||||
|
-- side (apps/server/src/modules.ts); each change signs a config_change. Additive, nullable:
|
||||||
|
-- existing deployments see no behaviour change. See wiki/decisions/venue-modules.md.
|
||||||
|
ALTER TABLE `site_config` ADD `modules_json` text;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- Car Wash module (wiki/decisions/venue-modules.md): admin master data (categories,
|
||||||
|
-- services, the category × service price matrix) + the order rows that are the wash
|
||||||
|
-- desk's queue. Orders freeze names + price at intake; their life is signed onto the
|
||||||
|
-- ledger (carwash_order / carwash_payment). Additive; tables exist whether or not the
|
||||||
|
-- module is entitled/activated at a site (modules are always migrated).
|
||||||
|
CREATE TABLE `carwash_categories` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
`active` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
`deleted_at` text,
|
||||||
|
`deleted_by` text
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE TABLE `carwash_services` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
`active` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
`deleted_at` text,
|
||||||
|
`deleted_by` text
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE TABLE `carwash_prices` (
|
||||||
|
`category_id` text NOT NULL,
|
||||||
|
`service_id` text NOT NULL,
|
||||||
|
`price_minor` integer NOT NULL,
|
||||||
|
PRIMARY KEY(`category_id`, `service_id`),
|
||||||
|
FOREIGN KEY (`category_id`) REFERENCES `carwash_categories`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`service_id`) REFERENCES `carwash_services`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE TABLE `carwash_orders` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`identity` text NOT NULL,
|
||||||
|
`plate` text,
|
||||||
|
`category_id` text NOT NULL,
|
||||||
|
`category_name` text NOT NULL,
|
||||||
|
`service_id` text NOT NULL,
|
||||||
|
`service_name` text NOT NULL,
|
||||||
|
`price_minor` integer NOT NULL,
|
||||||
|
`currency` text NOT NULL,
|
||||||
|
`pay_at` text NOT NULL,
|
||||||
|
`status` text DEFAULT 'open' NOT NULL,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`created_by` text NOT NULL,
|
||||||
|
`done_at` text,
|
||||||
|
`done_by` text,
|
||||||
|
`paid_at` text,
|
||||||
|
`paid_by` text,
|
||||||
|
`tender` text,
|
||||||
|
`payment_event_id` text,
|
||||||
|
`validation_event_id` text,
|
||||||
|
`void_at` text,
|
||||||
|
`void_by` text,
|
||||||
|
`void_reason` text
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE INDEX `carwash_orders_identity_idx` ON `carwash_orders` (`identity`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `carwash_orders_status_idx` ON `carwash_orders` (`status`);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Car Wash module: site-level settings singleton. `pay_at` decides WHERE wash money is
|
||||||
|
-- taken at this site (booth = on the parking ticket; bay = the wash operator's own till)
|
||||||
|
-- — a Setup → Car wash choice, no longer a per-order radio on the desk (user, 2026-09-05).
|
||||||
|
CREATE TABLE `carwash_config` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`pay_at` text DEFAULT 'booth' NOT NULL,
|
||||||
|
`updated_at` text,
|
||||||
|
`updated_by` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `carwash_config` (`id`, `pay_at`) VALUES (1, 'booth');
|
||||||
@@ -183,6 +183,27 @@
|
|||||||
"when": 1788078414270,
|
"when": 1788078414270,
|
||||||
"tag": "0025_backup_last_status",
|
"tag": "0025_backup_last_status",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788596918862,
|
||||||
|
"tag": "0026_site_modules",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788599998177,
|
||||||
|
"tag": "0027_carwash",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788605000000,
|
||||||
|
"tag": "0028_carwash_config",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-2
@@ -1,5 +1,5 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { blob, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
import { blob, integer, primaryKey, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
// Schema notes:
|
// Schema notes:
|
||||||
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
|
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
|
||||||
@@ -233,6 +233,12 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
* own price and may differ. null = no site default set. See
|
* own price and may differ. null = no site default set. See
|
||||||
* wiki/entities/subscription.md. */
|
* wiki/entities/subscription.md. */
|
||||||
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
||||||
|
/** Venue modules the site admin has ACTIVATED (JSON array of ModuleId, e.g.
|
||||||
|
* ["parking","validation"]). null = never set → everything the site is entitled to.
|
||||||
|
* The effective set is entitled (MODULES_ENTITLED env) ∩ this, computed server-side
|
||||||
|
* (apps/server/src/modules.ts); each change signs a config_change. Disabling a module
|
||||||
|
* never deletes anything. See wiki/decisions/venue-modules.md. */
|
||||||
|
modulesJson: text("modules_json"),
|
||||||
/** When ON, the occupancy/full gate RESERVES a spot for each active subscriber's car
|
/** When ON, the occupancy/full gate RESERVES a spot for each active subscriber's car
|
||||||
* (by quantity) even when they're not parked — so transients see "full" sooner and
|
* (by quantity) even when they're not parked — so transients see "full" sooner and
|
||||||
* the subscriber's spot is held. When OFF (default), only cars physically inside
|
* the subscriber's spot is held. When OFF (default), only cars physically inside
|
||||||
@@ -485,7 +491,7 @@ export const validationPrograms = sqliteTable("validation_programs", {
|
|||||||
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
|
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
// How the program discounts — see @parking/shared ValidationMode.
|
// How the program discounts — see @parking/shared ValidationMode.
|
||||||
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent"] })
|
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent", "doneTolerance", "washPrice"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("comp"),
|
.default("comp"),
|
||||||
// timeCredit: the free minutes.
|
// timeCredit: the free minutes.
|
||||||
@@ -615,3 +621,102 @@ export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
|
|||||||
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
|
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
|
||||||
export type SessionRow = typeof sessions.$inferSelect;
|
export type SessionRow = typeof sessions.$inferSelect;
|
||||||
export type AppLogRow = typeof appLogs.$inferSelect;
|
export type AppLogRow = typeof appLogs.$inferSelect;
|
||||||
|
|
||||||
|
// --- Car Wash module (wiki/decisions/venue-modules.md) ----------------------
|
||||||
|
// Admin-maintained master data (categories, services, the price matrix) + the order
|
||||||
|
// rows that ARE the wash desk's queue. Master data is plainly mutable; every order
|
||||||
|
// freezes the category/service NAMES and the price at intake, and the order's life
|
||||||
|
// (created / done / void, and a bay payment) is signed onto the ledger — so history
|
||||||
|
// never depends on these rows. Soft-delete on the master data (recycle-bin pattern).
|
||||||
|
|
||||||
|
export const carwashCategories = sqliteTable("carwash_categories", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
/** Display name, e.g. "Car", "SUV", "Van", "Truck". */
|
||||||
|
name: text("name").notNull(),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const carwashServices = sqliteTable("carwash_services", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
/** Display name, e.g. "Standard", "Outside", "Inside", "Details". */
|
||||||
|
name: text("name").notNull(),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The price matrix: one row per (category, service) the admin priced. A missing pair
|
||||||
|
* is simply not sellable. Minor units. */
|
||||||
|
export const carwashPrices = sqliteTable(
|
||||||
|
"carwash_prices",
|
||||||
|
{
|
||||||
|
categoryId: text("category_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => carwashCategories.id),
|
||||||
|
serviceId: text("service_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => carwashServices.id),
|
||||||
|
priceMinor: integer("price_minor").notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
pk: primaryKey({ columns: [t.categoryId, t.serviceId] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const carwashOrders = sqliteTable("carwash_orders", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
/** The parking ticket id = the customer identity (the wash sits inside the park). */
|
||||||
|
identity: text("identity").notNull(),
|
||||||
|
plate: text("plate"),
|
||||||
|
categoryId: text("category_id").notNull(),
|
||||||
|
/** Frozen at intake (renames never rewrite an order). */
|
||||||
|
categoryName: text("category_name").notNull(),
|
||||||
|
serviceId: text("service_id").notNull(),
|
||||||
|
serviceName: text("service_name").notNull(),
|
||||||
|
priceMinor: integer("price_minor").notNull(),
|
||||||
|
currency: text("currency").notNull(),
|
||||||
|
/** "booth" | "bay" — see @parking/shared CarWashPayAt. */
|
||||||
|
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull(),
|
||||||
|
/** "open" | "done" | "void". Paid-ness is the separate paidAt below. */
|
||||||
|
status: text("status", { enum: ["open", "done", "void"] }).notNull().default("open"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
createdBy: text("created_by").notNull(),
|
||||||
|
doneAt: text("done_at"),
|
||||||
|
doneBy: text("done_by"),
|
||||||
|
/** Set when settled — at the bay (carwash_payment) or at the booth (the parking
|
||||||
|
* payment that carried this order as a charge line). */
|
||||||
|
paidAt: text("paid_at"),
|
||||||
|
paidBy: text("paid_by"),
|
||||||
|
tender: text("tender"),
|
||||||
|
/** Ledger event id of the payment that settled it (carwash_payment or payment). */
|
||||||
|
paymentEventId: text("payment_event_id"),
|
||||||
|
/** Ledger event id of the sponsorship validation this order applied, if any. */
|
||||||
|
validationEventId: text("validation_event_id"),
|
||||||
|
voidAt: text("void_at"),
|
||||||
|
voidBy: text("void_by"),
|
||||||
|
voidReason: text("void_reason"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Module-level settings singleton (id = 1). `payAt`: where wash money is taken at this
|
||||||
|
* site — "booth" (on the parking ticket) or "bay" (the wash operator's own till). */
|
||||||
|
export const carwashConfig = sqliteTable("carwash_config", {
|
||||||
|
id: integer("id").primaryKey(),
|
||||||
|
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull().default("booth"),
|
||||||
|
updatedAt: text("updated_at"),
|
||||||
|
updatedBy: text("updated_by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CarwashCategoryRow = typeof carwashCategories.$inferSelect;
|
||||||
|
export type CarwashServiceRow = typeof carwashServices.$inferSelect;
|
||||||
|
export type CarwashPriceRow = typeof carwashPrices.$inferSelect;
|
||||||
|
export type CarwashOrderRow = typeof carwashOrders.$inferSelect;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const RESOURCES = [
|
|||||||
"log", // application/diagnostic logs (app_logs) — view + retention
|
"log", // application/diagnostic logs (app_logs) — view + retention
|
||||||
"recyclebin", // soft-deleted master data: view / restore / purge
|
"recyclebin", // soft-deleted master data: view / restore / purge
|
||||||
"backup", // encrypted DB backups: configure target + trigger a manual run
|
"backup", // encrypted DB backups: configure target + trigger a manual run
|
||||||
|
"carwash", // Car Wash module: orders/queue (read), intake (create), done/pay/void (update)
|
||||||
] as const;
|
] as const;
|
||||||
export type Resource = (typeof RESOURCES)[number];
|
export type Resource = (typeof RESOURCES)[number];
|
||||||
|
|
||||||
@@ -86,6 +87,13 @@ export const PERMISSIONS: readonly Permission[] = [
|
|||||||
// action on a fresh appliance, never reachable from the running console. See
|
// action on a fresh appliance, never reachable from the running console. See
|
||||||
// wiki/concepts/backup-recovery.md.
|
// wiki/concepts/backup-recovery.md.
|
||||||
"backup:read", "backup:update", "backup:create",
|
"backup:read", "backup:update", "backup:create",
|
||||||
|
// Car Wash module (venue-modules.md): read = the wash desk's queue + ticket lookup
|
||||||
|
// (+ the wash till's shift state and the wash live feed); create = intake an order;
|
||||||
|
// update = mark done / take a bay payment / void; cash = WORK the wash till — open and
|
||||||
|
// close its shift, record its cash in/out (the wash's own `shift:create` +
|
||||||
|
// `drawer:create`; see ModuleManifest.tillGuards). Settings (categories, services,
|
||||||
|
// price matrix, sponsorship program) ride site:update.
|
||||||
|
"carwash:read", "carwash:create", "carwash:update", "carwash:cash",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** The protected built-in role: non-deletable, non-editable, always = ALL
|
/** The protected built-in role: non-deletable, non-editable, always = ALL
|
||||||
@@ -288,6 +296,13 @@ export type LedgerEventType =
|
|||||||
// the referenced validation event (append-only correction, mirrors cash_review).
|
// the referenced validation event (append-only correction, mirrors cash_review).
|
||||||
// See wiki/concepts/validation-discounts.md.
|
// See wiki/concepts/validation-discounts.md.
|
||||||
| "validation"
|
| "validation"
|
||||||
|
// Car Wash module (venue-modules.md). `carwash_order` is the order's life on the
|
||||||
|
// chain — payload.action = "created" | "done" | "void", with the category/service/
|
||||||
|
// price FROZEN at intake so renames never rewrite history. `carwash_payment` is
|
||||||
|
// money taken AT THE BAY (payAt = "bay"); a wash paid AT THE BOOTH rides the
|
||||||
|
// parking `payment` as chargeLines instead (see PayStation charge providers).
|
||||||
|
| "carwash_order"
|
||||||
|
| "carwash_payment"
|
||||||
| "anomaly";
|
| "anomaly";
|
||||||
|
|
||||||
/** How money was tendered (for payment events + the shift Z-report). */
|
/** How money was tendered (for payment events + the shift Z-report). */
|
||||||
@@ -317,6 +332,19 @@ export interface LedgerPayload {
|
|||||||
/** payment: the per-validation receipt lines as settled (label + amount taken off) —
|
/** payment: the per-validation receipt lines as settled (label + amount taken off) —
|
||||||
* stamped so the printed receipt reproduces without re-deriving the fold. */
|
* stamped so the printed receipt reproduces without re-deriving the fold. */
|
||||||
readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[];
|
readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[];
|
||||||
|
/** payment: non-parking charges a module folded into this settlement (e.g. a wash
|
||||||
|
* paid at the booth). `amountMinor` (NET) INCLUDES them; `parkingMinor` is the
|
||||||
|
* parking-only net; `chargesMinor` their sum. See PayStation charge providers. */
|
||||||
|
readonly chargeLines?: ChargeLine[];
|
||||||
|
readonly chargesMinor?: number;
|
||||||
|
readonly parkingMinor?: number;
|
||||||
|
/** carwash_order / carwash_payment: the order + what was frozen at intake. */
|
||||||
|
readonly orderId?: string;
|
||||||
|
readonly action?: string;
|
||||||
|
readonly categoryName?: string;
|
||||||
|
readonly serviceName?: string;
|
||||||
|
readonly priceMinor?: number;
|
||||||
|
readonly payAt?: string;
|
||||||
/** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */
|
/** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */
|
||||||
readonly programId?: string;
|
readonly programId?: string;
|
||||||
readonly programLabel?: string;
|
readonly programLabel?: string;
|
||||||
@@ -326,6 +354,12 @@ export interface LedgerPayload {
|
|||||||
readonly percent?: number;
|
readonly percent?: number;
|
||||||
/** validation / cash vouchers: the username of the user who recorded it. */
|
/** validation / cash vouchers: the username of the user who recorded it. */
|
||||||
readonly operator?: string;
|
readonly operator?: string;
|
||||||
|
/** MONEY events (payment, carwash_payment, cash_in/out, shift_open, shift_z_report):
|
||||||
|
* the TILL the money belongs to. A shift is opened on a till; every taking and
|
||||||
|
* voucher names one; the drawer fold and the Z-report filter by it. ABSENT = the
|
||||||
|
* booth (every event before tills existed, 2026-09-05, is booth money — so the
|
||||||
|
* chain re-folds identically). See wiki/concepts/shift.md "Tills". */
|
||||||
|
readonly till?: TillId;
|
||||||
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
||||||
readonly fxRate?: number | null;
|
readonly fxRate?: number | null;
|
||||||
/** void / anomaly / override: a human-readable English sentence, signed as the
|
/** void / anomaly / override: a human-readable English sentence, signed as the
|
||||||
@@ -744,8 +778,20 @@ export interface SessionPayment {
|
|||||||
|
|
||||||
/** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed
|
/** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed
|
||||||
* by the merchant at scan time, capped) / a percentage off. */
|
* by the merchant at scan time, capped) / a percentage off. */
|
||||||
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent";
|
/** How a validation program discounts the parking fee. The first four are the merchant
|
||||||
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
|
* modes (applied at scan). The last two are RESOLVED at apply time by the Car Wash module
|
||||||
|
* and can only be applied through a wash order (a merchant scan refuses them):
|
||||||
|
* - doneTolerance: the WASH WINDOW is free — from the order's intake until it is marked
|
||||||
|
* DONE, plus `minutes` tolerance — resolved into a timeCredit of (window + minutes).
|
||||||
|
* Parking before the order and after the tolerance stays at the tariff;
|
||||||
|
* - washPrice: the wash price comes off the parking fee, floored at 0 — resolved into a
|
||||||
|
* fixed discount of the order's price. */
|
||||||
|
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent" | "doneTolerance" | "washPrice";
|
||||||
|
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent", "doneTolerance", "washPrice"];
|
||||||
|
/** Modes a MERCHANT may apply at scan (the wash-only modes need a wash order's context). */
|
||||||
|
export const MERCHANT_VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
|
||||||
|
/** Modes the Car Wash discount editor offers (no typed amounts, no percent — see venue-modules.md). */
|
||||||
|
export const CARWASH_VALIDATION_MODES: readonly ValidationMode[] = ["comp", "doneTolerance", "washPrice", "timeCredit"];
|
||||||
|
|
||||||
/** An admin-composed validation program (one per merchant station; `bar` and `lavazh`
|
/** An admin-composed validation program (one per merchant station; `bar` and `lavazh`
|
||||||
* are the well-known ids the /setup/site checkboxes toggle). */
|
* are the well-known ids the /setup/site checkboxes toggle). */
|
||||||
@@ -1705,3 +1751,355 @@ export interface Signer {
|
|||||||
* verifies via its public key). */
|
* verifies via its public key). */
|
||||||
verify(payload: string, signature: string): boolean;
|
verify(payload: string, signature: string): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Venue modules -------------------------------------------------------------
|
||||||
|
// Optional per-site features (Car Wash, Bar, …) and — deliberately — the parking
|
||||||
|
// product itself are MODULES on a shared venue core (identity/roles, the signed
|
||||||
|
// ledger, devices, shift/cash, printing, reports, site config). One binary; a module
|
||||||
|
// is enabled per site at RUNTIME as `entitled ∩ activated`:
|
||||||
|
// - entitled = what the vendor deployed for this site (MODULES_ENTITLED env, set in
|
||||||
|
// the Komodo stack; unset = every registered module — existing
|
||||||
|
// deployments keep working unchanged);
|
||||||
|
// - activated = what the site admin has switched on in Setup → Site
|
||||||
|
// (site_config.modules_json; null = everything entitled).
|
||||||
|
// The server ENFORCES the effective set (requireModule guard, apps/server/src/
|
||||||
|
// modules.ts); the web only HIDES nav/routes from it. Disabling never deletes:
|
||||||
|
// tables stay migrated, history stays, role grants stay; routes reject and UI hides.
|
||||||
|
// Design + rationale: wiki/decisions/venue-modules.md.
|
||||||
|
|
||||||
|
export const MODULE_IDS = ["parking", "validation", "carwash"] as const;
|
||||||
|
export type ModuleId = (typeof MODULE_IDS)[number];
|
||||||
|
|
||||||
|
// --- Tills --------------------------------------------------------------------
|
||||||
|
// A TILL is a physical cash drawer with its own accountability: shifts are opened on
|
||||||
|
// a till, money events name their till, and the Z-report reconciles one till. The
|
||||||
|
// booth is the till that has always existed; a money-taking module declares its own
|
||||||
|
// (Car Wash → "carwash") so its operator counts THEIR drawer against THEIR expected
|
||||||
|
// figure — the wash operator and the booth operator do not share a shift. A till is
|
||||||
|
// available when the module that declares it is effective. See wiki/concepts/shift.md.
|
||||||
|
export const TILL_IDS = ["booth", "carwash"] as const;
|
||||||
|
export type TillId = (typeof TILL_IDS)[number];
|
||||||
|
/** The till every pre-till event and every un-tagged money event belongs to. */
|
||||||
|
export const BOOTH_TILL: TillId = "booth";
|
||||||
|
export function isTillId(v: unknown): v is TillId {
|
||||||
|
return typeof v === "string" && (TILL_IDS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
/** The till a money event belongs to: its payload's `till`, else the booth. ONE rule,
|
||||||
|
* shared by the drawer fold, the Z-report, and the UI — never re-derive it elsewhere. */
|
||||||
|
export function tillOf(payload: { till?: TillId } | null | undefined): TillId {
|
||||||
|
return payload?.till ?? BOOTH_TILL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleManifest {
|
||||||
|
readonly id: ModuleId;
|
||||||
|
/** Cannot be deactivated (and is always entitled). Parking is the product today. */
|
||||||
|
readonly required: boolean;
|
||||||
|
/** Modules that must be effective for this one to be activated. Enforced at the point
|
||||||
|
* of change (activating with a dependency off is refused; deactivating a dependency of
|
||||||
|
* an active module is refused) and again when computing the effective set. */
|
||||||
|
readonly dependsOn: readonly ModuleId[];
|
||||||
|
/** Permission resources this module contributes to the catalog (informational for the
|
||||||
|
* role composer; the core resources belong to no module). */
|
||||||
|
readonly resources: readonly Resource[];
|
||||||
|
/** Ledger event types this module appends (informational; the union stays ONE
|
||||||
|
* append-only type — see LedgerEventType). */
|
||||||
|
readonly ledgerEventTypes: readonly LedgerEventType[];
|
||||||
|
/** The TILL this module takes money on, if it takes money at its own desk. Its
|
||||||
|
* operators open shifts on that till and reconcile that drawer. Absent = the
|
||||||
|
* module has no money of its own (validation) — or, for parking, the booth. */
|
||||||
|
readonly till?: TillId;
|
||||||
|
/** Who may SEE and WORK this module's till — each desk's money is guarded by that
|
||||||
|
* desk's own permissions (permissions-matrix decision, 2026-09-05): `read` = see the
|
||||||
|
* shift state / X-report / balance / history; `shift` = open + close the shift;
|
||||||
|
* `cash` = record cash in/out. The booth's are parking's `shift:*` / `drawer:*`; the
|
||||||
|
* wash's are `carwash:read` / `carwash:cash`. A wash role holds no `shift:*` at all,
|
||||||
|
* so it cannot touch the booth by construction. Required when `till` is set. */
|
||||||
|
readonly tillGuards?: TillGuards;
|
||||||
|
/** The permission that admits this module's ledger events to a role's live feed
|
||||||
|
* (`ledgerEventTypes` above). Absent = the core `event:read`. */
|
||||||
|
readonly feedPermission?: Permission;
|
||||||
|
/** JOBS — named permission bundles the role composer offers as one click ("Booth
|
||||||
|
* operator", "Wash operator"). The grid stays the enforcement layer; a job is only a
|
||||||
|
* starting point the admin may fine-tune. Names live in the web i18n (`jobs.<id>`). */
|
||||||
|
readonly jobs: readonly JobPreset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TillGuards {
|
||||||
|
readonly read: Permission;
|
||||||
|
readonly shift: Permission;
|
||||||
|
readonly cash: Permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobPreset {
|
||||||
|
readonly id: string;
|
||||||
|
readonly permissions: readonly Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The registry. Adding a module = one entry here + its server/web folders
|
||||||
|
* (apps/server/src/modules/<id>, apps/web/src/modules/<id>). Order = display order. */
|
||||||
|
export const MODULES: readonly ModuleManifest[] = [
|
||||||
|
{
|
||||||
|
id: "parking",
|
||||||
|
required: true,
|
||||||
|
dependsOn: [],
|
||||||
|
resources: ["tariff", "subscription", "payment", "session"],
|
||||||
|
ledgerEventTypes: ["vehicle_entry", "vehicle_exit", "payment", "barrier_open_command", "barrier_open_observed"],
|
||||||
|
till: "booth",
|
||||||
|
tillGuards: { read: "shift:read", shift: "shift:create", cash: "drawer:create" },
|
||||||
|
jobs: [
|
||||||
|
{
|
||||||
|
// Runs the booth: sessions, payments, own shift + drawer, the live feed, devices.
|
||||||
|
id: "booth-operator",
|
||||||
|
permissions: [
|
||||||
|
"session:read", "session:create", "payment:read", "payment:create", "event:read",
|
||||||
|
"shift:read", "shift:create", "drawer:create", "device:read",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Everything the operator has, plus what an operator must NOT: voids, every
|
||||||
|
// operator's shifts, drawer review, reports, subscriptions, tariff reading.
|
||||||
|
id: "booth-supervisor",
|
||||||
|
permissions: [
|
||||||
|
"session:read", "session:create", "payment:read", "payment:create", "event:read",
|
||||||
|
"shift:read", "shift:create", "drawer:create", "device:read",
|
||||||
|
"event:void", "shift:cash", "drawer:review", "report:read",
|
||||||
|
"subscription:read", "subscription:create", "subscription:update", "tariff:read", "validation:read",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Merchant-scan ticket validation, kept for the Bar until a Bar module absorbs it
|
||||||
|
// (wiki/decisions/venue-modules.md, decision 1).
|
||||||
|
id: "validation",
|
||||||
|
required: false,
|
||||||
|
dependsOn: ["parking"],
|
||||||
|
resources: ["validation"],
|
||||||
|
ledgerEventTypes: ["validation"],
|
||||||
|
// A merchant's whole role: scan-and-validate, nothing else. Their validation events
|
||||||
|
// ride the booth log (event:read), so no feed permission of their own.
|
||||||
|
jobs: [{ id: "merchant", permissions: ["validation:create"] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The pilot module. Depends on parking only (the wash sits inside the park; the
|
||||||
|
// ticket IS the customer identity). The parking-discount ENGINE (validation programs +
|
||||||
|
// applyValidation) is CORE — the `validation` module is just the merchant's scan
|
||||||
|
// screen — so a site can run Car Wash without any merchant validation (2026-09-06).
|
||||||
|
id: "carwash",
|
||||||
|
required: false,
|
||||||
|
dependsOn: ["parking"],
|
||||||
|
resources: ["carwash"],
|
||||||
|
ledgerEventTypes: ["carwash_order", "carwash_payment"],
|
||||||
|
// Money taken AT THE BAY lands on the wash operator's own till, never the booth's.
|
||||||
|
till: "carwash",
|
||||||
|
tillGuards: { read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" },
|
||||||
|
feedPermission: "carwash:read",
|
||||||
|
jobs: [
|
||||||
|
// Runs the wash desk and its own till; sees nothing of the booth.
|
||||||
|
{ id: "wash-operator", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The guards of a till (its module's `tillGuards`). */
|
||||||
|
export function tillGuards(till: TillId): TillGuards {
|
||||||
|
const m = MODULES.find((x) => x.till === till);
|
||||||
|
if (!m?.tillGuards) throw new Error(`till without guards: ${till}`);
|
||||||
|
return m.tillGuards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which permission admits a ledger event type to a role's live feed: the owning
|
||||||
|
* module's `feedPermission`, else the core `event:read`. */
|
||||||
|
export function feedPermissionFor(type: LedgerEventType): Permission {
|
||||||
|
const m = MODULES.find((x) => x.ledgerEventTypes.includes(type));
|
||||||
|
return m?.feedPermission ?? "event:read";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every permission that admits a role to the live WebSocket at all (it then receives
|
||||||
|
* only what each permission covers): the core feed/occupancy/device permissions plus
|
||||||
|
* each effective module's own feed permission. `report:read` is NOT among them — the
|
||||||
|
* reports screen and the live feed are different things (user, 2026-09-05). */
|
||||||
|
export function watchPermissions(effective: readonly ModuleId[]): Permission[] {
|
||||||
|
const out = new Set<Permission>(["event:read", "session:read", "device:read"]);
|
||||||
|
for (const m of MODULES) if (m.feedPermission && effective.includes(m.id)) out.add(m.feedPermission);
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which tills a role may work more than one of — the composer's "mixes desks" lint. */
|
||||||
|
export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
|
||||||
|
return tillsFor(effective, has, "shift");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tills available given the EFFECTIVE modules — the booth always (parking is
|
||||||
|
* required), plus each effective module's own till. Registry order. */
|
||||||
|
export function tillsOf(effective: readonly ModuleId[]): TillId[] {
|
||||||
|
const out = new Set<TillId>([BOOTH_TILL]);
|
||||||
|
for (const m of MODULES) if (m.till && effective.includes(m.id)) out.add(m.till);
|
||||||
|
return TILL_IDS.filter((t) => out.has(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tills a ROLE may SEE (`kind` = read, default) or WORK (`shift` / `cash`) at
|
||||||
|
* this site: the effective tills whose module guard the role holds. What the
|
||||||
|
* shift/drawer routes enforce and what the UI offers (header button, start buttons,
|
||||||
|
* drawer switch). */
|
||||||
|
export function tillsFor(
|
||||||
|
effective: readonly ModuleId[],
|
||||||
|
has: (p: Permission) => boolean,
|
||||||
|
kind: keyof TillGuards = "read",
|
||||||
|
): TillId[] {
|
||||||
|
return tillsOf(effective).filter((t) => has(tillGuards(t)[kind]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Car Wash module ----------------------------------------------------------
|
||||||
|
// Types shared by apps/server/src/modules/carwash and apps/web/src/modules/carwash.
|
||||||
|
// Data model + rules: wiki/decisions/venue-modules.md ("Car Wash — the pilot module").
|
||||||
|
|
||||||
|
/** Where the wash is paid — a per-order choice at intake. `booth`: the wash is a charge
|
||||||
|
* line on the parking settlement at the booth (the exit barrier opens after that
|
||||||
|
* payment as usual). `bay`: the wash operator collects at the bay; the parking session
|
||||||
|
* is then settled to zero-due (via the sponsorship program) so the exit READER opens. */
|
||||||
|
export type CarWashPayAt = "booth" | "bay";
|
||||||
|
export const CARWASH_PAY_AT: readonly CarWashPayAt[] = ["booth", "bay"];
|
||||||
|
/** Where wash money is taken is a SITE setting (Setup → Car wash), not a per-order
|
||||||
|
* choice: the site either settles washes at the booth (on the parking ticket) or at
|
||||||
|
* the bay (the wash operator's own till). Every order freezes the policy in force. */
|
||||||
|
export const CARWASH_PAY_AT_DEFAULT: CarWashPayAt = "booth";
|
||||||
|
|
||||||
|
/** An order's working state. `paid` is tracked separately (paidAt / payment ref) since a
|
||||||
|
* bay order may be paid before or after the wash is done. */
|
||||||
|
export type CarWashOrderStatus = "open" | "done" | "void";
|
||||||
|
|
||||||
|
/** The validation-program row id the Car Wash module uses for its parking sponsorship —
|
||||||
|
* the same shape a merchant validation has (comp / timeCredit / fixed / percent,
|
||||||
|
* maxPerDay), composed on Setup → Car wash, applied automatically when a wash is done. */
|
||||||
|
export const CARWASH_PROGRAM_ID = "carwash";
|
||||||
|
|
||||||
|
/** A non-parking charge folded into a booth settlement by a module (today: a wash
|
||||||
|
* ordered with payAt = "booth"). Frozen onto the `payment` payload as `chargeLines`. */
|
||||||
|
export interface ChargeLine {
|
||||||
|
/** Who owns the line — the module id. */
|
||||||
|
readonly module: ModuleId;
|
||||||
|
/** The module's own record this settles (e.g. the wash order id). */
|
||||||
|
readonly ref: string;
|
||||||
|
/** Receipt/display label, e.g. "Car wash — SUV · Standard". */
|
||||||
|
readonly label: string;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setup → Car wash: the admin-maintained master data, as read/written by
|
||||||
|
* GET/PUT /api/carwash/settings. Ids are stable; names are display text. */
|
||||||
|
export interface CarwashSettingsView {
|
||||||
|
readonly categories: { id: string; name: string; sortOrder: number; active: boolean }[];
|
||||||
|
readonly services: { id: string; name: string; sortOrder: number; active: boolean }[];
|
||||||
|
/** One entry per priced (category, service) pair. */
|
||||||
|
readonly prices: { categoryId: string; serviceId: string; priceMinor: number }[];
|
||||||
|
readonly currency: string | null;
|
||||||
|
/** Where wash money is taken at this site (booth = on the parking ticket; bay = the
|
||||||
|
* wash operator's till). Site-level; the desk no longer asks per order. */
|
||||||
|
readonly payAt: CarWashPayAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A wash order as the desk sees it (GET /api/carwash/orders). */
|
||||||
|
export interface CarwashOrderView {
|
||||||
|
readonly id: string;
|
||||||
|
readonly identity: string;
|
||||||
|
readonly plate: string | null;
|
||||||
|
readonly categoryId: string;
|
||||||
|
readonly categoryName: string;
|
||||||
|
readonly serviceId: string;
|
||||||
|
readonly serviceName: string;
|
||||||
|
readonly priceMinor: number;
|
||||||
|
readonly currency: string;
|
||||||
|
readonly payAt: CarWashPayAt;
|
||||||
|
readonly status: CarWashOrderStatus;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly createdBy: string;
|
||||||
|
readonly doneAt: string | null;
|
||||||
|
readonly doneBy: string | null;
|
||||||
|
readonly paidAt: string | null;
|
||||||
|
readonly paidBy: string | null;
|
||||||
|
readonly tender: Tender | null;
|
||||||
|
/** True once the order needs nothing more (done + paid, or void). */
|
||||||
|
readonly closed: boolean;
|
||||||
|
readonly validationEventId: string | null;
|
||||||
|
readonly voidBy: string | null;
|
||||||
|
readonly voidReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModuleId(v: unknown): v is ModuleId {
|
||||||
|
return typeof v === "string" && (MODULE_IDS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleManifest(id: ModuleId): ModuleManifest {
|
||||||
|
const m = MODULES.find((x) => x.id === id);
|
||||||
|
if (!m) throw new Error(`unknown module: ${id}`);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ids of the modules that can never be off. */
|
||||||
|
export const REQUIRED_MODULE_IDS: readonly ModuleId[] = MODULES.filter((m) => m.required).map((m) => m.id);
|
||||||
|
|
||||||
|
/** Parse a comma-separated entitlement list (the MODULES_ENTITLED env). Unknown ids
|
||||||
|
* are dropped (returned in `unknown` so the caller can warn); required modules are
|
||||||
|
* always included; unset/blank = everything registered. */
|
||||||
|
export function parseEntitledModules(raw: string | undefined | null): { entitled: ModuleId[]; unknown: string[] } {
|
||||||
|
const trimmed = (raw ?? "").trim();
|
||||||
|
if (trimmed === "") return { entitled: [...MODULE_IDS], unknown: [] };
|
||||||
|
const entitled = new Set<ModuleId>(REQUIRED_MODULE_IDS);
|
||||||
|
const unknown: string[] = [];
|
||||||
|
for (const part of trimmed.split(",")) {
|
||||||
|
const id = part.trim();
|
||||||
|
if (id === "") continue;
|
||||||
|
if (isModuleId(id)) entitled.add(id);
|
||||||
|
else unknown.push(id);
|
||||||
|
}
|
||||||
|
return { entitled: MODULE_IDS.filter((id) => entitled.has(id)), unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModuleActivationResult =
|
||||||
|
| { ok: true; modules: ModuleId[] }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/** Validate a requested activation set against the entitlement. Required modules are
|
||||||
|
* always included; anything not entitled or with an inactive dependency is refused
|
||||||
|
* with a human-readable reason (the UI shows it verbatim). Returns the normalized set
|
||||||
|
* in registry order. */
|
||||||
|
export function resolveModuleActivation(
|
||||||
|
entitled: readonly ModuleId[],
|
||||||
|
requested: readonly ModuleId[],
|
||||||
|
): ModuleActivationResult {
|
||||||
|
const active = new Set<ModuleId>(REQUIRED_MODULE_IDS);
|
||||||
|
for (const id of requested) active.add(id);
|
||||||
|
for (const id of active) {
|
||||||
|
if (!entitled.includes(id)) return { ok: false, error: `module "${id}" is not entitled for this site` };
|
||||||
|
}
|
||||||
|
for (const id of active) {
|
||||||
|
for (const dep of moduleManifest(id).dependsOn) {
|
||||||
|
if (!active.has(dep)) return { ok: false, error: `module "${id}" requires "${dep}" to be enabled` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, modules: MODULE_IDS.filter((id) => active.has(id)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The effective set = required ∪ (entitled ∩ activated), then any module whose
|
||||||
|
* dependency is not effective is dropped (defensive: an entitlement can shrink after
|
||||||
|
* activation was recorded). `activated === null` means "never set" → everything
|
||||||
|
* entitled. Registry order. */
|
||||||
|
export function effectiveModules(entitled: readonly ModuleId[], activated: readonly ModuleId[] | null): ModuleId[] {
|
||||||
|
const on = new Set<ModuleId>(REQUIRED_MODULE_IDS);
|
||||||
|
for (const id of activated ?? entitled) {
|
||||||
|
if (entitled.includes(id)) on.add(id);
|
||||||
|
}
|
||||||
|
// Drop dependency-broken modules until stable (the registry is tiny; a loop is fine).
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const id of [...on]) {
|
||||||
|
if (moduleManifest(id).dependsOn.some((dep) => !on.has(dep))) {
|
||||||
|
on.delete(id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MODULE_IDS.filter((id) => on.has(id));
|
||||||
|
}
|
||||||
|
|||||||
Generated
+33
@@ -108,12 +108,24 @@ importers:
|
|||||||
'@tanstack/react-router':
|
'@tanstack/react-router':
|
||||||
specifier: ^1.170.16
|
specifier: ^1.170.16
|
||||||
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@tauri-apps/api':
|
||||||
|
specifier: ^2.11.1
|
||||||
|
version: 2.11.1
|
||||||
|
'@tauri-apps/plugin-http':
|
||||||
|
specifier: ^2.5.2
|
||||||
|
version: 2.6.0
|
||||||
'@tauri-apps/plugin-process':
|
'@tauri-apps/plugin-process':
|
||||||
specifier: ^2.3.1
|
specifier: ^2.3.1
|
||||||
version: 2.3.1
|
version: 2.3.1
|
||||||
|
'@tauri-apps/plugin-store':
|
||||||
|
specifier: ^2.4.0
|
||||||
|
version: 2.4.4
|
||||||
'@tauri-apps/plugin-updater':
|
'@tauri-apps/plugin-updater':
|
||||||
specifier: ^2.10.1
|
specifier: ^2.10.1
|
||||||
version: 2.10.1
|
version: 2.10.1
|
||||||
|
'@tauri-apps/plugin-websocket':
|
||||||
|
specifier: ^2.3.0
|
||||||
|
version: 2.4.3
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^26.3.1
|
specifier: ^26.3.1
|
||||||
version: 26.3.1(typescript@6.0.3)
|
version: 26.3.1(typescript@6.0.3)
|
||||||
@@ -1577,12 +1589,21 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-http@2.6.0':
|
||||||
|
resolution: {integrity: sha512-QYXwbGb4hQ9/8Riv/ejU/kPFFnbBIrBcWwV1LIXv2xBKfoj8lkWfGkd9pkCSsBI/pljPtz+IPqfrE3t3bVl3mg==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-process@2.3.1':
|
'@tauri-apps/plugin-process@2.3.1':
|
||||||
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-store@2.4.4':
|
||||||
|
resolution: {integrity: sha512-oxSMaj/QpVfJcBMYX5aOQV94fWvga0MwQMfD6TLlbK2dh+ShPWAzefd8HWXhvOKjPRJdGVAkW7ZGO76JzzjaDA==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-updater@2.10.1':
|
'@tauri-apps/plugin-updater@2.10.1':
|
||||||
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-websocket@2.4.3':
|
||||||
|
resolution: {integrity: sha512-c85ykljg6AzY6Zw4KpYsEBaLirjPIs6m8xxC6hZcdwAchckaBU368US+oSsa5B43PjSLukjwD5vOOqzYYnswWA==}
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4089,14 +4110,26 @@ snapshots:
|
|||||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.3
|
'@tauri-apps/cli-win32-ia32-msvc': 2.11.3
|
||||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.3
|
'@tauri-apps/cli-win32-x64-msvc': 2.11.3
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-http@2.6.0':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-process@2.3.1':
|
'@tauri-apps/plugin-process@2.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.1
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-store@2.4.4':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-updater@2.10.1':
|
'@tauri-apps/plugin-updater@2.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.1
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-websocket@2.4.3':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
|
|||||||
+43
-1
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, shifts, anti-fraud]
|
tags: [parking, domain, business, shifts, anti-fraud]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-05
|
updated: 2026-09-05
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,6 +23,12 @@ don't force one model across both.
|
|||||||
|
|
||||||
## Site-wide single-open + the booth gate (decided + built 2026-06-18)
|
## Site-wide single-open + the booth gate (decided + built 2026-06-18)
|
||||||
|
|
||||||
|
> **Superseded 2026-09-05 — now PER TILL (built).** With money-taking venue modules (Car Wash
|
||||||
|
> at the bay), "site-wide" became **per till**: one open shift and one drawer per till
|
||||||
|
> (`booth`, `carwash`, …), each with its own operator, float, vouchers and Z-report; every
|
||||||
|
> money event names its till. See §"Tills" below and [[venue-modules]] §"Tills". Everything
|
||||||
|
> in this section stays true *within* a till.
|
||||||
|
|
||||||
A shift is a **site-wide accountability period**: at most **one shift may be open at a time** across
|
A shift is a **site-wide accountability period**: at most **one shift may be open at a time** across
|
||||||
the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit
|
the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit
|
||||||
falls inside exactly one operator's window. Consequences:
|
falls inside exactly one operator's window. Consequences:
|
||||||
@@ -266,6 +272,42 @@ read a slow open as a dead click. Every shift open/close button (header, /shifts
|
|||||||
"open shift now", the end-shift confirm) now pairs the busy label with an animated spinner
|
"open shift now", the end-shift confirm) now pairs the busy label with an animated spinner
|
||||||
(`ui/Spinner.tsx`, reusable) and dims while disabled.
|
(`ui/Spinner.tsx`, reusable) and dims while disabled.
|
||||||
|
|
||||||
|
## Tills — one shift and one drawer per money-taking desk (built 2026-09-05)
|
||||||
|
|
||||||
|
A **till** is a physical cash drawer with its own accountability. The booth is the till that
|
||||||
|
always existed; a venue module that takes money at its own desk declares its own till in its
|
||||||
|
manifest (Car Wash → `carwash`; a future Bar → `bar`). Rules:
|
||||||
|
|
||||||
|
- A shift is **opened on a till**. At most one shift open per till; tills are independent (the
|
||||||
|
booth and the wash desk run side by side, by different — or the same — operators).
|
||||||
|
- **Every money event names its till** (`payload.till`): parking `payment` and the
|
||||||
|
subscription sale = `booth` (a wash paid at the booth rides the parking payment as
|
||||||
|
`chargeLines`, so it is booth money too); `carwash_payment` at the bay = `carwash`;
|
||||||
|
`cash_in`/`cash_out` carry the drawer they moved. `shift_open`/`shift_z_report` carry theirs.
|
||||||
|
- **Absent `till` = booth.** Every event before tills existed is booth money, so the chain
|
||||||
|
re-folds identically and old Z-reports read as booth shifts. `tillOf()` in `@parking/shared`
|
||||||
|
is the one place this rule lives.
|
||||||
|
- The drawer fold, the X/Z-report window (payments **and** vouchers) and carry-forward all
|
||||||
|
filter by till: the wash operator's expected drawer is *their* float + *their* bay cash +
|
||||||
|
*their* vouchers, and the booth's never includes bay money. The counted-vs-expected moment
|
||||||
|
therefore sits with whoever holds the cash — which is the whole point (see §below).
|
||||||
|
- "Take money at the bay" requires the **carwash** shift, not the booth's; the wash desk
|
||||||
|
carries its own shift control. The header button stays the booth's. The shift hub lists
|
||||||
|
every open shift with a till badge; the drawer hub switches tills.
|
||||||
|
- **Each desk's money is guarded by that desk's own permissions** (2026-09-05, after the
|
||||||
|
user found a wash user could open the *booth's* shift; design on [[venue-modules]]
|
||||||
|
§"Permissions matrix"). The manifest declares `tillGuards { read, shift, cash }`: booth =
|
||||||
|
`shift:read` / `shift:create` / `drawer:create` (unchanged), carwash = `carwash:read` /
|
||||||
|
`carwash:cash` / `carwash:cash`. The shift + drawer routes resolve the guard FROM THE TILL
|
||||||
|
(`requireTill(kind)`; `403 till_forbidden`), `/api/shift/tills` lists the tills a role may
|
||||||
|
read with a `canWork` flag, and history / movements without a till filter return the
|
||||||
|
union of the role's readable tills. So a wash role holds no `shift:*` at all and cannot
|
||||||
|
touch the booth by construction; the header button, the hub's start buttons and the
|
||||||
|
drawer switch never offer a till the server would refuse. (A first cut that borrowed
|
||||||
|
`session:read` as "works the booth till" lived for a few hours and is gone.)
|
||||||
|
- Not done: the per-shift *activity log* is still a time window over the whole chain (money
|
||||||
|
figures are per till, the event list is not); bay slips print on the booth printer.
|
||||||
|
|
||||||
## Where the fraud control actually lives
|
## Where the fraud control actually lives
|
||||||
|
|
||||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
||||||
|
|||||||
@@ -462,9 +462,11 @@ before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Cad
|
|||||||
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
|
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
|
||||||
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
|
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
|
||||||
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
|
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
|
||||||
`hosts`/DNS ON-SITE, never an image rebuild. The **Tauri desktop app** is hardcoded to
|
`hosts`/DNS ON-SITE, never an image rebuild. The **Tauri desktop app** (install the `.deb` from
|
||||||
`localhost:3000` (CSP + endpoints) and can't reach a remote booth without code changes — a browser
|
`mca/public_releases`, NOT the AppImage — see [[desktop-shell-tauri]]) asks for the server address
|
||||||
works; the desktop app is a separate workstream.
|
on first launch (`127.0.0.1:3000` on the booth itself, or any `<ip>:3000` / `<name>` via Caddy);
|
||||||
|
nothing is baked in since v0.1.5. In-app updates need the **admin** password (polkit) — by
|
||||||
|
decision, updates are an admin action, so plan to be at the box when bringing it online for one.
|
||||||
|
|
||||||
## Quick-reference: the gotchas, in order they bit us
|
## Quick-reference: the gotchas, in order they bit us
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, decisions, desktop, frontend]
|
tags: [parking, decisions, desktop, frontend]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-09-03
|
updated: 2026-09-04
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -140,10 +140,74 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
|||||||
- **Right-click:** the context menu is blocked in **prod only** (`apps/web/src/lib/kiosk.ts`,
|
- **Right-click:** the context menu is blocked in **prod only** (`apps/web/src/lib/kiosk.ts`,
|
||||||
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
|
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
|
||||||
prod build and the desktop build (same SPA).
|
prod build and the desktop build (same SPA).
|
||||||
- **`VITE_API_BASE` wired to the environment:** `apps/web/.env.production` (committed, non-secret,
|
- **Mixed content blocks http(s)/ws(s) from the webview — fixed 2026-09-03.** Even with
|
||||||
allow-listed in `.gitignore`) sets `VITE_API_BASE=http://127.0.0.1:3000`, auto-loaded by
|
`VITE_API_BASE` correctly set (below), login still failed with WebKit's generic `"Load failed"`.
|
||||||
`vite build` (which the desktop bundle runs). So the desktop build targets Fastify with no manual
|
Root cause is a separate, deeper issue: WebKitGTK treats `tauri://localhost` as a **secure
|
||||||
export; the browser-served-by-Fastify build should override to `""`.
|
origin**, so a plain `http://127.0.0.1:3000` `fetch()` — or a `ws://127.0.0.1:3000` WebSocket —
|
||||||
|
from inside it is blocked as **mixed content**. (Nearest upstream ticket:
|
||||||
|
[bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934) — note that one is
|
||||||
|
specifically about *loopback* addresses from https pages; a LAN IP such as `192.168.1.50:3000`
|
||||||
|
would stay mixed content even if it were fixed, so the plugin route below is the right
|
||||||
|
architecture for a remote booth regardless, not a stopgap.) `connect-src` in the
|
||||||
|
CSP does **not** override this — it's a different browser security layer entirely, so the request
|
||||||
|
never even reaches the network layer to be diagnosable via server logs. **Fix:** two Tauri plugins
|
||||||
|
route the SPA's traffic through Tauri's native (Rust) side instead of the webview's own
|
||||||
|
fetch/WebSocket, which sidesteps the check entirely:
|
||||||
|
- **`tauri-plugin-http`** — `apps/web/src/lib/origin.ts`'s `platformFetch()` dynamically imports
|
||||||
|
`@tauri-apps/plugin-http`'s `fetch` (a genuine drop-in for the standard Fetch API) inside Tauri,
|
||||||
|
plain `fetch` in the browser. `api.ts` and `logger.ts` both call `platformFetch` instead of the
|
||||||
|
global `fetch` now.
|
||||||
|
- **`tauri-plugin-websocket`** — NOT a drop-in (async `connect()`/listener-callback API, not
|
||||||
|
`onopen`/`onmessage`/sync `send`/`close`). `apps/web/src/lib/platform-ws.ts` adapts it behind
|
||||||
|
the same native-`WebSocket`-shaped interface `use-live-feed.ts` already expects (hardened for
|
||||||
|
reconnect backoff + StrictMode double-invoke), so that hook needed zero changes.
|
||||||
|
- Capability grants: `apps/desktop/src-tauri/capabilities/default.json` adds `websocket:default`
|
||||||
|
and a scoped `http:default` (`allow: [{url: "http://127.0.0.1:3000"}, {url:
|
||||||
|
"http://localhost:3000"}]`) — deny-by-default, matching the CSP's existing allowlist.
|
||||||
|
- `logger.ts`'s `flushBeacon()` (page-hide `navigator.sendBeacon`) is a native browser API with no
|
||||||
|
Tauri equivalent — it still drops silently in the desktop shell on unload. Accepted: the regular
|
||||||
|
4s-interval flush (now fixed, routes through `platformFetch`) covers the common case.
|
||||||
|
- **Gotcha (found immediately after shipping the above): the native WS plugin sends no `Origin`
|
||||||
|
header.** `tauri-plugin-websocket`'s `connect()` runs on Tauri's Rust side, not inside the
|
||||||
|
webview page — there's no page context to auto-attach `Origin: tauri://localhost` the way a real
|
||||||
|
browser `WebSocket` would. (The **HTTP** plugin, by contrast, *does* attach that Origin itself —
|
||||||
|
`tauri-plugin-http/src/commands.rs`, "ensure we have an Origin header set" — so only the WS
|
||||||
|
path needs the explicit header.) The server's anti-CSWSH check (`routes/ws.ts`, `isAllowedOrigin`)
|
||||||
|
treats a missing Origin as untrusted and 403s the handshake before touching auth — the live feed
|
||||||
|
showed **"JASHTË LINJË"** (offline) in the desktop app while the browser showed **"LIVE"**, same
|
||||||
|
server, same moment. **Fix (two parts, both needed):** `platform-ws.ts`'s `connect()` call now
|
||||||
|
passes `{ headers: { Origin: "tauri://localhost" } }` explicitly; separately, `komodo/
|
||||||
|
resources.toml`'s booth Stacks had `WS_ALLOWED_ORIGINS=` **empty** in production (despite
|
||||||
|
`.env.example` documenting `tauri://localhost,http://tauri.localhost` as required) — even a
|
||||||
|
correct Origin header is useless if the server's allowlist doesn't include it. Both fixed
|
||||||
|
together; a `resources.toml` change still needs a Komodo sync + Stack redeploy to take effect on
|
||||||
|
a live booth, it isn't automatic from a git push alone — and see [[fleet-deployment-komodo]] for
|
||||||
|
a real ResourceSync-branch gotcha this exact fix ran into.
|
||||||
|
- **No way to see the installed app's own version (found + fixed 2026-09-03).** `VersionBadge` in
|
||||||
|
`router.tsx` shows the *server's* `<branch>-<sha>` (from `/api/version`, gated `site:read`) — but
|
||||||
|
nothing showed the *desktop client's* own version. An operator debugging a stuck update had no way
|
||||||
|
to confirm which build was actually installed short of reading the update-available prompt's
|
||||||
|
target version and inferring backwards. Fixed with `DesktopVersionBadge`, next to `VersionBadge`:
|
||||||
|
calls `@tauri-apps/api/app`'s `getVersion()` (the real running app's version, baked in from
|
||||||
|
`tauri.conf.json` — synced to the git tag by `release.yml`, see the version-drift gotcha above),
|
||||||
|
no-ops/renders nothing in a browser (`inTauri()` guard, now exported from `origin.ts` instead of
|
||||||
|
redefined a 4th time). `@tauri-apps/api` added as an explicit dependency (was only ever transitive
|
||||||
|
via the plugins).
|
||||||
|
- **`VITE_API_BASE` — desktop vs. browser (regression found + fixed 2026-09-03):**
|
||||||
|
`apps/web/.env.production` (committed, shared by both builds) sets `VITE_API_BASE=` (empty) — this
|
||||||
|
is correct for the **browser/booth** build (Fastify same-origin, stays relative) since commit
|
||||||
|
`96fd97e` (2026-06-27), but that same change silently broke the **desktop** build, which was never
|
||||||
|
given its own override. Result: the desktop shell's `apiUrl()` returned a bare relative path
|
||||||
|
(`/api/auth/login`) to `fetch()` from a page loaded at `tauri://localhost` — WebKitGTK has no base
|
||||||
|
to resolve a relative URL against from a non-`http(s)` origin, and threw `DOMException: "The
|
||||||
|
string did not match the expected pattern."` on the first authenticated request (login). Login
|
||||||
|
worked fine in the browser (same-origin, no absolute URL needed) the whole time, which is what
|
||||||
|
made this easy to miss. **Fix:** `tauri.conf.json`'s `build.beforeBuildCommand` now sets
|
||||||
|
`VITE_API_BASE=http://127.0.0.1:3000` inline (`VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter
|
||||||
|
@parking/web build`) — process env vars override `.env.production` in Vite's load order, so this
|
||||||
|
overrides the shared file for the desktop build only, without touching it (the browser/booth build
|
||||||
|
still gets the empty value, unaffected). Verified: rebuilding with the override bakes
|
||||||
|
`127.0.0.1:3000` into the bundle; rebuilding without it stays clean/relative.
|
||||||
- **Auto-update (prompt-on-update, self-hosted):** `tauri-plugin-updater` + `tauri-plugin-process`.
|
- **Auto-update (prompt-on-update, self-hosted):** `tauri-plugin-updater` + `tauri-plugin-process`.
|
||||||
On launch the SPA checks the endpoint (`apps/web/src/lib/desktop-updater.ts`, no-op in browser /
|
On launch the SPA checks the endpoint (`apps/web/src/lib/desktop-updater.ts`, no-op in browser /
|
||||||
offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`.
|
offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`.
|
||||||
@@ -221,3 +285,202 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
|
|||||||
The unsigned CI build therefore overrides it off with
|
The unsigned CI build therefore overrides it off with
|
||||||
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
|
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
|
||||||
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
|
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
|
||||||
|
- **Gotcha (tag ≠ tauri.conf.json version — found + fixed 2026-09-03, v0.1.1).** The git tag
|
||||||
|
(`v0.1.1`) and `tauri.conf.json`'s own `"version"` field are two independent values with nothing
|
||||||
|
syncing them. Tauri bakes `"version"` into the bundle filename, the app's internal version, AND
|
||||||
|
what the updater compares against — NOT the git tag. Bumping only the tag (as the release
|
||||||
|
procedure implied) left the file at the prior `0.1.0`: the signed binary was built and named as
|
||||||
|
`0.1.0`, `latest.json` (built from `TAG`) correctly claimed `0.1.1`, and the updater found an
|
||||||
|
"update," downloaded it, then failed signature verification against a manifest that didn't
|
||||||
|
actually describe the file it pointed at. Compounded by a second bug (below) that made this
|
||||||
|
failure completely invisible to the operator. **Fix:** `release.yml` now has a "Sync
|
||||||
|
tauri.conf.json version to the git tag" step (`sed`-patches `"version"` from `TAG` right before
|
||||||
|
`tauri build`) — the checked-in value is now only a placeholder for local dev builds; every real
|
||||||
|
release derives its version from the tag automatically.
|
||||||
|
- **Gotcha (silent updater failure — found + fixed 2026-09-03).** `desktop-updater.ts`'s
|
||||||
|
`checkForDesktopUpdate` wrapped the ENTIRE check-download-install-relaunch sequence in one
|
||||||
|
`catch {}` that swallowed everything, by design, for the offline/no-server case. But that meant
|
||||||
|
a REAL failure after the operator already accepted the prompt (bad signature, corrupted
|
||||||
|
download, disk/permission error) failed exactly the same way as "endpoint unreachable" — no
|
||||||
|
error, no log, the app just silently reverted to the old version and re-showed the same "update
|
||||||
|
available" prompt on next launch, forever. This is what actually surfaced the tag-sync bug
|
||||||
|
above (download traffic visible, then nothing). Fixed by nesting `downloadAndInstall()` in its
|
||||||
|
own try/catch that logs and rethrows — offline/no-update still no-ops silently (outer catch),
|
||||||
|
but a failure *after* the operator accepted now logs to the console instead of vanishing.
|
||||||
|
|
||||||
|
### Runtime-configurable backend origin — no more one-install-per-booth builds (2026-09-04)
|
||||||
|
|
||||||
|
**Problem:** `VITE_API_BASE` was a **build-time** Vite env var (`tauri.conf.json`'s
|
||||||
|
`beforeBuildCommand`), hardcoded to `http://127.0.0.1:3000`. The desktop shell is a single
|
||||||
|
generic `.deb`/`.AppImage` distributed via [[fleet-deployment-komodo|mca/public_releases]] — it is
|
||||||
|
**not** built per-booth — so a build-time backend address meant the installer could only ever talk
|
||||||
|
to a server on the same machine, and pointing an install at any other host (a remote appliance, a
|
||||||
|
different port) needed a full rebuild. **Fix:** the backend origin is now an **operator-entered,
|
||||||
|
runtime-persisted** value.
|
||||||
|
|
||||||
|
- **`ConnectScreen.tsx`** — shown by `App.tsx` BEFORE `fetchMe()`/`Login` whenever running inside
|
||||||
|
Tauri (`inTauri()`) and no backend URL is saved yet (first launch, or after "Change server").
|
||||||
|
Operator types a host, hits **Test** (`backend-config.ts`'s `testBackendUrl`, an unauthenticated-
|
||||||
|
from-the-client's-perspective `GET /api/version` probe — see the CSRF gotcha below for why that
|
||||||
|
route isn't actually public), then **Save & continue**.
|
||||||
|
- **`tauri-plugin-store`** persists the value (`backend-config.json` in the OS config dir,
|
||||||
|
`autoSave: true`) — survives restarts, is NOT `localStorage` (deliberately; matches the existing
|
||||||
|
server-persisted-preference pattern elsewhere in this app, and a real file is easier to inspect/
|
||||||
|
back up on an appliance). `origin.ts`'s `API_BASE` changed from a `const` to a `let`, set once via
|
||||||
|
`initApiBase()` (called by `App.tsx` before mount) and again via `setApiBase()` after the
|
||||||
|
ConnectScreen saves — no restart required to start using it.
|
||||||
|
- **CSP had to loosen, deliberately, to a narrower real boundary.** `connect-src` was
|
||||||
|
`'self' http://127.0.0.1:3000 ... ws://127.0.0.1:3000 ...`; an operator-chosen arbitrary LAN host
|
||||||
|
can't be named at build time, so it's now **`'self'` only** — meaning a raw `fetch()`/`WebSocket`
|
||||||
|
from the webview is blocked to EVERY origin, not just disallowed ones. This is intentional, not a
|
||||||
|
regression: all backend traffic already went through `tauri-plugin-http`/`tauri-plugin-websocket`
|
||||||
|
anyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, **outside**
|
||||||
|
`connect-src`'s jurisdiction entirely. The real access boundary moved to
|
||||||
|
`capabilities/default.json`'s `http:default` scope, which is now wildcarded
|
||||||
|
(`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed: the scope is a
|
||||||
|
URLPattern, and a pattern with no port matches only the scheme's *default* port, so `http://*`
|
||||||
|
covers `:80` (Caddy) while `http://*:*` is what covers `:3000`). `websocket:
|
||||||
|
default` already had no scope restriction. Net effect: **the app can now reach any host the
|
||||||
|
operator types in, and nothing else** — same shape of guarantee as before, just operator-directed
|
||||||
|
instead of build-directed.
|
||||||
|
- **"Change server"** — `router.tsx`'s `DesktopServerButton`, in the Setup nav bar next to
|
||||||
|
`DesktopVersionBadge` (both `inTauri()`-gated, invisible in the browser). Confirm-modal (reuses
|
||||||
|
the shared `Modal`, not a bespoke dialog) → `clearBackendUrl()` → reload, which drops back to
|
||||||
|
ConnectScreen. Deliberately not an inline editor: repointing a booth's app is a rare, deliberate
|
||||||
|
admin action, not a frequent setting — same reasoning as why logout is a plain action button with
|
||||||
|
no separate "are you sure" for THAT (this one gets a confirm because it also blows away the
|
||||||
|
session, unlike a normal logout-then-relogin against the same server).
|
||||||
|
- **Gotcha (found via research before shipping, not in the field — worth recording anyway): the
|
||||||
|
CSRF double-submit cookie is invisible to `document.cookie` on desktop.** `tauri-plugin-http`'s
|
||||||
|
`fetch()` doesn't run through the webview — it's dispatched to Tauri's Rust side and executed by
|
||||||
|
`reqwest`, which keeps its **own** cookie jar, entirely separate from WebKitGTK's. `Set-Cookie` on
|
||||||
|
a `tauri-plugin-http` response is stored in that reqwest jar and IS correctly re-sent by
|
||||||
|
reqwest on later requests (so plain session auth — GETs — silently worked) — but it is **never**
|
||||||
|
synced into the webview's own cookie store, so `document.cookie` on the `tauri://localhost` page
|
||||||
|
can never see it. Upstream: [tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045)
|
||||||
|
(open — asks for exactly this jar→webview sync) and
|
||||||
|
[#11518](https://github.com/tauri-apps/tauri/issues/11518) (closed, without adding a sync) — not
|
||||||
|
something fixable on our side by changing how/when we read the cookie. The reqwest jar itself
|
||||||
|
IS persisted (`.cookies` in the app cache dir), so a desktop session survives an app restart
|
||||||
|
just like the browser's 30-day cookie does. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf`
|
||||||
|
cookie via `document.cookie` to echo it in `X-CSRF-Token` (double-submit — see
|
||||||
|
[[local-jwt-auth]]), this meant **every mutating request from the desktop app was silently sending
|
||||||
|
no CSRF header at all**, pre-dating this runtime-URL change (it was equally true against the old
|
||||||
|
hardcoded `127.0.0.1:3000`) — caught now because widening the backend to "any host" was the
|
||||||
|
occasion to actually trace the desktop auth path end-to-end. **Fix, without touching
|
||||||
|
`assertCsrf()`'s verification logic at all:** the server's `sessionView()` (`routes/auth.ts`,
|
||||||
|
shared by `login` and `me`) now optionally echoes the CSRF token value in the JSON response body
|
||||||
|
(`csrfToken`) — the SAME value already set as the cookie, just a second channel to learn it. The
|
||||||
|
desktop client (`api.ts`) stashes that value in an in-memory-only variable (`desktopCsrfToken`,
|
||||||
|
never persisted — a fresh launch always re-learns it via login or `/api/auth/me`) and echoes THAT
|
||||||
|
instead of reading `document.cookie` when `inTauri()`. The actual cookie is still what
|
||||||
|
`assertCsrf()` checks server-side (and reqwest still sends it correctly, per above) — this only
|
||||||
|
fixes how the desktop *client* learns what value to put in the header, so browser behavior and
|
||||||
|
server verification are both completely unchanged.
|
||||||
|
|
||||||
|
### Live feed needs a WS *ticket*, not the cookie — and desktop logs never reached the server (2026-09-04, v0.1.6)
|
||||||
|
|
||||||
|
A retrospective of the 2026-09-03/04 run found that v0.1.4's Origin fix cleared only the **first**
|
||||||
|
of two gates in `routes/ws.ts`'s preHandler, and that the diagnostic channel everyone was staring
|
||||||
|
at was itself broken on desktop. Booth evidence: `docker logs park-2-server-1 | grep /api/ws` showed
|
||||||
|
a fresh handshake every 10 s (use-live-feed's capped backoff), i.e. every connect rejected.
|
||||||
|
|
||||||
|
- **Gate two: `req.jwtVerify()` reads the HttpOnly `parking_token` cookie — which the WebSocket
|
||||||
|
plugin cannot send.** `tauri-plugin-websocket` is a bare tokio-tungstenite client with **no
|
||||||
|
cookie jar at all** (its source has no cookie handling); the cookie lives in
|
||||||
|
`tauri-plugin-http`'s reqwest jar and is HttpOnly besides, so JS can't copy it across either.
|
||||||
|
Origin OK + no cookie → 401 → reconnect forever. **Fix: a single-use WS ticket.** The desktop
|
||||||
|
client `POST`s `/api/ws/ticket` over normal HTTP auth (cookie + CSRF, which it CAN do) and gets
|
||||||
|
a 32-byte random ticket bound to its user, valid 30 s, single-use, in-memory only; it presents
|
||||||
|
it in an `x-ws-ticket` header on the handshake (`platform-ws.ts`), and the preHandler accepts
|
||||||
|
ticket-or-cookie *after* the Origin check, then does the same `report:read` role check for both.
|
||||||
|
A browser page can't set custom WebSocket headers, so the ticket path is unreachable from a
|
||||||
|
browser and adds no CSWSH surface. **Rejected:** echoing the JWT in the login body and sending
|
||||||
|
it as `Authorization: Bearer` (fastify-jwt would accept it) — that puts the session token in JS,
|
||||||
|
which HttpOnly exists to prevent; the ticket keeps it out. Verified locally with an 11-case
|
||||||
|
handshake script: ticket/no-cookie → 101 + hello; reused/bogus/absent → 401; ticket + bad Origin
|
||||||
|
→ 403; cookie path unchanged. **Field-verified 2026-09-04:** v0.1.6 on the park-2 booth against
|
||||||
|
image `stage-8fa66c9` shows **LIVE** — the first desktop build to do so.
|
||||||
|
- **Desktop client logs had never reached `app_logs`.** `logger.ts`'s flush read the CSRF token
|
||||||
|
from `document.cookie` (null on desktop — the same jar split as above), so every
|
||||||
|
`POST /api/logs` from the desktop 403'd under `requireAuth`→`assertCsrf`, and the flush drops
|
||||||
|
failures by design (loop safety). Consequences: the 2026-09-03 "route update-failure logging
|
||||||
|
through logClient" fix wrote to a dead channel, and the v0.1.5 CSRF fix patched `api.ts` but not
|
||||||
|
`logger.ts`. **Fix:** the stash moved to a dependency-free `lib/desktop-csrf.ts` (so `logger.ts`
|
||||||
|
can read it without importing `api.ts`, which imports `logger.ts`), and the flush uses it when
|
||||||
|
`inTauri()`. `platform-ws.ts`'s connect failure now goes through `logClient` too (rate-limited
|
||||||
|
to one row/min — reconnects are every ≤10 s), instead of `console.error`, which only forwards at
|
||||||
|
debug/trace.
|
||||||
|
- **ConnectScreen probe now hits `/health`.** The v0.1.5 probe hit an auth-guarded route and
|
||||||
|
treated 401/403 as "ours" — any password-protected service on the LAN would have passed it, and
|
||||||
|
the comment claiming no unauthenticated route existed was wrong (`/health` has been there all
|
||||||
|
along). `/health` now also returns `app: "parking-system"`, and the probe requires both a 2xx
|
||||||
|
and that value.
|
||||||
|
- **Why every one of these was found in the field:** `tauri dev` loads `http://localhost:5173`,
|
||||||
|
not `tauri://localhost`, so the relative-URL error, mixed content, the missing Origin, and the
|
||||||
|
cookie-jar split *cannot* reproduce in dev mode. The pre-tag gate is now: build the bundle
|
||||||
|
locally, run the AppImage against a local server, log in, confirm **LIVE**, do one mutation,
|
||||||
|
and confirm a desktop-sourced row appears in the Logs viewer (`apps/desktop/README.md`).
|
||||||
|
|
||||||
|
### In-app update never worked: the manifest only described the AppImage, the booths run the .deb (2026-09-04)
|
||||||
|
|
||||||
|
Every self-update attempt from v0.1.0 through v0.1.6 ended the same way — prompt, download
|
||||||
|
traffic, then nothing, and the same prompt again next launch. The version-sync (v0.1.2) and
|
||||||
|
error-logging fixes were real but not the cause. **Root cause:** `tauri-plugin-updater` resolves
|
||||||
|
the download target as `{os}-{arch}-{installer}` **first** (`linux-x86_64-deb` here — the
|
||||||
|
bundler stamps `__TAURI_BUNDLE_TYPE_VAR_DEB` into the `.deb`'s binary, verified with `strings`
|
||||||
|
on a local build), then falls back to bare `{os}-{arch}`. `release.yml`'s `latest.json` carried
|
||||||
|
**only** `linux-x86_64`, pointing at the **AppImage**. So a `.deb` install found the update,
|
||||||
|
downloaded the AppImage, verified its signature (which was correct — for the AppImage), then
|
||||||
|
handed the bytes to `install_deb()`, whose first line checks `infer::archive::is_deb(bytes)` and
|
||||||
|
returns `InvalidUpdaterFormat`. Before v0.1.6 that error never reached the server (the desktop
|
||||||
|
log channel was itself broken — see the previous section), so it looked like a silent no-op.
|
||||||
|
Sources: `tauri-plugin-updater-2.10.1/src/updater.rs` (`get_urls`, `install_inner`,
|
||||||
|
`install_deb`), `tauri-utils/src/platform.rs` (`bundle_type`).
|
||||||
|
|
||||||
|
- **Fix:** `latest.json` now carries one signed entry per installer — `linux-x86_64-deb`,
|
||||||
|
`linux-x86_64-rpm` (when built), and bare `linux-x86_64` for the AppImage — assembled by a
|
||||||
|
small Node script in the workflow (the `.sig` files for `.deb`/`.rpm` were already being
|
||||||
|
produced and uploaded, just never referenced).
|
||||||
|
- **What a booth update now looks like:** prompt → download → **polkit password dialog**
|
||||||
|
(`pkexec dpkg -i`) → relaunch into the new version. The prompt is deliberate, not a wart: the
|
||||||
|
package is root-owned in `/usr/bin`, and under the [[threat-model]] the operator must not be
|
||||||
|
able to replace the app silently; whoever brings the box online for an update is the admin.
|
||||||
|
Cancelling the dialog leaves the old version running and logs
|
||||||
|
`desktop_update_install_failed` to `app_logs`.
|
||||||
|
- **Rejected:** switching booths to the AppImage so updates need no privilege. It would work
|
||||||
|
(the updater rewrites the AppImage in place), but the binary would then be operator-writable,
|
||||||
|
it needs FUSE on the appliance image, and launcher/autostart integration becomes manual —
|
||||||
|
three regressions to avoid one password prompt.
|
||||||
|
- **Judgment note for the retrospective:** three fixes were shipped against this symptom
|
||||||
|
without reading the updater's install path once. The whole chain is ~60 lines of vendored
|
||||||
|
Rust in `~/.cargo/registry`; it names the exact failure (`InvalidUpdaterFormat`).
|
||||||
|
|
||||||
|
### Decision: desktop updates are an admin-only action — the polkit prompt stays (2026-09-04)
|
||||||
|
|
||||||
|
Settled with the user after the first successful self-update (v0.1.6 → v0.1.7 on the park-2
|
||||||
|
booth, `pkexec dpkg -i`, polkit dialog, relaunch, badge shows 0.1.7). The prompt asks for an
|
||||||
|
**admin** password the operator does not have — and that is now the intended gate, not a defect.
|
||||||
|
|
||||||
|
- **AppImage was tried and rejected on evidence, not theory.** The v0.1.6 AppImage fails to
|
||||||
|
start on the Ubuntu 26.04 booth: `libgvfscommon.so: undefined symbol:
|
||||||
|
g_variant_builder_init_static` (the host's newer gvfs modules loading into the *bundled* older
|
||||||
|
glib) followed by `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...` (the
|
||||||
|
bundled WebKitGTK vs. the host's Mesa). Tauri's AppImage freezes the CI runner's (24.04)
|
||||||
|
GTK/WebKitGTK/glib into the bundle, which throws away the one property this platform decision
|
||||||
|
rests on — the **distro-maintained, Canonical-patched WebKitGTK** — and replaces it with a
|
||||||
|
host-mismatch hazard at every OS update. `WEBKIT_DISABLE_DMABUF_RENDERER=1` /
|
||||||
|
`WEBKIT_DISABLE_COMPOSITING_MODE=1` may paper over the EGL abort; they don't fix the shape.
|
||||||
|
**The `.deb` is the right artifact; only its install step needs root.**
|
||||||
|
- **Passwordless polkit/sudoers for `dpkg -i` rejected:** any rule that lets the operator
|
||||||
|
account pass that prompt silently lets them run `pkexec dpkg -i <anything>` — root — which the
|
||||||
|
[[threat-model]] forbids outright.
|
||||||
|
- **Deferred, not rejected — the fleet-grade answer:** a root systemd timer shipped inside the
|
||||||
|
`.deb` (via Tauri's deb `files` + postinstall) that fetches `latest.json` from
|
||||||
|
`public_releases`, verifies the `.deb` with `minisign` against the same embedded pubkey, and
|
||||||
|
`dpkg -i`s it when the box is online; the in-app updater then only *notifies*. No prompt, no
|
||||||
|
privileged code in the shell, standard appliance practice. Revisit when more than one booth
|
||||||
|
needs keeping current, or when someone other than the admin has to bring a box online.
|
||||||
|
- **Operator-facing consequence:** the in-app prompt now says the install needs the
|
||||||
|
administrator password (i18n `update.prompt`, en + sq). An operator who accepts and can't
|
||||||
|
authenticate simply stays on the current version; nothing breaks, and the failure is logged.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-07
|
updated: 2026-09-03
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -192,3 +192,32 @@ FILE is read from, each stack's `branch` picks its compose files, `TAG` picks th
|
|||||||
secrets even in the lab (blast radius). The lab box earned its keep immediately: it caught the
|
secrets even in the lab (blast radius). The lab box earned its keep immediately: it caught the
|
||||||
USB close-cancel truncation, the printer/controller wizard gate, and the Periphery v2.2.0
|
USB close-cancel truncation, the printer/controller wizard gate, and the Periphery v2.2.0
|
||||||
root_directory default before any of them reached a real booth ([[appliance-provisioning]] §7a).
|
root_directory default before any of them reached a real booth ([[appliance-provisioning]] §7a).
|
||||||
|
|
||||||
|
## ResourceSync branch drift — the exact gotcha this page already warned about (2026-09-03)
|
||||||
|
|
||||||
|
This page's own §"park-lab" note (2026-07-07) already spelled it out: *"the ResourceSync's own
|
||||||
|
branch only governs where the FILE is read from"* — independent of any `[[stack]]`'s own `branch`
|
||||||
|
field. It bit anyway. `resource-sync-park-systems` in Komodo Core was pointed at **`dev`**, while
|
||||||
|
`park-buzi` and `park-2` are `stage`-tier Stacks (`branch = "stage"`, pinned `TAG=stage-<sha>`, per
|
||||||
|
the promotion-tiers model above). `resources.toml` had been byte-identical on `dev` and `stage`
|
||||||
|
since park-buzi's Stack was first written, so this had **zero observable effect for months** — until
|
||||||
|
a desktop-app debugging session (see [[desktop-shell-tauri]]) landed 9 real commits on `dev`
|
||||||
|
(including a `WS_ALLOWED_ORIGINS` fix) that were never merged to `stage`, creating the first genuine
|
||||||
|
divergence between the two branches.
|
||||||
|
|
||||||
|
**Symptom:** merged `dev` → `stage`, pushed, bumped `TAG` in `resources.toml` on `stage`, committed,
|
||||||
|
pushed — then destroyed + recreated the `park-2` Stack in Komodo Core and it STILL came back running
|
||||||
|
the old image. Every sync was silently re-reading `resources.toml` from `dev` (which still had the
|
||||||
|
stale `TAG`), overwriting the correct value just committed on `stage`. No error, no warning — the
|
||||||
|
sync just quietly did what it was configured to do, from the wrong branch.
|
||||||
|
|
||||||
|
**Fix:** pointed `resource-sync-park-systems` at `stage` in Komodo Core's UI (Sync config → branch
|
||||||
|
field), then re-synced + redeployed `park-2` — confirmed via `/api/version` (previously 404,
|
||||||
|
proving a stale image; correctly 401-auth-gated after the fix, proving the new image + route exist).
|
||||||
|
|
||||||
|
**Standing lesson, now written twice:** a `[[stack]]`'s promotion tier (which branch its own
|
||||||
|
`branch`/`TAG` fields track) and the ResourceSync resource's own git branch are **two independently
|
||||||
|
configured settings in Komodo Core — nothing enforces they agree**, and a mismatch is invisible
|
||||||
|
until the two branches' `resources.toml` actually diverge. **Check this FIRST** whenever a
|
||||||
|
redeploy doesn't pick up an expected `resources.toml` change, before assuming the change itself,
|
||||||
|
the CI build, or the deploy step is broken.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, decisions, open]
|
tags: [parking, decisions, open]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-29
|
updated: 2026-09-04
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -129,3 +129,20 @@ procurement. (See [[parking-system-architecture]] §10.)
|
|||||||
app code**, and are **unverified on hardware**. Close this once the printer transport per site is
|
app code**, and are **unverified on hardware**. Close this once the printer transport per site is
|
||||||
fixed and (if USB) the udev/usblp rule is in the image and a real USB print is verified. Relates
|
fixed and (if USB) the udev/usblp rule is in the image and a real USB print is verified. Relates
|
||||||
to #1 (lane topology / image standardization). See [[printer-usb-transport]], [[rongta-printer]].
|
to #1 (lane topology / image standardization). See [[printer-usb-transport]], [[rongta-printer]].
|
||||||
|
|
||||||
|
15. **Venue modules — Car Wash / Bar as peers of Parking.** _(Raised by the user, 2026-09-04.)_
|
||||||
|
Optional per-site modules on a shared venue core, with Parking itself becoming a module.
|
||||||
|
Name stays `parking-system` (settled 2026-09-05); validation stays for the Bar, only the
|
||||||
|
Lavazh station retires when Car Wash (the pilot module) ships. **Registry + Car Wash v1 are
|
||||||
|
built (2026-09-05), and so are tills** — shifts/drawers per money-taking module (a bay
|
||||||
|
payment lands on the wash operator's own till, never the booth's). Open: vision category
|
||||||
|
flag, bay camera, the Bar's scope. Full design and the remaining questions on
|
||||||
|
[[venue-modules]].
|
||||||
|
|
||||||
|
16. **Permissions matrix after venue modules.** _(Raised by the user, 2026-09-05.)_ The flat
|
||||||
|
`resource:action` grid was composed for one desk; a second desk (Car Wash) exposed borrowed
|
||||||
|
meanings (`session:read` as "works the booth till", `report:read` as "may open the socket")
|
||||||
|
and a composer at the wrong altitude. Decision + three moves (per-desk till guards, jobs on
|
||||||
|
top of the grid, a permission-scoped live feed) on [[venue-modules]] §"Permissions matrix";
|
||||||
|
moves built 2026-09-05. Open: default supervisor bundle, re-applying jobs after a module
|
||||||
|
update, signing role edits.
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ The decisions treated as settled in the design notes. (See [[parking-system-arch
|
|||||||
(chosen over Electron, 2026-06-21) — small footprint, no bundled Chromium to patch, and a
|
(chosen over Electron, 2026-06-21) — small footprint, no bundled Chromium to patch, and a
|
||||||
deny-by-default native surface that fits [[threat-model|the booth-operator threat model]]. The
|
deny-by-default native surface that fits [[threat-model|the booth-operator threat model]]. The
|
||||||
shell stays **thin**: all privileged logic remains in [[fastify]]. One open dependency — the
|
shell stays **thin**: all privileged logic remains in [[fastify]]. One open dependency — the
|
||||||
appliance's WebKitGTK version (see [[open-questions]] #11).
|
appliance's WebKitGTK version (see [[open-questions]] #11). Ships as a **`.deb`** (the AppImage
|
||||||
|
bundles a runner's WebKitGTK and fails on the 26.04 booth — 2026-09-04); its backend address is
|
||||||
|
**operator-entered at runtime**, not baked in; and **in-app updates are an admin-only action**
|
||||||
|
behind the polkit password prompt (user, 2026-09-04) — never make that prompt passwordless.
|
||||||
- **Integrity:** append-only, hash-chained, **software-signed** event log
|
- **Integrity:** append-only, hash-chained, **software-signed** event log
|
||||||
([[append-only-event-chain]]) — hardware-backed signing (a non-extractable key in the
|
([[append-only-event-chain]]) — hardware-backed signing (a non-extractable key in the
|
||||||
**[[tpm|TPM]]** or a **USB HSM**; the [[atecc608]] is [[open-questions|upcoming, not present]]) is
|
**[[tpm|TPM]]** or a **USB HSM**; the [[atecc608]] is [[open-questions|upcoming, not present]]) is
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
tags: [parking, decisions, open, modules, architecture]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-09-05
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Venue modules — Car Wash, Bar/Restaurant, and Parking as peers
|
||||||
|
|
||||||
|
**Status: OPEN** (Car Wash v1 and the module registry are BUILT — see the two "As-built" sections; open items remain below). Design
|
||||||
|
captured from working sessions with the user on 2026-09-04/05. Decisions marked **(settled)** were stated by the
|
||||||
|
user in that session; everything else is the proposed shape awaiting a go.
|
||||||
|
|
||||||
|
## The ask
|
||||||
|
|
||||||
|
Some sites need a **Car Wash** (al. *lavazh*), some a **Bar / Caffè / Restaurant**, some both, some
|
||||||
|
neither. These must be optional per site, may relate to each other, and must not be second-tier
|
||||||
|
citizens of a product whose every identifier says "parking".
|
||||||
|
|
||||||
|
## What exists today (and the gap)
|
||||||
|
|
||||||
|
Optionality already appears three ways, none named: a process-level env flag (`VISION_ENABLED`
|
||||||
|
→ [[vision-service]]), a per-site DB flag (`validation_programs.active`, the Bar/Lavazh
|
||||||
|
checkboxes of [[validation-discounts]]), and permissions gating nav/routes ([[local-jwt-auth]]).
|
||||||
|
There is **no registry**: adding a feature hand-wires seven places (`server.ts` route list, the
|
||||||
|
`RESOURCES`/`PERMISSIONS` catalog in `@parking/shared`, the `LedgerEventType` union, a schema
|
||||||
|
table + migration, `router.tsx` routes + nav, i18n). Nothing keeps them consistent and nothing
|
||||||
|
lets the server say "this feature is off at this site."
|
||||||
|
|
||||||
|
## Decisions taken in the session
|
||||||
|
|
||||||
|
1. **Validation stays — for the Bar (revised 2026-09-05).** The 2026-09-04 session first
|
||||||
|
settled on decommissioning [[validation-discounts]] entirely; the user revised this the next
|
||||||
|
day: the merchant-scan validation is needed **as is for the Bar** until a Bar management
|
||||||
|
module exists, at which point it folds into that module. Only the **Lavazh station is
|
||||||
|
retired** when Car Wash ships (Car Wash sponsors parking through its own order flow, below),
|
||||||
|
so `STATIONS` shrinks to `["bar"]`; no tables are dropped, the `"validation"` ledger type stays
|
||||||
|
live. Consequence, accepted: two sponsorship mechanisms coexist for a while — merchant-scan
|
||||||
|
validation (Bar) and order-driven comp/credit (Car Wash). In the registry, validation is
|
||||||
|
registered as its own module (`validation`, no dependencies) so the Bar module can later
|
||||||
|
declare `dependsOn` or absorb it.
|
||||||
|
2. **Parking is a module, a peer of Car Wash and Bar (settled in principle).** What is *not*
|
||||||
|
parking in the server today — identity/roles, the signed ledger, device adapters + monitoring,
|
||||||
|
[[shift]], cash drawer, payment terminal, receipt printing, reports, site config, logs, backup —
|
||||||
|
is a point-of-sale + audit **platform for a venue**, and every new module needs all of it. The
|
||||||
|
core is that list; Parking is access control (barriers, readers, ANPR), [[parking-session]],
|
||||||
|
occupancy, [[tariff]], [[subscription]].
|
||||||
|
3. **Updates/enablement authority — two layers (proposed, user agreed in discussion).** See below.
|
||||||
|
|
||||||
|
## Proposed shape
|
||||||
|
|
||||||
|
### One binary, modules enabled per site at runtime
|
||||||
|
|
||||||
|
Not build variants, not a package per site. The fleet just reached "same image + same installer at
|
||||||
|
every booth" ([[fleet-deployment-komodo]], [[desktop-shell-tauri]]); per-site builds would undo
|
||||||
|
it. Enabling is a runtime decision, recorded, reversible.
|
||||||
|
|
||||||
|
### A module = a manifest + three folders
|
||||||
|
|
||||||
|
- **Manifest** in `packages/shared`: `id`, `dependsOn: id[]`, the permission `resources` it
|
||||||
|
contributes, the `ledgerEventTypes` it appends (prefixed: `carwash_*`, `bar_*`), site-config
|
||||||
|
defaults.
|
||||||
|
- **Folders**: `apps/server/src/modules/<id>/` (`register(app, deps)` + schema),
|
||||||
|
`apps/web/src/modules/<id>/` (routes + nav entries), an i18n namespace.
|
||||||
|
- **Registry**: one array of manifests. `server.ts` iterates it instead of ~30 flat calls;
|
||||||
|
`router.tsx` likewise. Adding a module = a folder + one registry line; a missing piece fails at
|
||||||
|
startup, not in the field.
|
||||||
|
- **Not packages yet.** Folder-per-module is enough at this scale; packages earn their keep only
|
||||||
|
when a module needs its own release cadence, and with one image per commit none will.
|
||||||
|
|
||||||
|
### Enablement: entitled ∩ activated
|
||||||
|
|
||||||
|
- **Layer 1 — entitlement (vendor).** What a site *may* have is a commercial/deployment decision
|
||||||
|
and belongs to the vendor, not to any app role. Home: the Komodo stack environment, next to
|
||||||
|
`VISION_ENABLED`/`TAG`/secrets — e.g. `MODULES_ENTITLED=parking,carwash`. Changes only via a
|
||||||
|
Komodo sync + redeploy (the vendor's channel, offline-safe, invisible to app roles). This is
|
||||||
|
exactly today's vision pattern: env = entitlement, `anprEntryEnabled` = the site's own switch.
|
||||||
|
- **Layer 2 — activation (site admin).** Whether the site is *using* it now (a car wash closed for
|
||||||
|
winter) belongs to the site admin, within the entitled set: a checkbox in Setup → Site, stored
|
||||||
|
in `site_config.modules`, every change a signed `config_change` ledger event with the actor
|
||||||
|
(the presence-bypass precedent, [[entry-presence-bypass]]). Rides on `site:update`; a separate
|
||||||
|
`module:update` permission is one line later if a "tariffs but not modules" owner role is ever
|
||||||
|
needed. No new super-role.
|
||||||
|
- **Effective set = entitled ∩ activated.** The server enforces it with a `requireModule(id)`
|
||||||
|
guard beside `requirePermission` (permissions alone are insufficient: a role may hold
|
||||||
|
`carwash:create` at a site with no car wash). `/api/site-config` and `/api/auth/me` expose it
|
||||||
|
so the SPA can hide nav before it knows anything else — the web only *hides*, the server
|
||||||
|
*enforces*.
|
||||||
|
- **Rules:** disabling never deletes (tables, history, role grants all stay; routes reject, UI
|
||||||
|
disappears; re-enable restores). Dependencies enforced at the point of change from the
|
||||||
|
manifests: enabling Car Wash with a dependency off enables it or refuses with a message;
|
||||||
|
disabling a dependency of an enabled module refuses. Entitlement is a boundary against app
|
||||||
|
roles, not against root on the box — right level given [[disk-os-hardening]]; if commercial
|
||||||
|
enforcement ever matters, it becomes a small **minisign-signed** file checked with the same key
|
||||||
|
infrastructure the updater already uses, with nothing above changing.
|
||||||
|
|
||||||
|
### Schema and ledger stay uniform
|
||||||
|
|
||||||
|
Tables for every module are **always migrated**, enabled or not (empty tables; no conditional
|
||||||
|
migrations on an offline appliance). The ledger stays **one** append-only union; module event
|
||||||
|
types just carry the module prefix, so the chain and its signing never change shape.
|
||||||
|
|
||||||
|
### Relations between modules: manifest + ledger, never imports
|
||||||
|
|
||||||
|
Car Wash and Bar both need "this customer's parking is sponsored/discounted". For the Bar that
|
||||||
|
is today's merchant-scan validation, kept as is (decision 1). For Car Wash it is expressed as
|
||||||
|
**events**: a `carwash_wash_complete` event is appended; the parking module reacts (comp/credit
|
||||||
|
the session) through the existing event bus ([[event-streams-split]]). A module never calls another module's
|
||||||
|
routes or imports its code; `dependsOn` in the manifest is the only coupling the registry knows.
|
||||||
|
Merchant-type users (bar tender, wash operator) belong to the module they operate.
|
||||||
|
|
||||||
|
### Naming and identity — one irreversible constraint
|
||||||
|
|
||||||
|
The product name touches three things at different costs:
|
||||||
|
|
||||||
|
- **Desktop app `identifier` (`com.parking.desktop`) and the `.deb` package name (from
|
||||||
|
`productName`) — irreversible in practice.** Changing either means an installed booth will not
|
||||||
|
update into the new app: dpkg treats it as a different package, and a new identifier gets a
|
||||||
|
fresh config dir (saved server address + session lost). Survivable now with one staging booth
|
||||||
|
and a manual reinstall; a fleet migration later. **Settle the platform name and apply it here
|
||||||
|
before the second booth is provisioned, then never touch it again.**
|
||||||
|
- Repo name / Gitea project / image names / Komodo stack names — cheaper, but each is a place
|
||||||
|
the wiki and runbooks point at. Let them follow at the point the first non-parking module ships.
|
||||||
|
- Package scope `@parking/*` — can stay until the core/parking boundary exists in code. Renaming is
|
||||||
|
cheap once, expensive twice.
|
||||||
|
|
||||||
|
### Migration path (no big-bang)
|
||||||
|
|
||||||
|
1. Remove validation (its own commit).
|
||||||
|
2. Decide the platform name; apply it to the desktop identifier + package name (irreversible one
|
||||||
|
first).
|
||||||
|
3. Introduce the registry: `site_config.modules`, manifest type, `requireModule`, nav gating, the
|
||||||
|
entitlement env. Register **parking** as the first module *without* moving code yet
|
||||||
|
(its seam is drawn in a wiki page; code moves across it as each subsystem is touched, starting
|
||||||
|
with the obviously-core pieces such as shift and cash).
|
||||||
|
4. Build **Car Wash** as the first new module against the contract: it must need zero changes to
|
||||||
|
core files beyond its own folder and registry line. Its data model, queue, services, pricing
|
||||||
|
are a separate scoping conversation.
|
||||||
|
|
||||||
|
## Vehicle category from vision — advisory, flagged, never authoritative
|
||||||
|
|
||||||
|
Raised alongside: Car Wash prices by body type (e.g. **SUV > Car**), so can the ANPR service
|
||||||
|
help? Facts first ([[vision-service]], [[opencv-anpr-service]]): the service is **stateless**
|
||||||
|
(`GET /health`, `POST /analyze`, no DB, no volume; the Node server is the only writer of record),
|
||||||
|
and today's `fast-alpr` is plate-only — YOLOv9 *plate* detector + CCT OCR, no notion of the
|
||||||
|
vehicle. The Hikvision push's `detectionTarget` only says `vehicle`/`human` on the G3H.
|
||||||
|
|
||||||
|
- **Cheap path:** a general detector beside the plate detector, behind the existing `Recognizer`
|
||||||
|
class boundary (no app change). Licence decides the model: Ultralytics YOLOv8 is **AGPL — out**;
|
||||||
|
**YOLOX** and **RT-DETR** ship Apache-2.0 ONNX weights on the ONNX Runtime already in use. COCO
|
||||||
|
gives `car/bus/truck/motorcycle/bicycle` — **no van, minivan, pickup, and no SUV vs sedan**.
|
||||||
|
- **Real path for SUV-vs-Car:** a body-type classifier (sedan/hatchback/SUV/minivan/pickup/van)
|
||||||
|
fine-tuned on a few thousand **own entry-camera frames** on an Apache-2.0 backbone; public
|
||||||
|
car datasets are often research-only — check the licence before touching one. Expect 85–95 %
|
||||||
|
on frontal gate views once tuned: enough to *flag*, nowhere near enough to *bill*.
|
||||||
|
- **Design (the [[threat-model]] shape — operator is the adversary):** vision **proposes**, the
|
||||||
|
operator can override, the override is on the record.
|
||||||
|
- Wash intake ties the order to the parking session by plate, so the **entry snapshot already
|
||||||
|
exists** — no new camera, no new capture; classify the vehicle crop in that frame.
|
||||||
|
- The intake form pre-selects vision's category. The order stores `visionCategory`,
|
||||||
|
`visionConfidence`, `operatorCategory`, actor.
|
||||||
|
- Differ **and** confidence ≥ threshold → append an **`anomaly`** ledger event (existing type,
|
||||||
|
new reason) with the snapshot attached; a reviewer sees car, both categories, operator, in one
|
||||||
|
row. **Downgrades** (vision SUV, operator Car — the cash-difference vector) get the flag and
|
||||||
|
optionally a mandatory reason; upgrades log without one. Reports: discrepancies per operator
|
||||||
|
per [[shift]].
|
||||||
|
- **Never block.** A wrong classifier must not stop a wash. Flag, don't gate.
|
||||||
|
- Classifier output is advisory data on the event, **never a tariff input by itself**; threshold
|
||||||
|
and the flagged-category set are **site config** (a minivan-heavy site tunes the noise down).
|
||||||
|
- CPU: a second model per frame on the i5-8500 — analyse one frame per vehicle, not every push.
|
||||||
|
|
||||||
|
## Car Wash — the pilot module (settled 2026-09-05)
|
||||||
|
|
||||||
|
- **Car Wash is the pilot for the registry (settled).** It is built *as* the first module, and
|
||||||
|
the acceptance test of the module design is that it needs zero changes outside its own folder
|
||||||
|
and registry line. Validation removal clears the ground first.
|
||||||
|
- **The wash sits inside the parking (settled).** Every vehicle therefore already has a
|
||||||
|
[[parking-session]], a plate, and an entry snapshot — the plate is the customer identity for
|
||||||
|
free, no intake capture, and every anti-fraud signal below works from day one. Walk-ins from the
|
||||||
|
street are out of scope.
|
||||||
|
- **A camera on the wash bay (settled).** Through the same stateless [[vision-service]]; its job
|
||||||
|
is presence/vehicle counting at the bay, not plates.
|
||||||
|
|
||||||
|
### v1 scope
|
||||||
|
|
||||||
|
- **In:** a services catalogue priced by vehicle category; orders with a queue (waiting → in
|
||||||
|
progress → done → paid); vision's category as the advisory pre-selection with the override
|
||||||
|
flag (above); payment through the existing [[shift]] / cash drawer / P2PE terminal; receipts
|
||||||
|
on the existing printer path; one integration with parking — a completed wash may comp or
|
||||||
|
credit the session, emitted as a ledger event the parking module reacts to; reports per
|
||||||
|
operator and per shift. A wash desk is just a second desktop install pointed at the same
|
||||||
|
server (runtime backend address, [[desktop-shell-tauri]]).
|
||||||
|
- **Out (each is its own module-sized thing):** memberships / prepaid packages, loyalty, chemical
|
||||||
|
stock, staff scheduling, appointment booking, customer accounts. Design the order so a payment
|
||||||
|
can later reference a package, and stop there.
|
||||||
|
|
||||||
|
### v1 answers from the user (2026-09-05) — these shape the tables
|
||||||
|
|
||||||
|
1. **Price = category × service.** The admin declares a price per (vehicle category, service)
|
||||||
|
pair — e.g. Car·Standard 500, Car·Inside 300, Car·Outside 300, SUV·Standard 700. Categories
|
||||||
|
(Car, SUV, Van, Truck, …) and services (Standard, Outside, Inside, Details, …) are both
|
||||||
|
admin-maintained lists; a missing pair simply isn't sellable.
|
||||||
|
2. **Where the money is taken — "in booth" or "in bay" — is a SITE setting** (Setup → Car
|
||||||
|
wash; **changed 2026-09-05 from a per-order radio** at the user's request: "remove it from
|
||||||
|
/wash"). The desk shows the policy in force read-only; every order freezes it
|
||||||
|
(`carwash_config.pay_at`, migration 0028; a flip signs `config_change carwash.payAt` with
|
||||||
|
prev/value — it decides which till the cash lands on and which device releases the car, so it
|
||||||
|
is attributed like other fraud-relevant config). A stale client sending the other value is
|
||||||
|
refused (`409 pay_at_policy`), never silently overridden.
|
||||||
|
- *In booth*: the wash is a line on the parking settlement at the booth; after that payment
|
||||||
|
the exit-lane barrier opens exactly as it does for a parking-only exit.
|
||||||
|
- *In bay*: the wash operator collects at the bay; the customer then leaves by scanning the
|
||||||
|
ticket barcode at the exit **reader**, which must open — i.e. the parking session must be
|
||||||
|
settled to zero-due by then (see 4).
|
||||||
|
3. **Queue = a plain list of open orders, oldest first.** No display board, no "next car".
|
||||||
|
4. **The wash grants the parking discount through the same ability validations have**
|
||||||
|
(review 2026-09-05, after the first build — labelled **"Zbritje parkimi"** / "Parking
|
||||||
|
discount", not "sponsorship"). The wash editor offers: *free* (comp), **free while the wash
|
||||||
|
runs + N minutes tolerance** (`doneTolerance`, resolved at done into a timeCredit of the
|
||||||
|
WASH WINDOW — order intake → done — plus N; NOT the time since entry: a first build
|
||||||
|
anchored it at entry and a ticket parked 74 days would have been comped by a wash —
|
||||||
|
caught by the user on ticket 92498375903 the same day; parking before the order and
|
||||||
|
after the tolerance stays at the tariff), **the wash price off the parking fee, floored at 0** (`washPrice`, resolved
|
||||||
|
into a fixed discount of the order's price), and first-N-minutes. It does NOT offer the
|
||||||
|
typed-amount mode (the only mode where the operator picks the money — the highest-risk one,
|
||||||
|
kept for the Bar behind its cap + per-day limit + attribution) nor percent (a real Bar use,
|
||||||
|
not a wash one). The two wash-only modes can't be applied by a merchant scan (400) — they
|
||||||
|
need a wash order's context, and the signed event records the resolved mode plus
|
||||||
|
`programMode` for audit. The
|
||||||
|
site admin configures, for the car wash, the same program shape a merchant validation has
|
||||||
|
(comp / first N minutes free / amount / percent, max per day); a completed wash applies it
|
||||||
|
to the customer's session automatically, attributed to the wash operator. ~~So the module
|
||||||
|
`dependsOn` **validation** (the sponsorship engine) as well as parking~~ — **corrected
|
||||||
|
2026-09-06:** the discount ENGINE (program rows + `applyValidation()`) is CORE; the
|
||||||
|
`validation` module is only the merchant's scan screen. Car Wash depends on parking alone
|
||||||
|
(a site set to `MODULES_ENTITLED=parking,carwash` had the wash silently dropped as
|
||||||
|
"dependency broken" — the user's first field test). The earlier "own event, validation
|
||||||
|
absorbed later" idea stays superseded: the engine IS the shared piece. With
|
||||||
|
program = comp, an in-bay-paid wash lets the car out at the reader; with a partial program
|
||||||
|
the remainder is still paid at the booth (the reader refuses, as for any unpaid session).
|
||||||
|
|
||||||
|
### Anti-fraud — the reason this fits here and not a generic wash product
|
||||||
|
|
||||||
|
Same adversary as the booth ([[threat-model]]): the person taking cash. The fraud is the
|
||||||
|
**unrecorded wash** — cash pocketed, nothing in the system. Two signals, both from things the
|
||||||
|
platform already owns:
|
||||||
|
|
||||||
|
1. **Session vs order.** A vehicle that dwelt at the bay (bay camera presence, or simply a long
|
||||||
|
session with no order) and exited with no wash order → `anomaly` ledger event with the entry
|
||||||
|
snapshot and the dwell evidence attached.
|
||||||
|
2. **Bay count vs order count.** The bay camera counts vehicles washed per shift; orders recorded
|
||||||
|
per shift come from the ledger; a divergence above a site-config tolerance → `anomaly` per
|
||||||
|
shift, on the operator's record. Never blocks the wash; reporting only.
|
||||||
|
Plus the category-override flag described above (SUV recorded as Car).
|
||||||
|
|
||||||
|
### Build order
|
||||||
|
|
||||||
|
1. Retire the Lavazh validation station (small; Bar station and all tables stay).
|
||||||
|
2. ~~Platform name~~ — settled, unchanged.
|
||||||
|
3. Registry: `site_config.modules`, manifest type, `requireModule`, nav gating,
|
||||||
|
`MODULES_ENTITLED` env; register `parking` and `validation` without moving code.
|
||||||
|
4. Car Wash v1 as above, on the staging booth. Vision category last — it needs gate frames
|
||||||
|
collected and labelled first; the bay-count signal can ship before it (presence only).
|
||||||
|
Rough size: four to six weeks including the registry.
|
||||||
|
|
||||||
|
## As-built: the registry (2026-09-05, build-order steps 1 + 3)
|
||||||
|
|
||||||
|
Built as the groundwork for the Car Wash pilot. `parking` and `validation` are registered;
|
||||||
|
no parking code moved (the seam exists, the code crosses it as each subsystem is touched).
|
||||||
|
|
||||||
|
- **`packages/shared/src/index.ts`** — `MODULE_IDS`, `ModuleManifest` {`id`, `required`,
|
||||||
|
`dependsOn`, `resources`, `ledgerEventTypes`}, the `MODULES` registry, and the rules as pure
|
||||||
|
functions: `parseEntitledModules(env)` (unset/blank = everything; required always in; unknown
|
||||||
|
ids reported), `resolveModuleActivation(entitled, requested)` (required always in; refuses
|
||||||
|
not-entitled and missing-dependency with a human-readable reason), `effectiveModules(entitled,
|
||||||
|
activated)` (required ∪ entitled ∩ activated, dependency-broken modules dropped).
|
||||||
|
- **DB** — `site_config.modules_json` (nullable JSON array; null = everything entitled),
|
||||||
|
migration `0026_site_modules` (hand-written + journal entry: `drizzle-kit generate` needs a
|
||||||
|
TTY and this repo's snapshots stop at 0003 — migrations have been hand-written since).
|
||||||
|
- **Server** — `apps/server/src/modules.ts`: `entitledModules()` (env, read per request),
|
||||||
|
`activatedModulesOf(row)`, `effectiveModulesFor(db)`, and the **`requireModule(db, id)`**
|
||||||
|
preHandler (403, `code: "module_disabled"`), composed BEFORE `requirePermission` in a
|
||||||
|
preHandler array so a disabled module answers identically for every role.
|
||||||
|
`apps/server/src/modules/index.ts` iterates `MODULES` and calls each folder-based module's
|
||||||
|
`register(app, deps)` (today: `modules/validation/index.ts` → `routes/validations.ts`,
|
||||||
|
unchanged location, now guarded); boot logs `{entitled, effective}` so "why is X missing" is
|
||||||
|
answerable from the container log. `routes/site.ts`: GET returns `modules` / `modulesEntitled`
|
||||||
|
/ `modulesActivated`; PUT accepts the full desired `modules` set, validates via the shared
|
||||||
|
rules (400 with the reason), and signs one `config_change` `{setting: "modules.<id>", value,
|
||||||
|
prev, operator}` per module whose effective state actually flips (no-op resaves sign nothing).
|
||||||
|
`routes/auth.ts` `sessionView` carries `modules` so the SPA can hide nav on first paint.
|
||||||
|
- **Web** — `apps/web/src/lib/modules.ts` (`moduleOn(user, id)`, `WebModule` {nav, routes(root)}),
|
||||||
|
`apps/web/src/modules/index.ts` (`WEB_MODULES`), `modules/validation/index.tsx` (the
|
||||||
|
`/validate` route + nav entry, gated on module-on + permission). `router.tsx` spreads
|
||||||
|
`WEB_MODULES` into the header nav and the route tree and no longer names the validate screen.
|
||||||
|
`SiteSettings.tsx`: a **Modules** panel listing the entitled modules (required ones shown
|
||||||
|
disabled, dependencies shown as a hint); each flip PUTs the full set and shows the server's
|
||||||
|
refusal reason verbatim; the merchant-validation section only renders when `validation` is
|
||||||
|
effective. i18n `modules.*` (en + sq).
|
||||||
|
**Gotcha found in the browser check:** route-context consumers (the header nav) only re-read
|
||||||
|
the router context on navigation, so `setUser(freshMe)` alone left the nav stale after a
|
||||||
|
flip — `App.tsx` now `router.invalidate()`s whenever `user` changes (fixes the same latent
|
||||||
|
issue for every other `setUser` caller). The programs fetch is also gated on the module being
|
||||||
|
effective, so opening Setup → Site with validation off no longer logs a 403 to app_logs.
|
||||||
|
Verified live (Playwright against the Vite dev server): flip off → "Validations" leaves the
|
||||||
|
header and the validation sections hide; flip on → both return, no reload.
|
||||||
|
- **Deploy** — `MODULES_ENTITLED=parking,validation` added explicitly to both booth stacks in
|
||||||
|
`komodo/resources.toml`; documented in `apps/server/.env.example`.
|
||||||
|
- **Lavazh station retired** (step 1): `STATIONS = ["bar"]`; existing `lavazh` program rows are
|
||||||
|
untouched data (the server accepts any kebab slug) — they simply have no checkbox now.
|
||||||
|
- **Tests** — `apps/server/src/modules.test.ts` (7): defaults; deactivate → 403
|
||||||
|
`module_disabled` + signed flip + reversible; required can't be deactivated; unknown id → 400;
|
||||||
|
no-op resave signs nothing; `MODULES_ENTITLED=parking` → not offered, not activatable, routes
|
||||||
|
403; required entitled even when omitted, unknown ids ignored. Full suite 329/329.
|
||||||
|
- **Acceptance test for Car Wash** (unchanged): one manifest entry, one `SERVER_MODULES` line,
|
||||||
|
one `WEB_MODULES` line, its two folders, its migration — nothing else in the core touched.
|
||||||
|
|
||||||
|
## As-built: Car Wash v1 (2026-09-05) — the pilot, delivered
|
||||||
|
|
||||||
|
Built the same day the v1 answers landed. Everything the module is lives in
|
||||||
|
`apps/server/src/modules/carwash/` and `apps/web/src/modules/carwash/`; the core changed only
|
||||||
|
at the two seams the design names, and the registry earned its keep: **one manifest entry, one
|
||||||
|
`SERVER_MODULES` line, one `WEB_MODULES` line, one migration, two folders.**
|
||||||
|
|
||||||
|
- **Data** (`0027_carwash`): `carwash_categories`, `carwash_services` (admin lists, soft-delete),
|
||||||
|
`carwash_prices` (category × service → minor units; a missing pair is unsellable),
|
||||||
|
`carwash_orders` (the queue; names + price + currency FROZEN at intake; `pay_at` booth|bay;
|
||||||
|
`status` open|done|void; paid-ness is `paid_at` + the settling event id, separate from status
|
||||||
|
because a bay order may be paid before or after the wash).
|
||||||
|
- **Ledger:** `carwash_order` (payload `action` created|done|void, frozen names/price) and
|
||||||
|
`carwash_payment` (money at the bay). A wash paid at the booth is NOT its own event — it rides
|
||||||
|
the parking `payment` as `chargeLines` / `chargesMinor` / `parkingMinor`.
|
||||||
|
- **Two core seams, both deliberate:**
|
||||||
|
1. **`PayStation.registerChargeProvider()`** — a module folds charges into the booth
|
||||||
|
settlement: `lines(identity)` at quote time, `onPaid(identity, lines, payment)` after the
|
||||||
|
payment is signed. `Quote`/`SessionLookup` gained `chargeLines`, `chargesMinor`,
|
||||||
|
`parkingMinor`; `BoothPayModal` renders the "+" lines. A provider fault is logged and
|
||||||
|
priced around, never blocks a parking settlement.
|
||||||
|
2. **`applyValidation()`** extracted from the merchant route into `validations.ts` — the
|
||||||
|
decision chain + signed append, shared; the merchant route keeps only its program↔user
|
||||||
|
binding check. The wash applies the site's **`carwash`** validation program (composed on
|
||||||
|
Setup → Car wash with the same `StationForm`, users hidden) when an order is marked done,
|
||||||
|
attributed to the wash operator.
|
||||||
|
Plus the shift money folds (`#drawerBalanceAt`, Z-report tender totals) now include
|
||||||
|
`carwash_payment` so the expected drawer is right; a separate wash bucket on the Z-report is
|
||||||
|
a follow-up.
|
||||||
|
- **The exit-reader rule, honoured:** a validation alone opens nothing — the reader checks for
|
||||||
|
a signed `payment` + grace. So after a bay payment on a done order the module asks the core
|
||||||
|
for a quote and, if the sponsorship made it zero-due, signs the $0 parking payment via
|
||||||
|
`PayStation.pay()`. A partial sponsorship leaves the remainder for the booth (verified).
|
||||||
|
- **Modules reach the core only via `ServerModuleDeps`** (db, eventLog, payStation,
|
||||||
|
shiftService) — no module imports another; `dependsOn: ["parking"]` (validation dropped
|
||||||
|
2026-09-06; the program routes moved out from behind the validation gate — the merchant
|
||||||
|
scan routes stay gated).
|
||||||
|
- **Deploy gotcha (2026-09-06):** `MODULES_ENTITLED` reaches the container ONLY through
|
||||||
|
`docker-compose.yml`'s `environment:` block — a value in the Komodo stack env alone is just
|
||||||
|
compose interpolation input. It was missing there, so every booth on `55d6242` had Car Wash
|
||||||
|
on (unset = everything). Fixed: compose forwards it with a default of `parking,validation`;
|
||||||
|
a booth is never entitled to a module its stack env does not name. Check on the box:
|
||||||
|
`docker exec <stack>-server-1 env | grep MODULES_ENTITLED` and the boot log line
|
||||||
|
`venue modules (entitled = …; effective = …)`.
|
||||||
|
- **Web:** `/wash` (the desk: ticket lookup → category/service/price → order, the site's
|
||||||
|
booth|bay policy shown read-only;
|
||||||
|
the queue oldest-first with Done / Paid cash / Paid card / Void) and `/setup/carwash`
|
||||||
|
(categories, services, the price matrix, the sponsorship program). `WebModule` gained
|
||||||
|
`setupNav` / `setupRoutes`; the Setup tab bar spreads them like the header does. i18n en+sq.
|
||||||
|
- **Verified:** 7 new server tests (`modules/carwash/carwash.test.ts`: settings + signed
|
||||||
|
config_change; intake rules + oldest-first queue; booth path = charge line on quote + payment
|
||||||
|
payload + order marked paid; bay path = validation applied, `carwash_payment`, $0 parking
|
||||||
|
payment, within grace, queue empty; partial sponsorship leaves a balance; void takes back a
|
||||||
|
live sponsorship; module off = 403 + no charge lines). Suite 337/337 (two backup-service
|
||||||
|
tests flake under the parallel run, pass in isolation — pre-existing, unrelated). Live in the
|
||||||
|
browser on the dev server: activated the module in Setup → Site (header + Setup tab appeared
|
||||||
|
without reload), saved the sponsorship, seeded master data, and ran a real bay-paid SUV wash
|
||||||
|
against an open ticket — ledger read `carwash_order:created → validation → carwash_order:done
|
||||||
|
→ carwash_payment 70000 → payment 0`, session `paidAt` set, `withinGrace: true`.
|
||||||
|
- **Review fixes (same day):** the price matrix let you type prices for new rows only after a
|
||||||
|
save (new rows had no id) — the Save button now does two requests behind one click (lists
|
||||||
|
first, then prices mapped to the returned ids). Discount modes extended as in "v1 answers" 4.
|
||||||
|
- **Review fix 2 (same day):** `doneTolerance` re-anchored at the order's intake (above);
|
||||||
|
long durations now display as `Xy Xd Xh Xm` everywhere (`formatDuration` /
|
||||||
|
`formatMinutes`), so a stale ticket reads "74d 21h 23m", not "1797h 23m".
|
||||||
|
- **Not yet:** the booths' `MODULES_ENTITLED` stays `parking,validation` — entitle `carwash`
|
||||||
|
per site when a site buys it. Vision category (advisory flag) and the bay-camera signals are
|
||||||
|
the next increment, as designed. Receipt label for a booth-paid wash is `Lavazh — <category> ·
|
||||||
|
<service>` (Albanian, frozen on the payment).
|
||||||
|
|
||||||
|
## Permissions matrix — rethink (OPEN DECISION, raised 2026-09-05; moves 1–2 built same day)
|
||||||
|
|
||||||
|
**Why (user: "I feel we opened Pandora's box with this car wash module. We need to rethink
|
||||||
|
the permissions matrix.").** The flat `resource:action` grid was composed for ONE desk. Three
|
||||||
|
things broke once a second desk existed:
|
||||||
|
|
||||||
|
1. **Permissions named data, not jobs, and their meanings got borrowed.** `session:read` meant
|
||||||
|
"may use the booth screen"; on the first tills cut it also decided who may work the booth
|
||||||
|
till. `report:read` meant "may open the live socket". `shift:create` opened *the* shift. Each
|
||||||
|
was a proxy for a job, and proxies are how the dev `Lavazhier` role ended up with booth
|
||||||
|
rights and without `carwash:create`.
|
||||||
|
2. **Cross-cutting resources have no owner.** Shifts, drawer, events, the feed are core, but
|
||||||
|
every *instance* now belongs to a desk; the grid cannot say "shifts, but only the wash's".
|
||||||
|
3. **The composer is at the wrong altitude.** ~60 checkboxes of nouns and verbs ask the admin to
|
||||||
|
reconstruct a job from parts; at a site where the operator is the adversary a mis-composed
|
||||||
|
role is a security bug.
|
||||||
|
|
||||||
|
**Decision (three moves; the grid stays the enforcement layer — no guard semantics change for
|
||||||
|
the booth).**
|
||||||
|
|
||||||
|
- **Move 1 — each desk's money is guarded by that desk's own permissions.** The manifest
|
||||||
|
declares `tillGuards { read, shift, cash }`: booth = `shift:read` / `shift:create` /
|
||||||
|
`drawer:create` (parking's own, unchanged); carwash = `carwash:read` / **`carwash:cash`** (new)
|
||||||
|
/ `carwash:cash`. Shift + drawer routes resolve the guard FROM THE TILL
|
||||||
|
(`requireTill(kind)`), so a wash role holds no `shift:*` at all and cannot touch the booth by
|
||||||
|
construction; a role that should work both simply holds both. Replaces the one-day-old
|
||||||
|
`session:read` borrowing (`tillPermission`), which is deleted. `/api/shift/tills` lists the
|
||||||
|
tills a role may *read* with a `canWork` flag; history and movements without a till filter
|
||||||
|
return the union of the role's readable tills (admin scopes `shift:cash` / `drawer:review`
|
||||||
|
unchanged).
|
||||||
|
- **Move 2 — jobs on top of the grid.** Manifest `jobs[]` = named permission bundles: parking →
|
||||||
|
*Booth operator*, *Booth supervisor*; validation → *Merchant*; carwash → *Wash operator*. The
|
||||||
|
roles composer offers the jobs of the EFFECTIVE modules as one-click chips (add / remove the
|
||||||
|
bundle), with the grid kept as the fine-tune view, and LINTS the result: **mixes desks** (the
|
||||||
|
role may open more than one till) and **partial job** (holds a module's read permission but
|
||||||
|
not the rest of its job — e.g. a desk that can look but not create). Warnings, not blocks: the
|
||||||
|
admin is not the adversary, but must see what they composed.
|
||||||
|
- **Move 3 — the live feed follows the same rule (user: "The user should have websocket for
|
||||||
|
live events. This does not mean it can read the /reports section.").** The socket is no longer
|
||||||
|
gated on `report:read`. A role may connect if it holds ANY watch permission
|
||||||
|
(`event:read`, `session:read`, `device:read`, or an effective module's `feedPermission` —
|
||||||
|
carwash: `carwash:read`), and each pushed message is FILTERED per role: a ledger event needs
|
||||||
|
`feedPermissionFor(type)` (the owning module's, else `event:read`); occupancy needs
|
||||||
|
`session:read`; device / printer / lane / radar need `device:read`; plate backfill needs
|
||||||
|
`session:read`. So the wash desk gets a live queue without the booth's ledger, and the booth
|
||||||
|
operator keeps a feed without reports. `report:read` now means exactly the reports screen.
|
||||||
|
|
||||||
|
**Rejected.** Scoped permission strings (`shift:create@carwash`) — changes the `Permission`
|
||||||
|
type everywhere for what a manifest lookup expresses; a per-module copy of the shift/drawer
|
||||||
|
resources — the till already IS that copy. Role *templates stored in the DB* — jobs are code
|
||||||
|
(they change with the module), roles are data; keep that line.
|
||||||
|
|
||||||
|
**Status.** Moves 1, 2 and 3 built 2026-09-05 (see the Tills as-built below and [[shift]]
|
||||||
|
§Tills). Open: whether `booth-supervisor` should carry `subscription:*` by default; whether a
|
||||||
|
job should be *re-applicable* after a module update (today a chip only adds/removes the bundle
|
||||||
|
as it is now); an audit `config_change` on role edits.
|
||||||
|
|
||||||
|
## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05)
|
||||||
|
|
||||||
|
**The problem, found on the first wash-desk review.** [[shift]] is a single **site-wide**
|
||||||
|
accountability period with one drawer, implicitly the booth's. A bay payment today (a) requires
|
||||||
|
the *booth's* shift to be open and (b) folds its cash into the *booth's* expected drawer. So the
|
||||||
|
booth operator's Z-report comes up short by exactly what the wash operator holds, and the wash
|
||||||
|
operator — who neither cares about nor belongs to the park shift — has no Z-report at all. That
|
||||||
|
is the opposite of what the [[threat-model]] wants: the counted-vs-expected moment is the one
|
||||||
|
control against the unrecorded-wash vector, and it must sit with the person holding the cash.
|
||||||
|
|
||||||
|
**Decision (user: "go ahead and start building it", 2026-09-05):** a shift belongs to a
|
||||||
|
**till**, not to the site.
|
||||||
|
|
||||||
|
- `booth` is the till that exists today. A money-taking module declares its own till in its
|
||||||
|
manifest (`carwash`; a future `bar`). Two shifts may be open at once — one per till — each
|
||||||
|
with its own operator, opening float, cash in/out, expected drawer and Z-report.
|
||||||
|
- Every money event names its till: parking `payment` (and the booth-paid wash riding it as
|
||||||
|
`chargeLines`) = `booth`; `carwash_payment` at the bay = `carwash`. Drawer fold and
|
||||||
|
Z-report filter by till. Ledger events without a `till` field are booth events, so history
|
||||||
|
verifies and folds unchanged.
|
||||||
|
- The header shift button stays the booth's. The wash desk gets its own shift control (open,
|
||||||
|
cash in/out, Z-report — the same ceremony); **"take money at the bay" requires the
|
||||||
|
`carwash` shift to be open**, not the booth's.
|
||||||
|
- Rejected: no shift for the wash, reconciling from the per-operator report — it throws away
|
||||||
|
the counted-vs-expected control, which is also what the bay-camera signal will reconcile
|
||||||
|
against later.
|
||||||
|
- Cost: ~a day in the core `ShiftService` (till on shift + payment events, folds, per-till
|
||||||
|
Z-reports), a shift control on the wash desk, tests. Modules then get a drawer for free.
|
||||||
|
|
||||||
|
### As-built (2026-09-05)
|
||||||
|
|
||||||
|
- **Shared:** `TILL_IDS = ["booth", "carwash"]`, `TillId`, `BOOTH_TILL`, `isTillId`, and the
|
||||||
|
one rule everything reads through — `tillOf(payload) = payload.till ?? "booth"`.
|
||||||
|
`ModuleManifest.till?` (parking → `booth`, carwash → `carwash`); `tillsOf(effective)` = the
|
||||||
|
tills addressable at a site (booth + each effective module's). `LedgerPayload.till?`.
|
||||||
|
- **ShiftService** (`apps/server/src/shift-service.ts`): every public method takes a `till`
|
||||||
|
defaulting to the booth — `open/close/currentOpenShift/openShiftFor/requireOpenShift/
|
||||||
|
currentReport/drawerBalance/listShifts({till})/listOperators(till)/recordVoucher({till})/
|
||||||
|
movementsWithStatus({till})`. `shift_open` and `shift_z_report` payloads carry `till`; the
|
||||||
|
drawer fold, the window summary (payments **and** vouchers) and the shift-boundary scan all
|
||||||
|
filter by `tillOf`. Single-open is **per till**; no cross-till rule (a small site's one
|
||||||
|
person may hold both). Z-report/voucher slips print an `Arka: Lavazhi` line off the booth
|
||||||
|
only, so booth slips stay byte-identical.
|
||||||
|
- **Producers stamp their till:** `PayStation.pay()` (both parking paths) and the
|
||||||
|
subscription sale write `till: "booth"`; `carwash_payment` writes `till: "carwash"` and
|
||||||
|
`payAtBay` requires the **carwash** shift (`409 no_shift` now also returns `till`).
|
||||||
|
- **Routes:** `GET /api/shift/current?till=`, new `GET /api/shift/tills` (every till's state
|
||||||
|
in one read), `GET /api/shift/report?till=`, `POST /api/shift/open|close { till }`,
|
||||||
|
`GET /api/shifts?till=` (+ `tills` in the answer), `POST /api/drawer/movement { till }`,
|
||||||
|
`GET /api/drawer/movements?till=`, `GET /api/drawer/balance?till=`. `parseTill()` (server
|
||||||
|
`modules.ts`) answers `400 bad_till` for an unknown till or one whose module is off.
|
||||||
|
- **Web:** `useShift(till)` (key `["shift","current",till]`, under the WS-invalidated
|
||||||
|
prefix); the header `ShiftButton` moved to `ShiftControl.tsx` and takes a `till` — the
|
||||||
|
header renders the booth's, the **wash desk renders `till="carwash"`** with "Wash drawer
|
||||||
|
now" and gates *Paid cash/card* on **my** wash shift; the shift hub lists every open shift
|
||||||
|
(one per till) with Booth/Wash badges, a till filter and a start button per idle till; the
|
||||||
|
drawer hub has a till switch (only when the site has >1) scoping every panel.
|
||||||
|
- **Tests:** 6 in `shift-service.test.ts` (per-till single-open, requireOpenShift per till,
|
||||||
|
money folds into its till only, vouchers per till + separate carry-forward, close per till,
|
||||||
|
history/filter + pre-till = booth); carwash bay test now proves the booth shift does *not*
|
||||||
|
cover the bay and that the wash Z carries the money while the booth Z does not. Verified
|
||||||
|
live: booth held by `admin` since July, `testadmin` opened + closed a wash shift around one
|
||||||
|
bay payment — wash Z: cash 500, booth untouched, drawer hub shows each till's own figure.
|
||||||
|
|
||||||
|
- **Till access = module permission (2026-09-05, same day).** `ModuleManifest.tillPermission`
|
||||||
|
(booth `session:read`, carwash `carwash:read`) + `tillsFor(effective, has)`; server
|
||||||
|
`accessibleTillsFor(db, roleId)` guards open/close/current/report and cash movements
|
||||||
|
(`403 till_forbidden`); `/api/shift/tills` lists only the role's tills. A wash role
|
||||||
|
therefore never sees or opens the booth's shift. A role that *should* work both simply
|
||||||
|
holds both permissions.
|
||||||
|
- **Landing per module (2026-09-05).** `WebModule.landing` (`/wash` for `carwash:read`,
|
||||||
|
`/validate` for `validation:create`); the index route lands on the booth iff
|
||||||
|
`session:read`, else the first module landing the role holds, else `/shifts`, else the
|
||||||
|
profile; every guard bounces to `/` (the resolver), never to the booth, and `/booth`
|
||||||
|
itself now requires `session:read`. The hard-coded merchant special case is gone.
|
||||||
|
- **Not done:** role presets from the manifest (a one-click "wash operator" role in Setup
|
||||||
|
→ Roles); a permission-scoped live feed for module desks (the WS is `report:read` only —
|
||||||
|
the wash desk polls, 5 s / 15 s).
|
||||||
|
|
||||||
|
**Known follow-ups.** A shift's *activity log* (right pane of the hub, Drawer "today") is
|
||||||
|
still a time window over the whole chain, so a booth shift's log shows wash events in that
|
||||||
|
window (money figures are per till; the log is not). A separate wash bucket on the booth's
|
||||||
|
Z-report (booth-paid washes ride `chargeLines`) is still open. Bay slips print on the booth
|
||||||
|
printer until a wash-desk printer role exists.
|
||||||
|
|
||||||
|
## Review log — issues and ideas from the first hands-on pass (2026-09-05)
|
||||||
|
|
||||||
|
Recorded so the reasoning survives; each item's fix is in the As-built sections above.
|
||||||
|
|
||||||
|
1. **"Parking sponsorship" → "Zbritje parkimi" / "Parking discount".** Wording.
|
||||||
|
2. **Discount modes for the wash.** Owner-level needs: free *during* the wash (+ tolerance),
|
||||||
|
and "parking fee − wash price, floored at 0". Both added as wash-only modes resolved at
|
||||||
|
done. The typed-amount mode is the only one where the operator picks the money (highest
|
||||||
|
fraud exposure; kept for the Bar behind cap + per-day + attribution, hidden for the wash);
|
||||||
|
percent is a Bar use, not a wash one (hidden for the wash).
|
||||||
|
3. **Price-matrix cells for new rows were disabled until save** (no id yet). Fixed with a
|
||||||
|
two-request save behind one button.
|
||||||
|
4. **"Free until done" comped a 74-day stay** (ticket 92498375903, 1797h) — the credit was
|
||||||
|
anchored at entry. Re-anchored at the order's intake: only the wash window (+ tolerance)
|
||||||
|
is credited. Also: long durations now render `Xy Xd Xh Xm` everywhere.
|
||||||
|
5. **The desk needs to see finished washes.** Added a "Finished" list (done + paid, or
|
||||||
|
voided; newest first; who closed it; reason on void) under the queue.
|
||||||
|
6. **Wash operators vs the park shift** → the tills requirement above.
|
||||||
|
|
||||||
|
## Open questions to settle before building
|
||||||
|
|
||||||
|
- ~~Platform name~~ — **settled 2026-09-05: it stays `parking-system` / `com.parking.desktop`.**
|
||||||
|
"This is a Parking Systems after all." The second-tier concern is answered by the architecture
|
||||||
|
(peer modules on a shared core), not by renaming; the irreversible-identifier warning above
|
||||||
|
remains true and is now simply moot.
|
||||||
|
- **Which body-type categories the Car Wash tariff actually needs** — decides COCO-five vs
|
||||||
|
training.
|
||||||
|
- **Entitlement as env vs signed file** — start with env; revisit only for commercial reasons.
|
||||||
|
- ~~**Tills**~~ — **built 2026-09-05** (above). Left: per-till activity log, wash bucket on
|
||||||
|
the booth Z, a printer role for the wash desk.
|
||||||
|
- The **Bar** data model — separate scoping session (Car Wash v1 scope is above).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
[[standing-decisions]] · [[desktop-shell-tauri]] · [[fleet-deployment-komodo]] ·
|
||||||
|
[[validation-discounts]] (kept for the Bar; Lavazh station retired) · [[validation-sponsorship]] · [[vision-service]]
|
||||||
|
· [[threat-model]] · [[append-only-event-chain]] · [[open-questions]] #15
|
||||||
@@ -32,6 +32,11 @@ Authentication and authorization, kept **fully local** — a direct consequence
|
|||||||
name. The JWT carries `roleId` (not the permission list); the guard resolves the role's permission
|
name. The JWT carries `roleId` (not the permission list); the guard resolves the role's permission
|
||||||
set per-request from an **in-memory cache** (`bumpPermsCache()` on any role write), so editing a
|
set per-request from an **in-memory cache** (`bumpPermsCache()` on any role write), so editing a
|
||||||
role applies immediately — no re-login, no token bloat. No Casbin/engine needed at this scale.
|
role applies immediately — no re-login, no token bloat. No Casbin/engine needed at this scale.
|
||||||
|
**The token's `roleId` is only a hint (2026-09-05):** after every `jwtVerify` the guard replaces it
|
||||||
|
with the user's CURRENT role from the DB (`refreshRole()`; cached per user, cleared by the same
|
||||||
|
`bumpPermsCache()`, which user update/delete now call), so REASSIGNING a user's role — or deleting
|
||||||
|
the user (→ 401 on their next request) — applies immediately too. Found when a user moved to a new
|
||||||
|
wash role kept the old role's rights until logout.
|
||||||
- **Protected built-in `admin` role** (`id='admin'`, `builtin=1`): non-editable, non-deletable, and
|
- **Protected built-in `admin` role** (`id='admin'`, `builtin=1`): non-editable, non-deletable, and
|
||||||
always resolves to the FULL permission set in code. The app refuses to delete or downgrade the
|
always resolves to the FULL permission set in code. The app refuses to delete or downgrade the
|
||||||
**last user holding admin** — administration can never be locked out of the appliance.
|
**last user holding admin** — administration can never be locked out of the appliance.
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
|||||||
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
||||||
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
||||||
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds).
|
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds).
|
||||||
|
- [[venue-modules]] — 🟡 OPEN: optional per-site modules (Car Wash, Bar/Restaurant) with Parking as a peer module on a venue POS/audit core; manifest registry, entitled ∩ activated enablement (vendor env + site-admin config), validation kept for the Bar (Lavazh station retires with Car Wash), name stays parking-system, vision vehicle-category as an advisory anomaly flag.
|
||||||
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
||||||
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
|
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
|
||||||
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
||||||
|
|||||||
+309
@@ -2740,3 +2740,312 @@ model (booth operator as primary adversary) makes an extractable, hard-to-rotate
|
|||||||
deployed binary worse than just publishing installers publicly. `release.yml`,
|
deployed binary worse than just publishing installers publicly. `release.yml`,
|
||||||
`apps/desktop/src-tauri/tauri.conf.json`, `apps/desktop/README.md` updated; full detail on
|
`apps/desktop/src-tauri/tauri.conf.json`, `apps/desktop/README.md` updated; full detail on
|
||||||
[[desktop-shell-tauri]].
|
[[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop login broken by a VITE_API_BASE regression from the booth same-origin fix
|
||||||
|
|
||||||
|
The 2026-06-27 booth fix (commit 96fd97e) correctly blanked `apps/web/.env.production`'s
|
||||||
|
`VITE_API_BASE` for the browser/booth same-origin case, but the desktop build shares that same
|
||||||
|
file and was never given its own override — the desktop shell has been building with an empty
|
||||||
|
API base since that commit, unnoticed until now. Symptom: login threw `DOMException: "The string
|
||||||
|
did not match the expected pattern."` — WebKitGTK rejecting a relative `fetch()` URL with no base
|
||||||
|
to resolve against, since the desktop window's origin is `tauri://localhost`. Browser login was
|
||||||
|
unaffected (same-origin, no absolute URL needed), which is why this went unnoticed through the CI
|
||||||
|
mirror-repo debugging session. Fixed by setting `VITE_API_BASE=http://127.0.0.1:3000` inline in
|
||||||
|
`tauri.conf.json`'s `beforeBuildCommand`, overriding the shared `.env.production` for the desktop
|
||||||
|
build only (process env wins in Vite's load order) — verified both builds independently. Full
|
||||||
|
detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop updater silently failed: tag/version drift + swallowed install errors
|
||||||
|
|
||||||
|
Two compounding bugs, both closed out on [[desktop-shell-tauri]] §"Desktop in CI": (1) the v0.1.1
|
||||||
|
release bumped only the git tag — tauri.conf.json's own "version" field (what Tauri actually bakes
|
||||||
|
into the bundle filename and internal version) stayed at 0.1.0, so the signed binary didn't match
|
||||||
|
what latest.json claimed to describe, and signature verification failed on every download; (2)
|
||||||
|
desktop-updater.ts's single blanket try/catch swallowed that failure identically to "offline/no
|
||||||
|
update," so the operator saw the prompt, watched it download, then nothing — repeating forever with
|
||||||
|
zero diagnostic trail. Fixed release.yml to sed-patch tauri.conf.json's version from the git tag
|
||||||
|
right before building (checked-in value is now dev-only, never hand-maintained for releases), and
|
||||||
|
split desktop-updater.ts's catch so a real post-accept failure logs instead of vanishing. Full
|
||||||
|
detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop login "Load failed": WebKit mixed-content, not CORS/CSP
|
||||||
|
|
||||||
|
After fixing VITE_API_BASE, login still failed with WebKit's generic "Load failed" — a raw browser
|
||||||
|
fetch() rejection with no server-side trace, since the request never reached the network. Root
|
||||||
|
cause: WebKitGTK treats tauri://localhost as a secure origin, so http://127.0.0.1:3000 (and
|
||||||
|
ws://127.0.0.1:3000) from inside it is blocked as mixed content — a known WebKit limitation, NOT
|
||||||
|
fixable via CSP connect-src. Fixed by routing both through Tauri plugins that use the native (Rust)
|
||||||
|
HTTP/WS client instead of the webview's own: tauri-plugin-http (a genuine fetch() drop-in, wired
|
||||||
|
into api.ts/logger.ts via a new platformFetch() in origin.ts) and tauri-plugin-websocket (NOT a
|
||||||
|
drop-in — async/listener API — adapted behind a native-WebSocket-shaped interface in the new
|
||||||
|
platform-ws.ts so use-live-feed.ts needed no changes). Full detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop live feed offline: native WS plugin sends no Origin, prod allowlist was empty
|
||||||
|
|
||||||
|
Login worked after the mixed-content fix, but the live feed showed offline in the desktop app while
|
||||||
|
the browser showed LIVE, same server. tauri-plugin-websocket's connect() runs on Tauri's Rust side,
|
||||||
|
not inside the webview page, so it never auto-attaches an Origin header — routes/ws.ts's anti-CSWSH
|
||||||
|
check treats a missing Origin as untrusted and 403s before auth. Compounded by a second, independent
|
||||||
|
gap: komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in production, despite
|
||||||
|
.env.example documenting tauri://localhost as required for the desktop app. Fixed both: platform-ws.ts
|
||||||
|
now passes Origin: tauri://localhost explicitly in connect()'s headers; resources.toml's two Stacks
|
||||||
|
get the real allowlist. Needs a Komodo sync + redeploy to reach a live booth, not just a git push.
|
||||||
|
Also confirmed the "update downloads then nothing happens" report was an older pre-fix build (v0.1.2)
|
||||||
|
self-updating — expected, not a new bug; v0.1.3 carries the error-logging fix from the mixed-content
|
||||||
|
commit and should surface a real error going forward. Full detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Update failures were invisible: console-forward gate blocked the error logging
|
||||||
|
|
||||||
|
The desktop-updater.ts error logging added earlier this session used console.error/console.warn,
|
||||||
|
but logger.ts only forwards console output to the server when the client log level is debug/trace
|
||||||
|
(default: info) — so the "fix" never actually surfaced anything, and a real v0.1.3→v0.1.4 update
|
||||||
|
failure showed zero logs anywhere, sending debugging in circles (a WebKit remote-inspector attempt
|
||||||
|
via WEBKIT_INSPECTOR_SERVER also dead-ended — this build doesn't answer standard discovery
|
||||||
|
endpoints). Fixed by calling logClient() directly in desktop-updater.ts, unconditionally, bypassing
|
||||||
|
the console-forward gate entirely — a genuine post-accept install failure now always reaches
|
||||||
|
app_logs regardless of client log level. Also added download-progress logging. Separately: found
|
||||||
|
and fixed a real, pre-existing Komodo ResourceSync misconfig (resource-sync-park-systems pointed at
|
||||||
|
`dev`, not `stage`, silently reading resources.toml from the wrong branch for months with zero
|
||||||
|
effect until dev/stage first diverged today) — full writeup on [[fleet-deployment-komodo]], which
|
||||||
|
had already warned about exactly this gotcha back in 2026-07-07 and it happened anyway.
|
||||||
|
|
||||||
|
## [2026-09-03] feat | Desktop app version now visible in the UI (was invisible)
|
||||||
|
|
||||||
|
There was no way to see which desktop build was actually installed anywhere in the app — an
|
||||||
|
operator debugging a stuck update had to infer it backwards from the update prompt's target
|
||||||
|
version ("it's offering v0.1.4, so I must be on v0.1.3"). Added DesktopVersionBadge next to the
|
||||||
|
existing server-side VersionBadge in router.tsx, using @tauri-apps/api's getVersion() (the real
|
||||||
|
running app version, synced to the git tag at build time by release.yml). No-ops in a browser.
|
||||||
|
Full detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-04] feat | Desktop backend origin is now runtime-configurable (was build-time)
|
||||||
|
|
||||||
|
The desktop shell is one generic .deb/.AppImage distributed via mca/public_releases — not built
|
||||||
|
per-booth — but VITE_API_BASE was a build-time env var hardcoded to http://127.0.0.1:3000, so the
|
||||||
|
same installer could only ever talk to a server on its own machine. Added ConnectScreen (shown
|
||||||
|
before Login in Tauri when no backend is saved), backed by tauri-plugin-store persisting the
|
||||||
|
operator-entered URL across restarts; origin.ts's API_BASE became a runtime-settable `let`. CSP's
|
||||||
|
connect-src tightened to 'self' only (all backend traffic already went through
|
||||||
|
tauri-plugin-http/websocket, which run Rust-side and are outside connect-src's reach anyway); the
|
||||||
|
real boundary moved to capabilities/default.json's http:default scope, wildcarded to any host so
|
||||||
|
the operator-chosen address is actually reachable. Added a "Change server" control (Setup nav,
|
||||||
|
desktop-only) that clears the saved URL and reloads back to ConnectScreen.
|
||||||
|
|
||||||
|
While tracing the desktop auth path for this, found a pre-existing (not newly introduced) bug:
|
||||||
|
tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its own cookie jar separate
|
||||||
|
from the webview — document.cookie on tauri://localhost never sees the parking_csrf cookie the
|
||||||
|
server sets (open upstream bug, tauri-apps/tauri#13045/#11518), so the desktop app has likely been
|
||||||
|
silently sending no CSRF header on every mutation since the shell was first built, regardless of
|
||||||
|
which host it targeted. Fixed by having sessionView() (routes/auth.ts) also echo the same csrf
|
||||||
|
value in the login/me JSON body; the desktop client stashes it in memory and echoes that instead of
|
||||||
|
reading document.cookie. assertCsrf() itself is untouched — the cookie is still what's verified,
|
||||||
|
and reqwest was already sending it correctly; this only fixes how the desktop client *learns* the
|
||||||
|
value. Full detail (including the exact CSP/capability tradeoffs) on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-04] fix | Desktop live feed: WS handshake can't carry the cookie → single-use ticket; desktop logs never reached app_logs
|
||||||
|
|
||||||
|
Retrospective of the 2026-09-03/04 desktop run (six releases in 26 h) found the v0.1.4 Origin fix
|
||||||
|
cleared only gate one of two in routes/ws.ts: gate two is req.jwtVerify() reading the HttpOnly
|
||||||
|
cookie, and tauri-plugin-websocket has no cookie jar at all — so every desktop handshake 401'd and
|
||||||
|
use-live-feed reconnected every 10 s (confirmed in the park-2 server log). Fixed with a 30-second,
|
||||||
|
single-use, in-memory WS ticket minted by POST /api/ws/ticket over normal cookie+CSRF auth and
|
||||||
|
presented in an x-ws-ticket header; Origin check still runs first, browser path unchanged, JWT
|
||||||
|
stays out of JS. Second finding: logger.ts read the CSRF cookie via document.cookie, null on
|
||||||
|
desktop, so every desktop POST /api/logs 403'd and was dropped silently — no desktop client log had
|
||||||
|
EVER reached app_logs, which is why "no logs whatsoever" kept happening and why yesterday's
|
||||||
|
logClient fix couldn't help. Stash moved to lib/desktop-csrf.ts, shared by api.ts and logger.ts;
|
||||||
|
WS connect failures now go through logClient (rate-limited). Third: the ConnectScreen probe now
|
||||||
|
uses the unauthenticated /health (extended with app: "parking-system") instead of accepting any
|
||||||
|
401. Also corrected four wiki citations (WebKit 171934 scope, tauri#11518 is closed, the HTTP
|
||||||
|
plugin does set Origin itself, the http-scope "quirk" is URLPattern default-port semantics) and
|
||||||
|
added a local-AppImage pre-tag gate to the desktop README, since tauri dev cannot reproduce any
|
||||||
|
of these origin-dependent bugs. Full detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-04] fix | Desktop in-app update never worked: latest.json described only the AppImage, booths run the .deb
|
||||||
|
|
||||||
|
tauri-plugin-updater looks up `{os}-{arch}-{installer}` first (linux-x86_64-deb — the bundler
|
||||||
|
stamps the installer type into the binary; verified with strings on a local .deb) and only then
|
||||||
|
bare linux-x86_64. release.yml's latest.json carried only the bare key → the AppImage, so every
|
||||||
|
.deb install downloaded the AppImage, passed signature verification, then failed install_deb()'s
|
||||||
|
is_deb check with InvalidUpdaterFormat — invisible until v0.1.6 fixed the desktop log channel.
|
||||||
|
This, not version drift or swallowed errors, is why v0.1.0→…→v0.1.6 never self-updated.
|
||||||
|
latest.json now has one signed entry per installer (deb, rpm, AppImage); a .deb update ends in a
|
||||||
|
polkit password prompt (pkexec dpkg -i), which is the intended admin gate on a root-installed
|
||||||
|
package. README + [[desktop-shell-tauri]] updated. First real test: tag v0.1.7 and accept the
|
||||||
|
prompt on the v0.1.6 booth.
|
||||||
|
|
||||||
|
## [2026-09-04] decision | Desktop updates are admin-only: keep the polkit prompt; AppImage rejected on field evidence
|
||||||
|
|
||||||
|
First successful desktop self-update (v0.1.6 → v0.1.7, pkexec dpkg -i + polkit dialog) raised
|
||||||
|
the question of the admin password the operator lacks. Tried the AppImage as the no-root path:
|
||||||
|
it fails to start on the Ubuntu 26.04 booth (bundled 24.04 glib/WebKitGTK vs host gvfs/Mesa —
|
||||||
|
EGL_BAD_PARAMETER abort), and structurally it abandons the distro-maintained WebKitGTK the
|
||||||
|
platform decision depends on. Passwordless polkit for dpkg is root-for-the-operator, rejected.
|
||||||
|
Decision (user, 2026-09-04): the .deb stays, updates are an admin action behind the prompt; the
|
||||||
|
in-app prompt now says so (en + sq). A root systemd updater timer shipped in the .deb (minisign-
|
||||||
|
verified, notify-only in-app) is recorded as the deferred fleet-grade option on
|
||||||
|
[[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-04] decision | Venue modules design recorded as OPEN — Car Wash / Bar as peers of Parking
|
||||||
|
|
||||||
|
Captured the 2026-09-04 design conversation on [[venue-modules]]: a manifest-registry module
|
||||||
|
system (folder per module, always-migrated schema, one ledger union with prefixed event types,
|
||||||
|
relations only via manifest dependsOn + ledger events), enablement as entitled ∩ activated
|
||||||
|
(vendor-set Komodo env vs site-admin site-config toggle recorded as config_change; server
|
||||||
|
enforces with requireModule, web only hides; disabling never deletes), Parking recast as one
|
||||||
|
module on a venue POS/audit core, validation decommissioned (ledger type kept for history), the
|
||||||
|
desktop identifier / .deb name flagged as the one irreversible naming step, and vision-derived
|
||||||
|
vehicle category (SUV vs Car for the wash tariff) as an advisory signal that raises an `anomaly`
|
||||||
|
ledger event on operator override — never a tariff input by itself. Added as open-questions #15;
|
||||||
|
indexed.
|
||||||
|
|
||||||
|
## [2026-09-05] decision | Car Wash is the pilot module; wash inside the parking; bay camera
|
||||||
|
|
||||||
|
Settled with the user on [[venue-modules]]: Car Wash is built as the first module and is the
|
||||||
|
acceptance test of the registry (zero changes outside its folder). The wash sits inside the
|
||||||
|
parking, so every vehicle already has a session, plate and entry snapshot — no intake capture,
|
||||||
|
walk-ins out of scope. A bay camera (same stateless vision service, presence/counting not
|
||||||
|
plates) gives two anti-fraud signals for the unrecorded-wash vector: session-vs-order and
|
||||||
|
bay-count-vs-order-count per shift, both as `anomaly` ledger events, never blocking. v1 scope
|
||||||
|
(catalogue by category, queue, advisory vision category, existing shift/cash/receipts, one
|
||||||
|
parking comp/credit event, per-operator reports) and the out-list (memberships, loyalty, stock,
|
||||||
|
scheduling, booking, accounts) recorded, plus the build order. Remaining before code: platform
|
||||||
|
name (→ desktop identifier) and the validation-removal go.
|
||||||
|
|
||||||
|
## [2026-09-05] decision | Name stays parking-system; validation kept for the Bar, only the Lavazh station retires
|
||||||
|
|
||||||
|
Two revisions to [[venue-modules]] from the user: (1) the platform name stays — "this is a
|
||||||
|
Parking Systems after all" — so `com.parking.desktop` and the .deb name are untouched and the
|
||||||
|
irreversible-identifier concern is moot; the peer-module architecture, not a rename, answers the
|
||||||
|
second-tier worry. (2) Validation is NOT decommissioned: the merchant-scan flow is needed as is
|
||||||
|
for the Bar until a Bar module exists and absorbs it. Only the Lavazh station is retired when Car
|
||||||
|
Wash ships (Car Wash sponsors parking via its own order event). Two sponsorship mechanisms
|
||||||
|
coexist for now, accepted. Build order updated; validation is registered as its own module in the
|
||||||
|
registry so Bar can later depend on or absorb it.
|
||||||
|
|
||||||
|
## [2026-09-05] feat | Venue-module registry built (pilot groundwork): entitled ∩ activated, requireModule, Setup panel
|
||||||
|
|
||||||
|
Implemented build-order steps 1 + 3 of [[venue-modules]]: Lavazh validation station retired
|
||||||
|
(STATIONS = ["bar"], rows untouched); @parking/shared gains MODULE_IDS / ModuleManifest / MODULES
|
||||||
|
(parking required, validation dependsOn parking) plus the pure rule functions; site_config
|
||||||
|
gets modules_json (migration 0026, hand-written — drizzle-kit generate needs a TTY and the
|
||||||
|
snapshots end at 0003); server modules.ts adds requireModule(db, id) (403 module_disabled,
|
||||||
|
composed before requirePermission), modules/index.ts registers folder-based modules by
|
||||||
|
iterating the registry (validation is the first), site-config GET/PUT expose and set the
|
||||||
|
activation with dependency rules and one signed config_change per module that actually flips,
|
||||||
|
/api/auth/me carries the effective set; web gains lib/modules.ts + modules/{index,validation}
|
||||||
|
and router.tsx spreads WEB_MODULES into nav + route tree, SiteSettings gets a Modules panel and
|
||||||
|
hides the validation section when the module is off; MODULES_ENTITLED set explicitly in both
|
||||||
|
booth stacks. 7 new server tests, suite 329/329, web build clean. Car Wash next.
|
||||||
|
|
||||||
|
## [2026-09-05] feat | Car Wash v1 built as the pilot venue module — one manifest, two folders, two core seams
|
||||||
|
|
||||||
|
Delivered the pilot on [[venue-modules]]: carwash_{categories,services,prices,orders} (migration
|
||||||
|
0027), ledger types carwash_order (created/done/void, names + price frozen) and carwash_payment
|
||||||
|
(bay money), a server module (settings, oldest-first queue, intake against an open ticket, done →
|
||||||
|
applies the "carwash" validation program via the extracted applyValidation(), bay payment →
|
||||||
|
signs carwash_payment and, if the sponsorship made the session zero-due, the $0 parking payment
|
||||||
|
the exit reader needs; void takes back a live sponsorship) and a web module (/wash desk,
|
||||||
|
/setup/carwash master data + sponsorship editor). Two deliberate core seams: PayStation charge
|
||||||
|
providers (a wash paid at the booth rides the parking payment as chargeLines; BoothPayModal shows
|
||||||
|
them) and applyValidation() shared with the merchant route; shift money folds include bay
|
||||||
|
payments. Registry proof: the module needed exactly one manifest entry + one line in each
|
||||||
|
registry + its folders. 7 new tests, suite 337/337; full bay flow verified live in the browser.
|
||||||
|
Booth stacks not yet entitled to carwash. Next increment: vision category flag + bay camera.
|
||||||
|
|
||||||
|
## [2026-09-05] query | Car Wash review pass: discount modes, price matrix, 74-day comp bug, finished list — and the tills requirement
|
||||||
|
|
||||||
|
Hands-on review of Car Wash v1 with the user, recorded on [[venue-modules]] §"Review log":
|
||||||
|
renamed the discount section; added two wash-only discount modes (free during the wash +
|
||||||
|
tolerance; wash price off the fee, floored at 0) resolved at done via applyValidation() and
|
||||||
|
refused at merchant scan; hid typed-amount and percent from the wash editor (typed amount =
|
||||||
|
the operator picks the money → highest fraud exposure; kept for the Bar); fixed the
|
||||||
|
price-matrix cells for unsaved rows (two-request save); found and fixed a real money bug —
|
||||||
|
"free until done" was anchored at entry and would have comped a 74-day stay for one wash
|
||||||
|
(ticket 92498375903) — now anchored at the order's intake; long durations render y/d/h/m;
|
||||||
|
added a Finished list to the desk. Raised a structural requirement: shifts must become
|
||||||
|
per-till (booth / carwash) — a bay payment currently needs the booth shift and folds into
|
||||||
|
the booth drawer, which breaks both operators' Z-reports; design recorded, awaiting a go
|
||||||
|
([[shift]] carries a forward pointer).
|
||||||
|
|
||||||
|
## [2026-09-05] ingest | Tills built: one shift + one drawer per money-taking desk
|
||||||
|
|
||||||
|
The user confirmed the tills design ("go ahead and start building it"). Built in the core:
|
||||||
|
a shift is opened on a till (`booth` | `carwash`), every money event names its till
|
||||||
|
(`payload.till`, absent = booth so the chain re-folds identically), the ShiftService folds,
|
||||||
|
X/Z-reports, vouchers and carry-forward are per till, single-open is per till, and a bay
|
||||||
|
payment now requires the **carwash** shift. Web: the shift button became a per-till
|
||||||
|
component (header = booth, wash desk = carwash, with "Wash drawer now" and pay buttons
|
||||||
|
gated on my wash shift); the shift hub lists every open shift with till badges + a till
|
||||||
|
filter; the drawer hub switches tills. 6 new shift tests + the bay test now proves the booth
|
||||||
|
shift does not cover the bay; verified live with the booth held by another operator. Recorded
|
||||||
|
on [[shift]] §"Tills" and [[venue-modules]] §"Tills → As-built". Follow-ups: per-till
|
||||||
|
activity log, wash bucket on the booth Z, wash-desk printer role.
|
||||||
|
|
||||||
|
## [2026-09-05] ingest | Where wash money is taken became a site setting (Setup → Car wash)
|
||||||
|
|
||||||
|
User: the booth|bay choice belongs in `/setup/carwash` ("Pagesa: në kabinë / në lavazh"), and
|
||||||
|
the per-order radio goes away from `/wash`. Built: `carwash_config` singleton (migration 0028,
|
||||||
|
default booth), `payAt` on the settings view/body, signed `config_change carwash.payAt` on a
|
||||||
|
flip, orders freeze the policy in force, `409 pay_at_policy` for a stale client; Setup radio;
|
||||||
|
desk shows the policy read-only. Tests: 1 new (default, persist, sign, freeze, refuse).
|
||||||
|
Recorded on [[venue-modules]] §"v1 answers" item 2.
|
||||||
|
|
||||||
|
## [2026-09-05] ingest | Till access by module permission; landing per module
|
||||||
|
|
||||||
|
The user found their wash user could open the booth's shift (any `shift:create` could open any
|
||||||
|
till) and asked for the wash interface to be filtered off booth screens. Built: manifest
|
||||||
|
`tillPermission` + `tillsFor()`; server `accessibleTillsFor()` guards shift open/close/state
|
||||||
|
and cash movements (`403 till_forbidden`), `/api/shift/tills` returns only the role's tills;
|
||||||
|
header shift button needs `session:read`; module `landing` replaces the hard-coded merchant
|
||||||
|
landing, all guards bounce to `/`, `/booth` needs `session:read`. Diagnosed the dev
|
||||||
|
`Lavazhier` role: it holds booth permissions (`session:read`, `payment:create`,
|
||||||
|
`session:create`) and lacks `carwash:create/update` — a role problem, not a code one. The
|
||||||
|
WebSocket stays `report:read`-only by design; the desk polls. Recorded on [[shift]] §Tills
|
||||||
|
and [[venue-modules]] §Tills → As-built.
|
||||||
|
|
||||||
|
## [2026-09-05] ingest | Permissions matrix rethink — three moves built
|
||||||
|
|
||||||
|
User: "we opened Pandora's box with this car wash module … rethink the permissions matrix";
|
||||||
|
and "the user should have websocket for live events — this does not mean it can read
|
||||||
|
/reports". Decision recorded on [[venue-modules]] §"Permissions matrix" (open-questions
|
||||||
|
#16), then built: (1) per-desk till guards — manifest `tillGuards`, new `carwash:cash`,
|
||||||
|
`requireTill(kind)` resolves the guard from the till, `tillPermission`/`session:read`
|
||||||
|
borrowing removed; (2) jobs — manifest `jobs[]` (booth-operator, booth-supervisor,
|
||||||
|
merchant, wash-operator) as one-click chips in Setup → Roles with "mixes desks" / "partial
|
||||||
|
job" lints; (3) the live feed admits any WATCH permission (event/session/device read or a
|
||||||
|
module's `feedPermission`) and filters every push per role — `report:read` is the reports
|
||||||
|
screen only. 352/352 server tests; lavazhier (event:read) now shows LIVE. Their dev role
|
||||||
|
still needs `carwash:cash` (+ create/update) and should drop the booth permissions — the
|
||||||
|
"Wash operator" chip is exactly that.
|
||||||
|
|
||||||
|
## [2026-09-05] ingest | Role reassignment now takes effect without re-login
|
||||||
|
|
||||||
|
User: a user moved to a new "Lavazh NEW" role kept getting `403` on `POST /api/carwash/orders`.
|
||||||
|
Cause: the login token pins the `roleId` current at LOGIN; `/api/auth/me` read the user row (new
|
||||||
|
role) while every guard read the token (old role). Editing a role already took effect per
|
||||||
|
request (the permission cache); reassigning one did not. Fix in `auth.ts`: `refreshRole()` after
|
||||||
|
every `jwtVerify` resolves the user's CURRENT role from the DB (cached per user, cleared by
|
||||||
|
`bumpPermsCache()`, which the user update/delete routes now call); a deleted user's session
|
||||||
|
ends with 401 on its next request; the WS cookie path uses the same. Test: moved user creates
|
||||||
|
an order on the next request with the same cookie. Recorded on [[local-jwt-auth]].
|
||||||
|
|
||||||
|
## [2026-09-06] ingest | MODULES_ENTITLED never reached the container
|
||||||
|
|
||||||
|
User set park-2 to `MODULES_ENTITLED=parking`, re-synced, destroyed + redeployed the stack —
|
||||||
|
Lavazh still there. Cause: the variable was in the Komodo stack env and `.env.example` but not
|
||||||
|
in `docker-compose.yml`'s server `environment:` block, so the container never saw it; unset =
|
||||||
|
every module → every booth on 55d6242 had Car Wash entitled. Fix: compose forwards it with
|
||||||
|
default `parking,validation`. Troubleshoot on a booth with `docker exec … env | grep MODULES`
|
||||||
|
and the boot log line `venue modules (entitled = …; effective = …)`. Recorded on
|
||||||
|
[[venue-modules]] §As-built (deploy gotcha).
|
||||||
|
|
||||||
|
## [2026-09-06] ingest | Car Wash no longer depends on the validation module
|
||||||
|
|
||||||
|
User set `MODULES_ENTITLED=parking,carwash` on park-2 — no Lavazh. Cause: the manifest said
|
||||||
|
carwash `dependsOn: ["parking","validation"]`, so the effective set dropped it as dependency-
|
||||||
|
broken, and the program compose/read routes sat behind the validation module gate. That was a
|
||||||
|
design error: the discount ENGINE (validation program rows + `applyValidation()`) is core; the
|
||||||
|
`validation` module is only the merchant's scan screen. Fixed: `dependsOn: ["parking"]`; the
|
||||||
|
program routes are plain site:read/site:update; the merchant routes (mine/lookup/apply/void)
|
||||||
|
stay module-gated. Tests updated. Recorded on [[venue-modules]] (v1 answers item 4 + As-built).
|
||||||
|
|||||||
Reference in New Issue
Block a user