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:
@@ -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 };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<void> {
|
||||
// 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" });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
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 +
|
||||
// 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);
|
||||
await payRoutes(app, db, payStation, exitFlow);
|
||||
await payRoutes(app, db, payStation, exitFlow, shiftService);
|
||||
|
||||
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||
// 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.
|
||||
await permitRoutes(app, db);
|
||||
|
||||
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
|
||||
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
|
||||
const shiftService = new ShiftService(db, eventLog, app.log);
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService);
|
||||
|
||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user