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
+47 -3
View File
@@ -11,9 +11,16 @@ import type { EventLog } from "./event-log.js";
// See wiki/concepts/shift.md.
export class ShiftAlreadyOpenError extends Error {
constructor(operator: string) {
super(`operator ${operator} already has an open shift`);
/** The operator who currently holds the open shift (may be someone else). */
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.heldBy = heldBy;
}
}
export class NoOpenShiftError extends Error {
@@ -22,6 +29,14 @@ export class NoOpenShiftError extends Error {
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 {
readonly operator: string;
@@ -82,6 +97,31 @@ export class ShiftService {
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
* 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({