55d6242c7d
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix", open-questions #16) — the grid stays the enforcement layer: - Move 1: each desk's money is guarded by that desk's own permissions. Manifest tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot touch the booth by construction. Replaces the session:read borrowing (tillPermission). /api/shift/tills lists the role's readable tills with canWork; history/movements without a till filter return the union of readable tills. - Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor, merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and "partial job" lints (warnings, never blocks). - Move 3: the live WebSocket admits any watch permission (event/session/device read or a module's feedPermission) and filters every push per role; report:read is the reports screen only. Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's role applies on the next request and a deleted user's session ends with 401. Tests: till guards + look-only role, feed rules, every job's permissions exist, role reassignment without re-login. 353/353. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
127 lines
6.1 KiB
TypeScript
127 lines
6.1 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import type { Db } from "@parking/db";
|
|
import type { TillId } from "@parking/shared";
|
|
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
|
|
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
|
|
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
|
|
|
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
|
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
|
|
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
|
|
// - POST /api/drawer/movement : operator records a cash_in/cash_out on a till.
|
|
// Guard = the till's `cash` (booth drawer:create,
|
|
// wash carwash:cash).
|
|
// - GET /api/drawer/movements: list with review status, over the tills the role may
|
|
// read (own movements); reviewers (drawer:review) see all
|
|
// tills + all operators and can filter status.
|
|
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
|
// - GET /api/drawer/balance : a till's physical balance NOW (guard = the till's read).
|
|
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
|
|
// judgment about the operator settled outside the app, never a cash reversal.
|
|
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); each
|
|
// desk's cash is guarded by that desk's own permissions (venue-modules.md §"Permissions
|
|
// matrix").
|
|
|
|
interface MovementBody {
|
|
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
|
* cash_out = Mandat Pagese (pay-OUT). */
|
|
type: "cash_in" | "cash_out";
|
|
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
|
amountMinor: number;
|
|
reason?: string;
|
|
currency?: string;
|
|
/** Which drawer (default: the booth). */
|
|
till?: string;
|
|
}
|
|
|
|
interface ReviewBody {
|
|
/** The cash_in/cash_out event id being decided on. */
|
|
refId: string;
|
|
decision: "authorize" | "deny";
|
|
/** Optional admin note (e.g. why denied). */
|
|
note?: string;
|
|
}
|
|
|
|
interface MovementsQuery {
|
|
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
|
status?: MovementStatus;
|
|
/** Filter to one till; absent = every till the role may read (reviewers: every till). */
|
|
till?: string;
|
|
}
|
|
|
|
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
|
const reviewGuard = requirePermission("drawer:review");
|
|
|
|
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
|
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: requireTill(db, "cash", "body") }, async (req, reply) => {
|
|
const b = req.body ?? ({} as MovementBody);
|
|
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
|
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
|
}
|
|
try {
|
|
return await shift.recordVoucher({
|
|
type: b.type,
|
|
operator: req.user.username,
|
|
amountMinor: b.amountMinor,
|
|
reason: b.reason ?? "",
|
|
currency: b.currency,
|
|
till: req.till!,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
|
return reply.code(500).send({ error: (err as Error).message });
|
|
}
|
|
});
|
|
|
|
// List movements + review status. Operators are hard-scoped to their OWN movements on
|
|
// the tills they may read; a reviewer sees ALL and may filter by status (the pending
|
|
// review queue).
|
|
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: requireAuth }, async (req, reply) => {
|
|
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
|
const readable = tillsReadableBy(db, req.user.roleId);
|
|
if (!canReview && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
|
|
const q = req.query ?? {};
|
|
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
|
let tills: TillId[] | undefined = canReview ? undefined : readable;
|
|
if (q.till?.trim()) {
|
|
const parsed = parseTill(db, q.till.trim());
|
|
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
|
if (!canReview && !readable.includes(parsed)) {
|
|
return reply.code(403).send({ error: `your role cannot see the ${parsed} till`, code: "till_forbidden", till: parsed });
|
|
}
|
|
tills = [parsed];
|
|
}
|
|
const operator = canReview ? undefined : req.user.username;
|
|
const movements = tills
|
|
? tills.flatMap((till) => shift.movementsWithStatus({ operator, status, till })).sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
|
|
: shift.movementsWithStatus({ operator, status });
|
|
return { movements, scope: canReview ? "all" : "self" };
|
|
});
|
|
|
|
// A till's physical drawer balance now. Same visibility as the open shift's X-report
|
|
// (the till's read guard) — a drawer is a shared till, not per-operator data.
|
|
app.get("/api/drawer/balance", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
|
|
till: req.till!,
|
|
...shift.drawerBalance(req.till!),
|
|
}));
|
|
|
|
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
|
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
|
const b = req.body ?? ({} as ReviewBody);
|
|
if (!b.refId || (b.decision !== "authorize" && b.decision !== "deny")) {
|
|
return reply.code(400).send({ error: "refId and decision (authorize|deny) are required" });
|
|
}
|
|
try {
|
|
return await shift.reviewMovement({
|
|
refId: b.refId,
|
|
decision: b.decision,
|
|
reviewedBy: req.user.username,
|
|
note: b.note,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
|
return reply.code(500).send({ error: (err as Error).message });
|
|
}
|
|
});
|
|
}
|