Files
parking_solution/apps/server/src/routes/shift.ts
T
julian 040c0ff4ca feat: tabbed setup, user metadata, light theme, scoped shift history
Consolidate the config screens under a single /setup hub with permission-
gated tabs (Devices/Tariff/Subscriptions/Site/Users/Roles/Shifts), collapsing
the top nav to Booth·Shift·Setup; old top-level paths redirect.

Users: add optional profile metadata (full name, phone, email, address) on
create/edit. Theme: a light palette saved to the user's profile (users.theme),
toggled in the header beside the language switch and applied on load like the
language preference. Both ride on a single additive migration (0008).

Shift history: a new GET /api/shifts folds the signed shift_z_report chain into
completed shifts, SCOPED server-side — operators see only their own; holders of
shift:cash see all with an operator + date-range filter. Surfaced as the Shifts
tab; an operator cannot read another operator's takings (param spoofing is
ignored).

These three features share the router, api client and i18n catalogs, so they
land together. Verified live: theme persists across reload, metadata round-
trips to the DB, and shift scoping holds (operator self-only, admin all+filter).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:09:18 +02:00

106 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FastifyInstance } from "fastify";
import { requirePermission, roleHasPermissions } from "../auth.js";
import {
InvalidCashMovementError,
NoOpenShiftError,
ShiftAlreadyOpenError,
type ShiftService,
} from "../shift-service.js";
interface CashMovementBody {
/** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */
amountMinor: number;
reason?: string;
currency?: string;
}
interface ShiftsQuery {
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
operator?: string;
/** ISO window over shift START time. */
from?: string;
to?: string;
}
// 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
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
// Reading the shift state vs. opening/closing one's own shift.
const readGuard = requirePermission("shift:read");
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 drawer = shift.drawerBalance();
return {
operator: me,
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
isMine: open != null && heldBy === me,
drawerMinor: drawer.balanceMinor,
currency: drawer.currency,
};
});
// Completed shift history. SCOPED by permission:
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
// `operator` and a `from`/`to` time window over each shift's START.
// 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.
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
const q = req.query ?? {};
// Non-admins are hard-scoped to themselves regardless of any operator param.
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
const shifts = shift.listShifts({ operator, from, to });
return { shifts, scope: canSeeAll ? "all" : "self" };
});
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
app.post<{ Body: CashMovementBody }>(
"/api/cash-movement",
{ preHandler: requirePermission("shift:cash") },
async (req, reply) => {
const { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody);
try {
return await shift.recordCashMovement(req.user.username, amountMinor, reason ?? "", currency);
} catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
},
);
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
try {
return await shift.open(req.user.username);
} catch (err) {
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
});
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
try {
return await shift.close(req.user.username);
} catch (err) {
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
});
}