d0841c8601
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.
@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).
DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).
auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.
Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).
Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).
Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
118 lines
5.2 KiB
TypeScript
118 lines
5.2 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
|
import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared";
|
|
import { requirePermission } from "../auth.js";
|
|
|
|
/** Default site timezone for wall-clock tariff windows when none is configured. */
|
|
const DEFAULT_TZ = "Europe/Tirane";
|
|
|
|
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
|
|
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never
|
|
// mutates one; a session reprices against the version in force at its entry, and
|
|
// the `payment` event records the tariffVersionId. "One active tariff per site" for
|
|
// now (a single `tariffs` row, lazily created). See wiki/concepts/tariff.md.
|
|
|
|
interface PublishBody {
|
|
currency: string;
|
|
structure: TariffStructure;
|
|
/** When this version takes effect (ISO-8601). Defaults to now. */
|
|
effectiveFrom?: string;
|
|
}
|
|
|
|
const SITE_TARIFF_NAME = "Site tariff";
|
|
|
|
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|
// Reading the rate card (pay station / operator UI needs it).
|
|
const readGuard = requirePermission("tariff:read");
|
|
// Publishing a new version changes what customers are charged.
|
|
const writeGuard = requirePermission("tariff:update");
|
|
|
|
// The single site tariff row, created on first read/publish.
|
|
function ensureSiteTariff(): string {
|
|
const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
|
if (existing) return existing.id;
|
|
const id = randomUUID();
|
|
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
|
|
return id;
|
|
}
|
|
|
|
// Current state: the active (latest-effective, ≤ now) version + the full history.
|
|
app.get("/api/tariff", { preHandler: readGuard }, async () => {
|
|
const tariffId = ensureSiteTariff();
|
|
const versions = db
|
|
.select()
|
|
.from(tariffVersions)
|
|
.where(eq(tariffVersions.tariffId, tariffId))
|
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
|
.all();
|
|
const now = new Date().toISOString();
|
|
const active = versions.find((v) => v.effectiveFrom <= now) ?? null;
|
|
return { tariffId, active, versions };
|
|
});
|
|
|
|
// Publish a new immutable version. Validates the structure first — a malformed
|
|
// rate card can never be published (the fee calc + the chain depend on it).
|
|
app.post<{ Body: PublishBody }>(
|
|
"/api/tariff/versions",
|
|
{ preHandler: writeGuard },
|
|
async (req, reply) => {
|
|
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
|
|
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
|
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
|
}
|
|
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
|
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
|
|
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
|
let toStore: TariffStructure = structure;
|
|
if (structure && isTariffV2(structure)) {
|
|
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
|
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
|
toStore = { ...structure, tz };
|
|
}
|
|
|
|
const problems = validateTariffStructure(toStore);
|
|
if (problems.length) {
|
|
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
|
}
|
|
|
|
// effectiveFrom must NOT be in the past. A version is selected by
|
|
// "latest effectiveFrom <= entry time", so a backdated effectiveFrom would
|
|
// retroactively reprice already-entered sessions — exactly the immutability
|
|
// the versioning exists to prevent (wiki/concepts/tariff.md). So we forbid
|
|
// backdating: a new version applies only from publish (now) forward; a future
|
|
// effectiveFrom (scheduling a price change) is allowed. A small skew tolerance
|
|
// absorbs client/server clock drift + request round-trip. Once a car has
|
|
// entered, no later publish can reprice it (no effectiveFrom can predate it).
|
|
const now = Date.now();
|
|
const SKEW_MS = 60_000; // 1 min: clock skew + round-trip slack
|
|
let effective = new Date().toISOString();
|
|
if (effectiveFrom != null) {
|
|
const t = Date.parse(effectiveFrom);
|
|
if (Number.isNaN(t)) {
|
|
return reply.code(400).send({ error: "effectiveFrom must be a valid ISO-8601 timestamp" });
|
|
}
|
|
if (t < now - SKEW_MS) {
|
|
return reply.code(400).send({
|
|
error: "effectiveFrom cannot be in the past — backdating a tariff would retroactively reprice entered sessions",
|
|
});
|
|
}
|
|
effective = new Date(t).toISOString();
|
|
}
|
|
|
|
const tariffId = ensureSiteTariff();
|
|
const id = randomUUID();
|
|
const row = {
|
|
id,
|
|
tariffId,
|
|
effectiveFrom: effective,
|
|
currency,
|
|
structure: toStore as unknown as Record<string, unknown>,
|
|
createdBy: req.user?.username ?? null,
|
|
};
|
|
db.insert(tariffVersions).values(row).run();
|
|
return reply.code(201).send(row);
|
|
},
|
|
);
|
|
}
|