From 4e2e4feedb413049014cfeb5658d5f676f7f439d Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Thu, 18 Jun 2026 12:13:17 +0200 Subject: [PATCH] feat(shift): site-wide single-open shift + booth money-path gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shift becomes a SITE-WIDE accountability period — at most one open at a time — so every taking is unambiguously attributed to one operator. Login stays decoupled from shifts (an operator can log in off-shift to review). Backend: - ShiftService.currentOpenShift()/requireOpenShift(); open() refuses when ANY shift is open and throws ShiftAlreadyOpenError{heldBy} (self vs. other). - requireShift preHandler gates /api/pay, /api/exit, /api/voucher, /api/barrier/reopen → 409 {code:"no_shift"}; read-only lookups stay open. - GET /api/shift/current returns site-wide {open:{startedAt,operator},isMine}. - GET /api/events?since= for per-shift log scoping (db: re-export gte). Frontend: - Header shift button: open / close-mine / disabled-when-another-holds-it. - Pay/exit modal gate banner (one-click open; "held by X" when another's); pay/exit/voucher disabled until this operator's shift is open. - Active-Sessions barrier re-open gated the same way. - Live feed scoped to the open shift's window; shared useShift() Query invalidated over the WS on shift_open/shift_z_report/cash_movement. - sq/en strings for the control + gate. Wiki: shift.md (site-wide single-open + gate; superseded per-operator note), booth-console.md (header control + gate), log entry. Verified: site-wide invariant + heldBy + handover + chain integrity on a fresh migrated DB (11/11); db/server/web build clean. --- apps/server/src/routes/events.ts | 16 +++++-- apps/server/src/routes/pay.ts | 30 +++++++++++-- apps/server/src/routes/shift.ts | 18 +++++--- apps/server/src/server.ts | 14 +++--- apps/server/src/shift-service.ts | 50 ++++++++++++++++++++-- apps/web/src/ActiveSessions.tsx | 9 +++- apps/web/src/BoothPayModal.tsx | 60 +++++++++++++++++++++++++- apps/web/src/BoothScreen.tsx | 29 ++++++++++--- apps/web/src/api.ts | 19 +++++++-- apps/web/src/lib/i18n/en.ts | 14 ++++++ apps/web/src/lib/i18n/sq.ts | 18 +++++++- apps/web/src/lib/query.ts | 1 + apps/web/src/lib/use-live-feed.ts | 9 ++++ apps/web/src/lib/use-shift.ts | 42 ++++++++++++++++++ apps/web/src/router.tsx | 71 ++++++++++++++++++++++++++++++- packages/db/src/index.ts | 2 +- wiki/concepts/booth-console.md | 20 +++++++++ wiki/concepts/shift.md | 31 ++++++++++++-- wiki/log.md | 4 ++ 19 files changed, 415 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/lib/use-shift.ts diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts index 221d2fc..a563d11 100644 --- a/apps/server/src/routes/events.ts +++ b/apps/server/src/routes/events.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { desc, ledgerEvents, type Db } from "@parking/db"; +import { desc, gte, ledgerEvents, type Db } from "@parking/db"; import { requireRole } from "../auth.js"; import type { EventLog } from "../event-log.js"; @@ -17,12 +17,22 @@ export async function eventRoutes( const guard = requireRole("admin", "operator", "cashier", "readonly"); // Recent events, newest first. `limit` caps the page (default 100, max 1000). - app.get<{ Querystring: { limit?: string } }>( + // Optional `since` (ISO) scopes the page to events at/after that instant — the + // booth passes the current shift's start so the live feed shows ONLY this shift's + // activity (logs are per-shift, not all history). See wiki/concepts/shift.md. + app.get<{ Querystring: { limit?: string; since?: string } }>( "/api/events", { preHandler: guard }, async (req) => { const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); - const rows = db.select().from(ledgerEvents).orderBy(desc(ledgerEvents.index)).limit(limit).all(); + const since = (req.query.since ?? "").trim(); + const rows = db + .select() + .from(ledgerEvents) + .where(since ? gte(ledgerEvents.occurredAt, since) : undefined) + .orderBy(desc(ledgerEvents.index)) + .limit(limit) + .all(); return { events: rows }; }, ); diff --git a/apps/server/src/routes/pay.ts b/apps/server/src/routes/pay.ts index 364963d..81dc05b 100644 --- a/apps/server/src/routes/pay.ts +++ b/apps/server/src/routes/pay.ts @@ -8,6 +8,7 @@ import { type PayStation, } from "../pay-station.js"; import type { ExitFlow } from "../exit-flow.js"; +import { NoShiftOpenError, type ShiftService } from "../shift-service.js"; import { printExitVoucher } from "../booth-print.js"; // Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and — @@ -38,10 +39,31 @@ export async function payRoutes( db: Db, payStation: PayStation, exitFlow: ExitFlow, + shift: ShiftService, ): Promise { // Cashier/operator/admin operate the booth; readonly may not. const guard = requireRole("admin", "operator", "cashier"); + // Money-path gate: a shift must be open site-wide before any payment/exit/voucher/ + // re-open is processed, so every taking is attributed to a shift (one operator's + // accountability period). Read-only lookups (session/active/quote) stay ungated so + // the modal can still DISPLAY the session and prompt the operator to open a shift. + // Returns 409 { error, code: "no_shift" } so the UI can show the "open a shift" + // prompt rather than a generic failure. See wiki/concepts/shift.md. + const requireShift = async ( + _req: import("fastify").FastifyRequest, + reply: import("fastify").FastifyReply, + ) => { + try { + shift.requireOpenShift(); + } catch (err) { + if (err instanceof NoShiftOpenError) { + return reply.code(409).send({ error: err.message, code: "no_shift" }); + } + throw err; + } + }; + // Active sessions for the booth list: still-open OR exited-but-within-grace // (barrier unconfirmed → a paid/exited car is presumed possibly-present until // grace expires). Read-only. See wiki/concepts/booth-exit-flow.md. @@ -69,7 +91,7 @@ export async function payRoutes( // - clean exit → 200 { opened:true }. app.post<{ Body: ExitBody }>( "/api/exit", - { preHandler: guard }, + { preHandler: [guard, requireShift] }, async (req, reply) => { const identity = (req.body?.identity ?? "").trim(); if (!identity) return reply.code(400).send({ error: "identity required" }); @@ -85,7 +107,7 @@ export async function payRoutes( // (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md. app.post<{ Body: ExitBody }>( "/api/barrier/reopen", - { preHandler: guard }, + { preHandler: [guard, requireShift] }, async (req, reply) => { const identity = (req.body?.identity ?? "").trim(); if (!identity) return reply.code(400).send({ error: "identity required" }); @@ -114,7 +136,7 @@ export async function payRoutes( // Pay: take payment and append the signed `payment` event. app.post<{ Body: PayBody }>( "/api/pay", - { preHandler: guard }, + { preHandler: [guard, requireShift] }, async (req, reply) => { const { identity, tender, overrideMinor } = req.body ?? {}; if (!identity || (tender !== "cash" && tender !== "card")) { @@ -138,7 +160,7 @@ export async function payRoutes( // session to be PAID (no free vouchers for unpaid sessions). See booth-exit-flow.md. app.post<{ Body: VoucherBody }>( "/api/voucher", - { preHandler: guard }, + { preHandler: [guard, requireShift] }, async (req, reply) => { const identity = (req.body?.identity ?? "").trim(); if (!identity) return reply.code(400).send({ error: "identity required" }); diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts index af7a2d7..6f9690c 100644 --- a/apps/server/src/routes/shift.ts +++ b/apps/server/src/routes/shift.ts @@ -22,15 +22,21 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr // Cashier/operator/admin run shifts; readonly can't. const guard = requireRole("admin", "operator", "cashier"); - // Is the current operator's shift open? (For the UI to show Start vs. End.) - // Also returns the live drawer balance so the UI can show what's in the till. + // 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: guard }, async (req) => { - const operator = req.user.username; - const open = shift.openShiftFor(operator); + const me = req.user.username; + const open = shift.currentOpenShift(); + const heldBy = open?.identity ?? null; const drawer = shift.drawerBalance(); return { - operator, - open: open ? { startedAt: open.occurredAt } : null, + operator: me, + open: open ? { startedAt: open.occurredAt, operator: heldBy } : null, + isMine: open != null && heldBy === me, drawerMinor: drawer.balanceMinor, currency: drawer.currency, }; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 39812ba..e637ef9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -132,10 +132,16 @@ export async function buildServer(opts: BuildOptions = {}): Promise r.type === "shift_open" || r.type === "shift_z_report"); + const last = rows[rows.length - 1]; + return last && last.type === "shift_open" ? last : null; + } + + /** Require an open shift for the booth money path; returns it or throws. */ + requireOpenShift() { + const open = this.currentOpenShift(); + if (!open) throw new NoShiftOpenError(); + return open; + } + /** * The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not * by operator — a cash_movement is the admin's, not the shift operator's). Cash @@ -149,7 +189,11 @@ export class ShiftService { /** Open a shift for the operator (explicit start). The opening float is auto- * inherited from the chain = the drawer balance at the start instant. */ async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> { - if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator); + // Site-wide single-open invariant: refuse if ANY shift is open — whether this + // operator's own (double-open) or another operator's (handover not done). Only + // one accountability period at a time. + const current = this.currentOpenShift(); + if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator); const startedAt = new Date().toISOString(); const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt); await this.#log.append({ diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx index 0d0e012..e093df6 100644 --- a/apps/web/src/ActiveSessions.tsx +++ b/apps/web/src/ActiveSessions.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js"; import { qk } from "./lib/query.js"; +import { useShift } from "./lib/use-shift.js"; import { formatDuration, formatTime } from "./lib/format.js"; import { Panel } from "./ui/Panel.js"; @@ -24,6 +25,10 @@ function statusBadge(s: ActiveSession): { key: string; cls: string } { export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) { const { t } = useTranslation(); const qc = useQueryClient(); + // The audited barrier re-open is a money-path action (server-gated on an open + // shift); disable it unless this operator's shift is open. + const { isOpen: shiftOpen, isMine: shiftMine } = useShift(); + const shiftReady = shiftOpen && shiftMine; const { data, isLoading } = useQuery({ queryKey: qk.activeSessions, queryFn: fetchActiveSessions, @@ -97,10 +102,10 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void {s.paidAt ? ( diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 5d6a3d9..1df944f 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -6,11 +6,13 @@ import { boothExit, fetchSiteConfig, lookupSession, + openShift, paySession, printVoucher, type SessionLookup, } from "./api.js"; import { qk } from "./lib/query.js"; +import { useShift } from "./lib/use-shift.js"; import { formatDuration, formatMoney, formatTime } from "./lib/format.js"; import { SnapshotStrip } from "./ui/SnapshotStrip.js"; @@ -28,18 +30,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose const session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) }); const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig }); + // A shift must be open (and mine) before any pay/exit/voucher action — the booth + // money path is gated. The server enforces this too (409 no_shift); the modal + // surfaces it up front and offers a one-click open. See wiki/concepts/shift.md. + const { isOpen: shiftOpen, isMine: shiftMine, blockedByOther, heldBy } = useShift(); + const shiftReady = shiftOpen && shiftMine; + const [tender, setTender] = useState<"cash" | "card">("cash"); const [printVoucherChecked, setPrintVoucherChecked] = useState(null); const [phase, setPhase] = useState("review"); const [error, setError] = useState(null); const [result, setResult] = useState(null); + const [openingShift, setOpeningShift] = useState(false); const s: SessionLookup | undefined = session.data; // Checkbox default comes from config the first time it loads; operator can toggle. const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false; const alreadyPaid = s?.paidAt != null; - const canPay = s?.found && s.open && !alreadyPaid; + const canPay = shiftReady && s?.found && s.open && !alreadyPaid; + + async function handleOpenShift() { + setOpeningShift(true); + setError(null); + try { + await openShift(); + void qc.invalidateQueries({ queryKey: qk.shift }); + void qc.invalidateQueries({ queryKey: qk.events }); + } catch (e) { + setError((e as Error).message); + } finally { + setOpeningShift(false); + } + } async function handlePayAndExit() { if (!s) return; @@ -91,6 +114,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
+ {/* Shift gate — block all actions until THIS operator has a shift open. + Another operator's open shift can't be operated under (no shared + till); only an "open mine" path when no shift is open at all. */} + {!shiftReady && ( +
+ {blockedByOther ? ( + <> +
+ {t("shift.gateOtherTitle")} +
+
+ {t("shift.gateOtherBody", { operator: heldBy ?? "?" })} +
+ + ) : ( + <> +
+ {t("shift.gateTitle")} +
+
{t("shift.gateBody")}
+ + + )} +
+ )} + {session.isLoading &&
{t("pay.lookingUp")}
} {s && !s.found && ( @@ -200,7 +256,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose + {!isOpen && ( + {t("shift.headerNoShift")} + )} + {err && {err}} +
+ ); +} + function RootLayout() { const { user, setUser } = rootRoute.useRouteContext(); const { t } = useTranslation(); @@ -102,6 +168,7 @@ function RootLayout() { {isAdmin && }
+ {user && } {user && } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index f426f98..9751460 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -5,7 +5,7 @@ import * as schema from "./schema.js"; export * from "./schema.js"; // Re-export the query helpers consumers need, so they don't depend on // drizzle-orm directly (it's an implementation detail of this package). -export { eq, and, desc, sql } from "drizzle-orm"; +export { eq, and, desc, gte, sql } from "drizzle-orm"; /** * Open the local SQLite database in WAL mode. WAL allows many concurrent readers diff --git a/wiki/concepts/booth-console.md b/wiki/concepts/booth-console.md index 5f362a6..6d12f71 100644 --- a/wiki/concepts/booth-console.md +++ b/wiki/concepts/booth-console.md @@ -63,6 +63,26 @@ a right column with the **live event ticker**. Submitting/clicking a ticket open modal** (entry/duration/total, tender, voucher checkbox, entry/exit snapshots). All live-refreshed via the WS. +## The shift control (header) + the booth gate + +The header carries a single **shift button** that expresses the [[shift|site-wide single-open +shift]] (added 2026-06-18): + +- **No shift open** → "Open shift" (green, enabled). +- **My shift open** → "Close shift" (red, enabled — signs + prints the Z-report). +- **Another operator's shift open** → **disabled**, titled with who holds it. You can neither open + yours nor close theirs until they hand over. + +State comes from one shared Query (`useShift()` → `GET /api/shift/current`, returning `{ open: +{startedAt, operator} | null, isMine }`); the WS invalidates it on `shift_open` / `shift_z_report` / +`cash_movement`, so the button (and the per-shift log scope) update live without polling. + +The **booth screen gates on this**: the pay/exit modal shows an "open a shift" banner (with a +one-click *Open shift now*) and disables pay/exit/voucher until **this operator's** shift is open; +the Active-Sessions "Open barrier" is disabled the same way. The server enforces it regardless +(`requireShift` 409 `no_shift`) — the UI just front-runs the rejection. The live feed is **scoped to +the open shift's window** (empty when no shift is open). See [[shift]] for the rule and the routes. + ## Dev notes - Vite proxies `/api/ws` (`ws: true`) to the backend; the backend's Origin allowlist must include the dev SPA origin (`WS_ALLOWED_ORIGINS=http://localhost:5173`). In production Fastify serves the SPA diff --git a/wiki/concepts/shift.md b/wiki/concepts/shift.md index a57f333..f91c1d5 100644 --- a/wiki/concepts/shift.md +++ b/wiki/concepts/shift.md @@ -2,7 +2,7 @@ type: concept tags: [parking, domain, business, shifts, anti-fraud] sources: [] -updated: 2026-06-15 +updated: 2026-06-18 status: open --- @@ -21,6 +21,29 @@ is **no operator and no shift**; what replaces it is the pay station's **cash-co [[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation; don't force one model across both. +## Site-wide single-open + the booth gate (decided + built 2026-06-18) + +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 +falls inside exactly one operator's window. Consequences: + +- **Login ≠ shift.** An operator may log in **off-shift** (e.g. to review their own past activity); + logging in never opens a shift. Conversely a shift can't be opened by two people at once. +- **Opening is refused when ANY shift is open** — whether the operator's own (double-open) or + *another* operator's (handover not done). `ShiftService.open()` checks `currentOpenShift()` (the + single site-wide open shift = most recent shift event on the whole chain is a `shift_open`), and + throws `ShiftAlreadyOpenError` carrying `heldBy` so the UI can name who holds it. Operator B can + only start once operator A closes — that's the handover. +- **The booth money path is GATED on an open shift.** `/api/pay`, `/api/exit`, `/api/voucher`, + `/api/barrier/reopen` run a `requireShift` preHandler that 409s `{ code: "no_shift" }` when none + is open. Read-only lookups (`/api/session/:id`, `/api/sessions/active`, `/api/pay/quote`) stay + ungated so the modal can still *display* a session and prompt "open a shift". The server is the + enforcement point; the UI mirrors it (see [[booth-console]]). +- **"Operate under someone else's shift" is deliberately disallowed.** B's takings would land in A's + Z-report and corrupt the attribution, so B is fully blocked until B's own shift is open. +- **Logs are per-shift.** The booth live feed shows only events from the open shift's window + (`GET /api/events?since=`); no shift open → no feed, just the "open a shift" prompt. + ## A shift is NOT time-based It is delimited by **explicit operator action**, never by a clock: @@ -56,8 +79,10 @@ no variance gate, no manager override. - A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the - event `identity`; a shift is **open** iff that operator's most recent shift event is a - `shift_open`. `ShiftService` (`apps/server/src/shift-service.ts`). + event `identity`. `ShiftService` (`apps/server/src/shift-service.ts`). + > **Superseded 2026-06-18:** open-ness is now judged **site-wide** (`currentOpenShift()` — the most + > recent shift event on the *whole* chain), not per-operator. See "Site-wide single-open" above. + > `openShiftFor(operator)` survives only for `close()` (you close your own shift). - **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt diff --git a/wiki/log.md b/wiki/log.md index 5dcb39c..e0e91a7 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -796,3 +796,7 @@ Two languages via react-i18next, Albanian default/fallback. Language is a per-us ## [2026-06-18] lint | Reconcile wiki with the session's work Audited wiki vs. the session: three major builds (live WebSocket, frontend foundation, i18n) had NO log entry and NO concept page. Filed [[i18n]] (resolved a dangling code-comment link) and [[booth-console]] (operator-UI architecture: stack, /api/ws live feed, anti-CSWSH, booth screen). Updated stale [[react-vite-spa]] (the "plain React, no framework" claim is now qualified). Backfilled the three missing build log entries. Standing gaps flagged across pages: NO automated tests (front or back); ATECC608 not yet wired (software-HMAC signing is tamper-evident, not tamper-proof); pre-existing admin screens not on the terminal theme. + +## [2026-06-18] ingest | Shift gating — site-wide single-open, booth money-path gate, per-shift logs + +Built the shift-enforcement model. A shift is now **site-wide single-open** (was per-operator): `ShiftService.currentOpenShift()` reads the most recent shift event on the whole chain; `open()` refuses if ANY shift is open and throws `ShiftAlreadyOpenError{heldBy}`. Login stays decoupled from shifts (operator can log in off-shift to review). The booth money path is **gated**: `/api/pay`, `/api/exit`, `/api/voucher`, `/api/barrier/reopen` get a `requireShift` preHandler → 409 `{code:"no_shift"}`; read-only lookups stay open so the modal can display + prompt. `GET /api/shift/current` now returns the site-wide `{open:{startedAt,operator},isMine}`. Logs are **per-shift** via `GET /api/events?since=`. UI: header shift button (open / close-mine / disabled-when-other), pay-modal gate banner with one-click open, gated Active-Sessions re-open, shift-scoped live feed; shared `useShift()` Query invalidated by the WS on shift/cash events. Updated [[shift]] (new "Site-wide single-open + booth gate" section; superseded the per-operator as-built note) and [[booth-console]] (header control + gate). Verified the invariant + chain integrity on a fresh migrated DB (11/11 assertions). Builds clean across db/server/web.