feat(shift): site-wide single-open shift + booth money-path gate

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=<iso> 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.
This commit is contained in:
2026-06-18 12:13:17 +02:00
parent 48660d3ec8
commit 4e2e4feedb
19 changed files with 415 additions and 42 deletions
+13 -3
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify"; 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 { requireRole } from "../auth.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
@@ -17,12 +17,22 @@ export async function eventRoutes(
const guard = requireRole("admin", "operator", "cashier", "readonly"); const guard = requireRole("admin", "operator", "cashier", "readonly");
// Recent events, newest first. `limit` caps the page (default 100, max 1000). // 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", "/api/events",
{ preHandler: guard }, { preHandler: guard },
async (req) => { async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const 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 }; return { events: rows };
}, },
); );
+26 -4
View File
@@ -8,6 +8,7 @@ import {
type PayStation, type PayStation,
} from "../pay-station.js"; } from "../pay-station.js";
import type { ExitFlow } from "../exit-flow.js"; import type { ExitFlow } from "../exit-flow.js";
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
import { printExitVoucher } from "../booth-print.js"; import { printExitVoucher } from "../booth-print.js";
// Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and — // Booth endpoints (pay-on-foot): look up a session, quote it, take payment, and —
@@ -38,10 +39,31 @@ export async function payRoutes(
db: Db, db: Db,
payStation: PayStation, payStation: PayStation,
exitFlow: ExitFlow, exitFlow: ExitFlow,
shift: ShiftService,
): Promise<void> { ): Promise<void> {
// Cashier/operator/admin operate the booth; readonly may not. // Cashier/operator/admin operate the booth; readonly may not.
const guard = requireRole("admin", "operator", "cashier"); 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 // Active sessions for the booth list: still-open OR exited-but-within-grace
// (barrier unconfirmed → a paid/exited car is presumed possibly-present until // (barrier unconfirmed → a paid/exited car is presumed possibly-present until
// grace expires). Read-only. See wiki/concepts/booth-exit-flow.md. // grace expires). Read-only. See wiki/concepts/booth-exit-flow.md.
@@ -69,7 +91,7 @@ export async function payRoutes(
// - clean exit → 200 { opened:true }. // - clean exit → 200 { opened:true }.
app.post<{ Body: ExitBody }>( app.post<{ Body: ExitBody }>(
"/api/exit", "/api/exit",
{ preHandler: guard }, { preHandler: [guard, requireShift] },
async (req, reply) => { async (req, reply) => {
const identity = (req.body?.identity ?? "").trim(); const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" }); if (!identity) return reply.code(400).send({ error: "identity required" });
@@ -85,7 +107,7 @@ export async function payRoutes(
// (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md. // (no-unpaid-bypass). See wiki/concepts/booth-exit-flow.md.
app.post<{ Body: ExitBody }>( app.post<{ Body: ExitBody }>(
"/api/barrier/reopen", "/api/barrier/reopen",
{ preHandler: guard }, { preHandler: [guard, requireShift] },
async (req, reply) => { async (req, reply) => {
const identity = (req.body?.identity ?? "").trim(); const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" }); if (!identity) return reply.code(400).send({ error: "identity required" });
@@ -114,7 +136,7 @@ export async function payRoutes(
// Pay: take payment and append the signed `payment` event. // Pay: take payment and append the signed `payment` event.
app.post<{ Body: PayBody }>( app.post<{ Body: PayBody }>(
"/api/pay", "/api/pay",
{ preHandler: guard }, { preHandler: [guard, requireShift] },
async (req, reply) => { async (req, reply) => {
const { identity, tender, overrideMinor } = req.body ?? {}; const { identity, tender, overrideMinor } = req.body ?? {};
if (!identity || (tender !== "cash" && tender !== "card")) { 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. // session to be PAID (no free vouchers for unpaid sessions). See booth-exit-flow.md.
app.post<{ Body: VoucherBody }>( app.post<{ Body: VoucherBody }>(
"/api/voucher", "/api/voucher",
{ preHandler: guard }, { preHandler: [guard, requireShift] },
async (req, reply) => { async (req, reply) => {
const identity = (req.body?.identity ?? "").trim(); const identity = (req.body?.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" }); if (!identity) return reply.code(400).send({ error: "identity required" });
+12 -6
View File
@@ -22,15 +22,21 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
// Cashier/operator/admin run shifts; readonly can't. // Cashier/operator/admin run shifts; readonly can't.
const guard = requireRole("admin", "operator", "cashier"); const guard = requireRole("admin", "operator", "cashier");
// Is the current operator's shift open? (For the UI to show Start vs. End.) // The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
// Also returns the live drawer balance so the UI can show what's in the till. // 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) => { app.get("/api/shift/current", { preHandler: guard }, async (req) => {
const operator = req.user.username; const me = req.user.username;
const open = shift.openShiftFor(operator); const open = shift.currentOpenShift();
const heldBy = open?.identity ?? null;
const drawer = shift.drawerBalance(); const drawer = shift.drawerBalance();
return { return {
operator, operator: me,
open: open ? { startedAt: open.occurredAt } : null, open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
isMine: open != null && heldBy === me,
drawerMinor: drawer.balanceMinor, drawerMinor: drawer.balanceMinor,
currency: drawer.currency, currency: drawer.currency,
}; };
+9 -5
View File
@@ -132,10 +132,16 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md. // and replies the SDK verdict. See wiki/entities/gee-qr-er80.md, qrcode-sdk.md.
await qrReaderRoutes(app, db, readDispatcher); await qrReaderRoutes(app, db, readDispatcher);
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
// (sum payments by tender, print the Z-report). Constructed before the pay routes
// because the booth money path is GATED on an open shift. See wiki/concepts/shift.md.
const shiftService = new ShiftService(db, eventLog, app.log);
// Pay station (pay-on-foot): quote an open session against the active tariff + // Pay station (pay-on-foot): quote an open session against the active tariff +
// take payment → signed `payment` event. See wiki/concepts/tariff.md. // take payment → signed `payment` event. The booth pay/exit/voucher/re-open
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
const payStation = new PayStation(db, eventLog, app.log); const payStation = new PayStation(db, eventLog, app.log);
await payRoutes(app, db, payStation, exitFlow); await payRoutes(app, db, payStation, exitFlow, shiftService);
// Tariff composer: admin publishes effective-dated, immutable rate-card versions // Tariff composer: admin publishes effective-dated, immutable rate-card versions
// the pay station prices against. See wiki/concepts/tariff.md. // the pay station prices against. See wiki/concepts/tariff.md.
@@ -144,9 +150,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Permit (subscription) admin CRUD. See wiki/entities/permit.md. // Permit (subscription) admin CRUD. See wiki/entities/permit.md.
await permitRoutes(app, db); await permitRoutes(app, db);
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report // Shift open/close + drawer endpoints (shiftService constructed above).
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
const shiftService = new ShiftService(db, eventLog, app.log);
await shiftRoutes(app, shiftService); await shiftRoutes(app, shiftService);
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry // Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
+47 -3
View File
@@ -11,9 +11,16 @@ import type { EventLog } from "./event-log.js";
// See wiki/concepts/shift.md. // See wiki/concepts/shift.md.
export class ShiftAlreadyOpenError extends Error { export class ShiftAlreadyOpenError extends Error {
constructor(operator: string) { /** The operator who currently holds the open shift (may be someone else). */
super(`operator ${operator} already has an open shift`); readonly heldBy: string;
constructor(operator: string, heldBy: string) {
super(
heldBy === operator
? `operator ${operator} already has an open shift`
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
);
this.name = "ShiftAlreadyOpenError"; this.name = "ShiftAlreadyOpenError";
this.heldBy = heldBy;
} }
} }
export class NoOpenShiftError extends Error { export class NoOpenShiftError extends Error {
@@ -22,6 +29,14 @@ export class NoOpenShiftError extends Error {
this.name = "NoOpenShiftError"; this.name = "NoOpenShiftError";
} }
} }
/** Thrown by the booth money path when NO shift is open site-wide — an operator
* must open a shift before any payment/exit can be attributed to a shift. */
export class NoShiftOpenError extends Error {
constructor() {
super("no shift is open — open a shift before processing tickets");
this.name = "NoShiftOpenError";
}
}
export interface ShiftReport { export interface ShiftReport {
readonly operator: string; readonly operator: string;
@@ -82,6 +97,31 @@ export class ShiftService {
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
* period: at most ONE may be open at a time (so booth takings are unambiguously
* attributed to one operator). It's open iff the most recent shift event on the
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
* appended yet). Returns that row so callers can read its operator/startedAt.
*/
currentOpenShift() {
const rows = this.#db
.select()
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter((r) => 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 * 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 * 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- /** Open a shift for the operator (explicit start). The opening float is auto-
* inherited from the chain = the drawer balance at the start instant. */ * inherited from the chain = the drawer balance at the start instant. */
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> { async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
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 startedAt = new Date().toISOString();
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt); const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
await this.#log.append({ await this.#log.append({
+7 -2
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js"; import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
import { qk } from "./lib/query.js"; import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatTime } from "./lib/format.js"; import { formatDuration, formatTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.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 }) { export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// The audited barrier re-open is a money-path action (server-gated on an open
// shift); disable it unless this operator's shift is open.
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
const shiftReady = shiftOpen && shiftMine;
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: qk.activeSessions, queryKey: qk.activeSessions,
queryFn: fetchActiveSessions, queryFn: fetchActiveSessions,
@@ -97,10 +102,10 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
{s.paidAt ? ( {s.paidAt ? (
<button <button
type="button" type="button"
disabled={reopen.isPending} disabled={reopen.isPending || !shiftReady}
onClick={() => handleReopen(s)} onClick={() => handleReopen(s)}
className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50" className="shrink-0 rounded-term border border-term-cyan px-2 py-0.5 text-[10px] uppercase tracking-wider text-term-cyan hover:bg-term-cyan/10 disabled:opacity-50"
title={t("booth.openBarrierTitle")} title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
> >
{t("booth.openBarrier")} {t("booth.openBarrier")}
</button> </button>
+58 -2
View File
@@ -6,11 +6,13 @@ import {
boothExit, boothExit,
fetchSiteConfig, fetchSiteConfig,
lookupSession, lookupSession,
openShift,
paySession, paySession,
printVoucher, printVoucher,
type SessionLookup, type SessionLookup,
} from "./api.js"; } from "./api.js";
import { qk } from "./lib/query.js"; import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatTime } from "./lib/format.js"; import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.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 session = useQuery({ queryKey: ["session", identity], queryFn: () => lookupSession(identity) });
const config = useQuery({ queryKey: qk.siteConfig, queryFn: fetchSiteConfig }); 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 [tender, setTender] = useState<"cash" | "card">("cash");
const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null); const [printVoucherChecked, setPrintVoucherChecked] = useState<boolean | null>(null);
const [phase, setPhase] = useState<Phase>("review"); const [phase, setPhase] = useState<Phase>("review");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null); const [result, setResult] = useState<string | null>(null);
const [openingShift, setOpeningShift] = useState(false);
const s: SessionLookup | undefined = session.data; const s: SessionLookup | undefined = session.data;
// Checkbox default comes from config the first time it loads; operator can toggle. // Checkbox default comes from config the first time it loads; operator can toggle.
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false; const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
const alreadyPaid = s?.paidAt != null; 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() { async function handlePayAndExit() {
if (!s) return; if (!s) return;
@@ -91,6 +114,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</div> </div>
<div className="flex flex-col gap-3 p-4"> <div className="flex flex-col gap-3 p-4">
{/* 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 && (
<div className="rounded-term border border-term-amber bg-term-amber/5 px-3 py-2">
{blockedByOther ? (
<>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateOtherTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">
{t("shift.gateOtherBody", { operator: heldBy ?? "?" })}
</div>
</>
) : (
<>
<div className="text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{t("shift.gateTitle")}
</div>
<div className="mt-1 text-[12px] text-term-text">{t("shift.gateBody")}</div>
<button
type="button"
onClick={handleOpenShift}
disabled={openingShift}
className="mt-2 rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
>
{openingShift ? t("shift.opening") : t("shift.openNow")}
</button>
</>
)}
</div>
)}
{session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>} {session.isLoading && <div className="text-term-muted">{t("pay.lookingUp")}</div>}
{s && !s.found && ( {s && !s.found && (
@@ -200,7 +256,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<button <button
type="button" type="button"
onClick={handlePayAndExit} onClick={handlePayAndExit}
disabled={phase === "paying" || phase === "finishing"} disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50" className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
> >
{phase === "paying" {phase === "paying"
+24 -5
View File
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js"; import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
import { qk } from "./lib/query.js"; import { qk } from "./lib/query.js";
import { useLiveStore } from "./lib/live-store.js"; import { useLiveStore } from "./lib/live-store.js";
import { useShift } from "./lib/use-shift.js";
import { Panel } from "./ui/Panel.js"; import { Panel } from "./ui/Panel.js";
import { StatusDot } from "./ui/StatusDot.js"; import { StatusDot } from "./ui/StatusDot.js";
import { BoothPayModal } from "./BoothPayModal.js"; import { BoothPayModal } from "./BoothPayModal.js";
@@ -124,9 +125,19 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
export function BoothScreen() { export function BoothScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
// Initial load via Query (also the fallback if the WS is briefly down). // The site-wide shift drives the log scope: the feed shows ONLY the open shift's
// window (per-shift logs, not all history). When no shift is open, the feed is
// empty and the operator is prompted to open one.
const { isOpen: shiftOpen, startedAt: shiftStart } = useShift();
// Initial load via Query (also the fallback if the WS is briefly down). The events
// query is scoped to the current shift's start so it never shows prior shifts.
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy }); const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
const eventsQuery = useQuery({ queryKey: qk.events, queryFn: () => fetchEvents(100) }); const eventsQuery = useQuery({
queryKey: [...qk.events, shiftStart ?? "none"],
queryFn: () => fetchEvents(100, shiftStart ?? undefined),
enabled: shiftOpen,
});
// The ticket currently open in the pay/exit modal (null = no modal). // The ticket currently open in the pay/exit modal (null = no modal).
const [activeTicket, setActiveTicket] = useState<string | null>(null); const [activeTicket, setActiveTicket] = useState<string | null>(null);
@@ -138,10 +149,16 @@ export function BoothScreen() {
// Prefer the live-pushed occupancy; fall back to the query. // Prefer the live-pushed occupancy; fall back to the query.
const occ = liveOcc ?? occQuery.data ?? null; const occ = liveOcc ?? occQuery.data ?? null;
// Merge: live events first (newest), then the queried history, de-duped by id. // Merge: live events first (newest), then the queried history, de-duped by id —
// then clip to the current shift window (the live store spans shifts; the feed
// must not show events from before this shift's start). No shift → no feed.
const seen = new Set(liveFeed.map((e) => e.id)); const seen = new Set(liveFeed.map((e) => e.id));
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id)); const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
const events = [...liveFeed, ...history].slice(0, 200); const merged = [...liveFeed, ...history].slice(0, 200);
const events =
shiftOpen && shiftStart
? merged.filter((e) => e.occurredAt >= shiftStart)
: [];
return ( return (
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]"> <div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
@@ -176,7 +193,9 @@ export function BoothScreen() {
className="min-h-0" className="min-h-0"
> >
<div className="h-full overflow-y-auto pr-1"> <div className="h-full overflow-y-auto pr-1">
{events.length === 0 ? ( {!shiftOpen ? (
<div className="text-term-amber">{t("shift.gateTitle")}</div>
) : events.length === 0 ? (
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div> <div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
) : ( ) : (
events.map((e) => <EventRow key={e.id} e={e} />) events.map((e) => <EventRow key={e.id} e={e} />)
+15 -4
View File
@@ -317,8 +317,12 @@ export function deletePermit(id: string): Promise<void> {
// --- Shifts --------------------------------------------------------------- // --- Shifts ---------------------------------------------------------------
export interface ShiftStatus { export interface ShiftStatus {
/** The requesting (logged-in) operator. */
operator: string; operator: string;
open: { startedAt: string } | null; /** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
open: { startedAt: string; operator: string | null } | null;
/** True iff the open shift belongs to the requesting operator (can close it). */
isMine: boolean;
/** Live physical drawer balance (cash payments + cash movements). */ /** Live physical drawer balance (cash payments + cash movements). */
drawerMinor: number; drawerMinor: number;
currency: string | null; currency: string | null;
@@ -394,9 +398,16 @@ export function fetchOccupancy(): Promise<Occupancy> {
export type { LedgerEvent } from "@parking/shared"; export type { LedgerEvent } from "@parking/shared";
/** Recent ledger events, newest first (default 100, max 1000). Used for the /** Recent ledger events, newest first (default 100, max 1000). Used for the
* booth feed's initial load; live updates then arrive over the WS. */ * booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
export function fetchEvents(limit = 100): Promise<{ events: import("@parking/shared").LedgerEvent[] }> { * scopes to events at/after that instant — the booth passes the current shift's
return apiFetch(`/api/events?limit=${limit}`); * start so the feed shows ONLY this shift's activity. */
export function fetchEvents(
limit = 100,
since?: string,
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
const qs = new URLSearchParams({ limit: String(limit) });
if (since) qs.set("since", since);
return apiFetch(`/api/events?${qs.toString()}`);
} }
// --- Booth: session lookup, payment, exit --------------------------------- // --- Booth: session lookup, payment, exit ---------------------------------
+14
View File
@@ -183,6 +183,20 @@ export const en: Catalog = {
expectedDrawer: "Expected drawer:", expectedDrawer: "Expected drawer:",
printedToReceipt: "Printed to booth receipt.", printedToReceipt: "Printed to booth receipt.",
recordedNoPrinter: "Recorded (no printer to print to).", recordedNoPrinter: "Recorded (no printer to print to).",
// Header shift control + the booth shift gate.
headerNoShift: "No shift",
headerOpen: "Open shift",
headerClose: "Close shift",
headerHeldBy: "Shift open — {{operator}}",
headerHeldByShort: "Shift: {{operator}}",
gateTitle: "Open a shift to process tickets",
gateBody:
"No shift is open. Open your shift so payments and exits are recorded against it.",
gateOtherTitle: "The open shift belongs to another operator",
gateOtherBody:
"{{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",
opening: "Opening…",
}, },
pay: { pay: {
ticket: "Ticket", ticket: "Ticket",
+16 -2
View File
@@ -28,7 +28,7 @@ export const sq = {
site: "Vendi", site: "Vendi",
}, },
status: { status: {
live: "DREJTPËRDREJT", live: "LIVE",
connecting: "DUKE U LIDHUR", connecting: "DUKE U LIDHUR",
offline: "JASHTË LINJE", offline: "JASHTË LINJE",
}, },
@@ -43,7 +43,7 @@ export const sq = {
uncapped: "pa kufi", uncapped: "pa kufi",
free: "lirë", free: "lirë",
lotFull: "● parkimi plot", lotFull: "● parkimi plot",
liveFeed: "Aktiviteti i drejtpërdrejtë", liveFeed: "Aktiviteti live",
events: "ngjarje", events: "ngjarje",
noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.", noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.",
activeSessions: "Sesionet aktive", activeSessions: "Sesionet aktive",
@@ -185,6 +185,20 @@ export const sq = {
expectedDrawer: "Arka e pritshme:", expectedDrawer: "Arka e pritshme:",
printedToReceipt: "Printuar te printeri i kabinës.", printedToReceipt: "Printuar te printeri i kabinës.",
recordedNoPrinter: "Regjistruar (pa printer për të printuar).", recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
// Header shift control + the booth shift gate.
headerNoShift: "Asnjë turn",
headerOpen: "Hap turnin",
headerClose: "Mbyll turnin",
headerHeldBy: "Turn i hapur nga {{operator}}",
headerHeldByShort: "Turni: {{operator}}",
gateTitle: "Hap një turn për të proceduar biletat",
gateBody:
"Asnjë turn nuk është i hapur. Hap turnin tënd që pagesat dhe daljet të regjistrohen te ky turn.",
gateOtherTitle: "Turni i hapur i përket një operatori tjetër",
gateOtherBody:
"{{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",
opening: "Duke hapur…",
}, },
pay: { pay: {
ticket: "Bileta", ticket: "Bileta",
+1
View File
@@ -25,4 +25,5 @@ export const qk = {
events: ["events"] as const, events: ["events"] as const,
activeSessions: ["active-sessions"] as const, activeSessions: ["active-sessions"] as const,
siteConfig: ["site-config"] as const, siteConfig: ["site-config"] as const,
shift: ["shift"] as const,
} as const; } as const;
+9
View File
@@ -64,6 +64,15 @@ export function useLiveFeed(): void {
void qc.invalidateQueries({ queryKey: qk.events }); void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy }); void qc.invalidateQueries({ queryKey: qk.occupancy });
void qc.invalidateQueries({ queryKey: qk.activeSessions }); void qc.invalidateQueries({ queryKey: qk.activeSessions });
// A shift open/close (or a drawer movement) changes the header control
// state and the per-shift log window — refresh the shift status too.
if (
msg.event.type === "shift_open" ||
msg.event.type === "shift_z_report" ||
msg.event.type === "cash_movement"
) {
void qc.invalidateQueries({ queryKey: qk.shift });
}
} else if (msg.kind === "printer-status") { } else if (msg.kind === "printer-status") {
void qc.invalidateQueries({ queryKey: ["printers"] }); void qc.invalidateQueries({ queryKey: ["printers"] });
} }
+42
View File
@@ -0,0 +1,42 @@
import { useQuery } from "@tanstack/react-query";
import { fetchShift, type ShiftStatus } from "../api.js";
import { qk } from "./query.js";
// 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
// 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
// invalidates qk.shift on shift_open/shift_z_report/cash_movement, so this stays
// live without polling. See wiki/concepts/shift.md.
export interface ShiftState {
/** Raw status from the server (null while loading / on error). */
status: ShiftStatus | undefined;
/** Is ANY shift open site-wide? */
isOpen: boolean;
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
isMine: boolean;
/** A shift is open but belongs to someone else — this operator is blocked. */
blockedByOther: boolean;
/** ISO start of the open shift, for scoping the per-shift log. */
startedAt: string | null;
/** Whoever holds the open shift (for "held by X" messaging). */
heldBy: string | null;
isLoading: boolean;
}
export function useShift(): ShiftState {
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift });
const s = q.data;
const isOpen = s?.open != null;
const isMine = s?.isMine ?? false;
return {
status: s,
isOpen,
isMine,
blockedByOther: isOpen && !isMine,
startedAt: s?.open?.startedAt ?? null,
heldBy: s?.open?.operator ?? null,
isLoading: q.isLoading,
};
}
+69 -2
View File
@@ -6,12 +6,15 @@ import {
Outlet, Outlet,
redirect, redirect,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import type { Lang, SessionUser } from "./api.js"; import type { Lang, SessionUser } from "./api.js";
import { logout, setLanguagePref } from "./api.js"; import { closeShift, logout, openShift, setLanguagePref } from "./api.js";
import { queryClient } from "./lib/query.js"; import { qk, queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js"; import { setLanguage } from "./lib/i18n/index.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 { 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";
@@ -82,6 +85,69 @@ function LanguageToggle({
); );
} }
/**
* 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);
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={() => act(isMine ? "close" : "open")}
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
>
{busy ? t("shift.opening") : label}
</button>
{!isOpen && (
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
)}
{err && <span className="text-[10px] text-term-red">{err}</span>}
</div>
);
}
function RootLayout() { function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext(); const { user, setUser } = rootRoute.useRouteContext();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -102,6 +168,7 @@ function RootLayout() {
{isAdmin && <NavLink to="/site" label={t("nav.site")} />} {isAdmin && <NavLink to="/site" label={t("nav.site")} />}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
{user && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />} {user && <LanguageToggle user={user} setUser={setUser} />}
<StatusDot /> <StatusDot />
<span className="text-[11px] text-term-muted"> <span className="text-[11px] text-term-muted">
+1 -1
View File
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
export * from "./schema.js"; export * from "./schema.js";
// Re-export the query helpers consumers need, so they don't depend on // Re-export the query helpers consumers need, so they don't depend on
// drizzle-orm directly (it's an implementation detail of this package). // drizzle-orm directly (it's an implementation detail of this package).
export { eq, and, 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 * Open the local SQLite database in WAL mode. WAL allows many concurrent readers
+20
View File
@@ -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 modal** (entry/duration/total, tender, voucher checkbox, entry/exit snapshots). All live-refreshed via
the WS. 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 ## Dev notes
- Vite proxies `/api/ws` (`ws: true`) to the backend; the backend's Origin allowlist must include the - 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 dev SPA origin (`WS_ALLOWED_ORIGINS=http://localhost:5173`). In production Fastify serves the SPA
+28 -3
View File
@@ -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-06-15 updated: 2026-06-18
status: open 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; [[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
don't force one model across both. 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=<shiftStart>`); no shift open → no feed, just the "open a shift" prompt.
## A shift is NOT time-based ## A shift is NOT time-based
It is delimited by **explicit operator action**, never by a clock: 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 - 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 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 event `identity`. `ShiftService` (`apps/server/src/shift-service.ts`).
`shift_open`. `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 - **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 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 new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt
+4
View File
@@ -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 ## [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. 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=<shiftStart>`. 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.