Merge pull request #1524 from Tria-plc/reschedule

Reschedule
This commit is contained in:
Abubeker Yasin
2026-09-08 11:13:52 +03:00
committed by GitHub
80 changed files with 3269 additions and 439 deletions

View File

@@ -4,11 +4,14 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "nest start --watch", "dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:debug:brk": "nest build && node --inspect-brk dist/main.js",
"build": "prisma generate && nest build", "build": "prisma generate && nest build",
"start": "node dist/main.js", "start": "node dist/main.js",
"start:prod": "node dist/main.js", "start:prod": "node dist/main.js",
"lint": "eslint src", "lint": "eslint src",
"test": "jest", "test": "jest",
"test:debug": "node --inspect-brk node_modules/jest/bin/jest.js --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"test:e2e:report": "jest --config ./test/jest-e2e.json; open e2e-report/index.html", "test:e2e:report": "jest --config ./test/jest-e2e.json; open e2e-report/index.html",
"test:e2e:all": "bash ../../e2e/run.sh", "test:e2e:all": "bash ../../e2e/run.sh",
@@ -18,6 +21,7 @@
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"iam:migrate": "node --env-file=.env scripts/run-iam-migrations.cjs", "iam:migrate": "node --env-file=.env scripts/run-iam-migrations.cjs",
"iam:seed-dev-user": "node --env-file=.env scripts/seed-iam-dev-user.cjs", "iam:seed-dev-user": "node --env-file=.env scripts/seed-iam-dev-user.cjs",
"iam:seed-legacy-users": "node --env-file=.env scripts/seed-legacy-role-users.cjs",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy", "prisma:migrate": "prisma migrate deploy",
"prisma:migrate:dev": "prisma migrate dev", "prisma:migrate:dev": "prisma migrate dev",

View File

@@ -0,0 +1,456 @@
#!/usr/bin/env node
/**
* Double-booking reproduction harness — drives the real HTTP API only.
*
* No direct database access, no row edits: every seat here is claimed the same way a
* passenger's browser claims it (POST /seats/hold, POST /bookings/guest). If a scenario
* reports FAIL, two bookings hold the same seat on the same schedule and the API let it
* happen through its own public endpoints.
*
* node scripts/repro-double-booking.cjs \
* --schedule <uuid> --origin <uuid> --destination <uuid> [--scenario S3] [--concurrency 8]
*
* The three UUIDs come straight off any booking-detail response (data.schedule.id,
* data.schedule.origin.id, data.schedule.destination.id). Pick a FUTURE departure — holds
* are refused inside the check-in cutoff.
*
* Bookings land in PENDING_PAYMENT and are never paid, so no JourneySegment or ticket is
* produced. Every bookingRef created is printed at the end for cleanup.
*/
const BASE = (process.env.API_URL || 'http://localhost:3002').replace(/\/$/, '');
// ── args ──────────────────────────────────────────────────────────────────────
const argv = process.argv.slice(2);
const arg = (name, fallback) => {
const i = argv.indexOf(`--${name}`);
return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback;
};
const SCHEDULE_ID = arg('schedule');
const ORIGIN_ID = arg('origin');
const DESTINATION_ID = arg('destination');
const SEAT_CLASS_ID = arg('seat-class-id');
const ONLY = arg('scenario');
const CONCURRENCY = Number(arg('concurrency', 8));
if (!SCHEDULE_ID || !ORIGIN_ID || !DESTINATION_ID) {
console.error('Usage: --schedule <uuid> --origin <uuid> --destination <uuid>');
process.exit(1);
}
const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:|\/|$)/.test(BASE);
if (!isLocal && process.env.ALLOW_REMOTE !== '1') {
console.error(`Refusing to run against ${BASE} — this CREATES REAL BOOKINGS.`);
console.error('Set ALLOW_REMOTE=1 only if this is a staging/dev API you are willing to dirty.');
process.exit(1);
}
// ── http ──────────────────────────────────────────────────────────────────────
let callCount = 0;
async function api(method, path, body) {
callCount++;
const res = await fetch(`${BASE}${path}`, {
method,
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
json = { raw: text.slice(0, 300) };
}
// A global interceptor wraps every payload as { success, data, timestamp } — unwrap it
// so callers see the resource itself, not the envelope.
const data =
json && typeof json === 'object' && 'success' in json && 'data' in json ? json.data : json;
return { ok: res.ok, status: res.status, json, data };
}
const uuid = () => crypto.randomUUID();
const msg = (r) => r.json?.message ?? r.json?.error ?? JSON.stringify(r.json).slice(0, 160);
// ── building blocks ───────────────────────────────────────────────────────────
function holdBody(seatIds, direction, passengerId) {
return {
scheduleId: SCHEDULE_ID,
originStationId: ORIGIN_ID,
destinationStationId: DESTINATION_ID,
...(direction ? { journeyDirection: direction } : {}),
passengers: seatIds.map((seatId) => ({ passengerId: passengerId ?? uuid(), seatId })),
};
}
/**
* POST /seats/hold — public, exactly what the seat map calls. Pass `passengerId` to make
* two holds look like the same traveller, which is what the OUTBOUND/RETURN exemption
* legitimately covers.
*/
async function hold(seatIds, direction, passengerId) {
const r = await api('POST', '/seats/hold', holdBody(seatIds, direction, passengerId));
return { ...r, holdId: r.data?.id ?? r.data?.holdId };
}
// One traveller may not hold two unpaid bookings on the same train (see
// assertIdentitiesNotAlreadyBooked). Every run therefore needs fresh identities, or the
// leftovers from the previous run reject this one for the wrong reason.
const RUN = Date.now().toString(36).slice(-5).toUpperCase();
let personCounter = 0;
function passenger(seatId, extra = {}) {
personCounter++;
return {
seatId,
passengerName: `Repro ${RUN} ${personCounter}`,
dateOfBirth: '1990-05-15',
idDocumentType: 'PASSPORT',
passportNumber: `RP${RUN}${String(personCounter).padStart(3, '0')}`,
passportCountry: 'Djibouti',
nationality: 'Other',
phone: `+2519${String(10000000 + personCounter).slice(0, 8)}`,
...extra,
};
}
/**
* POST /bookings/guest — public, no auth. `holdId` and the seat ids in `passengers`
* are sent independently, which is the whole point of scenarios 1 and 2.
*/
async function guestBook({ holdId, seatIds, seatClassId }) {
const r = await api('POST', '/bookings/guest', {
bookingType: 'ONE_WAY',
scheduleId: SCHEDULE_ID,
holdId,
originStationId: ORIGIN_ID,
destinationStationId: DESTINATION_ID,
seatClassId,
skipIdentityVerification: true,
passengers: seatIds.map((s) => passenger(s)),
});
const ref = r.data?.bookingRef ?? r.data?.booking?.bookingRef;
return { ...r, bookingRef: ref };
}
// ── ledger: who ended up on which seat ────────────────────────────────────────
const claims = []; // { seatId, seatNumber, bookingRef, scenario }
const created = []; // every bookingRef we made, for cleanup
function record(scenario, seat, bookingRef) {
claims.push({ seatId: seat.id, seatNumber: seat.seatNumber, bookingRef, scenario });
created.push(bookingRef);
}
// ── seat pool ─────────────────────────────────────────────────────────────────
async function loadSeats() {
const q = `originStationId=${ORIGIN_ID}&destinationStationId=${DESTINATION_ID}`;
const r = await api('GET', `/seats/seatmap/${SCHEDULE_ID}?${q}`);
if (!r.ok) throw new Error(`seatmap failed (${r.status}): ${msg(r)}`);
const pool = [];
for (const coach of r.data?.coaches ?? []) {
for (const s of coach.seats ?? []) {
if (String(s.status).toUpperCase() !== 'AVAILABLE') continue;
pool.push({
id: s.id,
seatNumber: s.seatNumber,
coach: coach.coachNumber,
coachClass: coach.seatClass,
});
}
}
return pool;
}
async function resolveSeatClassId(seat) {
if (SEAT_CLASS_ID) return SEAT_CLASS_ID;
const r = await api('GET', '/seat-classes');
const list = Array.isArray(r.data) ? r.data : (r.data?.items ?? []);
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const hit = list.find((c) => norm(c.name) === norm(seat.coachClass)) ?? list[0];
if (!hit) throw new Error('No seat classes returned — pass --seat-class-id explicitly.');
return hit.id;
}
// ── scenarios ─────────────────────────────────────────────────────────────────
// Each returns { verdict: 'FAIL' | 'PASS' | 'INCONCLUSIVE', detail }.
// S* are attacks: FAIL means the double booking reproduced.
// P* are positive controls: FAIL means a legitimate booking got blocked, which is just
// as much a regression — a guard that refuses everything is not a fix.
const scenarios = {
/** P1 — the ordinary happy path: hold a seat, book that same seat. Must succeed. */
async P1(pool, seatClassId) {
const seat = pool.shift();
const h = await hold([seat.id]);
if (!h.holdId) return { verdict: 'FAIL', detail: `could not hold seat ${seat.seatNumber}: ${msg(h)}` };
const b = await guestBook({ holdId: h.holdId, seatIds: [seat.id], seatClassId });
if (!b.bookingRef) {
return { verdict: 'FAIL', detail: `held seat ${seat.seatNumber} but booking was refused (${b.status}): ${msg(b)}` };
}
record('P1', seat, b.bookingRef);
return { verdict: 'PASS', detail: `seat ${seat.seatNumber} held and booked normally — ${b.bookingRef}` };
},
/**
* P2 — one traveller, both legs of a turnaround round trip on the same seat. This is
* what the OUTBOUND/RETURN exemption is for, so it must keep working after the
* exemption is narrowed to the requester's own holds.
*/
async P2(pool) {
const seat = pool.shift();
const traveller = uuid();
const outbound = await hold([seat.id], 'OUTBOUND', traveller);
if (!outbound.holdId) {
return { verdict: 'FAIL', detail: `outbound hold refused on seat ${seat.seatNumber}: ${msg(outbound)}` };
}
const ret = await hold([seat.id], 'RETURN', traveller);
if (!ret.holdId) {
return {
verdict: 'FAIL',
detail: `same traveller was blocked from holding their own return leg on seat ${seat.seatNumber}: ${msg(ret)}`,
};
}
return { verdict: 'PASS', detail: `one traveller held seat ${seat.seatNumber} on both legs` };
},
/**
* S1 — Hold laundering. Hold seat A, then guest-book seat B while presenting A's holdId.
* bookings.service.ts runs validateSeatIdsAgainstHold here; guest-booking.service.ts
* does not, so the seat you pay for need never be the seat you held.
*/
async S1(pool, seatClassId) {
const [decoy, target] = [pool.shift(), pool.shift()];
const h = await hold([decoy.id]);
if (!h.holdId) return { verdict: 'INCONCLUSIVE', detail: `hold on decoy failed: ${msg(h)}` };
const b = await guestBook({ holdId: h.holdId, seatIds: [target.id], seatClassId });
if (!b.bookingRef) return { verdict: 'PASS', detail: `rejected (${b.status}): ${msg(b)}` };
record('S1', target, b.bookingRef);
return {
verdict: 'FAIL',
detail: `held seat ${decoy.seatNumber} but booked seat ${target.seatNumber}${b.bookingRef}`,
};
},
/**
* S2 — Steal a live hold. Someone else holds the seat; we present an unrelated hold
* and book their seat anyway. This is the production shape: a seat that another
* passenger is mid-checkout on gets sold underneath them.
*/
async S2(pool, seatClassId) {
const [victimSeat, mySeat] = [pool.shift(), pool.shift()];
const victimHold = await hold([victimSeat.id]);
if (!victimHold.holdId)
return { verdict: 'INCONCLUSIVE', detail: `victim hold failed: ${msg(victimHold)}` };
const myHold = await hold([mySeat.id]);
if (!myHold.holdId)
return { verdict: 'INCONCLUSIVE', detail: `attacker hold failed: ${msg(myHold)}` };
const b = await guestBook({ holdId: myHold.holdId, seatIds: [victimSeat.id], seatClassId });
if (!b.bookingRef) return { verdict: 'PASS', detail: `rejected (${b.status}): ${msg(b)}` };
record('S2', victimSeat, b.bookingRef);
return {
verdict: 'FAIL',
detail: `seat ${victimSeat.seatNumber} was under a live hold, booked anyway — ${b.bookingRef}`,
};
},
/**
* S3 — Concurrent guest bookings, same seat. N independent "browsers" each hold their
* own throwaway seat, then all fire at the same instant for one shared seat. Closest
* analogue to peak-hour traffic. More than one bookingRef means the seat was sold twice.
*/
async S3(pool, seatClassId) {
const target = pool.shift();
const decoys = pool.splice(0, CONCURRENCY);
if (decoys.length < CONCURRENCY)
return { verdict: 'INCONCLUSIVE', detail: 'not enough free seats' };
const holds = await Promise.all(decoys.map((d) => hold([d.id])));
const usable = holds.filter((h) => h.holdId);
if (usable.length < 2)
return { verdict: 'INCONCLUSIVE', detail: 'fewer than 2 holds succeeded' };
const results = await Promise.all(
usable.map((h) => guestBook({ holdId: h.holdId, seatIds: [target.id], seatClassId })),
);
const won = results.filter((r) => r.bookingRef);
won.forEach((r) => record('S3', target, r.bookingRef));
if (won.length <= 1) {
return {
verdict: 'PASS',
detail: `${won.length}/${usable.length} succeeded on seat ${target.seatNumber}`,
};
}
return {
verdict: 'FAIL',
detail: `seat ${target.seatNumber} sold ${won.length}x concurrently — ${won.map((r) => r.bookingRef).join(', ')}`,
};
},
/**
* S4 — Concurrent holds, same seat. Tests the hold transaction itself, upstream of any
* booking. If two holds coexist on one seat, every downstream check is already poisoned.
*/
async S4(pool) {
const target = pool.shift();
const results = await Promise.all(Array.from({ length: CONCURRENCY }, () => hold([target.id])));
const won = results.filter((r) => r.holdId);
if (won.length <= 1) {
return {
verdict: 'PASS',
detail: `${won.length}/${CONCURRENCY} holds granted on seat ${target.seatNumber}`,
};
}
return {
verdict: 'FAIL',
detail: `seat ${target.seatNumber} held ${won.length}x simultaneously — holdIds ${won.map((r) => r.holdId).join(', ')}`,
};
},
/**
* S5 — Direction bypass. checkDirectionConflict (journey-direction.utils.ts:13) returns
* false for OUTBOUND vs RETURN so a round trip can reuse a seat across its own legs.
* The rule is per-seat, not per-booking, so two *different* passengers can straddle it.
*/
async S5(pool, seatClassId) {
const target = pool.shift();
const outbound = await hold([target.id], 'OUTBOUND');
const ret = await hold([target.id], 'RETURN');
if (!outbound.holdId || !ret.holdId) {
return {
verdict: 'PASS',
detail: `second direction refused: ${msg(outbound.holdId ? ret : outbound)}`,
};
}
const a = await guestBook({ holdId: outbound.holdId, seatIds: [target.id], seatClassId });
const b = await guestBook({ holdId: ret.holdId, seatIds: [target.id], seatClassId });
const won = [a, b].filter((r) => r.bookingRef);
won.forEach((r) => record('S5', target, r.bookingRef));
if (won.length <= 1) {
return {
verdict: 'INCONCLUSIVE',
detail: `both directions held seat ${target.seatNumber} at once, but only ${won.length} booking stuck`,
};
}
return {
verdict: 'FAIL',
detail: `seat ${target.seatNumber} sold as OUTBOUND and RETURN — ${won.map((r) => r.bookingRef).join(', ')}`,
};
},
/**
* S6 — Hold expiry gap. A booking sits in PENDING_PAYMENT; its hold lapses; no
* JourneySegment exists yet because those are only written on payment success
* (payments.service.ts:2163). The seat reads as free to everyone until the first
* booking pays. Needs --wait-hold-expiry, since it must outlive SEAT_HOLD_DURATION_MINUTES.
*/
async S6(pool, seatClassId) {
const waitMin = Number(arg('wait-hold-expiry', '0'));
if (!waitMin) {
return {
verdict: 'SKIPPED',
detail: 'pass --wait-hold-expiry <minutes, > SEAT_HOLD_DURATION_MINUTES>',
};
}
const target = pool.shift();
const first = await hold([target.id]);
if (!first.holdId) return { verdict: 'INCONCLUSIVE', detail: `hold failed: ${msg(first)}` };
const b1 = await guestBook({ holdId: first.holdId, seatIds: [target.id], seatClassId });
if (!b1.bookingRef)
return { verdict: 'INCONCLUSIVE', detail: `first booking failed: ${msg(b1)}` };
record('S6', target, b1.bookingRef);
console.log(` waiting ${waitMin} min for the hold to lapse (booking stays PENDING_PAYMENT)…`);
await new Promise((r) => setTimeout(r, waitMin * 60 * 1000));
const second = await hold([target.id]);
if (!second.holdId)
return { verdict: 'PASS', detail: `re-hold refused after expiry: ${msg(second)}` };
const b2 = await guestBook({ holdId: second.holdId, seatIds: [target.id], seatClassId });
if (!b2.bookingRef) return { verdict: 'PASS', detail: `second booking refused: ${msg(b2)}` };
record('S6', target, b2.bookingRef);
return {
verdict: 'FAIL',
detail: `seat ${target.seatNumber} rebooked after hold lapsed — ${b1.bookingRef} and ${b2.bookingRef}`,
};
},
};
// ── runner ────────────────────────────────────────────────────────────────────
(async () => {
console.log(`API ${BASE}`);
console.log(`Schedule ${SCHEDULE_ID}`);
console.log(`Route ${ORIGIN_ID} -> ${DESTINATION_ID}\n`);
const pool = await loadSeats();
console.log(`${pool.length} seats reported AVAILABLE.\n`);
if (pool.length < CONCURRENCY + 6) {
console.error(
`Need at least ${CONCURRENCY + 6} free seats; lower --concurrency or pick an emptier departure.`,
);
process.exit(1);
}
const seatClassId = await resolveSeatClassId(pool[0]);
const names = ONLY ? [ONLY] : Object.keys(scenarios);
const summary = [];
for (const name of names) {
const fn = scenarios[name];
if (!fn) {
console.error(`Unknown scenario ${name}`);
continue;
}
process.stdout.write(`${name}`);
let out;
try {
out = await fn(pool, seatClassId);
} catch (e) {
out = { verdict: 'ERROR', detail: e.message };
}
console.log(`${out.verdict}\n ${out.detail}`);
summary.push({ name, ...out });
}
// Collision ledger — built only from what the API handed back to us.
const bySeat = new Map();
for (const c of claims) {
if (!bySeat.has(c.seatId)) bySeat.set(c.seatId, []);
bySeat.get(c.seatId).push(c);
}
const collisions = [...bySeat.values()].filter((g) => g.length > 1);
console.log(`\n─────── ${callCount} API calls, ${created.length} bookings created ───────`);
for (const s of summary) console.log(` ${s.verdict.padEnd(13)} ${s.name}`);
if (collisions.length) {
console.log('\nDOUBLE-BOOKED SEATS:');
for (const g of collisions) {
console.log(` seat ${g[0].seatNumber} (${g[0].seatId})`);
for (const c of g) console.log(` ${c.bookingRef} [${c.scenario}]`);
}
} else {
console.log('\nNo seat was claimed by more than one booking.');
}
if (created.length) {
console.log('\nCreated (PENDING_PAYMENT, unpaid — cancel these):');
console.log(` ${created.join(' ')}`);
}
process.exit(collisions.length ? 2 : 0);
})().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,236 @@
/**
* Create the three legacy backoffice roles and their users.
*
* Deliberately a standalone CLI, NOT part of the app lifecycle: nothing here runs on
* `onApplicationBootstrap`, so a deploy can decide when accounts appear. Contrast
* `EdrPassengerOrgSeeder` / `PassengerStaffUsersSeeder`, which run at boot behind
* SEED_EDR_PASSENGER_ORG / SEED_PASSENGER_STAFF.
*
* pnpm --filter @edr/passenger-api iam:seed-legacy-users
* pnpm --filter @edr/passenger-api iam:seed-legacy-users -- --dry-run
*
* The roles below carry ONLY the permission keys that existed before the granular
* create/edit/delete change (47 of them). None of the newer narrow keys are granted.
* That is the point: these three model how the app was used before, so running them
* against the new guards proves the granular change stayed backwards-compatible.
*
* Idempotent, and safe to re-run: roles and users are upserted by their natural key,
* and each role's permission links are synced to exactly the set declared here —
* extras are pruned so the file stays the source of truth.
*
* Requires DATABASE_* in the environment (`node --env-file=.env` does this).
* The password comes from SEED_USER_PASSWORD, falling back to DEFAULT_PASSWORD.
* In production one of them MUST be set — there is no built-in default there.
*/
const { DataSource } = require('typeorm');
const { hashPassword } = require('@tria-plc/api-common/utils/argon');
const APP = 'edr_passenger_app';
const ORG_KEY = 'edr';
const DRY_RUN = process.argv.includes('--dry-run');
const p = (s) => `${APP}:${s}`;
/** Every `:view` / `:view_all` key that existed before the granular change. */
const LEGACY_VIEW_SUFFIXES = [
'agents:view', 'audit:view', 'bookings:view', 'classes:view', 'coaches:view',
'currencies:view', 'dashboard:view', 'fraud:view', 'inquiries:view', 'packages:view',
'passengers:view', 'payment_methods:view', 'payments:view', 'payments:view_all',
'reports:view', 'routes:view', 'schedules:view', 'seats:view', 'stations:view',
'tariff_rates:view', 'tickets:view', 'trains:view',
];
/** The rest of the pre-granular registry — 25 non-view keys. */
const LEGACY_WRITE_SUFFIXES = [
'admin',
'agents:manage', 'bookings:cancel', 'bookings:manage', 'bookings:reschedule',
'classes:manage', 'coaches:manage', 'currencies:manage', 'fraud:manage',
'inquiries:manage', 'notifications:send', 'packages:manage', 'passengers:manage',
'payment_methods:manage', 'payments:manage', 'payments:manage_methods',
'payments:refund', 'routes:manage', 'schedules:manage', 'seats:manage',
'stations:manage', 'tariff_rates:manage', 'tickets:generate', 'tickets:manage',
'trains:manage',
];
const LEGACY_VIEW = LEGACY_VIEW_SUFFIXES.map(p);
const LEGACY_ALL = [...LEGACY_VIEW_SUFFIXES, ...LEGACY_WRITE_SUFFIXES].map(p);
/**
* Withheld from the chief. `payment_methods:manage` and the legacy alias
* `payments:manage_methods` both open POST/PATCH /payments/methods, so excluding only
* one would leave the ability intact — both have to go for "no managing payment
* methods" to actually hold.
*/
const CHIEF_EXCLUDED = [
p('payment_methods:manage'),
p('payments:manage_methods'),
p('tickets:generate'),
p('admin'),
];
const ROLES = [
{
key: 'old_ticketofficer',
name: { en: 'Ticket Officer (legacy)', am: 'የቲኬት ኦፊሰር' },
email: 'old.ticketofficer@edr.local',
username: 'old_ticketofficer',
permissions: [
p('passengers:view'),
p('bookings:view'),
p('tickets:view'),
p('tickets:manage'),
p('payments:view'),
p('payments:manage'),
],
},
{
key: 'old_passengerchief',
name: { en: 'Passenger Chief (legacy)', am: 'የተሳፋሪ ኃላፊ' },
email: 'old.passengerchief@edr.local',
username: 'old_passengerchief',
permissions: LEGACY_ALL.filter((k) => !CHIEF_EXCLUDED.includes(k)),
},
{
key: 'old_passengerdirector',
name: { en: 'Passenger Director (legacy)', am: 'የተሳፋሪ ዳይሬክተር' },
email: 'old.passengerdirector@edr.local',
username: 'old_passengerdirector',
permissions: LEGACY_VIEW,
},
];
function resolvePassword() {
const pw = (process.env.SEED_USER_PASSWORD || process.env.DEFAULT_PASSWORD || '').trim();
if (pw) return pw;
if (process.env.NODE_ENV === 'production') {
throw new Error('SEED_USER_PASSWORD (or DEFAULT_PASSWORD) must be set in production');
}
return '12345678';
}
const ds = new DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT || 5432),
database: process.env.DATABASE_NAME,
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
});
(async () => {
const password = resolvePassword();
await ds.initialize();
// Fail before writing anything if a key is not seeded — a typo here would otherwise
// create a role that silently grants less than intended.
const wanted = [...new Set(ROLES.flatMap((r) => r.permissions))];
const found = await ds.query(
`SELECT key FROM iam.permissions WHERE key = ANY($1::text[])`,
[wanted],
);
const missing = wanted.filter((k) => !found.some((f) => f.key === k));
if (missing.length) {
throw new Error(
`these permission keys are not in iam.permissions — run the app once with ` +
`SEED_EDR_PASSENGER_ORG=true first:\n ${missing.join('\n ')}`,
);
}
const [org] = await ds.query(`SELECT id FROM iam.organizations WHERE key = $1`, [ORG_KEY]);
if (!org) throw new Error(`missing_organization:${ORG_KEY}`);
if (DRY_RUN) {
console.log('\n=== DRY RUN — nothing written ===');
for (const r of ROLES) {
console.log(`\n${r.key} (${r.email}) ${r.permissions.length} permissions`);
for (const k of [...r.permissions].sort()) console.log(' ', k);
}
await ds.destroy();
return;
}
const hashed = await hashPassword(password);
await ds.transaction(async (m) => {
for (const r of ROLES) {
const [role] = await m.query(
`INSERT INTO iam.roles (id, key, name, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2::jsonb, now(), now())
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
RETURNING id`,
[r.key, JSON.stringify(r.name)],
);
// Sync links to exactly this set: add what is missing, drop what is extra.
await m.query(
`INSERT INTO iam.role_permissions (id, role_id, permission_id, created_at, updated_at)
SELECT gen_random_uuid(), $1, p.id, now(), now()
FROM iam.permissions p
WHERE p.key = ANY($2::text[])
AND NOT EXISTS (SELECT 1 FROM iam.role_permissions rp
WHERE rp.role_id = $1 AND rp.permission_id = p.id)`,
[role.id, r.permissions],
);
// TypeORM's postgres driver returns `[rows, affectedCount]` for a DELETE ... RETURNING,
// so the rows are at [0] — reading `.length` off the outer array would report 2 every time.
const deleted = await m.query(
`DELETE FROM iam.role_permissions rp
USING iam.permissions p
WHERE rp.permission_id = p.id
AND rp.role_id = $1
AND NOT (p.key = ANY($2::text[]))
RETURNING rp.id`,
[role.id, r.permissions],
);
const prunedCount = (Array.isArray(deleted[0]) ? deleted[0] : deleted).length;
const [user] = await m.query(
`INSERT INTO iam.users (id, name, username, email, user_type, status, is_active,
has_set_password, created_at, updated_at)
VALUES (gen_random_uuid(), $1::jsonb, $2, $3, 'individual', 'accepted', true, true,
now(), now())
ON CONFLICT (email) DO UPDATE SET updated_at = now()
RETURNING id`,
[JSON.stringify(r.name), r.username, r.email],
);
// Never overwrite a password that already exists — re-running must not reset a
// credential someone has since changed.
await m.query(
`INSERT INTO iam.user_credentials (id, user_id, password, is_active, created_at, updated_at)
SELECT gen_random_uuid(), $1, $2, true, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM iam.user_credentials
WHERE user_id = $1 AND is_active = true)`,
[user.id, hashed],
);
await m.query(
`INSERT INTO iam.user_roles (id, user_id, role_id, organization_id, created_at, updated_at)
SELECT gen_random_uuid(), $1, $2, $3, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM iam.user_roles
WHERE user_id = $1 AND role_id = $2)`,
[user.id, role.id, org.id],
);
await m.query(
`INSERT INTO iam.employees (id, user_id, organization_id, is_current, name, created_at, updated_at)
SELECT gen_random_uuid(), $1, $2, true, $3::jsonb, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM iam.employees
WHERE user_id = $1 AND organization_id = $2 AND is_current = true)`,
[user.id, org.id, JSON.stringify(r.name)],
);
console.log(
` ${r.key.padEnd(24)} ${String(r.permissions.length).padStart(2)} permissions` +
`${prunedCount ? ` (${prunedCount} stale link(s) pruned)` : ''} -> ${r.email}`,
);
}
});
console.log('\nDone. Sign in with the email above and the seeded password.');
console.log('Re-running is safe; an existing password is never overwritten.\n');
await ds.destroy();
})().catch((e) => {
console.error('[seed-legacy-role-users] FAIL:', e.message);
process.exit(1);
});

View File

@@ -0,0 +1,359 @@
#!/usr/bin/env node
/**
* Booking concurrency + regression harness.
*
* Companion to repro-double-booking.cjs: that one proves specific attacks are closed, this
* one proves the system stays correct under load and that legitimate bookings are not
* rejected as collateral.
*
* node scripts/stress-booking-concurrency.cjs
* --schedule <uuid> --origin <uuid> --destination <uuid>
* [--mode ladder|edge|all] [--levels 2,5,10,25,50,100]
* [--return-schedule <uuid> --return-origin <uuid> --return-destination <uuid>]
*
* ladder, per concurrency level N:
* hold-contest N browsers click one seat at once -> exactly 1 hold granted
* booking-storm N submits of one valid hold at once -> exactly 1 booking created
* diff-seats N users book N different seats at once -> all N succeed (no false rejects)
*
* edge: multi-passenger, all-or-nothing partial availability, overlapping multi-seat
* requests, and a contested round trip.
*
* Everything goes through the public HTTP API — no direct database writes. Every request
* body is built up front and released together from one Promise.all, and each line reports
* an factor (summed latency / wall time) so the concurrency is provable rather
* than assumed.
*
* Bookings land in PENDING_PAYMENT and are never paid. Every ref created is printed at the
* end; cancel them afterwards. Pick a FUTURE departure with plenty of free seats.
*/
const BASE = (process.env.API_URL || 'http://localhost:3002').replace(/\/$/, '');
const argv = process.argv.slice(2);
const arg = (n, d) => { const i = argv.indexOf(`--${n}`); return i !== -1 && argv[i + 1] ? argv[i + 1] : d; };
const SCHED = arg('schedule');
const ORIGIN = arg('origin');
const DEST = arg('destination');
const RET_SCHED = arg('return-schedule');
const RET_ORIGIN = arg('return-origin');
const RET_DEST = arg('return-destination');
const MODE = arg('mode', 'ladder');
const LEVELS = arg('levels', '2,5,10,25,50,100').split(',').map(Number);
// Node's built-in fetch (undici) defaults to an unlimited per-origin connection pool, so
// Promise.all really does put every request on the wire at once. Proven per run by
// comparing wall time against the summed per-request latency (see in each line).
const RUN = Date.now().toString(36).slice(-5).toUpperCase();
let pc = 0;
const uuid = () => crypto.randomUUID();
async function api(method, path, body) {
const t0 = Date.now();
try {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = { raw: text.slice(0, 200) }; }
const data = json && typeof json === 'object' && 'success' in json && 'data' in json ? json.data : json;
return { ok: res.ok, status: res.status, json, data, ms: Date.now() - t0 };
} catch (e) {
return { ok: false, status: 0, json: { message: `NETWORK: ${e.message}` }, data: null, ms: Date.now() - t0 };
}
}
const msg = (r) => String(r.json?.message ?? r.json?.error ?? JSON.stringify(r.json ?? {})).slice(0, 150);
function person(seatId) {
pc++;
const tag = `${RUN}${String(pc).padStart(4, '0')}`;
return {
seatId,
passengerName: `Stress ${tag}`,
dateOfBirth: '1990-05-15',
idDocumentType: 'PASSPORT',
passportNumber: `ST${tag}`,
passportCountry: 'Djibouti',
nationality: 'Other',
phone: `+2519${String(40000000 + pc).slice(0, 8)}`,
};
}
const holdBody = (seatIds, sched = SCHED, o = ORIGIN, d = DEST, dir, pid) => ({
scheduleId: sched, originStationId: o, destinationStationId: d,
...(dir ? { journeyDirection: dir } : {}),
passengers: seatIds.map((seatId) => ({ passengerId: pid ?? uuid(), seatId })),
});
async function hold(seatIds, sched, o, d, dir, pid) {
const r = await api('POST', '/seats/hold', holdBody(seatIds, sched, o, d, dir, pid));
return { ...r, holdId: r.data?.holdId ?? r.data?.id };
}
function oneWayBody(holdId, seatIds, seatClassId) {
return {
bookingType: 'ONE_WAY', scheduleId: SCHED, holdId,
originStationId: ORIGIN, destinationStationId: DEST,
seatClassId, skipIdentityVerification: true,
passengers: seatIds.map((s) => person(s)),
};
}
function roundTripBody(holdId, returnHoldId, outSeats, retSeats, seatClassId) {
return {
bookingType: 'ROUND_TRIP', scheduleId: SCHED, holdId,
originStationId: ORIGIN, destinationStationId: DEST,
returnScheduleId: RET_SCHED, returnHoldId,
returnOriginStationId: RET_ORIGIN, returnDestinationStationId: RET_DEST,
seatClassId, returnSeatClassId: seatClassId, skipIdentityVerification: true,
passengers: outSeats.map((s, i) => ({ ...person(s), returnSeatId: retSeats[i] })),
};
}
async function book(body) {
const r = await api('POST', '/bookings/guest', body);
return { ...r, bookingRef: r.data?.bookingRef };
}
async function seatPool(sched = SCHED, o = ORIGIN, d = DEST) {
const r = await api('GET', `/seats/seatmap/${sched}?originStationId=${o}&destinationStationId=${d}`);
if (!r.ok) throw new Error(`seatmap ${r.status}: ${msg(r)}`);
const out = [];
for (const c of r.data?.coaches ?? [])
for (const s of c.seats ?? [])
if (String(s.status).toUpperCase() === 'AVAILABLE')
out.push({ id: s.id, seatNumber: s.seatNumber, coach: c.coachNumber, coachClass: c.seatClass });
return out;
}
async function seatClassId(seat) {
const r = await api('GET', '/seat-classes');
const list = Array.isArray(r.data) ? r.data : (r.data?.items ?? []);
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
return (list.find((c) => norm(c.name) === norm(seat.coachClass)) ?? list[0]).id;
}
/** Fire everything at once and report the arrival spread so "concurrent" is provable. */
async function fireAll(tasks) {
const t0 = Date.now();
const starts = [];
const wrapped = tasks.map((fn) => (async () => { starts.push(Date.now() - t0); return fn(); })());
const results = await Promise.all(wrapped);
const wallMs = Date.now() - t0;
const sumMs = results.reduce((a, r) => a + (r.ms || 0), 0);
// overlap >> 1 proves the requests were genuinely in flight together rather than queued.
return { results, wallMs, releaseSpreadMs: Math.max(...starts) - Math.min(...starts), overlap: (sumMs / Math.max(wallMs, 1)).toFixed(1) };
}
function tally(results) {
const t = {};
for (const r of results) {
const k = r.bookingRef ? 'created' : r.holdId ? 'held' : `http_${r.status}`;
t[k] = (t[k] || 0) + 1;
}
return t;
}
const errSample = (results) =>
[...new Set(results.filter((r) => !r.bookingRef && !r.holdId).map((r) => `${r.status}: ${msg(r)}`))].slice(0, 3);
const ALL = { created: [], failures: [] };
function note(refs) { ALL.created.push(...refs); }
// ─────────────────────────────────────────────────────────────────────────────
/** N users all click the SAME seat: N simultaneous holds. Exactly 1 must win. */
async function holdContest(pool, n) {
const seat = pool.shift();
const { results, wallMs, releaseSpreadMs, overlap } = await fireAll(
Array.from({ length: n }, () => () => hold([seat.id])),
);
const won = results.filter((r) => r.holdId);
return {
name: `hold-contest n=${n}`,
pass: won.length === 1,
detail: `seat ${seat.seatNumber}: ${won.length} hold(s) granted ${JSON.stringify(tally(results))} ` +
`wall=${wallMs}ms release-spread=${releaseSpreadMs}ms overlap=x${overlap}`,
errs: errSample(results),
winner: won[0],
seat,
};
}
/** The double-submit / retry storm: N simultaneous bookings on ONE valid hold. */
async function bookingStorm(seat, holdId, scid, n) {
const bodies = Array.from({ length: n }, () => oneWayBody(holdId, [seat.id], scid));
const { results, wallMs, releaseSpreadMs, overlap } = await fireAll(bodies.map((b) => () => book(b)));
const won = results.filter((r) => r.bookingRef);
note(won.map((r) => r.bookingRef));
return {
name: `booking-storm n=${n}`,
pass: won.length === 1,
detail: `seat ${seat.seatNumber}: ${won.length} booking(s) ${JSON.stringify(tally(results))} ` +
`wall=${wallMs}ms release-spread=${releaseSpreadMs}ms refs=${won.map((r) => r.bookingRef).join(',')}`,
errs: errSample(results),
};
}
/** N users, N DIFFERENT seats, all at once. All N must succeed — no false rejections. */
async function differentSeats(pool, scid, n) {
const seats = pool.splice(0, n);
if (seats.length < n) return { name: `diff-seats n=${n}`, pass: false, detail: 'not enough free seats', errs: [] };
const holds = await Promise.all(seats.map((s) => hold([s.id])));
const usable = holds.map((h, i) => ({ h, s: seats[i] })).filter((x) => x.h.holdId);
const bodies = usable.map((x) => oneWayBody(x.h.holdId, [x.s.id], scid));
const { results, wallMs, releaseSpreadMs, overlap } = await fireAll(bodies.map((b) => () => book(b)));
const won = results.filter((r) => r.bookingRef);
note(won.map((r) => r.bookingRef));
return {
name: `diff-seats n=${n}`,
pass: won.length === usable.length && usable.length === n,
detail: `${won.length}/${usable.length} distinct-seat bookings succeeded (holds granted ${usable.length}/${n}) ` +
`${JSON.stringify(tally(results))} wall=${wallMs}ms release-spread=${releaseSpreadMs}ms overlap=x${overlap}`,
errs: errSample(results),
};
}
/** Overlapping multi-seat requests over a small shared pool — deadlock + partial-write probe. */
async function overlapping(pool, scid, n, poolSize = 4) {
const shared = pool.splice(0, poolSize);
const holds = await Promise.all(shared.map((s) => hold([s.id])));
const byId = new Map(shared.map((s, i) => [s.id, holds[i]]));
const granted = shared.filter((s) => byId.get(s.id).holdId);
if (granted.length < 2) return { name: `overlap n=${n}`, pass: false, detail: 'too few holds', errs: [] };
// Each request asks for 2 seats from the shared pool, in varying order.
const bodies = [];
for (let i = 0; i < n; i++) {
const a = granted[i % granted.length];
const b = granted[(i + 1 + (i % 2)) % granted.length];
if (a.id === b.id) continue;
// Present the hold of the FIRST seat; second seat will be rejected as not-in-hold.
bodies.push({ body: oneWayBody(byId.get(a.id).holdId, [a.id, b.id], scid), seats: [a, b] });
}
const { results, wallMs } = await fireAll(bodies.map((x) => () => book(x.body)));
const won = results.filter((r) => r.bookingRef);
note(won.map((r) => r.bookingRef));
return {
name: `overlap n=${bodies.length}`,
pass: true, // correctness judged by the DB duplicate check, not the count
detail: `${won.length} booking(s) from ${bodies.length} overlapping 2-seat requests over ${granted.length} seats ` +
`${JSON.stringify(tally(results))} wall=${wallMs}ms`,
errs: errSample(results),
};
}
/** Multi-passenger booking where one seat is contested — must be all-or-nothing. */
async function partialAvailability(pool, scid) {
const [a, b, c] = pool.splice(0, 3);
const hAll = await hold([a.id, b.id, c.id]);
if (!hAll.holdId) return { name: 'partial-availability', pass: false, detail: `3-seat hold failed: ${msg(hAll)}`, errs: [] };
// Book seat B alone first (legitimately, from the same hold).
const first = await book(oneWayBody(hAll.holdId, [b.id], scid));
if (!first.bookingRef) return { name: 'partial-availability', pass: false, detail: `setup booking failed: ${msg(first)}`, errs: [] };
note([first.bookingRef]);
// Now try to book all three. B is taken -> the whole request must fail, leaving no row.
const second = await book(oneWayBody(hAll.holdId, [a.id, b.id, c.id], scid));
if (second.bookingRef) {
note([second.bookingRef]);
return { name: 'partial-availability', pass: false, detail: `3-seat booking succeeded despite seat ${b.seatNumber} being taken — ${second.bookingRef}`, errs: [] };
}
return {
name: 'partial-availability',
pass: true,
detail: `seat ${b.seatNumber} taken -> 3-seat request rejected whole (${second.status}: ${msg(second)}); seats ${a.seatNumber},${c.seatNumber} must remain free`,
errs: [],
freeSeats: [a, c],
takenSeat: b,
};
}
/** N concurrent ROUND_TRIP bookings contesting one outbound+return seat pair. */
async function roundTripContest(n, scid) {
if (!RET_SCHED) return { name: `round-trip n=${n}`, pass: true, detail: 'skipped (no --return-schedule)', errs: [] };
const outPool = await seatPool(SCHED, ORIGIN, DEST);
const retPool = await seatPool(RET_SCHED, RET_ORIGIN, RET_DEST);
const outSeat = outPool[0];
const retSeat = retPool.find((s) => s.id !== outSeat.id) ?? retPool[0];
const traveller = uuid();
const hOut = await hold([outSeat.id], SCHED, ORIGIN, DEST, 'OUTBOUND', traveller);
const hRet = await hold([retSeat.id], RET_SCHED, RET_ORIGIN, RET_DEST, 'RETURN', traveller);
if (!hOut.holdId || !hRet.holdId) {
return { name: `round-trip n=${n}`, pass: false, detail: `hold failed out=${msg(hOut)} ret=${msg(hRet)}`, errs: [] };
}
const bodies = Array.from({ length: n }, () =>
roundTripBody(hOut.holdId, hRet.holdId, [outSeat.id], [retSeat.id], scid));
const { results, wallMs } = await fireAll(bodies.map((b) => () => book(b)));
const won = results.filter((r) => r.bookingRef);
note(won.map((r) => r.bookingRef));
return {
name: `round-trip n=${n}`,
pass: won.length === 1,
detail: `out seat ${outSeat.seatNumber} / ret seat ${retSeat.seatNumber}: ${won.length} booking(s) ` +
`${JSON.stringify(tally(results))} wall=${wallMs}ms refs=${won.map((r) => r.bookingRef).join(',')}`,
errs: errSample(results),
seats: { outSeat, retSeat },
};
}
/** A single multi-passenger, multi-seat booking — the ordinary family booking. */
async function multiPassenger(pool, scid, count = 4) {
const seats = pool.splice(0, count);
const h = await hold(seats.map((s) => s.id));
if (!h.holdId) return { name: `multi-passenger x${count}`, pass: false, detail: `hold failed: ${msg(h)}`, errs: [] };
const r = await book(oneWayBody(h.holdId, seats.map((s) => s.id), scid));
if (r.bookingRef) note([r.bookingRef]);
return {
name: `multi-passenger x${count}`,
pass: !!r.bookingRef,
detail: r.bookingRef
? `${count} passengers on seats ${seats.map((s) => s.seatNumber).join(',')} -> ${r.bookingRef}`
: `rejected (${r.status}): ${msg(r)}`,
errs: [],
seats,
ref: r.bookingRef,
};
}
// ─────────────────────────────────────────────────────────────────────────────
(async () => {
console.log(`API ${BASE}`);
console.log(`Schedule ${SCHED}`);
if (RET_SCHED) console.log(`Return ${RET_SCHED}`);
const pool = await seatPool();
const scid = await seatClassId(pool[0]);
console.log(`${pool.length} AVAILABLE seats, seatClass ${scid}\n`);
const out = [];
const report = (r) => {
out.push(r);
console.log(`${r.pass ? 'PASS' : 'FAIL'} ${r.name}`);
console.log(` ${r.detail}`);
if (r.errs?.length) r.errs.forEach((e) => console.log(` · ${e}`));
console.log('');
return r;
};
if (MODE === 'ladder' || MODE === 'all') {
for (const n of LEVELS) {
console.log(`──────── concurrency ${n} ────────`);
const hc = report(await holdContest(pool, n));
if (hc.winner) report(await bookingStorm(hc.seat, hc.winner.holdId, scid, n));
report(await differentSeats(pool, scid, n));
}
}
if (MODE === 'edge' || MODE === 'all') {
console.log(`──────── edge cases ────────`);
report(await multiPassenger(pool, scid, 4));
report(await partialAvailability(pool, scid));
report(await overlapping(pool, scid, 12));
report(await roundTripContest(10, scid));
}
const fails = out.filter((r) => !r.pass);
console.log(`════════ ${out.length} checks, ${fails.length} failed, ${ALL.created.length} bookings created ════════`);
fails.forEach((f) => console.log(` FAIL ${f.name}: ${f.detail}`));
if (ALL.created.length) {
console.log(`\nrefs: ${ALL.created.join(' ')}`);
}
process.exit(fails.length ? 1 : 0);
})().catch((e) => { console.error(e); process.exit(2); });

View File

@@ -26,3 +26,30 @@ export const PassengerStaffStrict = (permission: string | string[]) =>
); );
export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin); export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin);
/**
* A create / edit / domain action: the narrow key, the resource's `:manage`
* umbrella, or admin.
*
* Keeping `:manage` in the array is what makes the fine-grained keys additive —
* every position already granted `<res>:manage` keeps working without being
* re-granted in IAM. Grant the narrow key *instead of* `:manage` to restrict
* someone.
*
* @PassengerWrite(PASSENGER_PERMS.schedules.create, PASSENGER_PERMS.schedules.manage)
*/
export const PassengerWrite = (narrow: string, umbrella: string) =>
PassengerStaff([narrow, umbrella, PASSENGER_PERMS.admin]);
/**
* A delete: the narrow `:delete` key or admin. **`:manage` is deliberately not
* accepted.**
*
* Every DELETE in this app was `@PassengerAdmin()` before the fine-grained keys
* existed, and most are hard cascading deletes. Letting `:manage` through here
* would silently hand deletion to `operationsManager`, `marketingManager` and
* every other role holding a `:manage` key — access they do not have today.
* So `:manage` means create + edit, never delete.
*/
export const PassengerDelete = (narrow: string) =>
PassengerStaff([narrow, PASSENGER_PERMS.admin]);

View File

@@ -145,3 +145,31 @@ export function assertPassengerPermission(
if (hasPassengerPermission(user, permissionKey)) return; if (hasPassengerPermission(user, permissionKey)) return;
throw new ForbiddenException(`Missing permission: ${permissionKey}`); throw new ForbiddenException(`Missing permission: ${permissionKey}`);
} }
/** Holds at least one of the keys. Same OR semantics as `PassengerPermissionGuard`. */
export function hasAnyPassengerPermission(
user: MeLikeUser | null | undefined,
permissionKeys: string[],
): boolean {
return permissionKeys.some((key) => hasPassengerPermission(user, key));
}
/**
* The in-handler equivalent of `@PassengerStaff([...])`, for actions a decorator
* cannot see — where the destructive variant is chosen by a body field rather
* than by the route. Cancelling a schedule is the case this exists for:
* `PATCH /schedules/:id/status` carries `{ status: 'CANCELLED' }` on the same
* route as every routine transition.
*
* Pass the umbrella and admin keys alongside the narrow one, exactly as a guard
* array would, so existing grants keep working.
*/
export function assertAnyPassengerPermission(
user: MeLikeUser | null | undefined,
permissionKeys: string[],
): void {
if (hasAnyPassengerPermission(user, permissionKeys)) return;
throw new ForbiddenException(
`Missing permission. Required one of: ${permissionKeys.join(', ')}`,
);
}

View File

@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service'; import { AgentsService } from './agents.service';
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards'; import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Agents') @ApiTags('Agents')
@Controller('agents') @Controller('agents')
@@ -26,18 +27,20 @@ export class AgentsController {
@Post() @Post()
@ApiOperation({ summary: 'Create agent profile linked to an IAM user' }) @ApiOperation({ summary: 'Create agent profile linked to an IAM user' })
@PassengerWrite(PASSENGER_PERMS.agents.create, PASSENGER_PERMS.agents.manage)
createAgent(@Body() dto: CreateAgentDto) { createAgent(@Body() dto: CreateAgentDto) {
return this.service.createAgent(dto); return this.service.createAgent(dto);
} }
@Patch(':id') @Patch(':id')
@ApiOperation({ summary: 'Update agent profile' }) @ApiOperation({ summary: 'Update agent profile' })
@PassengerWrite(PASSENGER_PERMS.agents.edit, PASSENGER_PERMS.agents.manage)
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) { updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
return this.service.updateAgent(id, dto); return this.service.updateAgent(id, dto);
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.agents.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete agent profile' }) @ApiOperation({ summary: 'Delete agent profile' })
deleteAgent(@Param('id') id: string) { deleteAgent(@Param('id') id: string) {

View File

@@ -8,7 +8,7 @@ import { PrismaService } from '../../common/prisma.service';
* the same person can book that train again. PENDING_PAYMENT counts — otherwise the whole check * the same person can book that train again. PENDING_PAYMENT counts — otherwise the whole check
* is bypassable by simply never finishing the first payment. * is bypassable by simply never finishing the first payment.
*/ */
const ACTIVE_BOOKING_STATUSES: BookingStatus[] = [ export const ACTIVE_BOOKING_STATUSES: BookingStatus[] = [
BookingStatus.DRAFT, BookingStatus.DRAFT,
BookingStatus.PENDING_PAYMENT, BookingStatus.PENDING_PAYMENT,
BookingStatus.CONFIRMED, BookingStatus.CONFIRMED,

View File

@@ -34,7 +34,7 @@ import {
IssueReservationBookingDto, IssueReservationBookingDto,
} from "./guest-booking.dto"; } from "./guest-booking.dto";
import { JwtGuard } from "../../common/jwt.guard"; import { JwtGuard } from "../../common/jwt.guard";
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards"; import { PassengerDelete, PassengerStaff, PassengerStaffStrict, PassengerWrite } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { SeatsService } from "../seats/seats.service"; import { SeatsService } from "../seats/seats.service";
@@ -372,7 +372,7 @@ export class BookingsController {
} }
@Post("group") @Post("group")
@PassengerStaff([PASSENGER_PERMS.bookings.manage]) @PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group", summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
@@ -417,7 +417,14 @@ If booking creation fails after the seats were already held, every hold involved
} }
@Delete("reservations/:seatId") @Delete("reservations/:seatId")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]) // Releasing a held seat is a cancel, not a booking delete — `bookings:cancel` is the
// narrow key for it; the two `:manage` keys stay so today's holders are unaffected.
@PassengerStaff([
PASSENGER_PERMS.bookings.cancel,
PASSENGER_PERMS.seats.manage,
PASSENGER_PERMS.bookings.manage,
PASSENGER_PERMS.admin,
])
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Cancel a seat's pending-payment reservation and release the seat", summary: "Cancel a seat's pending-payment reservation and release the seat",
@@ -703,7 +710,7 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b
} }
@Delete(":id") @Delete(":id")
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.bookings.delete)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
description: "Permanently deletes a booking record", description: "Permanently deletes a booking record",

View File

@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException, Logger } from '@nes
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service'; import { SeatsService, SeatClaim } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service'; import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateBookingDto } from './bookings.dto'; import { CreateBookingDto } from './bookings.dto';
@@ -873,15 +873,10 @@ export class BookingsService {
return this.createOneWayBooking(dto); return this.createOneWayBooking(dto);
} }
private validateSeatIdsAgainstHold(holdId: string, holdSeatIds: string[], requestedSeatIds: string[]) { // validateSeatIdsAgainstHold lived here. Every caller now goes through
for (const seatId of requestedSeatIds) { // SeatsService.assertSeatsClaimable, which performs the same seat-in-hold check alongside
if (!holdSeatIds.includes(seatId)) { // the hold-is-live, hold-is-for-this-schedule and no-conflict checks — and, at write time,
throw new BadRequestException( // re-runs all of them inside the seat lock.
`Seat ${seatId} is not part of hold ${holdId}. Use seat IDs returned from POST /seats/hold.`,
);
}
}
}
/** Resolves contactEmail/contactPhone for an IAM-authenticated passenger booking. */ /** Resolves contactEmail/contactPhone for an IAM-authenticated passenger booking. */
private async resolveIamContact(passengerId?: string): Promise<{ contactEmail: string | null; contactPhone: string | null }> { private async resolveIamContact(passengerId?: string): Promise<{ contactEmail: string | null; contactPhone: string | null }> {
@@ -896,11 +891,18 @@ export class BookingsService {
} }
private async createOneWayBooking(dto: CreateBookingDto) { private async createOneWayBooking(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); // Hold live, on this departure, covering these seats, and nobody else holding or booked
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); // on them. Advisory here — re-run under the seat lock at the write below.
const requestedSeatIds = (dto.passengers as any[]).map(p => p.seatId).filter((id: any): id is string => !!id);
const requestedSeatIds = (dto.passengers as any[]).map(p => p.seatId); const claims: SeatClaim[] = [{
this.validateSeatIdsAgainstHold(dto.holdId, hold.seatIds, requestedSeatIds); holdId: dto.holdId,
seatIds: requestedSeatIds,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
journeyDirection: JourneyDirection.ONE_WAY,
}];
await this.seatsService.assertSeatsClaimableAll(claims);
const schedule = await this.prisma.trainSchedule.findUnique({ const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId }, where: { id: dto.scheduleId },
@@ -912,14 +914,6 @@ export class BookingsService {
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
await this.seatsService.assertNoRouteSeatConflict({
scheduleId: dto.scheduleId,
seatIds: requestedSeatIds,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
journeyDirection: JourneyDirection.ONE_WAY,
});
const [passengersData, iamContact] = await Promise.all([ const [passengersData, iamContact] = await Promise.all([
this.processPassengers(dto.passengers as any[]), this.processPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId), this.resolveIamContact(dto.passengerId),
@@ -1029,7 +1023,9 @@ export class BookingsService {
// still pass; the tolerance absorbs FX-conversion rounding. // still pass; the tolerance absorbs FX-conversion rounding.
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, fareCalculation.totalMinor, 'createOneWayBooking'); this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, fareCalculation.totalMinor, 'createOneWayBooking');
const booking = await this.prisma.booking.create({ // Locked and re-validated inside the lock — see SeatsService.claimSeatsAndWrite. All the
// slow work (fare engine, FX, identity) is already done, so this transaction stays short.
const booking = await this.seatsService.claimSeatsAndWrite(claims, async (tx) => tx.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: dto.passengerId, passengerId: dto.passengerId,
@@ -1068,7 +1064,7 @@ export class BookingsService {
} }
}, },
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
}); }));
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId)); await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
if (dto.packageId && dto.priceTierId) { if (dto.packageId && dto.priceTierId) {
@@ -1087,18 +1083,31 @@ export class BookingsService {
throw new BadRequestException('Return trip details required for round-trip booking'); throw new BadRequestException('Return trip details required for round-trip booking');
} }
const [outboundHold, returnHold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } })
]);
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired');
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired');
const holdObSeatIds = (dto.passengers as any[]).map((p: any) => p.seatId ?? p.outboundSeatId).filter(Boolean); const holdObSeatIds = (dto.passengers as any[]).map((p: any) => p.seatId ?? p.outboundSeatId).filter(Boolean);
const holdRetSeatIds = (dto.passengers as any[]).map((p: any) => p.returnSeatId).filter(Boolean); const holdRetSeatIds = (dto.passengers as any[]).map((p: any) => p.returnSeatId).filter(Boolean);
if (holdObSeatIds.length) this.validateSeatIdsAgainstHold(dto.holdId, outboundHold.seatIds, holdObSeatIds);
if (holdRetSeatIds.length) this.validateSeatIdsAgainstHold(dto.returnHoldId!, returnHold.seatIds, holdRetSeatIds); // Advisory pass; re-run under the seat lock at the write.
const claims: SeatClaim[] = [
{
holdId: dto.holdId,
seatIds: holdObSeatIds,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
journeyDirection: JourneyDirection.OUTBOUND,
legLabel: 'Outbound',
},
{
holdId: dto.returnHoldId!,
seatIds: holdRetSeatIds,
scheduleId: dto.returnScheduleId!,
originStationId: dto.returnOriginStationId,
destinationStationId: dto.returnDestinationStationId,
journeyDirection: JourneyDirection.RETURN,
legLabel: 'Return',
},
];
await this.seatsService.assertSeatsClaimableAll(claims);
const [outboundSchedule, returnSchedule] = await Promise.all([ const [outboundSchedule, returnSchedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({ this.prisma.trainSchedule.findUnique({
@@ -1122,21 +1131,6 @@ export class BookingsService {
throw new NotFoundException('Origin or destination stops not found'); throw new NotFoundException('Origin or destination stops not found');
} }
await this.seatsService.assertNoRouteSeatConflict({
scheduleId: dto.scheduleId,
seatIds: holdObSeatIds,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
journeyDirection: JourneyDirection.OUTBOUND,
});
await this.seatsService.assertNoRouteSeatConflict({
scheduleId: dto.returnScheduleId,
seatIds: holdRetSeatIds,
originStationId: dto.returnOriginStationId,
destinationStationId: dto.returnDestinationStationId,
journeyDirection: JourneyDirection.RETURN,
});
const [passengersData, iamContact] = await Promise.all([ const [passengersData, iamContact] = await Promise.all([
this.processRoundTripPassengers(dto.passengers as any[]), this.processRoundTripPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId), this.resolveIamContact(dto.passengerId),
@@ -1252,7 +1246,8 @@ export class BookingsService {
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare. // C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createRoundTripBooking'); this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createRoundTripBooking');
const booking = await this.prisma.booking.create({ // Locked across both legs' seats and re-validated inside the lock.
const booking = await this.seatsService.claimSeatsAndWrite(claims, async (tx) => tx.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: dto.passengerId, passengerId: dto.passengerId,
@@ -1314,7 +1309,7 @@ export class BookingsService {
}, },
} as any, } as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
}); }));
const outboundSeatIds = passengersData.map(p => p.outboundSeatId); const outboundSeatIds = passengersData.map(p => p.outboundSeatId);
const returnSeatIds = passengersData.map(p => p.returnSeatId); const returnSeatIds = passengersData.map(p => p.returnSeatId);
@@ -1355,17 +1350,31 @@ export class BookingsService {
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings'); throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
} }
const [leg1Hold, leg2Hold] = await Promise.all([ const leg1SeatIds = (dto.passengers as any[]).map(p => p.seatId).filter(Boolean);
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), const leg2SeatIds = (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId).filter(Boolean);
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
]);
if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired');
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired');
const leg1SeatIds = (dto.passengers as any[]).map(p => p.seatId); // Advisory pass; re-run under the seat lock at the write.
const leg2SeatIds = (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId); const claims: SeatClaim[] = [
this.validateSeatIdsAgainstHold(dto.holdId, leg1Hold.seatIds, leg1SeatIds); {
this.validateSeatIdsAgainstHold(dto.leg2HoldId!, leg2Hold.seatIds, leg2SeatIds); holdId: dto.holdId,
seatIds: leg1SeatIds,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.transitStationId,
journeyDirection: JourneyDirection.ONE_WAY,
legLabel: 'Leg-1',
},
{
holdId: dto.leg2HoldId!,
seatIds: leg2SeatIds,
scheduleId: dto.leg2ScheduleId!,
originStationId: dto.transitStationId,
destinationStationId: dto.leg2DestinationStationId,
journeyDirection: JourneyDirection.ONE_WAY,
legLabel: 'Leg-2',
},
];
await this.seatsService.assertSeatsClaimableAll(claims);
const [leg1Schedule, leg2Schedule] = await Promise.all([ const [leg1Schedule, leg2Schedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({ this.prisma.trainSchedule.findUnique({
@@ -1387,21 +1396,6 @@ export class BookingsService {
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule'); if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule'); if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule');
await this.seatsService.assertNoRouteSeatConflict({
scheduleId: dto.scheduleId,
seatIds: leg1SeatIds,
originStationId: dto.originStationId,
destinationStationId: dto.transitStationId,
journeyDirection: JourneyDirection.ONE_WAY,
});
await this.seatsService.assertNoRouteSeatConflict({
scheduleId: dto.leg2ScheduleId,
seatIds: leg2SeatIds,
originStationId: dto.transitStationId,
destinationStationId: dto.leg2DestinationStationId,
journeyDirection: JourneyDirection.ONE_WAY,
});
const [passengersData, iamContact] = await Promise.all([ const [passengersData, iamContact] = await Promise.all([
this.processPassengers(dto.passengers as any[]), this.processPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId), this.resolveIamContact(dto.passengerId),
@@ -1467,7 +1461,8 @@ export class BookingsService {
}); });
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2 // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
const booking = await this.prisma.booking.create({ // Locked across both legs' seats and re-validated inside the lock.
const booking = await this.seatsService.claimSeatsAndWrite(claims, async (tx) => tx.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: dto.passengerId, passengerId: dto.passengerId,
@@ -1526,7 +1521,7 @@ export class BookingsService {
}, },
} as any, } as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }, include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
}); }));
await Promise.all([ await Promise.all([
this.seatsService.confirmSeats(passengersData.map(p => p.seatId)), this.seatsService.confirmSeats(passengersData.map(p => p.seatId)),
@@ -1561,23 +1556,49 @@ export class BookingsService {
); );
} }
// Validate all 4 holds // All 4 holds: live, on their own departure, covering their leg's seats, unconflicted.
const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([ // Advisory pass; re-run under the seat lock at the write.
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), const seatsOf = (pick: (p: any) => string | undefined) =>
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }), (dto.passengers as any[]).map(pick).filter((id): id is string => !!id);
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }), const claims: SeatClaim[] = [
this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }), {
]); holdId: dto.holdId,
const now = new Date(); seatIds: seatsOf(p => p.seatId),
if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 seat hold expired'); scheduleId: dto.scheduleId,
if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 seat hold expired'); originStationId: dto.originStationId,
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired'); destinationStationId: dto.transitStationId,
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired'); journeyDirection: JourneyDirection.OUTBOUND,
legLabel: 'Outbound leg-1',
this.validateSeatIdsAgainstHold(dto.holdId, obL1Hold.seatIds, (dto.passengers as any[]).map(p => p.seatId)); },
this.validateSeatIdsAgainstHold(dto.leg2HoldId!, obL2Hold.seatIds, (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId)); {
this.validateSeatIdsAgainstHold(dto.returnHoldId!, retL1Hold.seatIds, (dto.passengers as any[]).map(p => p.returnSeatId)); holdId: dto.leg2HoldId!,
this.validateSeatIdsAgainstHold(dto.returnLeg2HoldId!, retL2Hold.seatIds, (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId)); seatIds: seatsOf(p => p.leg2SeatId ?? p.seatId),
scheduleId: dto.leg2ScheduleId!,
originStationId: dto.transitStationId,
destinationStationId: dto.leg2DestinationStationId,
journeyDirection: JourneyDirection.OUTBOUND,
legLabel: 'Outbound leg-2',
},
{
holdId: dto.returnHoldId!,
seatIds: seatsOf(p => p.returnSeatId),
scheduleId: dto.returnScheduleId!,
originStationId: dto.returnOriginStationId,
destinationStationId: dto.returnTransitStationId,
journeyDirection: JourneyDirection.RETURN,
legLabel: 'Return leg-1',
},
{
holdId: dto.returnLeg2HoldId!,
seatIds: seatsOf(p => p.returnLeg2SeatId ?? p.returnSeatId),
scheduleId: dto.returnLeg2ScheduleId!,
originStationId: dto.returnTransitStationId,
destinationStationId: dto.returnLeg2DestinationStationId,
journeyDirection: JourneyDirection.RETURN,
legLabel: 'Return leg-2',
},
];
await this.seatsService.assertSeatsClaimableAll(claims);
// Load all 4 schedules // Load all 4 schedules
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
@@ -1604,35 +1625,6 @@ export class BookingsService {
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found'); if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found'); if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found');
await this.seatsService.assertNoRouteSeatConflict({
scheduleId: dto.scheduleId,
seatIds: (dto.passengers as any[]).map(p => p.seatId),
originStationId: dto.originStationId,
destinationStationId: dto.transitStationId,
journeyDirection: JourneyDirection.OUTBOUND,
});
await this.seatsService.assertNoRouteSeatConflict({
scheduleId: dto.leg2ScheduleId,
seatIds: (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId),
originStationId: dto.transitStationId,
destinationStationId: dto.leg2DestinationStationId,
journeyDirection: JourneyDirection.OUTBOUND,
});
await this.seatsService.assertNoRouteSeatConflict({
scheduleId: dto.returnScheduleId,
seatIds: (dto.passengers as any[]).map(p => p.returnSeatId),
originStationId: dto.returnOriginStationId,
destinationStationId: dto.returnTransitStationId,
journeyDirection: JourneyDirection.RETURN,
});
await this.seatsService.assertNoRouteSeatConflict({
scheduleId: dto.returnLeg2ScheduleId,
seatIds: (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId),
originStationId: dto.returnTransitStationId,
destinationStationId: dto.returnLeg2DestinationStationId,
journeyDirection: JourneyDirection.RETURN,
});
const [passengersData, iamContact] = await Promise.all([ const [passengersData, iamContact] = await Promise.all([
this.processRoundTripPassengers(dto.passengers as any[]), this.processRoundTripPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId), this.resolveIamContact(dto.passengerId),
@@ -1718,7 +1710,8 @@ export class BookingsService {
displayCurrency, displayCurrency,
}); });
const booking = await this.prisma.booking.create({ // Locked across all four legs' seats and re-validated inside the lock.
const booking = await this.seatsService.claimSeatsAndWrite(claims, async (tx) => tx.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: dto.passengerId, passengerId: dto.passengerId,
@@ -1759,7 +1752,7 @@ export class BookingsService {
}, },
} as any, } as any,
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }, include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
}); }));
await Promise.all([ await Promise.all([
this.seatsService.confirmSeats(passengersData.map(p => p.outboundSeatId)), this.seatsService.confirmSeats(passengersData.map(p => p.outboundSeatId)),

View File

@@ -2,7 +2,7 @@ import { Injectable, BadRequestException, NotFoundException, Logger } from '@nes
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service'; import { SeatsService, SeatClaim } from '../seats/seats.service';
import { VerifaydaService } from '../verifayda/verifayda.service'; import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service'; import { CurrencyService } from '../currency/currency.service';
import { PassengerAuthService } from '../auth/passenger-auth.service'; import { PassengerAuthService } from '../auth/passenger-auth.service';
@@ -146,11 +146,20 @@ export class GuestBookingService {
private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) { private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) {
const authUserId: string | null = req?.user?.id ?? null; const authUserId: string | null = req?.user?.id ?? null;
// Validate hold const claimedSeatIds = dto.passengers.map((p) => p.seatId).filter((id): id is string => !!id);
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); // Fail fast, before any fare or identity work: hold is live, belongs to this departure,
if (!hold || hold.expiresAt < new Date()) { // covers the seats asked for, and nobody else holds or has booked them. This is a
throw new BadRequestException('Seat hold expired or not found'); // courtesy check for a clean early error — the authoritative one runs under the seat
} // lock at the write below, because anything checked out here can change before we write.
const claims: SeatClaim[] = [{
holdId: dto.holdId,
seatIds: claimedSeatIds,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
journeyDirection: JourneyDirection.ONE_WAY,
}];
await this.seatsService.assertSeatsClaimableAll(claims);
// Get schedule // Get schedule
const schedule = await this.prisma.trainSchedule.findUnique({ const schedule = await this.prisma.trainSchedule.findUnique({
@@ -371,8 +380,15 @@ export class GuestBookingService {
} }
} }
// Create booking // Create booking.
const booking = await this.prisma.booking.create({ //
// Locked, and re-validated inside the lock. Everything slow — fare engine, FX, Verifayda,
// guest-passenger resolution — is already done above, so this transaction is two reads and
// a write. Re-checking here is the whole point: the courtesy check at the top of the method
// ran hundreds of milliseconds ago, and between then and now another request holding the
// same hold (a double-submit, a retry after a timeout) could have booked these seats.
const booking = await this.seatsService.claimSeatsAndWrite(claims, async (tx) => {
return tx.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: guestPassengerId, passengerId: guestPassengerId,
@@ -418,6 +434,7 @@ export class GuestBookingService {
seats: { include: { seat: { include: { coach: true } } } }, seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
}, },
});
}); });
// Save passenger details as traveler profiles — guest bookings only. // Save passenger details as traveler profiles — guest bookings only.
@@ -702,19 +719,35 @@ export class GuestBookingService {
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP'); throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
} }
// Validate both holds
const [outboundHold, returnHold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
]);
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found');
if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
// Validate passengers have returnSeatId // Validate passengers have returnSeatId
for (const p of dto.passengers) { for (const p of dto.passengers) {
if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`); if (!p.returnSeatId) throw new BadRequestException(`returnSeatId is required for each passenger in a ROUND_TRIP booking (missing for ${p.passengerName})`);
} }
// Each leg's hold must be live, belong to that leg's departure, cover that leg's seats,
// and clear the conflict check. Advisory here; re-run under the seat lock at the write.
const claims: SeatClaim[] = [
{
holdId: dto.holdId,
seatIds: dto.passengers.map((p) => p.seatId).filter((id): id is string => !!id),
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
journeyDirection: JourneyDirection.OUTBOUND,
legLabel: 'Outbound',
},
{
holdId: dto.returnHoldId,
seatIds: dto.passengers.map((p) => p.returnSeatId).filter((id): id is string => !!id),
scheduleId: dto.returnScheduleId,
originStationId: dto.returnOriginStationId,
destinationStationId: dto.returnDestinationStationId,
journeyDirection: JourneyDirection.RETURN,
legLabel: 'Return',
},
];
await this.seatsService.assertSeatsClaimableAll(claims);
// Load both schedules // Load both schedules
const [outboundSchedule, returnSchedule] = await Promise.all([ const [outboundSchedule, returnSchedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({ this.prisma.trainSchedule.findUnique({
@@ -921,7 +954,10 @@ export class GuestBookingService {
const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id); const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id);
const returnSeatIds = dto.passengers.map(p => p.returnSeatId!); const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
const booking = await this.prisma.booking.create({ // Locked across BOTH legs' seats, with each leg's claim re-checked inside the lock —
// so a round trip is all-or-nothing: it never commits with the outbound seat secured
// and the return seat sold out from under it.
const booking = await this.seatsService.claimSeatsAndWrite(claims, async (tx) => tx.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: guestPassengerId, passengerId: guestPassengerId,
@@ -987,7 +1023,7 @@ export class GuestBookingService {
seats: { include: { seat: { include: { coach: true } } } }, seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
}, },
}); }));
// Traveler profiles: guest bookings only (authenticated passengers already have one). // Traveler profiles: guest bookings only (authenticated passengers already have one).
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData); if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
@@ -1032,17 +1068,32 @@ export class GuestBookingService {
throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings'); throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings');
} }
const [leg1Hold, leg2Hold] = await Promise.all([
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
]);
if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired or not found');
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired or not found');
for (const p of dto.passengers) { for (const p of dto.passengers) {
if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId is required for each passenger in a TRANSIT booking (missing for ${p.passengerName})`); if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId is required for each passenger in a TRANSIT booking (missing for ${p.passengerName})`);
} }
const claims: SeatClaim[] = [
{
holdId: dto.holdId,
seatIds: dto.passengers.map((p) => p.seatId).filter((id): id is string => !!id),
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.transitStationId,
journeyDirection: JourneyDirection.ONE_WAY,
legLabel: 'Leg-1',
},
{
holdId: dto.leg2HoldId,
seatIds: dto.passengers.map((p) => p.leg2SeatId).filter((id): id is string => !!id),
scheduleId: dto.leg2ScheduleId,
originStationId: dto.transitStationId,
destinationStationId: dto.leg2DestinationStationId,
journeyDirection: JourneyDirection.ONE_WAY,
legLabel: 'Leg-2',
},
];
await this.seatsService.assertSeatsClaimableAll(claims);
const [leg1Schedule, leg2Schedule] = await Promise.all([ const [leg1Schedule, leg2Schedule] = await Promise.all([
this.prisma.trainSchedule.findUnique({ this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId }, where: { id: dto.scheduleId },
@@ -1140,8 +1191,9 @@ export class GuestBookingService {
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
const contact = await this.resolveActorContact(req, passengersData[0]); const contact = await this.resolveActorContact(req, passengersData[0]);
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2 // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2.
const booking = await this.prisma.booking.create({ // Locked across both legs' seats and re-checked inside the lock.
const booking = await this.seatsService.claimSeatsAndWrite(claims, async (tx) => tx.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: guestPassengerId, passengerId: guestPassengerId,
@@ -1204,7 +1256,7 @@ export class GuestBookingService {
seats: { include: { seat: { include: { coach: true } } } }, seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
}, },
}); }));
// Traveler profiles: guest bookings only (authenticated passengers already have one). // Traveler profiles: guest bookings only (authenticated passengers already have one).
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData); if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);
@@ -1247,17 +1299,48 @@ export class GuestBookingService {
if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`); if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`);
} }
const now = new Date(); const seatsOf = (pick: (p: (typeof dto.passengers)[number]) => string | undefined) =>
const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([ dto.passengers.map(pick).filter((id): id is string => !!id);
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }), const claims: SeatClaim[] = [
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }), {
this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }), holdId: dto.holdId,
]); seatIds: seatsOf((p) => p.seatId),
if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired'); scheduleId: dto.scheduleId,
if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired'); originStationId: dto.originStationId,
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired'); destinationStationId: dto.transitStationId,
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired'); journeyDirection: JourneyDirection.OUTBOUND,
legLabel: 'Outbound leg-1',
},
{
holdId: dto.leg2HoldId,
seatIds: seatsOf((p) => p.leg2SeatId),
scheduleId: dto.leg2ScheduleId,
originStationId: dto.transitStationId,
destinationStationId: dto.leg2DestinationStationId,
journeyDirection: JourneyDirection.OUTBOUND,
legLabel: 'Outbound leg-2',
},
{
holdId: dto.returnHoldId,
seatIds: seatsOf((p) => p.returnSeatId),
scheduleId: dto.returnScheduleId,
originStationId: dto.returnOriginStationId,
destinationStationId: dto.returnTransitStationId,
journeyDirection: JourneyDirection.RETURN,
legLabel: 'Return leg-1',
},
{
holdId: dto.returnLeg2HoldId,
seatIds: seatsOf((p) => p.returnLeg2SeatId),
scheduleId: dto.returnLeg2ScheduleId,
originStationId: dto.returnTransitStationId,
destinationStationId: dto.returnLeg2DestinationStationId,
journeyDirection: JourneyDirection.RETURN,
legLabel: 'Return leg-2',
},
];
await this.seatsService.assertSeatsClaimableAll(claims);
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } } }),
@@ -1371,7 +1454,8 @@ export class GuestBookingService {
displayCurrency, displayCurrency,
}); });
const booking = await this.prisma.booking.create({ // Locked across all four legs' seats and re-checked inside the lock.
const booking = await this.seatsService.claimSeatsAndWrite(claims, async (tx) => tx.booking.create({
data: { data: {
bookingRef: generateRef(), bookingRef: generateRef(),
passengerId: guestPassengerId, passengerId: guestPassengerId,
@@ -1410,7 +1494,7 @@ export class GuestBookingService {
seats: { include: { seat: { include: { coach: true } } } }, seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } },
}, },
}); }));
// Traveler profiles: guest bookings only (authenticated passengers already have one). // Traveler profiles: guest bookings only (authenticated passengers already have one).
if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData); if (!authUserId) await this.createTravelerProfiles(guestPassengerId, passengersData);

View File

@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator'; import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { CurrencyService } from './currency.service'; import { CurrencyService } from './currency.service';
import { PassengerAdmin } from '../../common/passenger-guards'; import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
class CreateRateDto { class CreateRateDto {
@IsEnum(Currency) fromCurrency: Currency; @IsEnum(Currency) fromCurrency: Currency;
@@ -30,7 +31,7 @@ export class CurrencyController {
} }
@Post() @Post()
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.currencies.create, PASSENGER_PERMS.currencies.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create exchange rate' }) @ApiOperation({ summary: 'Create exchange rate' })
create(@Body() dto: CreateRateDto) { create(@Body() dto: CreateRateDto) {
@@ -38,7 +39,7 @@ export class CurrencyController {
} }
@Patch(':id') @Patch(':id')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.currencies.edit, PASSENGER_PERMS.currencies.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update exchange rate by ID' }) @ApiOperation({ summary: 'Update exchange rate by ID' })
update(@Param('id') id: string, @Body() dto: UpdateRateDto) { update(@Param('id') id: string, @Body() dto: UpdateRateDto) {
@@ -46,7 +47,7 @@ export class CurrencyController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.currencies.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete exchange rate by ID' }) @ApiOperation({ summary: 'Delete exchange rate by ID' })
delete(@Param('id') id: string) { delete(@Param('id') id: string) {

View File

@@ -9,7 +9,27 @@ import {
ConfirmExcessOtpDto, ConfirmExcessOtpDto,
} from './excess-baggage.dto'; } from './excess-baggage.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards'; import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
/**
* Logging or re-sending a luggage charge bills a passenger and texts them a payment
* link, so it is its own grant. `bookings:manage` stays in the array as the umbrella —
* baggage hangs off a booking, and that is the key the backoffice already assumed.
*
* These two routes previously required no permission at all: the class-level
* `IamJwtGuard` is only authentication, so any signed-in account — a portal customer
* included — could raise a charge.
*/
const CanCharge = () =>
PassengerStaff([
PASSENGER_PERMS.excessBaggage.charge,
// Same shape as the supplementary-charge guard in payments.controller.ts: both bill a
// passenger and send them a pay link, so whoever manages payments can do either.
PASSENGER_PERMS.payments.manage,
PASSENGER_PERMS.bookings.manage,
PASSENGER_PERMS.admin,
]);
class UpsertBaggageAllowanceDto { class UpsertBaggageAllowanceDto {
@IsString() seatClassId: string; @IsString() seatClassId: string;
@@ -27,6 +47,7 @@ export class ExcessBaggageAgentController {
constructor(private service: ExcessBaggageService) {} constructor(private service: ExcessBaggageService) {}
@Post() @Post()
@CanCharge()
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' }) @ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) { logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
dto.agentId = req.user?.id ?? req.user?.sub ?? ''; dto.agentId = req.user?.id ?? req.user?.sub ?? '';
@@ -86,6 +107,7 @@ export class ExcessBaggageAgentController {
} }
@Post(':id/resend') @Post(':id/resend')
@CanCharge()
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' }) @ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
resendLink(@Param('id') id: string) { resendLink(@Param('id') id: string) {
return this.service.resendLink(id); return this.service.resendLink(id);

View File

@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR
import { FleetService } from './fleet.service'; import { FleetService } from './fleet.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Fleet') @ApiTags('Fleet')
@@ -22,7 +22,7 @@ export class FleetController {
} }
@Post('coach-types') @Post('coach-types')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.coaches.create, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ summary: 'Create a coach type' }) @ApiOperation({ summary: 'Create a coach type' })
@ApiBody({ type: CreateCoachTypeDto }) @ApiBody({ type: CreateCoachTypeDto })
@ApiResponse({ status: 201, description: 'Coach type created' }) @ApiResponse({ status: 201, description: 'Coach type created' })
@@ -31,7 +31,7 @@ export class FleetController {
} }
@Patch('coach-types/:id') @Patch('coach-types/:id')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ summary: 'Update a coach type' }) @ApiOperation({ summary: 'Update a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' }) @ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiBody({ type: UpdateCoachTypeDto }) @ApiBody({ type: UpdateCoachTypeDto })
@@ -42,7 +42,7 @@ export class FleetController {
} }
@Delete('coach-types/:id') @Delete('coach-types/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.coaches.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a coach type' }) @ApiOperation({ summary: 'Delete a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' }) @ApiParam({ name: 'id', description: 'Coach Type UUID' })
@@ -62,7 +62,7 @@ export class FleetController {
} }
@Post('classes') @Post('classes')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.classes.create, PASSENGER_PERMS.classes.manage)
@ApiOperation({ summary: 'Create a class' }) @ApiOperation({ summary: 'Create a class' })
@ApiBody({ type: CreateClassDto }) @ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' }) @ApiResponse({ status: 201, description: 'Class created' })
@@ -71,7 +71,7 @@ export class FleetController {
} }
@Patch('classes/:id') @Patch('classes/:id')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.classes.edit, PASSENGER_PERMS.classes.manage)
@ApiOperation({ summary: 'Update a class' }) @ApiOperation({ summary: 'Update a class' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto }) @ApiBody({ type: UpdateClassDto })
@@ -82,7 +82,7 @@ export class FleetController {
} }
@Delete('classes/:id') @Delete('classes/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.classes.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a class' }) @ApiOperation({ summary: 'Delete a class' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@@ -103,7 +103,7 @@ export class FleetController {
} }
@Post('seat-classes') @Post('seat-classes')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.classes.create, PASSENGER_PERMS.classes.manage)
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' }) @ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
@ApiBody({ type: CreateClassDto }) @ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' }) @ApiResponse({ status: 201, description: 'Class created' })
@@ -112,7 +112,7 @@ export class FleetController {
} }
@Patch('seat-classes/:id') @Patch('seat-classes/:id')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.classes.edit, PASSENGER_PERMS.classes.manage)
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' }) @ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto }) @ApiBody({ type: UpdateClassDto })
@@ -123,7 +123,7 @@ export class FleetController {
} }
@Delete('seat-classes/:id') @Delete('seat-classes/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.classes.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' }) @ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@@ -143,7 +143,7 @@ export class FleetController {
} }
@Post('trains') @Post('trains')
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.trains.create, PASSENGER_PERMS.trains.manage)
@ApiOperation({ summary: 'Create a train service' }) @ApiOperation({ summary: 'Create a train service' })
@ApiBody({ type: CreateTrainDto }) @ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 201, description: 'Train created' }) @ApiResponse({ status: 201, description: 'Train created' })
@@ -152,7 +152,7 @@ export class FleetController {
} }
@Patch('trains/:id') @Patch('trains/:id')
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.trains.edit, PASSENGER_PERMS.trains.manage)
@ApiOperation({ summary: 'Update a train service' }) @ApiOperation({ summary: 'Update a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' }) @ApiParam({ name: 'id', description: 'Train UUID' })
@ApiBody({ type: CreateTrainDto }) @ApiBody({ type: CreateTrainDto })
@@ -163,7 +163,7 @@ export class FleetController {
} }
@Delete('trains/:id') @Delete('trains/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.trains.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a train service' }) @ApiOperation({ summary: 'Delete a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' }) @ApiParam({ name: 'id', description: 'Train UUID' })
@@ -175,7 +175,7 @@ export class FleetController {
} }
@Patch('trains/:id/restore') @Patch('trains/:id/restore')
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.trains.edit, PASSENGER_PERMS.trains.manage)
@ApiOperation({ summary: 'Restore (reactivate) a deactivated train' }) @ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
@ApiParam({ name: 'id', description: 'Train UUID' }) @ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train restored' }) @ApiResponse({ status: 200, description: 'Train restored' })
@@ -229,6 +229,7 @@ export class FleetController {
} }
@Get('coaches/utilization') @Get('coaches/utilization')
@PassengerStaff([PASSENGER_PERMS.reports.coachUtilization.view, PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach for a selected schedule' }) @ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach for a selected schedule' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' }) @ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' })
@ApiResponse({ status: 200, description: 'Coach utilization data' }) @ApiResponse({ status: 200, description: 'Coach utilization data' })
@@ -279,7 +280,7 @@ export class FleetController {
} }
@Post('coaches') @Post('coaches')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.coaches.create, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' }) @ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
@ApiBody({ type: CreateCoachDto }) @ApiBody({ type: CreateCoachDto })
@ApiResponse({ @ApiResponse({
@@ -305,7 +306,7 @@ export class FleetController {
} }
@Patch('coaches/:id') @Patch('coaches/:id')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ summary: 'Update coach properties' }) @ApiOperation({ summary: 'Update coach properties' })
@ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto }) @ApiBody({ type: UpdateCoachDto })
@@ -332,7 +333,7 @@ export class FleetController {
} }
@Delete('coaches/:id') @Delete('coaches/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.coaches.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a coach' }) @ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiParam({ name: 'id', description: 'Coach UUID' })
@@ -344,7 +345,7 @@ export class FleetController {
} }
@Post('assignments') @Post('assignments')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ summary: 'Assign a coach to a schedule' }) @ApiOperation({ summary: 'Assign a coach to a schedule' })
@ApiBody({ type: AssignCoachDto }) @ApiBody({ type: AssignCoachDto })
@ApiResponse({ status: 201, description: 'Coach assigned' }) @ApiResponse({ status: 201, description: 'Coach assigned' })
@@ -354,7 +355,7 @@ export class FleetController {
} }
@Delete('assignments/:id') @Delete('assignments/:id')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a coach assignment' }) @ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'Assignment UUID' }) @ApiParam({ name: 'id', description: 'Assignment UUID' })
@@ -365,6 +366,7 @@ export class FleetController {
} }
@Post('seatmap/generate') @Post('seatmap/generate')
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ @ApiOperation({
summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED', summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED',
description: `Generates a structured seat map for bed coaches without persisting anything. description: `Generates a structured seat map for bed coaches without persisting anything.

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common'; import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FraudService, FraudRuleConfig } from './fraud.service'; import { FraudService, FraudRuleConfig } from './fraud.service';
import { PassengerStaff } from '../../common/passenger-guards'; import { PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Fraud Detection') @ApiTags('Fraud Detection')
@@ -41,7 +41,7 @@ export class FraudController {
* Create or update fraud rule * Create or update fraud rule
*/ */
@Post('rules') @Post('rules')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.fraud.create, PASSENGER_PERMS.fraud.manage)
@ApiOperation({ summary: 'Create or update fraud rule' }) @ApiOperation({ summary: 'Create or update fraud rule' })
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) { async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
const rule = await this.fraudService.upsertRule(body.type, body.config); const rule = await this.fraudService.upsertRule(body.type, body.config);
@@ -52,7 +52,7 @@ export class FraudController {
* Acknowledge a fraud alert * Acknowledge a fraud alert
*/ */
@Patch('alerts/:id/acknowledge') @Patch('alerts/:id/acknowledge')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
@ApiOperation({ summary: 'Acknowledge a fraud alert' }) @ApiOperation({ summary: 'Acknowledge a fraud alert' })
async acknowledgeAlert(@Param('id') id: string) { async acknowledgeAlert(@Param('id') id: string) {
const alert = await this.fraudService.acknowledgeAlert(id); const alert = await this.fraudService.acknowledgeAlert(id);
@@ -63,7 +63,7 @@ export class FraudController {
* Block user via userId * Block user via userId
*/ */
@Post('users/:userId/block') @Post('users/:userId/block')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
@ApiOperation({ summary: 'Block user by userId' }) @ApiOperation({ summary: 'Block user by userId' })
async blockUserById( async blockUserById(
@Param('userId') userId: string, @Param('userId') userId: string,
@@ -77,7 +77,7 @@ export class FraudController {
* Block user temporarily * Block user temporarily
*/ */
@Post('actions/block') @Post('actions/block')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
@ApiOperation({ summary: 'Block user temporarily' }) @ApiOperation({ summary: 'Block user temporarily' })
async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) { async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) {
await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes); await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes);
@@ -88,7 +88,7 @@ export class FraudController {
* Unblock user * Unblock user
*/ */
@Post('actions/unblock') @Post('actions/unblock')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
@ApiOperation({ summary: 'Unblock user' }) @ApiOperation({ summary: 'Unblock user' })
async unblockUser(@Body() body: { iamUserId: string }) { async unblockUser(@Body() body: { iamUserId: string }) {
await this.fraudService.unblockUser(body.iamUserId); await this.fraudService.unblockUser(body.iamUserId);

View File

@@ -97,6 +97,7 @@ export class NotificationsController {
} }
@Post('test') @Post('test')
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Test notification delivery (Admin only)' }) @ApiOperation({ summary: 'Test notification delivery (Admin only)' })
async testNotification(@Body() dto: TestNotificationDto) { async testNotification(@Body() dto: TestNotificationDto) {
return this.service.send( return this.service.send(

View File

@@ -7,7 +7,7 @@ import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDt
import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options'; import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Packages') @ApiTags('Packages')
@@ -36,7 +36,7 @@ export class PackagesController {
} }
@Patch('inquiries/:id/status') @Patch('inquiries/:id/status')
@PassengerStaff([PASSENGER_PERMS.inquiries.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.inquiries.edit, PASSENGER_PERMS.inquiries.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update inquiry status (backoffice)' }) @ApiOperation({ summary: 'Update inquiry status (backoffice)' })
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) { updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
@@ -44,7 +44,7 @@ export class PackagesController {
} }
@Delete('inquiries/:id') @Delete('inquiries/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.inquiries.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete inquiry (backoffice)' }) @ApiOperation({ summary: 'Delete inquiry (backoffice)' })
deleteInquiry(@Param('id') id: string) { deleteInquiry(@Param('id') id: string) {
@@ -126,7 +126,7 @@ export class PackagesController {
} }
@Post() @Post()
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.create, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create package (admin)' }) @ApiOperation({ summary: 'Create package (admin)' })
create(@Body() dto: CreatePackageDto) { create(@Body() dto: CreatePackageDto) {
@@ -134,7 +134,7 @@ export class PackagesController {
} }
@Patch(':id') @Patch(':id')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update package (admin)' }) @ApiOperation({ summary: 'Update package (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) { update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
@@ -142,7 +142,7 @@ export class PackagesController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.packages.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete package (admin)' }) @ApiOperation({ summary: 'Delete package (admin)' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' })
@@ -151,7 +151,7 @@ export class PackagesController {
} }
@Post(':id/image') @Post(':id/image')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions)) @UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@@ -166,7 +166,7 @@ export class PackagesController {
} }
@Delete(':id/image') @Delete(':id/image')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' }) @ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' })
removeImage(@Param('id') id: string) { removeImage(@Param('id') id: string) {
@@ -174,7 +174,7 @@ export class PackagesController {
} }
@Patch(':id/activate') @Patch(':id/activate')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Activate package (admin)' }) @ApiOperation({ summary: 'Activate package (admin)' })
activate(@Param('id') id: string) { activate(@Param('id') id: string) {
@@ -182,7 +182,7 @@ export class PackagesController {
} }
@Patch(':id/deactivate') @Patch(':id/deactivate')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Deactivate package (admin)' }) @ApiOperation({ summary: 'Deactivate package (admin)' })
deactivate(@Param('id') id: string) { deactivate(@Param('id') id: string) {
@@ -190,7 +190,7 @@ export class PackagesController {
} }
@Post(':id/tiers') @Post(':id/tiers')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add price tier to package (admin)' }) @ApiOperation({ summary: 'Add price tier to package (admin)' })
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) { addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
@@ -198,7 +198,7 @@ export class PackagesController {
} }
@Patch('tiers/:tierId') @Patch('tiers/:tierId')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update price tier (admin)' }) @ApiOperation({ summary: 'Update price tier (admin)' })
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) { updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
@@ -206,7 +206,7 @@ export class PackagesController {
} }
@Delete('tiers/:tierId') @Delete('tiers/:tierId')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete price tier (admin)' }) @ApiOperation({ summary: 'Delete price tier (admin)' })
deleteTier(@Param('tierId') tierId: string) { deleteTier(@Param('tierId') tierId: string) {

View File

@@ -28,7 +28,8 @@ import {
RegisterPassengerDto, RegisterPassengerDto,
} from "./passengers.dto"; } from "./passengers.dto";
import { JwtGuard } from "../../common/jwt.guard"; import { JwtGuard } from "../../common/jwt.guard";
import { PassengerAdmin } from "../../common/passenger-guards"; import { PassengerDelete } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { VerifaydaService } from "../verifayda/verifayda.service"; import { VerifaydaService } from "../verifayda/verifayda.service";
import { OptionalJwtGuard } from "../verifayda/optional-jwt.guard"; import { OptionalJwtGuard } from "../verifayda/optional-jwt.guard";
import { PrismaService } from "../../common/prisma.service"; import { PrismaService } from "../../common/prisma.service";
@@ -566,7 +567,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
} }
@Delete(":id") @Delete(":id")
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.passengers.delete)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Delete passenger (admin only)", summary: "Delete passenger (admin only)",

View File

@@ -37,7 +37,7 @@ import {
ForceConfirmDto, ForceConfirmDto,
ConfirmOtpDto, ConfirmOtpDto,
} from "./payments.dto"; } from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards"; import { PassengerDelete, PassengerStaff, PassengerWrite } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { resolveActingUser } from "../../common/acting-user"; import { resolveActingUser } from "../../common/acting-user";
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util"; import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
@@ -85,7 +85,7 @@ export class PaymentsController {
) {} ) {}
@Delete(":id") @Delete(":id")
@PassengerStaff([PASSENGER_PERMS.admin]) @PassengerDelete(PASSENGER_PERMS.payments.delete)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Delete a payment intent record (admin only)" }) @ApiOperation({ summary: "Delete a payment intent record (admin only)" })
deletePayment(@Param("id") id: string) { deletePayment(@Param("id") id: string) {
@@ -252,6 +252,7 @@ export class PaymentsController {
@Post("methods") @Post("methods")
@PassengerStaff([ @PassengerStaff([
PASSENGER_PERMS.paymentMethods.create,
PASSENGER_PERMS.paymentMethods.manage, PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin, PASSENGER_PERMS.admin,
@@ -266,6 +267,7 @@ export class PaymentsController {
@Patch("methods/:id") @Patch("methods/:id")
@PassengerStaff([ @PassengerStaff([
PASSENGER_PERMS.paymentMethods.edit,
PASSENGER_PERMS.paymentMethods.manage, PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin, PASSENGER_PERMS.admin,
@@ -394,7 +396,7 @@ export class PaymentsController {
// ── Supplementary Charges ────────────────────────────────────────────────── // ── Supplementary Charges ──────────────────────────────────────────────────
@Post('supplementary') @Post('supplementary')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @PassengerStaff([PASSENGER_PERMS.payments.supplementary, PASSENGER_PERMS.payments.create, PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' }) @ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) { createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) {
@@ -504,7 +506,7 @@ export class PaymentsController {
} }
@Post('supplementary/:id/mark-paid') @Post('supplementary/:id/mark-paid')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.payments.edit, PASSENGER_PERMS.payments.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' }) @ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' })
markSupplementaryPaid( markSupplementaryPaid(
@@ -515,7 +517,7 @@ export class PaymentsController {
} }
@Post('supplementary/:id/waive') @Post('supplementary/:id/waive')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.payments.edit, PASSENGER_PERMS.payments.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Waive a supplementary charge (staff only)' }) @ApiOperation({ summary: 'Waive a supplementary charge (staff only)' })
waiveSupplementaryCharge( waiveSupplementaryCharge(
@@ -528,7 +530,7 @@ export class PaymentsController {
} }
@Post('supplementary/:id/resend') @Post('supplementary/:id/resend')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @PassengerStaff([PASSENGER_PERMS.payments.supplementary, PASSENGER_PERMS.payments.create, PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' }) @ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' })
resendSupplementaryLink(@Param('id') id: string) { resendSupplementaryLink(@Param('id') id: string) {

View File

@@ -12,26 +12,60 @@ import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReport
import { PassengerStaff } from "../../common/passenger-guards"; import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
const R = PASSENGER_PERMS.reports;
/**
* One report's guard: its own key, the `reports:view` umbrella, or admin.
*
* The umbrella is kept in every array so the `finance`, `financeManager` and
* `director` presets — which hold `reports:view` — keep seeing every report.
* Granting only a per-report key hands out that report and nothing else.
*
* This is deliberately NOT a class-level decorator. Nest requires controller-
* level AND route-level guards to both pass, so a class-level
* `[reports.view, admin]` plus a per-route key would be an AND and would lock
* out everyone holding only `reports:view`.
*/
const Report = (key: string) => PassengerStaff([key, R.view, PASSENGER_PERMS.admin]);
/** The schedule picker is shared by five reports, so any report key opens it. */
const AnyReport = () =>
PassengerStaff([
R.overall.view,
R.finance.view,
R.coachUtilization.view,
R.seatStatus.view,
R.blockedSeats.view,
R.passengers.view,
R.boarding.view,
R.payments.view,
R.catalog.view,
R.view,
PASSENGER_PERMS.admin,
]);
@ApiTags("Reports") @ApiTags("Reports")
@Controller("reports") @Controller("reports")
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
export class ReportsController { export class ReportsController {
constructor(private service: ReportsService) {} constructor(private service: ReportsService) {}
@Post("generate") @Post("generate")
@Report(R.catalog.view)
@ApiOperation({ summary: "Generate operational report" }) @ApiOperation({ summary: "Generate operational report" })
generateReport(@Body() dto: GenerateReportDto) { generateReport(@Body() dto: GenerateReportDto) {
return this.service.generateReport(dto); return this.service.generateReport(dto);
} }
@Get('schedules') @Get('schedules')
@AnyReport()
@ApiOperation({ summary: 'List schedules for the passengers report picker' }) @ApiOperation({ summary: 'List schedules for the passengers report picker' })
listSchedulesForPicker(@Query('all') all?: string) { listSchedulesForPicker(@Query('all') all?: string) {
return this.service.listSchedulesForPicker(all === 'true'); return this.service.listSchedulesForPicker(all === 'true');
} }
@Get("passengers/list") @Get("passengers/list")
@Report(R.passengers.view)
@ApiOperation({ summary: "Flat passenger list for a specific schedule" }) @ApiOperation({ summary: "Flat passenger list for a specific schedule" })
getPassengerList(@Query("scheduleId") scheduleId: string) { getPassengerList(@Query("scheduleId") scheduleId: string) {
return this.service.getPassengerList(scheduleId); return this.service.getPassengerList(scheduleId);
@@ -39,6 +73,7 @@ export class ReportsController {
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order. // Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
@Get("passengers/overview") @Get("passengers/overview")
@Report(R.passengers.view)
@ApiOperation({ @ApiOperation({
summary: "Fleet-wide passenger mix across a departure window", summary: "Fleet-wide passenger mix across a departure window",
description: description:
@@ -56,12 +91,14 @@ export class ReportsController {
} }
@Get("passengers") @Get("passengers")
@Report(R.passengers.view)
@ApiOperation({ summary: "Passengers report for a specific schedule" }) @ApiOperation({ summary: "Passengers report for a specific schedule" })
getOccupancyReport(@Query("scheduleId") scheduleId: string) { getOccupancyReport(@Query("scheduleId") scheduleId: string) {
return this.service.getOccupancyBySchedule(scheduleId); return this.service.getOccupancyBySchedule(scheduleId);
} }
@Get("payment-discrepancy") @Get("payment-discrepancy")
@Report(R.payments.view)
@ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." }) @ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." })
getPaymentDiscrepancy( getPaymentDiscrepancy(
@Query('from') from?: string, @Query('from') from?: string,
@@ -73,6 +110,7 @@ export class ReportsController {
} }
@Get("seat-status") @Get("seat-status")
@Report(R.seatStatus.view)
@ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" }) @ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" })
getSeatStatusReport(@Query('scheduleId') scheduleId: string) { getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
return this.service.getSeatStatusReport(scheduleId); return this.service.getSeatStatusReport(scheduleId);
@@ -80,6 +118,7 @@ export class ReportsController {
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order. // Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
@Get("seat-status/overview") @Get("seat-status/overview")
@Report(R.seatStatus.view)
@ApiOperation({ @ApiOperation({
summary: "Fleet-wide seat status across a departure window", summary: "Fleet-wide seat status across a departure window",
description: description:
@@ -96,18 +135,21 @@ export class ReportsController {
} }
@Get("boarding") @Get("boarding")
@Report(R.boarding.view)
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" }) @ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
getBoardingReport(@Query('scheduleId') scheduleId: string) { getBoardingReport(@Query('scheduleId') scheduleId: string) {
return this.service.getBoardingReport(scheduleId); return this.service.getBoardingReport(scheduleId);
} }
@Get("payments") @Get("payments")
@Report(R.payments.view)
@ApiOperation({ summary: "Payments collected for a schedule" }) @ApiOperation({ summary: "Payments collected for a schedule" })
getPaymentsReport(@Query('scheduleId') scheduleId: string) { getPaymentsReport(@Query('scheduleId') scheduleId: string) {
return this.service.getPaymentsReport(scheduleId); return this.service.getPaymentsReport(scheduleId);
} }
@Get("payments/discrepancy") @Get("payments/discrepancy")
@Report(R.payments.view)
@ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" }) @ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" })
getPaymentDiscrepancyBySchedule( getPaymentDiscrepancyBySchedule(
@Query('scheduleId') scheduleId: string, @Query('scheduleId') scheduleId: string,
@@ -121,6 +163,7 @@ export class ReportsController {
// ── Finance Summary ────────────────────────────────────────────────────── // ── Finance Summary ──────────────────────────────────────────────────────
@Get("finance") @Get("finance")
@Report(R.finance.view)
@ApiOperation({ @ApiOperation({
summary: "Finance summary — revenue by period, origin/destination segment, trip type, booking type, payment method, and currency", summary: "Finance summary — revenue by period, origin/destination segment, trip type, booking type, payment method, and currency",
description: description:
@@ -151,6 +194,7 @@ export class ReportsController {
} }
@Get("finance/export") @Get("finance/export")
@Report(R.finance.export)
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" }) @ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
@ApiProduces("text/csv") @ApiProduces("text/csv")
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } }) @ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
@@ -167,6 +211,7 @@ export class ReportsController {
// ── Blocked Seat Revenue Loss ────────────────────────────────────────────── // ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
@Get("blocked-seats-revenue-loss") @Get("blocked-seats-revenue-loss")
@Report(R.blockedSeats.view)
@ApiOperation({ @ApiOperation({
summary: "Potential revenue lost to blocked seats, per schedule", summary: "Potential revenue lost to blocked seats, per schedule",
description: description:
@@ -189,6 +234,7 @@ export class ReportsController {
} }
@Get("blocked-seats-revenue-loss/export") @Get("blocked-seats-revenue-loss/export")
@Report(R.blockedSeats.export)
@ApiOperation({ @ApiOperation({
summary: "Blocked-seat revenue loss as CSV", summary: "Blocked-seat revenue loss as CSV",
description: description:
@@ -213,12 +259,14 @@ export class ReportsController {
} }
@Get(":reportId") @Get(":reportId")
@Report(R.catalog.view)
@ApiOperation({ summary: "Get report by ID" }) @ApiOperation({ summary: "Get report by ID" })
getReport(@Param("reportId") reportId: string) { getReport(@Param("reportId") reportId: string) {
return this.service.getReport(reportId); return this.service.getReport(reportId);
} }
@Get() @Get()
@Report(R.catalog.view)
@ApiOperation({ summary: "List reports" }) @ApiOperation({ summary: "List reports" })
listReports(@Query("type") type?: string) { listReports(@Query("type") type?: string) {
return this.service.listReports(type); return this.service.listReports(type);

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { RescheduleService } from './reschedule.service'; import { RescheduleService } from './reschedule.service';
import { import {
@@ -17,7 +17,7 @@ export class RescheduleController {
constructor(private service: RescheduleService) {} constructor(private service: RescheduleService) {}
@Get('reschedule/policies') @Get('reschedule/policies')
@PassengerStaff(PASSENGER_PERMS.bookings.view) @PassengerStaff([PASSENGER_PERMS.reschedulePolicies.view, PASSENGER_PERMS.reschedulePolicies.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' }) @ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' })
listPolicies() { listPolicies() {
@@ -25,7 +25,7 @@ export class RescheduleController {
} }
@Get('reschedule/policies/available-coach-types') @Get('reschedule/policies/available-coach-types')
@PassengerStaff(PASSENGER_PERMS.bookings.view) @PassengerStaff([PASSENGER_PERMS.reschedulePolicies.view, PASSENGER_PERMS.reschedulePolicies.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' }) @ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' })
listUnconfiguredCoachTypes() { listUnconfiguredCoachTypes() {
@@ -33,7 +33,7 @@ export class RescheduleController {
} }
@Post('reschedule/policies') @Post('reschedule/policies')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.reschedulePolicies.create, PASSENGER_PERMS.reschedulePolicies.manage)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' }) @ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' })
createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) { createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) {
@@ -41,7 +41,7 @@ export class RescheduleController {
} }
@Patch('reschedule/policies/:coachTypeId') @Patch('reschedule/policies/:coachTypeId')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.reschedulePolicies.edit, PASSENGER_PERMS.reschedulePolicies.manage)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' }) @ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' })
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) { updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) {
@@ -49,7 +49,7 @@ export class RescheduleController {
} }
@Delete('reschedule/policies/:coachTypeId') @Delete('reschedule/policies/:coachTypeId')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.reschedulePolicies.delete)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' }) @ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' })
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) { deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {

View File

@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { RoutesService } from './routes.service'; import { RoutesService } from './routes.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Routes') @ApiTags('Routes')
@@ -13,7 +13,7 @@ export class RoutesController {
// ── Routes ───────────────────────────────────────────────────────────────── // ── Routes ─────────────────────────────────────────────────────────────────
@Post() @Post()
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(PASSENGER_PERMS.routes.create, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Create a reusable route with its ordered stops', summary: 'Create a reusable route with its ordered stops',
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI). description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI).
@@ -41,7 +41,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getRoute(@Param('id') id: string) { return this.service.getRoute(id); } getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
@Patch(':id') @Patch(':id')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' }) @ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route updated' }) @ApiResponse({ status: 200, description: 'Route updated' })
@@ -49,7 +49,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); } updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.routes.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a route' }) @ApiOperation({ summary: 'Delete a route' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })
@@ -68,7 +68,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getStops(@Param('id') id: string) { return this.service.getStops(id); } getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Post(':id/stops') @Post(':id/stops')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add a stop to an existing route' }) @ApiOperation({ summary: 'Add a stop to an existing route' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 201, description: 'Stop added' }) @ApiResponse({ status: 201, description: 'Stop added' })
@@ -77,7 +77,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); } addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); }
@Delete(':id/stops/:sequence') @Delete(':id/stops/:sequence')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a stop from a route by sequence number' }) @ApiOperation({ summary: 'Remove a stop from a route by sequence number' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })
@@ -108,7 +108,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); } getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
@Put(':id/coaches') @Put(':id/coaches')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Set the default coach lineup for this route', summary: 'Set the default coach lineup for this route',
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.', description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
@@ -122,7 +122,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
} }
@Delete(':id/coaches') @Delete(':id/coaches')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Clear the default coach lineup for this route' }) @ApiOperation({ summary: 'Clear the default coach lineup for this route' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })

View File

@@ -1,25 +1,39 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SchedulesService } from './schedules.service'; import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto'; import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { assertAnyPassengerPermission } from '../../common/passenger-permission.util';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
const P = PASSENGER_PERMS;
const S = PASSENGER_PERMS.schedules;
const F = PASSENGER_PERMS.scheduleFares;
/**
* Fare rules live under /schedules but are a separate grant: editing a timetable
* and changing a price are different jobs. `schedules.manage` stays in the array
* so whoever edits fares today is not locked out the day this ships.
*/
const FareWrite = (narrow: string) =>
PassengerStaff([narrow, F.manage, S.manage, P.admin]);
const FareDelete = () => PassengerStaff([F.delete, F.manage, S.manage, P.admin]);
@ApiTags('Schedule') @ApiTags('Schedule')
@Controller('schedules') @Controller('schedules')
export class SchedulesController { export class SchedulesController {
constructor(private service: SchedulesService) {} constructor(private service: SchedulesService) {}
@Post('bulk-generate') @Post('bulk-generate')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(S.create, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Bulk generate repetitive schedules' }) @ApiOperation({ summary: 'Bulk generate repetitive schedules' })
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) { bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
return this.service.bulkGenerateSchedules(dto); return this.service.bulkGenerateSchedules(dto);
} }
@Post() @Post()
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(S.create, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a train schedule from a route template' }) @ApiOperation({ summary: 'Create a train schedule from a route template' })
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); } createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
@@ -42,13 +56,13 @@ export class SchedulesController {
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) ===== // ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
@Post('fares') @Post('fares')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @FareWrite(F.create) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' }) @ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
@ApiResponse({ status: 201, description: 'Fare rule created' }) @ApiResponse({ status: 201, description: 'Fare rule created' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); } createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
@Patch('fares/:id') @Patch('fares/:id')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a fare rule' }) @ApiOperation({ summary: 'Update a fare rule' })
@ApiParam({ name: 'id', description: 'FareRule UUID' }) @ApiParam({ name: 'id', description: 'FareRule UUID' })
@ApiResponse({ status: 200, description: 'Fare rule updated' }) @ApiResponse({ status: 200, description: 'Fare rule updated' })
@@ -57,7 +71,7 @@ export class SchedulesController {
} }
@Delete('fares/:id') @Delete('fares/:id')
@PassengerAdmin() @FareDelete()
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a fare rule' }) @ApiOperation({ summary: 'Delete a fare rule' })
@ApiParam({ name: 'id', description: 'FareRule UUID' }) @ApiParam({ name: 'id', description: 'FareRule UUID' })
@@ -65,13 +79,13 @@ export class SchedulesController {
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); } deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
@Post('segment-fares') @Post('segment-fares')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @FareWrite(F.create) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a segment fare rule' }) @ApiOperation({ summary: 'Create a segment fare rule' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); } createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
// Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules' // Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules'
@Delete('routes/fare-rules/:id') @Delete('routes/fare-rules/:id')
@PassengerAdmin() @FareDelete()
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a route-level fare override' }) @ApiOperation({ summary: 'Delete a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' }) @ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
@@ -80,7 +94,7 @@ export class SchedulesController {
} }
@Patch('routes/fare-rules/:id') @Patch('routes/fare-rules/:id')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a route-level fare override' }) @ApiOperation({ summary: 'Update a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' }) @ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
updateRouteFareRule(@Param('id') id: string, @Body() dto: any) { updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
@@ -96,7 +110,7 @@ export class SchedulesController {
} }
@Post('routes/:routeId/fare-rules') @Post('routes/:routeId/fare-rules')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @FareWrite(F.create) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a route-level fare override' }) @ApiOperation({ summary: 'Create a route-level fare override' })
@ApiParam({ name: 'routeId', description: 'Route UUID' }) @ApiParam({ name: 'routeId', description: 'Route UUID' })
createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) { createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) {
@@ -110,13 +124,13 @@ export class SchedulesController {
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); } getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
@Patch('segment-fares/:id') @Patch('segment-fares/:id')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a segment fare rule' }) @ApiOperation({ summary: 'Update a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); } updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
@Delete('segment-fares/:id') @Delete('segment-fares/:id')
@PassengerAdmin() @FareDelete()
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a segment fare rule' }) @ApiOperation({ summary: 'Delete a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
@@ -131,7 +145,7 @@ export class SchedulesController {
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); } getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
@Patch(':id') @Patch(':id')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a schedule (partial)' }) @ApiOperation({ summary: 'Update a schedule (partial)' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) { updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
@@ -139,15 +153,33 @@ export class SchedulesController {
} }
@Patch(':id/status') @Patch(':id/status')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update schedule status' }) @ApiOperation({
summary: 'Update schedule status',
description:
'Routine transitions need `schedules:edit`. Moving a schedule to CANCELLED additionally ' +
'needs `schedules:cancel` — cancelling strands every booked passenger, so it is a separate ' +
'grant from editing a timetable.',
})
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) { @ApiResponse({ status: 403, description: 'Cancelling without `schedules:cancel`' })
updateStatus(
@Param('id') id: string,
@Body() dto: UpdateScheduleStatusDto,
@Req() req: { user?: unknown },
) {
// The guard cannot see the body — CANCELLED arrives on the same route as
// BOARDING or DELAYED — so the narrower check happens here. `schedules.manage`
// is in the list, so a manage holder cancels exactly as they do today; an
// `edit`-only holder can retime a trip but not cancel it.
if (dto.status === TripStatus.CANCELLED) {
assertAnyPassengerPermission(req.user as never, [S.cancel, S.manage, P.admin]);
}
return this.service.updateScheduleStatus(id, dto); return this.service.updateScheduleStatus(id, dto);
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(S.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a schedule' }) @ApiOperation({ summary: 'Delete a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@@ -155,7 +187,7 @@ export class SchedulesController {
deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); } deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
@Post(':id/recalculate-stops') @Post(':id/recalculate-stops')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' }) @ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); } recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); }
@@ -167,7 +199,7 @@ export class SchedulesController {
getStops(@Param('id') id: string) { return this.service.getStops(id); } getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Patch(':id/stops/:sequence') @Patch(':id/stops/:sequence')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a stop time' }) @ApiOperation({ summary: 'Update a stop time' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'sequence', description: 'Stop sequence number' }) @ApiParam({ name: 'sequence', description: 'Stop sequence number' })
@@ -178,7 +210,7 @@ export class SchedulesController {
) { return this.service.updateStop(id, sequence, dto); } ) { return this.service.updateStop(id, sequence, dto); }
@Post(':id/delay') @Post(':id/delay')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount', summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount',
description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence
@@ -194,7 +226,7 @@ records the accumulated delay on the schedule's live status. Does not change sch
} }
@Put(':scheduleId/fares/:seatClassId') @Put(':scheduleId/fares/:seatClassId')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Override fare for a specific seat class on a schedule', summary: 'Override fare for a specific seat class on a schedule',
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.', description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
@@ -242,13 +274,13 @@ records the accumulated delay on the schedule's live status. Does not change sch
} }
@Post(':id/fares/sync') @Post(':id/fares/sync')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Sync fares from fare engine' }) @ApiOperation({ summary: 'Sync fares from fare engine' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); } syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
@Post(':id/coaches') @Post(':id/coaches')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Assign coaches to a schedule' }) @ApiOperation({ summary: 'Assign coaches to a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
assignCoaches( assignCoaches(
@@ -264,7 +296,7 @@ records the accumulated delay on the schedule's live status. Does not change sch
getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); } getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); }
@Delete(':id/coaches/:coachId') @Delete(':id/coaches/:coachId')
@PassengerAdmin() @PassengerWrite(S.edit, S.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a coach assignment' }) @ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })

View File

@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody }
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SeatClassesService } from './seat-classes.service'; import { SeatClassesService } from './seat-classes.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto'; import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Seat Classes') @ApiTags('Seat Classes')
@@ -26,7 +26,7 @@ export class SeatClassesController {
getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); } getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); }
@Post() @Post()
@PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(PASSENGER_PERMS.tariffRates.create, PASSENGER_PERMS.tariffRates.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a seat class' }) @ApiOperation({ summary: 'Create a seat class' })
@ApiBody({ type: CreateSeatClassDto }) @ApiBody({ type: CreateSeatClassDto })
@ApiResponse({ status: 201, description: 'Seat class created' }) @ApiResponse({ status: 201, description: 'Seat class created' })
@@ -34,7 +34,7 @@ export class SeatClassesController {
createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); } createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); }
@Patch(':id') @Patch(':id')
@PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(PASSENGER_PERMS.tariffRates.edit, PASSENGER_PERMS.tariffRates.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a seat class' }) @ApiOperation({ summary: 'Update a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' }) @ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiBody({ type: UpdateSeatClassDto }) @ApiBody({ type: UpdateSeatClassDto })
@@ -43,7 +43,7 @@ export class SeatClassesController {
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); } updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.tariffRates.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a seat class' }) @ApiOperation({ summary: 'Delete a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' }) @ApiParam({ name: 'id', description: 'Seat class UUID' })

View File

@@ -25,7 +25,7 @@ import { AutoAssignHoldDto, BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaint
import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user"; import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user";
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
import { JwtGuard } from "../../common/jwt.guard"; import { JwtGuard } from "../../common/jwt.guard";
import { PassengerStaff } from "../../common/passenger-guards"; import { PassengerDelete, PassengerWrite } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags("Seats") @ApiTags("Seats")
@@ -35,7 +35,7 @@ export class SeatsController {
// ── Blocked Seats ───────────────────────────────────────────────────────── // ── Blocked Seats ─────────────────────────────────────────────────────────
@Get('blocks') @Get('blocks')
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all blocked seats with reason and coach info' }) @ApiOperation({ summary: 'List all blocked seats with reason and coach info' })
@ApiResponse({ status: 200, description: 'Blocked seat records' }) @ApiResponse({ status: 200, description: 'Blocked seat records' })
@@ -187,7 +187,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
} }
@Post("auto-assign-hold") @Post("auto-assign-hold")
@PassengerStaff([PASSENGER_PERMS.bookings.manage]) @PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only", summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
@@ -247,7 +247,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
// ── Seat Block / Unblock ─────────────────────────────────────────────────── // ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(":seatId/block") @Post(":seatId/block")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Block a seat (e.g., maintenance, damage)", summary: "Block a seat (e.g., maintenance, damage)",
@@ -268,7 +268,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
} }
@Delete(":seatId/block") @Delete(":seatId/block")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Unblock a seat" }) @ApiOperation({ summary: "Unblock a seat" })
@ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -279,7 +279,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
// ── Maintenance ─────────────────────────────────────────────────────────── // ── Maintenance ───────────────────────────────────────────────────────────
@Post(":seatId/maintenance") @Post(":seatId/maintenance")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Set seat status to Under Maintenance" }) @ApiOperation({ summary: "Set seat status to Under Maintenance" })
@ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -294,7 +294,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
} }
@Delete(":seatId/maintenance") @Delete(":seatId/maintenance")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Clear seat maintenance status" }) @ApiOperation({ summary: "Clear seat maintenance status" })
@ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -305,7 +305,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
// ── Remove Seat ──────────────────────────────────────────────────────────── // ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(":seatId/remove") @Patch(":seatId/remove")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerDelete(PASSENGER_PERMS.seats.delete)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Remove a seat by marking with negative seatNumber", summary: "Remove a seat by marking with negative seatNumber",
@@ -321,7 +321,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
} }
@Patch(":seatId/undo-remove") @Patch(":seatId/undo-remove")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Undo seat removal by restoring original seatNumber", summary: "Undo seat removal by restoring original seatNumber",
@@ -338,7 +338,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
} }
@Get("export/csv/:scheduleId") @Get("export/csv/:scheduleId")
@UseGuards(JwtGuard) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth") @ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Export seats as CSV" }) @ApiOperation({ summary: "Export seats as CSV" })
async exportCSV(@Param("scheduleId") scheduleId: string) { async exportCSV(@Param("scheduleId") scheduleId: string) {
@@ -347,7 +347,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
} }
@Post("import/preview") @Post("import/preview")
@UseGuards(JwtGuard) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth") @ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Preview CSV import" }) @ApiOperation({ summary: "Preview CSV import" })
previewCSV(@Body() body: { csv: string }) { previewCSV(@Body() body: { csv: string }) {
@@ -355,7 +355,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
} }
@Post("import/commit") @Post("import/commit")
@UseGuards(JwtGuard) @PassengerWrite(PASSENGER_PERMS.seats.create, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth") @ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Commit CSV import" }) @ApiOperation({ summary: "Commit CSV import" })
importCSV( importCSV(
@@ -367,7 +367,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
// ── Duplicate seat management (backoffice) ──────────────────────────────── // ── Duplicate seat management (backoffice) ────────────────────────────────
@Get("duplicates") @Get("duplicates")
@UseGuards(JwtGuard) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth") @ApiBearerAuth("JWT-auth")
@ApiOperation({ @ApiOperation({
summary: "List duplicate seat assignments by schedule date", summary: "List duplicate seat assignments by schedule date",
@@ -418,7 +418,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
} }
@Post("duplicates/resolve") @Post("duplicates/resolve")
@UseGuards(JwtGuard) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth") @ApiBearerAuth("JWT-auth")
@ApiOperation({ @ApiOperation({
summary: "Auto-assign duplicate bookings to seats in selected coaches", summary: "Auto-assign duplicate bookings to seats in selected coaches",

View File

@@ -0,0 +1,217 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SeatsService } from './seats.service';
import { PrismaService } from '../../common/prisma.service';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
import { SmsClientService } from '../notifications/sms-client.service';
/**
* Regression guard for the double-booking fix.
*
* Every case below was reproduced against the running API before the fix and must stay
* closed. These are unit tests: they prove the guards exist, are wired in the right order,
* and read through the transaction client. They do NOT prove concurrency safety — only a
* real database can, and that proof lives in scripts/stress-booking-concurrency.cjs.
*/
describe('SeatsService — double-booking guards', () => {
let service: SeatsService;
const seatHold = { findUnique: jest.fn() };
const bookingSeat = { findMany: jest.fn() };
const tripStopTime = { findMany: jest.fn() };
const seat = { findMany: jest.fn() };
const queryRaw = jest.fn();
// SET LOCAL lock_timeout — see SeatsService.lockSeatsForUpdate.
const executeRawUnsafe = jest.fn();
const tx: any = { seatHold, bookingSeat, tripStopTime, seat, $queryRaw: queryRaw, $executeRawUnsafe: executeRawUnsafe };
const mockPrisma: any = {
seatHold, bookingSeat, tripStopTime, seat, $queryRaw: queryRaw, $executeRawUnsafe: executeRawUnsafe,
$transaction: jest.fn((fn: any) => fn(tx)),
};
const mockSegments = { getSeatAvailabilityMap: jest.fn() };
const LIVE_HOLD = {
id: 'hold-1',
scheduleId: 'sched-1',
seatIds: ['seat-1', 'seat-2'],
passengerId: 'pax-1',
expiresAt: new Date(Date.now() + 60_000),
};
const baseArgs = {
holdId: 'hold-1',
seatIds: ['seat-1'],
scheduleId: 'sched-1',
originStationId: 'a',
destinationStationId: 'c',
};
/** A BookingSeat row owned by someone else, spanning a -> c on this schedule. */
const ownedByOther = [{
seatId: 'seat-1',
seat: { seatNumber: '7' },
booking: {
bookingRef: 'AAA111', scheduleId: 'sched-1',
originStationId: 'a', destinationStationId: 'c',
returnScheduleId: null, returnOriginStationId: null, returnDestinationStationId: null,
leg2ScheduleId: null, leg2OriginStationId: null, leg2DestinationStationId: null,
returnLeg2ScheduleId: null, returnLeg2OriginStationId: null, returnLeg2DestStationId: null,
},
}];
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SeatsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: SegmentsService, useValue: mockSegments },
{ provide: SystemConfigService, useValue: { getNumber: jest.fn().mockResolvedValue(5) } },
{ provide: AuditService, useValue: { log: jest.fn() } },
{ provide: SmsClientService, useValue: { send: jest.fn() } },
],
}).compile();
service = module.get<SeatsService>(SeatsService);
jest.clearAllMocks();
seatHold.findUnique.mockResolvedValue(LIVE_HOLD);
bookingSeat.findMany.mockResolvedValue([]);
tripStopTime.findMany.mockResolvedValue([
{ stationId: 'a', sequence: 1 },
{ stationId: 'b', sequence: 2 },
{ stationId: 'c', sequence: 3 },
]);
seat.findMany.mockResolvedValue([{ id: 'seat-1', seatNumber: '7' }]);
mockSegments.getSeatAvailabilityMap.mockResolvedValue(new Map());
queryRaw.mockResolvedValue([]);
executeRawUnsafe.mockResolvedValue(0);
});
describe('assertSeatsClaimable', () => {
it('accepts a seat the presented hold actually covers', async () => {
await expect(service.assertSeatsClaimable(baseArgs)).resolves.toBeUndefined();
});
it('rejects a seat that is not part of the hold (hold laundering)', async () => {
await expect(service.assertSeatsClaimable({ ...baseArgs, seatIds: ['seat-99'] }))
.rejects.toThrow(/not part of hold/i);
});
it('rejects a hold taken on a different departure — one Seat row is reused across schedules', async () => {
await expect(service.assertSeatsClaimable({ ...baseArgs, scheduleId: 'sched-OTHER' }))
.rejects.toThrow(/different departure/i);
});
it('rejects an expired hold', async () => {
seatHold.findUnique.mockResolvedValue({ ...LIVE_HOLD, expiresAt: new Date(Date.now() - 1) });
await expect(service.assertSeatsClaimable(baseArgs)).rejects.toThrow(/expired or not found/i);
});
it('rejects a hold that does not exist', async () => {
seatHold.findUnique.mockResolvedValue(null);
await expect(service.assertSeatsClaimable(baseArgs)).rejects.toThrow(/expired or not found/i);
});
});
describe('active-booking conflict — enabled for booking writes only', () => {
it('rejects a seat an active booking already owns on an overlapping leg', async () => {
bookingSeat.findMany.mockResolvedValue(ownedByOther);
await expect(service.assertSeatsClaimable({ ...baseArgs, alsoRejectActiveBookings: true }))
.rejects.toThrow(/just booked by someone else/i);
});
it('allows a seat whose existing booking covers a non-overlapping stretch', async () => {
// existing leg b->c is [2,3); requested a->b is [1,2) — they only touch, so no conflict
bookingSeat.findMany.mockResolvedValue([{
...ownedByOther[0],
booking: { ...ownedByOther[0].booking, originStationId: 'b', destinationStationId: 'c' },
}]);
await expect(service.assertSeatsClaimable({
...baseArgs, destinationStationId: 'b', alsoRejectActiveBookings: true,
})).resolves.toBeUndefined();
});
it('does NOT run for plain holds — reschedule/upgrade legitimately re-hold their own seat', async () => {
bookingSeat.findMany.mockResolvedValue(ownedByOther);
await expect(service.assertSeatsClaimable(baseArgs)).resolves.toBeUndefined();
expect(bookingSeat.findMany).not.toHaveBeenCalled();
});
it('blocks conservatively when the existing booking leg cannot be resolved', async () => {
bookingSeat.findMany.mockResolvedValue([{
...ownedByOther[0],
booking: { ...ownedByOther[0].booking, originStationId: null, destinationStationId: null },
}]);
await expect(service.assertSeatsClaimable({ ...baseArgs, alsoRejectActiveBookings: true }))
.rejects.toThrow(/just booked by someone else/i);
});
it('reads BookingSeat through the transaction client, not the pool', async () => {
await service.claimSeatsAndWrite([baseArgs], async () => ({ id: 'b1' }) as any);
expect(bookingSeat.findMany).toHaveBeenCalled();
// same mock object is shared by tx and pool here, so assert the availability map —
// whose last positional argument is the client — received the transaction client.
const call = mockSegments.getSeatAvailabilityMap.mock.calls[0];
expect(call[call.length - 1]).toBe(tx);
});
});
describe('claimSeatsAndWrite', () => {
it('locks the seats FOR UPDATE, then re-checks, then writes — in that order', async () => {
const order: string[] = [];
queryRaw.mockImplementation(() => { order.push('lock'); return Promise.resolve([]); });
seatHold.findUnique.mockImplementation(() => { order.push('check'); return Promise.resolve(LIVE_HOLD); });
const write = jest.fn(async () => { order.push('write'); return { id: 'b1' } as any; });
await service.claimSeatsAndWrite([baseArgs], write);
expect(order).toEqual(['lock', 'check', 'write']);
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1);
});
it('never runs the write when a leg fails its claim check', async () => {
seatHold.findUnique.mockResolvedValue({ ...LIVE_HOLD, expiresAt: new Date(Date.now() - 1) });
const write = jest.fn();
await expect(service.claimSeatsAndWrite([baseArgs], write as any)).rejects.toThrow();
expect(write).not.toHaveBeenCalled();
});
it('takes one lock covering every leg of a multi-leg booking, in one transaction', async () => {
const write = jest.fn(async () => ({ id: 'b1' }) as any);
await service.claimSeatsAndWrite(
[baseArgs, { ...baseArgs, seatIds: ['seat-2', 'seat-1'], legLabel: 'Return' }],
write,
);
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenCalledTimes(1);
});
it('skips the lock statement when there are no seats to lock (free-child-only booking)', async () => {
const write = jest.fn(async () => ({ id: 'b1' }) as any);
await service.claimSeatsAndWrite([{ ...baseArgs, seatIds: [] }], write);
expect(queryRaw).not.toHaveBeenCalled();
expect(write).toHaveBeenCalledTimes(1);
});
});
describe('withSeatsLocked', () => {
it('de-duplicates and sorts seat ids so two overlapping requests cannot deadlock', async () => {
await service.withSeatsLocked(['s-b', 's-a', 's-b'], async () => null);
const sql = queryRaw.mock.calls[0][0];
// Prisma.sql carries the interpolated values in `values`; order proves the sort.
expect(sql.values).toEqual(['s-a', 's-b']);
});
it('bounds the lock wait so a deep queue returns 409, not a transaction timeout', async () => {
await service.withSeatsLocked(['s-a'], async () => null);
expect(executeRawUnsafe).toHaveBeenCalledWith(expect.stringMatching(/SET LOCAL lock_timeout/i));
});
it('reports a berth being claimed elsewhere as a conflict, not a 500', async () => {
queryRaw.mockRejectedValue(Object.assign(new Error('raw query failed'), { meta: { code: '55P03' } }));
await expect(service.withSeatsLocked(['s-a'], async () => null))
.rejects.toThrow(/being booked by someone else/i);
});
});
});

View File

@@ -1,16 +1,101 @@
import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto'; import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto';
import { ActingUser } from '../../common/acting-user'; import { ActingUser } from '../../common/acting-user';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service'; import { SegmentsService, PrismaClientLike } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service'; import { AuditService } from '../../common/audit.service';
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
import { SmsClientService } from '../notifications/sms-client.service'; import { SmsClientService } from '../notifications/sms-client.service';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils'; import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
// Same definition the one-ticket-per-identity check uses: the states that still hold a
// traveller's place. CANCELLED / REFUNDED / NO_SHOW must free the berth immediately.
import { ACTIVE_BOOKING_STATUSES } from '../bookings/booking-identity.util';
/**
* How long a request will queue for a berth's row lock before giving up and reporting the
* seat as taken. Must stay comfortably under Prisma's interactive-transaction timeout
* (5000 ms by default) so this is what fires, not the transaction ceiling — the latter
* surfaces as an opaque 500 instead of an actionable 409.
*/
const SEAT_LOCK_TIMEOUT_MS = 3000;
/** Postgres 55P03 lock_not_available, as surfaced through Prisma's raw-query wrapper. */
function isLockTimeout(err: unknown): boolean {
const meta = (err as any)?.meta;
if (meta?.code === '55P03') return true;
const text = `${(err as any)?.message ?? ''} ${meta?.message ?? ''}`;
return /55P03|lock timeout|canceling statement due to lock timeout/i.test(text);
}
/**
* One leg's claim on some seats: the hold being presented, the seats it must cover, and the
* stretch of that schedule they are wanted for. A one-way booking has one; a round-trip
* transit booking has four, each with its own hold.
*/
export interface SeatClaim {
holdId: string;
seatIds: string[];
scheduleId: string;
originStationId?: string;
destinationStationId?: string;
journeyDirection?: JourneyDirection;
/** Label used in error messages when a booking presents several holds (one per leg). */
legLabel?: string;
}
/**
* The stretch of track a booking occupies on one schedule.
*
* Resolved by matching the SCHEDULE rather than the leg number. `BookingSeat.leg` is
* numbered per booking type (1=outbound, 2=return/leg-2, 3-4=return transit legs), so
* reading it correctly means re-deriving the same branching PaymentsService.createJourneySegments
* does. Matching on scheduleId asks the question we actually care about — "what stretch of
* THIS departure does this booking hold?" — and stays correct if leg numbering ever changes.
*
* Leg boundaries mirror createJourneySegments exactly: on a transit booking the outbound leg
* ends at the transit station (leg2OriginStationId), not at the booking's final destination.
*
* A booking that somehow lands on one schedule twice yields the union of both stretches,
* which is the conservative reading.
*/
function resolveBookingLegRange(
booking: {
scheduleId: string;
originStationId: string | null;
destinationStationId: string | null;
returnScheduleId: string | null;
returnOriginStationId: string | null;
returnDestinationStationId: string | null;
leg2ScheduleId: string | null;
leg2OriginStationId: string | null;
leg2DestinationStationId: string | null;
returnLeg2ScheduleId: string | null;
returnLeg2OriginStationId: string | null;
returnLeg2DestStationId: string | null;
},
scheduleId: string,
): { originStationId: string | null; destinationStationId: string | null } | null {
const b = booking;
const legs = [
// Outbound. On a transit booking this leg stops at the transit station.
{ scheduleId: b.scheduleId, from: b.originStationId, to: b.leg2OriginStationId ?? b.destinationStationId },
{ scheduleId: b.leg2ScheduleId, from: b.leg2OriginStationId, to: b.leg2DestinationStationId },
// Return. Likewise stops at the return transit station when there is one.
{ scheduleId: b.returnScheduleId, from: b.returnOriginStationId, to: b.returnLeg2OriginStationId ?? b.returnDestinationStationId },
{ scheduleId: b.returnLeg2ScheduleId, from: b.returnLeg2OriginStationId, to: b.returnLeg2DestStationId },
].filter((l) => l.scheduleId === scheduleId && l.from && l.to);
if (!legs.length) return null;
if (legs.length === 1) {
return { originStationId: legs[0].from, destinationStationId: legs[0].to };
}
return { originStationId: legs[0].from, destinationStationId: legs[legs.length - 1].to };
}
@Injectable() @Injectable()
export class SeatsService { export class SeatsService {
@@ -206,6 +291,239 @@ export class SeatsService {
return legacyMap[col?.toUpperCase()] ?? null; return legacyMap[col?.toUpperCase()] ?? null;
} }
/**
* The single gate every booking path must pass before it may write a BookingSeat.
*
* It answers both halves of "may this request claim these seats": that the hold it
* presents actually covers them, and that nobody else has them for this leg. Those two
* checks used to live only in BookingsService, so POST /bookings/guest — a public,
* unauthenticated endpoint — validated nothing beyond "some hold exists and hasn't
* expired" and would happily connect any seat id the caller sent, held or not, free or
* not. Keep both booking services routed through here; a path that skips it can sell
* the same berth twice.
*/
async assertSeatsClaimable(args: SeatClaim & {
/**
* Read through this client. Callers inside `withSeatsLocked` pass their `tx` so the
* check runs on the connection that owns the row locks; everyone else gets the pool.
*/
db?: PrismaClientLike;
/** See assertNoRouteSeatConflict — booking writes turn this on, holds do not. */
alsoRejectActiveBookings?: boolean;
}): Promise<void> {
const { holdId, seatIds, legLabel } = args;
const db = args.db ?? this.prisma;
const prefix = legLabel ? `${legLabel} ` : '';
const hold = await db.seatHold.findUnique({ where: { id: holdId } });
if (!hold || hold.expiresAt < new Date()) {
throw new BadRequestException(`${prefix}seat hold expired or not found`.trim());
}
// A hold is scoped to one departure, but the same physical Seat row is reused across
// every schedule its coach is assigned to. Without this check a hold taken on a quiet
// departure redeems that seat on a busy one — and the resulting booking is then
// "protected" by a hold on the wrong schedule, i.e. not protected at all.
if (hold.scheduleId !== args.scheduleId) {
throw new BadRequestException(
`${prefix}hold ${holdId} belongs to a different departure. ` +
`Hold the seats on this schedule before booking them.`.trim(),
);
}
const heldSeatIds = new Set(hold.seatIds);
const stray = seatIds.filter((id) => id && !heldSeatIds.has(id));
if (stray.length) {
throw new BadRequestException(
`${prefix}seat(s) ${stray.join(', ')} are not part of hold ${holdId}. ` +
`Use seat IDs returned from POST /seats/hold.`.trim(),
);
}
await this.assertNoRouteSeatConflict({
scheduleId: args.scheduleId,
seatIds,
originStationId: args.originStationId,
destinationStationId: args.destinationStationId,
journeyDirection: args.journeyDirection,
requestingPassengerId: hold.passengerId,
db,
alsoRejectActiveBookings: args.alsoRejectActiveBookings,
legLabel,
});
}
/**
* Runs `work` inside a transaction that holds an exclusive row lock on every seat it
* touches — the write-side twin of the lock `holdSeats` takes.
*
* Validating seat availability and then writing the BookingSeat rows in two separate
* statements is a read-then-write with nothing between them: N concurrent requests all
* read "free" and all write, which is exactly how one berth was sold six times over.
* Wrapping both in this makes the second request block on the lock, then re-read and see
* the first request's committed booking.
*
* `ORDER BY id` matches holdSeats, so a hold and a booking contending for the same two
* seats take them in the same sequence and queue instead of deadlocking.
*
* The transaction must stay short: do fare calculation, FX conversion and identity
* verification BEFORE calling this. Slow I/O in here holds seat locks open for the whole
* round trip.
*/
/**
* Takes the exclusive row locks for `seatIds` inside the caller's transaction.
*
* Bounded on purpose. FOR UPDATE serialises everyone contending for a berth, so under a
* burst the Nth contender waits for the N-1 transactions ahead of it. Left unbounded that
* wait runs past Prisma's 5s interactive-transaction ceiling and the request dies with
* P2028 — a 500 reading "Internal server error" for what is really "someone else got the
* seat". lock_timeout fires first and turns that into the honest answer.
*
* Waiting at all is still right: a fast hand-off (the common case) succeeds normally. Only
* a queue deep enough to mean the berth is genuinely being taken gets the 409.
*/
private async lockSeatsForUpdate(tx: Prisma.TransactionClient, seatIds: string[]): Promise<void> {
if (!seatIds.length) return;
// SET LOCAL — scoped to this transaction, reset on commit/rollback. Must stay below the
// interactive-transaction timeout so it is the one that fires.
await tx.$executeRawUnsafe(`SET LOCAL lock_timeout = '${SEAT_LOCK_TIMEOUT_MS}ms'`);
try {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM passenger."Seat" WHERE id IN (${Prisma.join(seatIds)}) ORDER BY id FOR UPDATE`,
);
} catch (err) {
// 55P03 lock_not_available: another transaction is mid-claim on one of these berths.
if (isLockTimeout(err)) {
throw new ConflictException(
'Those seats are being booked by someone else right now. Please pick another seat.',
);
}
throw err;
}
}
async withSeatsLocked<T>(
seatIds: string[],
work: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
// Sorted here as well as in the SQL. ORDER BY is the intent, but Postgres is only
// guaranteed to apply it before locking when the plan produces rows in that order — a
// bitmap heap scan can lock first and sort after. Handing the IN-list in id order costs
// nothing and makes the ordering independent of the planner.
const distinct = [...new Set(seatIds.filter(Boolean))].sort();
return this.prisma.$transaction(async (tx) => {
await this.lockSeatsForUpdate(tx, distinct);
return work(tx);
});
}
/**
* The two-line contract every booking-creation path uses.
*
* const booking = await seatsService.claimSeatsAndWrite(claims, (tx) => tx.booking.create(…));
*
* Locks every seat across every leg, re-runs the full claim gate for each leg inside that
* lock (including the active-booking check), then runs `write` — all in one transaction, so
* a concurrent request either blocks and then sees this booking, or is seen by it.
*
* Call `assertSeatsClaimableAll` first, before the fare and identity work, so an obviously
* doomed request fails early instead of paying for a Verifayda round trip it will discard.
* That earlier pass is advisory only; this one decides.
*/
async claimSeatsAndWrite<T>(
claims: SeatClaim[],
write: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
const allSeatIds = claims.flatMap((c) => c.seatIds);
return this.withSeatsLocked(allSeatIds, async (tx) => {
for (const claim of claims) {
await this.assertSeatsClaimable({ ...claim, db: tx, alsoRejectActiveBookings: true });
}
return write(tx);
});
}
/** Advisory pre-flight for the same claims, outside any lock. See claimSeatsAndWrite. */
async assertSeatsClaimableAll(claims: SeatClaim[]): Promise<void> {
for (const claim of claims) {
await this.assertSeatsClaimable(claim);
}
}
/**
* Rejects seats that an active booking already owns on an overlapping stretch of this
* schedule.
*
* This is deliberately NOT part of assertNoRouteSeatConflict. That check is also run by
* holdSeats, and some flows (the backoffice reservation-issue path, reschedule, upgrade)
* legitimately re-hold a seat for a booking that already owns it — folding this in there
* would break them. Here it guards only the moment of writing a NEW booking, where no
* legitimate caller can already hold the seat.
*
* It exists because getSeatAvailabilityMap cannot see a pending booking: it reads
* SeatHolds and JourneySegments, and JourneySegments are only written on payment success
* (PaymentsService.createJourneySegments). Until then a PENDING_PAYMENT booking is
* represented by nothing but its hold — so any request whose own hold is skipped as
* "its own" saw a free seat and booked straight over it.
*/
async assertNoPendingBookingConflict(
db: PrismaClientLike,
args: { scheduleId: string; seatIds: string[]; reqFrom: number; reqTo: number; legLabel?: string },
): Promise<void> {
const { scheduleId, seatIds, reqFrom, reqTo, legLabel } = args;
if (!seatIds.length) return;
const rows = await db.bookingSeat.findMany({
where: {
scheduleId,
seatId: { in: seatIds },
booking: { status: { in: ACTIVE_BOOKING_STATUSES } },
},
select: {
seatId: true,
seat: { select: { seatNumber: true } },
booking: {
select: {
bookingRef: true, scheduleId: true,
originStationId: true, destinationStationId: true,
returnScheduleId: true, returnOriginStationId: true, returnDestinationStationId: true,
leg2ScheduleId: true, leg2OriginStationId: true, leg2DestinationStationId: true,
returnLeg2ScheduleId: true, returnLeg2OriginStationId: true, returnLeg2DestStationId: true,
},
},
},
});
if (!rows.length) return;
const stopTimes = await db.tripStopTime.findMany({
where: { scheduleId },
select: { stationId: true, sequence: true },
});
const seqOf = (id?: string | null) =>
id ? stopTimes.find(s => s.stationId === id)?.sequence : undefined;
const conflicts = new Map<string, string>();
for (const row of rows) {
const leg = resolveBookingLegRange(row.booking as any, scheduleId);
const from = seqOf(leg?.originStationId);
const to = seqOf(leg?.destinationStationId);
// Conservative: a row whose leg we cannot resolve blocks. Selling a berth twice is
// far worse than refusing one booking we could not prove safe.
const overlaps = from === undefined || to === undefined || (from < reqTo && reqFrom < to);
if (overlaps) {
conflicts.set(row.seatId, row.seat?.seatNumber ?? row.seatId);
}
}
if (conflicts.size > 0) {
const prefix = legLabel ? `${legLabel} ` : '';
throw new ConflictException(
`${prefix}seat(s) ${[...conflicts.values()].join(', ')} were just booked by someone else. ` +
`Pick another seat.`.trim(),
);
}
}
// Delegates the actual "is this seat held/booked for this leg" determination to // Delegates the actual "is this seat held/booked for this leg" determination to
// SegmentsService.getSeatAvailabilityMap — the same canonical check search results // SegmentsService.getSeatAvailabilityMap — the same canonical check search results
// (availabilityByClass) use — so the seatmap and search results can never disagree // (availabilityByClass) use — so the seatmap and search results can never disagree
@@ -217,11 +535,28 @@ export class SeatsService {
originStationId?: string; originStationId?: string;
destinationStationId?: string; destinationStationId?: string;
journeyDirection?: JourneyDirection; journeyDirection?: JourneyDirection;
/**
* Whoever is asking. OUTBOUND/RETURN on one schedule only stop conflicting when both
* legs belong to this same passenger — see getSeatAvailabilityMap.
*/
requestingPassengerId?: string;
/** Read through this client — a locking caller passes its own `tx`. */
db?: PrismaClientLike;
/**
* Also reject seats an active booking already owns. Off by default: holdSeats and the
* reschedule/upgrade/reservation flows legitimately re-hold a seat whose booking already
* exists. Only the moment of writing a NEW booking turns this on — see
* assertNoPendingBookingConflict.
*/
alsoRejectActiveBookings?: boolean;
/** Label used in error messages when a booking presents several holds (one per leg). */
legLabel?: string;
}): Promise<void> { }): Promise<void> {
const { scheduleId, seatIds, originStationId, destinationStationId, journeyDirection = JourneyDirection.ONE_WAY } = args; const { scheduleId, seatIds, originStationId, destinationStationId, journeyDirection = JourneyDirection.ONE_WAY } = args;
if (!seatIds.length) return; if (!seatIds.length) return;
const db = args.db ?? this.prisma;
const stopTimes = await this.prisma.tripStopTime.findMany({ const stopTimes = await db.tripStopTime.findMany({
where: { scheduleId }, where: { scheduleId },
select: { stationId: true, sequence: true }, select: { stationId: true, sequence: true },
}); });
@@ -245,11 +580,19 @@ export class SeatsService {
reqFrom, reqFrom,
reqTo, reqTo,
journeyDirection, journeyDirection,
args.requestingPassengerId,
db,
); );
if (args.alsoRejectActiveBookings) {
await this.assertNoPendingBookingConflict(db, {
scheduleId, seatIds, reqFrom, reqTo, legLabel: args.legLabel,
});
}
if (availability.size === 0) return; if (availability.size === 0) return;
const seats = await this.prisma.seat.findMany({ const seats = await db.seat.findMany({
where: { id: { in: seatIds } }, where: { id: { in: seatIds } },
select: { id: true, seatNumber: true }, select: { id: true, seatNumber: true },
}); });
@@ -413,6 +756,16 @@ export class SeatsService {
} }
const hold = await this.prisma.$transaction(async (tx) => { const hold = await this.prisma.$transaction(async (tx) => {
// Everything below is read-then-write — check no hold/segment covers these seats,
// then insert a hold — with no unique constraint behind it. Without a lock, N
// simultaneous requests all read "free" and all insert, which is how one berth ends
// up held (and then sold) several times over. Locking the Seat rows serializes those
// requests; ORDER BY id keeps two overlapping multi-seat requests taking the rows in
// the same sequence, so they queue instead of deadlocking. Held only for this
// transaction, so cross-schedule contention on a shared coach is negligible.
// Sorted so holds and booking writes take the same rows in the same sequence.
await this.lockSeatsForUpdate(tx, [...new Set(seatIds)].sort());
const seats = await tx.seat.findMany({ const seats = await tx.seat.findMany({
where: { id: { in: seatIds } }, where: { id: { in: seatIds } },
select: { id: true, status: true, seatNumber: true }, select: { id: true, status: true, seatNumber: true },
@@ -483,6 +836,16 @@ export class SeatsService {
originStationId: dto.originStationId, originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId, destinationStationId: dto.destinationStationId,
journeyDirection: currentDirection, journeyDirection: currentDirection,
// Matches how SeatHold.passengerId is written below, so this passenger's own
// outbound hold doesn't block them from holding their return leg.
requestingPassengerId: dto.passengers[0]?.passengerId,
// MUST be tx, not the pool. This runs while the transaction already owns a pooled
// connection; reading through `this.prisma` would check out a SECOND one. With N
// concurrent holds that is 2N connections against a pool of N_max, so past roughly
// half the pool every transaction ends up waiting for a connection only another
// transaction can release — a pool deadlock that fails every request with a 500,
// not just the losers. Reproduced at 25 concurrent holds before this was passed.
db: tx,
}); });
const activeHolds = await tx.seatHold.findMany({ const activeHolds = await tx.seatHold.findMany({
@@ -511,7 +874,14 @@ export class SeatsService {
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo); const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue; if (!legsOverlap) continue;
const directionsConflict = checkDirectionConflict(currentDirection, holdDirection); // OUTBOUND and RETURN coexist on one schedule only for a single traveller holding
// both legs of their own turnaround trip. Between two different travellers that
// exemption is just a double-sold berth, so it applies only to the requester's
// own holds.
const requestPassengerIds = new Set(dto.passengers.map((p) => p.passengerId));
const isOwnHold = passengerIds.some((id) => requestPassengerIds.has(id));
const directionsConflict = !isOwnHold || checkDirectionConflict(currentDirection, holdDirection);
if (!directionsConflict) continue; if (!directionsConflict) continue;
for (const { passengerId, seatId } of dto.passengers) { for (const { passengerId, seatId } of dto.passengers) {
@@ -1364,12 +1734,15 @@ export class SeatsService {
}); });
const occupiedIds = new Set(journeySegments.map(js => js.seatId!)); const occupiedIds = new Set(journeySegments.map(js => js.seatId!));
// Group BookingSeat rows by seatId::leg to find candidate duplicates, // Group BookingSeat rows by seatId to find candidate duplicates, then filter to
// then filter to only those whose booking segments actually overlap. // only those whose booking segments actually overlap.
type BS = (typeof bookingSeats)[number]; type BS = (typeof bookingSeats)[number];
const groups = new Map<string, BS[]>(); const groups = new Map<string, BS[]>();
// Grouped by seat alone. `leg` is a per-booking notion — one booking's leg 1 and
// another's leg 2 are the same physical berth on this departure — so keying on it
// hid every round-trip-vs-one-way collision from this report.
for (const bs of bookingSeats) { for (const bs of bookingSeats) {
const key = `${bs.seatId}::${bs.leg}`; const key = bs.seatId;
if (!groups.has(key)) groups.set(key, []); if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(bs); groups.get(key)!.push(bs);
} }
@@ -1463,16 +1836,20 @@ export class SeatsService {
} }
if (overlapping.length <= 1) continue; if (overlapping.length <= 1) continue;
const [seatId] = key.split('::'); const seatId = key;
const seat = coach.seats.find(s => s.id === seatId); const seat = coach.seats.find(s => s.id === seatId);
duplicates.push({ duplicates.push({
seatId, seatId,
seatNumber: seat?.seatNumber ?? seatId, seatNumber: seat?.seatNumber ?? seatId,
// A group can now span legs (one booking's outbound against another's return),
// so the authoritative leg is the per-booking one below; this stays for
// backwards compatibility with existing callers.
leg: overlapping[0].leg, leg: overlapping[0].leg,
bookings: overlapping.map(bs => ({ bookings: overlapping.map(bs => ({
bookingSeatId: bs.id, bookingSeatId: bs.id,
bookingId: bs.booking.id, bookingId: bs.booking.id,
bookingRef: bs.booking.bookingRef, bookingRef: bs.booking.bookingRef,
leg: bs.leg,
passengerName: bs.passengerName, passengerName: bs.passengerName,
contactPhone: bs.booking.contactPhone, contactPhone: bs.booking.contactPhone,
createdAt: bs.booking.createdAt, createdAt: bs.booking.createdAt,

View File

@@ -1,7 +1,15 @@
import { Injectable, BadRequestException } from '@nestjs/common'; import { Injectable, BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { JourneyDirection } from '../seats/seats.dto'; import { JourneyDirection } from '../seats/seats.dto';
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
/**
* Either the pooled client or a transaction-scoped one. A caller that has taken row locks
* must pass its own `tx`, or its "is this seat still free" read runs on a second connection
* outside the lock's transaction — burning a pool slot per in-flight booking and reading a
* snapshot the lock does not actually govern.
*/
export type PrismaClientLike = PrismaService | Prisma.TransactionClient;
export interface Segment { export interface Segment {
fromStationId: string; fromStationId: string;
@@ -73,9 +81,10 @@ export class SegmentsService {
* P3: A(1) → D(4) reqFrom=1, reqTo=4 * P3: A(1) → D(4) reqFrom=1, reqTo=4
* Check P3 vs P2: 1 < 4 AND 2 < 4 → true AND true → CONFLICT ✓ * Check P3 vs P2: 1 < 4 AND 2 < 4 → true AND true → CONFLICT ✓
* *
* journeyDirection lets a round-trip's OUTBOUND and RETURN holds coexist on the * A round-trip's OUTBOUND and RETURN holds coexist on the same schedule because they
* same schedule without blocking each other (see checkDirectionConflict) — omit it * belong to the same traveller — pass requestingPassengerId so that traveller's own
* for one-way contexts, where it defaults to ONE_WAY (conflicts with anything). * holds are skipped. Direction alone never grants that exemption: between two different
* travellers an OUTBOUND and a RETURN hold on one berth is a double sale.
* *
* Sources checked: * Sources checked:
* 1. Active SeatHolds — leg + direction decoded from createdBy JSON * 1. Active SeatHolds — leg + direction decoded from createdBy JSON
@@ -93,7 +102,24 @@ export class SegmentsService {
stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>, stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
reqFrom: number, reqFrom: number,
reqTo: number, reqTo: number,
/**
* Retained for call-site compatibility (this is a positional signature) and for the
* seatmap/search callers that still describe their leg. Hold conflicts no longer turn
* on it — see the own-hold rule below.
*/
journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY, journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY,
/**
* The passenger asking. Their own active holds are skipped, which is what lets one
* traveller keep a berth across both legs of a turnaround round trip. Omit it in
* display contexts (seatmap, search) so every hold shows as taken.
*/
requestingPassengerId?: string,
/**
* Transaction client to read through. Defaults to the pooled client, which is right for
* every display caller; a caller holding `FOR UPDATE` locks must pass its own `tx` so the
* read happens on the locked connection.
*/
db: PrismaClientLike = this.prisma,
): Promise<Map<string, 'HELD' | 'BOOKED'>> { ): Promise<Map<string, 'HELD' | 'BOOKED'>> {
const result = new Map<string, 'HELD' | 'BOOKED'>(); const result = new Map<string, 'HELD' | 'BOOKED'>();
if (seatIds.length === 0) return result; if (seatIds.length === 0) return result;
@@ -105,11 +131,11 @@ export class SegmentsService {
const now = new Date(); const now = new Date();
const [allHolds, bookedLegs] = await Promise.all([ const [allHolds, bookedLegs] = await Promise.all([
this.prisma.seatHold.findMany({ db.seatHold.findMany({
where: { scheduleId, expiresAt: { gt: now } }, where: { scheduleId, expiresAt: { gt: now } },
select: { seatIds: true, createdBy: true }, select: { seatIds: true, createdBy: true, passengerId: true },
}), }),
this.prisma.journeySegment.findMany({ db.journeySegment.findMany({
where: { where: {
scheduleId, scheduleId,
seatId: { in: seatIds }, seatId: { in: seatIds },
@@ -123,22 +149,29 @@ export class SegmentsService {
for (const hold of allHolds) { for (const hold of allHolds) {
let holdFrom: number | undefined; let holdFrom: number | undefined;
let holdTo: number | undefined; let holdTo: number | undefined;
let holdDirection = JourneyDirection.ONE_WAY;
try { try {
if (hold.createdBy) { if (hold.createdBy) {
const meta = JSON.parse(hold.createdBy as string); const meta = JSON.parse(hold.createdBy as string);
holdFrom = seqOf(meta.originStationId); holdFrom = seqOf(meta.originStationId);
holdTo = seqOf(meta.destinationStationId); holdTo = seqOf(meta.destinationStationId);
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
} }
} catch { /* ignore */ } } catch { /* ignore */ }
// A hold reserves the seat *for* whoever placed it, so it must never be read as an
// obstacle to that same person's own booking — including the other leg of their own
// round trip, which is what the OUTBOUND/RETURN exemption used to cover. Anyone
// else's hold blocks on leg overlap alone: direction is irrelevant between two
// different travellers, and treating OUTBOUND and RETURN as compatible there is
// exactly how one berth got sold to two people.
const isOwnHold =
requestingPassengerId != null && hold.passengerId === requestingPassengerId;
if (isOwnHold) continue;
for (const sid of hold.seatIds) { for (const sid of hold.seatIds) {
if (!seatIdSet.has(sid)) continue; if (!seatIdSet.has(sid)) continue;
// Conservative block if leg can't be resolved; otherwise check overlap. // Conservative block if leg can't be resolved; otherwise check overlap.
const legsOverlap = holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo); const legsOverlap = holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue; if (!legsOverlap) continue;
if (!checkDirectionConflict(journeyDirection, holdDirection)) continue;
result.set(sid, 'HELD'); result.set(sid, 'HELD');
} }
} }

View File

@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@ne
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { StationsService } from './stations.service'; import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto'; import { CreateStationDto } from './stations.dto';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Stations') @ApiTags('Stations')
@@ -79,7 +79,7 @@ export class StationsController {
findOne(@Param('id') id: string) { return this.service.findOne(id); } findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post() @Post()
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.stations.create, PASSENGER_PERMS.stations.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create new station' }) @ApiOperation({ summary: 'Create new station' })
@ApiResponse({ @ApiResponse({
@@ -105,7 +105,7 @@ export class StationsController {
create(@Body() dto: CreateStationDto) { return this.service.create(dto); } create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
@Patch(':id') @Patch(':id')
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.stations.edit, PASSENGER_PERMS.stations.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update station' }) @ApiOperation({ summary: 'Update station' })
@ApiResponse({ @ApiResponse({
@@ -134,7 +134,7 @@ export class StationsController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.stations.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete station' }) @ApiOperation({ summary: 'Delete station' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Query, Req, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, Query, Req, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service'; import { TicketsService } from './tickets.service';
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards'; import { PassengerStaff, PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { resolveActingUser } from '../../common/acting-user'; import { resolveActingUser } from '../../common/acting-user';
@@ -118,7 +118,7 @@ export class TicketsController {
} }
@Post('scan-board/:qrCodeOrRef') @Post('scan-board/:qrCodeOrRef')
@PassengerStaff(PASSENGER_PERMS.tickets.manage) @PassengerWrite(PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Scan QR code or booking ref and automatically board ticket', summary: 'Scan QR code or booking ref and automatically board ticket',
@@ -146,7 +146,7 @@ export class TicketsController {
} }
@Post(':bookingRef/validate') @Post(':bookingRef/validate')
@PassengerStaff(PASSENGER_PERMS.tickets.manage) @PassengerStaff([PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Validate ticket at gate with audit logging', summary: 'Validate ticket at gate with audit logging',
@@ -194,7 +194,7 @@ export class TicketsController {
} }
@Post('validate/offline') @Post('validate/offline')
@PassengerStaff(PASSENGER_PERMS.tickets.manage) @PassengerStaff([PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Batch import offline validations', summary: 'Batch import offline validations',
@@ -226,7 +226,7 @@ export class TicketsController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.tickets.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Delete ticket (admin only)', summary: 'Delete ticket (admin only)',
@@ -237,7 +237,7 @@ export class TicketsController {
} }
@Patch(':id/restore') @Patch(':id/restore')
@PassengerStaff(PASSENGER_PERMS.tickets.manage) @PassengerWrite(PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' }) @ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
restore(@Param('id') id: string) { restore(@Param('id') id: string) {

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { UpgradeService } from './upgrade.service'; import { UpgradeService } from './upgrade.service';
import { import {
@@ -18,7 +18,7 @@ export class UpgradeController {
constructor(private service: UpgradeService) {} constructor(private service: UpgradeService) {}
@Get('upgrade/policies') @Get('upgrade/policies')
@PassengerStaff(PASSENGER_PERMS.bookings.view) @PassengerStaff([PASSENGER_PERMS.upgradePolicies.view, PASSENGER_PERMS.upgradePolicies.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' }) @ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' })
listPolicies() { listPolicies() {
@@ -26,7 +26,7 @@ export class UpgradeController {
} }
@Get('upgrade/policies/available-coach-types') @Get('upgrade/policies/available-coach-types')
@PassengerStaff(PASSENGER_PERMS.bookings.view) @PassengerStaff([PASSENGER_PERMS.upgradePolicies.view, PASSENGER_PERMS.upgradePolicies.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' }) @ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' })
listUnconfiguredCoachTypes() { listUnconfiguredCoachTypes() {
@@ -34,7 +34,7 @@ export class UpgradeController {
} }
@Post('upgrade/policies') @Post('upgrade/policies')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.upgradePolicies.create, PASSENGER_PERMS.upgradePolicies.manage)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' }) @ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' })
createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) { createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) {
@@ -42,7 +42,7 @@ export class UpgradeController {
} }
@Patch('upgrade/policies/:coachTypeId') @Patch('upgrade/policies/:coachTypeId')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.upgradePolicies.edit, PASSENGER_PERMS.upgradePolicies.manage)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' }) @ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' })
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) { updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) {
@@ -50,7 +50,7 @@ export class UpgradeController {
} }
@Delete('upgrade/policies/:coachTypeId') @Delete('upgrade/policies/:coachTypeId')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.upgradePolicies.delete)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' }) @ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' })
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) { deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {

View File

@@ -68,6 +68,103 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('b4e63290-cc3a-4df8-9f2a-9ff726e86e36', 'edr_passenger_app:payment_methods:view', 'View payment methods'), perm('b4e63290-cc3a-4df8-9f2a-9ff726e86e36', 'edr_passenger_app:payment_methods:view', 'View payment methods'),
perm('f9fb6af2-e869-4e6e-938c-259643393315', 'edr_passenger_app:payment_methods:manage', 'Manage payment methods'), perm('f9fb6af2-e869-4e6e-938c-259643393315', 'edr_passenger_app:payment_methods:manage', 'Manage payment methods'),
// ── CRUD split — create / edit / delete for every resource that has a `:manage` ───
perm('1e443b37-e4f5-458f-bfcb-7a74acb9aaed', 'edr_passenger_app:bookings:create', 'Create bookings'),
perm('a7c66e27-bad9-4459-bf89-3624d11a9f49', 'edr_passenger_app:bookings:edit', 'Edit bookings'),
perm('f8b3ef47-7f58-41c2-af77-c55905b6bcd4', 'edr_passenger_app:bookings:delete', 'Delete bookings'),
perm('2cd6e2ad-e614-49c3-bd37-2a2286659702', 'edr_passenger_app:passengers:create', 'Create passengers'),
perm('be6b1ee9-7685-4e14-bcfb-60710791dac0', 'edr_passenger_app:passengers:edit', 'Edit passengers'),
perm('f97793b5-2500-4c91-add6-785a31dd21ac', 'edr_passenger_app:passengers:delete', 'Delete passengers'),
perm('78afaaab-e004-48a7-bb91-3d8c6195a624', 'edr_passenger_app:tickets:create', 'Create tickets'),
perm('bf148b6a-d5e6-489c-823c-3cabc4d0f7db', 'edr_passenger_app:tickets:edit', 'Edit tickets'),
perm('3557699a-5eee-480e-b9c5-7a731c546c12', 'edr_passenger_app:tickets:delete', 'Delete tickets'),
perm('800dee2f-6d6a-42e2-8ad3-dffbc66508d0', 'edr_passenger_app:payments:create', 'Create payments'),
perm('3e029a68-f7d5-4857-a4de-30a8bce27e9a', 'edr_passenger_app:payments:edit', 'Edit payments'),
perm('90965ca7-976b-476f-b7bb-96b96f081e6d', 'edr_passenger_app:payments:delete', 'Delete payments'),
perm('b656ef2a-07d2-4a14-9ae5-d16fd8311430', 'edr_passenger_app:payment_methods:create', 'Create payment methods'),
perm('58fda910-afa9-45f8-8316-491a7ee66c19', 'edr_passenger_app:payment_methods:edit', 'Edit payment methods'),
perm('d37ea9a6-1003-4dea-a41b-e12d43b0b3c7', 'edr_passenger_app:payment_methods:delete', 'Delete payment methods'),
perm('ab666cff-8bf1-4664-9b5c-47ce7ad680e8', 'edr_passenger_app:stations:create', 'Create stations'),
perm('30f71070-a6ae-466f-a7fe-67d31dbec79b', 'edr_passenger_app:stations:edit', 'Edit stations'),
perm('3e0b3798-d0ec-427d-b775-452e848538c7', 'edr_passenger_app:stations:delete', 'Delete stations'),
perm('832dc94d-d8a2-4bea-8de6-75ec5cc53577', 'edr_passenger_app:trains:create', 'Create trains'),
perm('032eb29d-c9ae-4974-88f4-7aa386fd988e', 'edr_passenger_app:trains:edit', 'Edit trains'),
perm('b9eeb7a7-9bb2-441e-8780-f9bbfd361608', 'edr_passenger_app:trains:delete', 'Delete trains'),
perm('4c2b86b1-7ba4-4fbe-b317-c2f98270cdf4', 'edr_passenger_app:coaches:create', 'Create coaches'),
perm('6bedb989-06fc-4609-9a3a-f73f9bb7d878', 'edr_passenger_app:coaches:edit', 'Edit coaches'),
perm('7d01fdcf-0661-479a-ad0f-af703a479520', 'edr_passenger_app:coaches:delete', 'Delete coaches'),
perm('7da048d8-eb12-4ea3-8204-2afe3778cd77', 'edr_passenger_app:seats:create', 'Create seats'),
perm('08ec0c8d-23a3-4061-8060-78223d3b9461', 'edr_passenger_app:seats:edit', 'Edit seats'),
perm('f72d65a9-5269-49e2-bcdb-5df69869119b', 'edr_passenger_app:seats:delete', 'Delete seats'),
perm('22c8f4f2-a1a3-48a6-8546-a8adf41dfbc2', 'edr_passenger_app:classes:create', 'Create classes'),
perm('c567c887-7c69-444c-a24b-78acd20692ac', 'edr_passenger_app:classes:edit', 'Edit classes'),
perm('e858d0f4-96e1-4614-95c2-1cf8adbb2494', 'edr_passenger_app:classes:delete', 'Delete classes'),
perm('b69019ed-2e55-4452-9b99-14a42781ab33', 'edr_passenger_app:routes:create', 'Create routes'),
perm('cf708629-cd10-4dd1-b287-4d043ec1ef79', 'edr_passenger_app:routes:edit', 'Edit routes'),
perm('084ae4a0-7c86-4d60-aefd-0ad768ae7394', 'edr_passenger_app:routes:delete', 'Delete routes'),
perm('827d5b4f-8a9d-4678-807d-92fc61a640e5', 'edr_passenger_app:schedules:create', 'Create schedules'),
perm('2c65502e-adda-4f5f-a38a-d964134ea826', 'edr_passenger_app:schedules:edit', 'Edit schedules'),
perm('ab592f0d-480e-4b68-99f1-fd98573beffb', 'edr_passenger_app:schedules:delete', 'Delete schedules'),
perm('1b04a00e-0027-4e77-9f29-33b77d7b1cfc', 'edr_passenger_app:packages:create', 'Create packages'),
perm('f9124c13-2314-42ea-8836-61fe981d6398', 'edr_passenger_app:packages:edit', 'Edit packages'),
perm('2e2b9fc9-b2ca-4502-ba74-8563bce6c94c', 'edr_passenger_app:packages:delete', 'Delete packages'),
perm('1c5cb191-25d3-4d6d-8fcf-168c09c434cd', 'edr_passenger_app:inquiries:create', 'Create package inquiries'),
perm('054c8ffd-0d82-4014-ac0d-8fd2e1bc257c', 'edr_passenger_app:inquiries:edit', 'Edit package inquiries'),
perm('dac24152-465c-4c89-a547-c73ad0818e34', 'edr_passenger_app:inquiries:delete', 'Delete package inquiries'),
perm('aa5bf1ee-5771-4008-a8c7-fd30cb112727', 'edr_passenger_app:tariff_rates:create', 'Create tariff rates'),
perm('24a53bea-cfcf-4ddf-a156-a3f4ca90ac1c', 'edr_passenger_app:tariff_rates:edit', 'Edit tariff rates'),
perm('540a9fe9-bd4e-4d9d-acbf-6a00762e4929', 'edr_passenger_app:tariff_rates:delete', 'Delete tariff rates'),
perm('cabb6e6d-3ba4-455e-b19b-eccdb60c9bca', 'edr_passenger_app:fraud:create', 'Create fraud rules'),
perm('a9a6942f-8ba0-4b33-abc2-0d4c25d62e26', 'edr_passenger_app:fraud:edit', 'Edit fraud rules'),
perm('9415abe7-0c11-4e8c-b59b-4e07d18ba649', 'edr_passenger_app:fraud:delete', 'Delete fraud rules'),
perm('5e8eaf85-d453-48fe-91ae-4bf2f6a8716f', 'edr_passenger_app:agents:create', 'Create agents'),
perm('763bbe62-53e7-494c-8a8e-553c52bdfb29', 'edr_passenger_app:agents:edit', 'Edit agents'),
perm('4fe4088f-fa55-4117-bd71-305a65f69056', 'edr_passenger_app:agents:delete', 'Delete agents'),
perm('6a678100-4dce-4fda-9cdb-7f75ffa1f187', 'edr_passenger_app:currencies:create', 'Create currencies'),
perm('c1d66663-6077-4199-9e2e-00b254e5a954', 'edr_passenger_app:currencies:edit', 'Edit currencies'),
perm('8c1295e8-fa77-4ab7-9684-e55fa970010c', 'edr_passenger_app:currencies:delete', 'Delete currencies'),
// ── Domain actions that create/edit/delete cannot express ───────────────────────
perm('2ce6d0e3-3426-4424-a239-3ddf95b001c4', 'edr_passenger_app:schedules:cancel', 'Cancel schedules'),
perm('045d3f55-d5fc-4ef4-8d4f-949496b77c25', 'edr_passenger_app:seats:block', 'Block and unblock seats'),
perm('8ac8f5de-26b0-469b-993e-2abfa005d223', 'edr_passenger_app:packages:publish', 'Activate and deactivate packages'),
perm('4c263527-9868-4ec6-8861-039cd5e37948', 'edr_passenger_app:tickets:board', 'Board passengers'),
perm('635a92ec-def7-4373-a076-5437ccac09b8', 'edr_passenger_app:payments:supplementary', 'Send supplementary payment'),
perm('7f6deaf2-37c9-4420-ab29-9278266e3a5d', 'edr_passenger_app:excess_baggage:charge', 'Send excess luggage payment'),
// ── Schedule fares — split out of `schedules:manage` ────────────────────────────
perm('7e7406b3-77dc-422a-b5de-a8286f973b38', 'edr_passenger_app:schedule_fares:view', 'View schedule fares'),
perm('c4c205a1-06d4-4a60-a2cd-c06f4732a733', 'edr_passenger_app:schedule_fares:create', 'Create schedule fares'),
perm('516f8fe2-792d-4f4a-a1d4-ea1a0bd759e2', 'edr_passenger_app:schedule_fares:edit', 'Edit schedule fares'),
perm('30e8aa00-e32d-492e-bfbe-ee8fe1e9ad24', 'edr_passenger_app:schedule_fares:delete', 'Delete schedule fares'),
perm('d358ede2-0c19-48d2-92a6-aa54fa91086d', 'edr_passenger_app:schedule_fares:manage', 'Manage schedule fares'),
// ── Per-report permissions — `reports:view` stays the all-reports umbrella ──────
perm('69cf2f52-e8be-447e-90fa-9d4f12d77824', 'edr_passenger_app:reports_overall:view', 'View overall report'),
perm('d4229615-1a7a-40c1-a8da-5fbc9681f8da', 'edr_passenger_app:reports_finance:view', 'View finance summary report'),
perm('a8291a6f-bd38-4be2-a9e5-0b3036242bd5', 'edr_passenger_app:reports_finance:export', 'Export finance summary report'),
perm('d50fd4ec-1914-48e2-ade2-056d3d97a79b', 'edr_passenger_app:reports_coach_utilization:view', 'View coach utilization report'),
perm('aa0c3321-ff08-4c7e-8e0f-24042c68c8a8', 'edr_passenger_app:reports_seat_status:view', 'View seat status report'),
perm('0ea7547f-d8a0-448c-a0d8-9341cd00383a', 'edr_passenger_app:reports_blocked_seats:view', 'View blocked seat revenue loss report'),
perm('4855be9b-23ac-4f27-8c5f-2a18974afeb7', 'edr_passenger_app:reports_blocked_seats:export', 'Export blocked seat revenue loss report'),
perm('8fec9101-4a7b-4089-b4cf-a252ca80481a', 'edr_passenger_app:reports_passengers:view', 'View passengers report'),
perm('744f6cd0-cbef-4beb-8afe-180866e3a4a2', 'edr_passenger_app:reports_boarding:view', 'View boarding report'),
perm('2a8fb77e-4bf6-42e0-80ca-53ca142b1b23', 'edr_passenger_app:reports_payments:view', 'View payments report'),
perm('3476bbe6-47f5-4040-8b3c-88b417b1c0c2', 'edr_passenger_app:reports_catalog:view', 'View generated report catalog'),
// ── Reschedule & upgrade policies ────────────────────────────────────────────
perm('9d27002e-7ee1-445a-b732-347f3b18d89a', 'edr_passenger_app:reschedule_policies:view', 'View reschedule policies'),
perm('19b50492-8915-4a05-8c15-61b39fe244d1', 'edr_passenger_app:reschedule_policies:manage', 'Manage reschedule policies'),
perm('9955afb1-61ee-4dd2-a863-b85631de730b', 'edr_passenger_app:reschedule_policies:create', 'Create reschedule policies'),
perm('c7d0e2e2-7894-40ad-95af-a91740658ae4', 'edr_passenger_app:reschedule_policies:edit', 'Edit reschedule policies'),
perm('a45a796e-4fd2-46b0-8d0a-1f2aa227763a', 'edr_passenger_app:reschedule_policies:delete', 'Delete reschedule policies'),
perm('3f088266-af6a-49b8-bfc6-287e8b459022', 'edr_passenger_app:upgrade_policies:view', 'View upgrade policies'),
perm('678363f4-cf2e-4488-80ad-9ed8b7760d55', 'edr_passenger_app:upgrade_policies:manage', 'Manage upgrade policies'),
perm('1383de71-b308-4fc4-9900-6f045d8a74d0', 'edr_passenger_app:upgrade_policies:create', 'Create upgrade policies'),
perm('c2842698-0998-4675-ba28-8cbc4e9ac1a8', 'edr_passenger_app:upgrade_policies:edit', 'Edit upgrade policies'),
perm('f43ef123-65bd-49c3-b5c2-c943f5f4aaf2', 'edr_passenger_app:upgrade_policies:delete', 'Delete upgrade policies'),
perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'), perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'),
]; ];
@@ -79,74 +176,189 @@ export const PASSENGER_PERMS = {
manage: 'edr_passenger_app:bookings:manage', manage: 'edr_passenger_app:bookings:manage',
cancel: 'edr_passenger_app:bookings:cancel', cancel: 'edr_passenger_app:bookings:cancel',
reschedule: 'edr_passenger_app:bookings:reschedule', reschedule: 'edr_passenger_app:bookings:reschedule',
create: 'edr_passenger_app:bookings:create',
edit: 'edr_passenger_app:bookings:edit',
delete: 'edr_passenger_app:bookings:delete',
},
/**
* Fee/window policy for changing a confirmed booking. Its own resource rather than a
* booking action: an agent who can manage bookings must not be able to rewrite the fee
* schedule those bookings are priced against.
*/
reschedulePolicies: {
view: 'edr_passenger_app:reschedule_policies:view',
manage: 'edr_passenger_app:reschedule_policies:manage',
create: 'edr_passenger_app:reschedule_policies:create',
edit: 'edr_passenger_app:reschedule_policies:edit',
delete: 'edr_passenger_app:reschedule_policies:delete',
},
upgradePolicies: {
view: 'edr_passenger_app:upgrade_policies:view',
manage: 'edr_passenger_app:upgrade_policies:manage',
create: 'edr_passenger_app:upgrade_policies:create',
edit: 'edr_passenger_app:upgrade_policies:edit',
delete: 'edr_passenger_app:upgrade_policies:delete',
}, },
passengers: { passengers: {
view: 'edr_passenger_app:passengers:view', view: 'edr_passenger_app:passengers:view',
manage: 'edr_passenger_app:passengers:manage', manage: 'edr_passenger_app:passengers:manage',
create: 'edr_passenger_app:passengers:create',
edit: 'edr_passenger_app:passengers:edit',
delete: 'edr_passenger_app:passengers:delete',
}, },
tickets: { tickets: {
view: 'edr_passenger_app:tickets:view', view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage', manage: 'edr_passenger_app:tickets:manage',
generate: 'edr_passenger_app:tickets:generate', generate: 'edr_passenger_app:tickets:generate',
/** Marking a passenger boarded — gate scanning separately from editing a ticket. */
board: 'edr_passenger_app:tickets:board',
create: 'edr_passenger_app:tickets:create',
edit: 'edr_passenger_app:tickets:edit',
delete: 'edr_passenger_app:tickets:delete',
}, },
payments: { payments: {
view: 'edr_passenger_app:payments:view', view: 'edr_passenger_app:payments:view',
manage: 'edr_passenger_app:payments:manage', manage: 'edr_passenger_app:payments:manage',
create: 'edr_passenger_app:payments:create',
/** Raising and re-sending a supplementary charge — bills a passenger and sends a pay link. */
supplementary: 'edr_passenger_app:payments:supplementary',
edit: 'edr_passenger_app:payments:edit',
delete: 'edr_passenger_app:payments:delete',
// legacy keys — retained as aliases for backward compatibility // legacy keys — retained as aliases for backward compatibility
viewAll: 'edr_passenger_app:payments:view_all', viewAll: 'edr_passenger_app:payments:view_all',
refund: 'edr_passenger_app:payments:refund', refund: 'edr_passenger_app:payments:refund',
manageMethods: 'edr_passenger_app:payments:manage_methods', manageMethods: 'edr_passenger_app:payments:manage_methods',
}, },
/**
* Excess luggage. Only the charge action is modelled: logging one bills the passenger
* and sends them a payment link, which is the part worth granting separately.
*/
excessBaggage: {
charge: 'edr_passenger_app:excess_baggage:charge',
},
paymentMethods: { paymentMethods: {
view: 'edr_passenger_app:payment_methods:view', view: 'edr_passenger_app:payment_methods:view',
manage: 'edr_passenger_app:payment_methods:manage', manage: 'edr_passenger_app:payment_methods:manage',
create: 'edr_passenger_app:payment_methods:create',
edit: 'edr_passenger_app:payment_methods:edit',
delete: 'edr_passenger_app:payment_methods:delete',
}, },
stations: { stations: {
view: 'edr_passenger_app:stations:view', view: 'edr_passenger_app:stations:view',
manage: 'edr_passenger_app:stations:manage', manage: 'edr_passenger_app:stations:manage',
create: 'edr_passenger_app:stations:create',
edit: 'edr_passenger_app:stations:edit',
delete: 'edr_passenger_app:stations:delete',
}, },
trains: { trains: {
view: 'edr_passenger_app:trains:view', view: 'edr_passenger_app:trains:view',
manage: 'edr_passenger_app:trains:manage', manage: 'edr_passenger_app:trains:manage',
create: 'edr_passenger_app:trains:create',
edit: 'edr_passenger_app:trains:edit',
delete: 'edr_passenger_app:trains:delete',
}, },
coaches: { coaches: {
view: 'edr_passenger_app:coaches:view', view: 'edr_passenger_app:coaches:view',
manage: 'edr_passenger_app:coaches:manage', manage: 'edr_passenger_app:coaches:manage',
create: 'edr_passenger_app:coaches:create',
edit: 'edr_passenger_app:coaches:edit',
delete: 'edr_passenger_app:coaches:delete',
}, },
seats: { seats: {
view: 'edr_passenger_app:seats:view', view: 'edr_passenger_app:seats:view',
manage: 'edr_passenger_app:seats:manage', manage: 'edr_passenger_app:seats:manage',
create: 'edr_passenger_app:seats:create',
edit: 'edr_passenger_app:seats:edit',
delete: 'edr_passenger_app:seats:delete',
block: 'edr_passenger_app:seats:block',
}, },
classes: { classes: {
view: 'edr_passenger_app:classes:view', view: 'edr_passenger_app:classes:view',
manage: 'edr_passenger_app:classes:manage', manage: 'edr_passenger_app:classes:manage',
create: 'edr_passenger_app:classes:create',
edit: 'edr_passenger_app:classes:edit',
delete: 'edr_passenger_app:classes:delete',
}, },
routes: { routes: {
view: 'edr_passenger_app:routes:view', view: 'edr_passenger_app:routes:view',
manage: 'edr_passenger_app:routes:manage', manage: 'edr_passenger_app:routes:manage',
create: 'edr_passenger_app:routes:create',
edit: 'edr_passenger_app:routes:edit',
delete: 'edr_passenger_app:routes:delete',
}, },
schedules: { schedules: {
view: 'edr_passenger_app:schedules:view', view: 'edr_passenger_app:schedules:view',
manage: 'edr_passenger_app:schedules:manage', manage: 'edr_passenger_app:schedules:manage',
create: 'edr_passenger_app:schedules:create',
edit: 'edr_passenger_app:schedules:edit',
delete: 'edr_passenger_app:schedules:delete',
cancel: 'edr_passenger_app:schedules:cancel',
},
/**
* Fare rules hanging off /schedules. Split out of `schedules:manage` so editing a
* timetable and changing a price are separate grants. Guards keep `schedules.manage`
* in the OR array so today's fare editors are not locked out.
*/
scheduleFares: {
view: 'edr_passenger_app:schedule_fares:view',
manage: 'edr_passenger_app:schedule_fares:manage',
create: 'edr_passenger_app:schedule_fares:create',
edit: 'edr_passenger_app:schedule_fares:edit',
delete: 'edr_passenger_app:schedule_fares:delete',
}, },
packages: { packages: {
view: 'edr_passenger_app:packages:view', view: 'edr_passenger_app:packages:view',
manage: 'edr_passenger_app:packages:manage', manage: 'edr_passenger_app:packages:manage',
create: 'edr_passenger_app:packages:create',
edit: 'edr_passenger_app:packages:edit',
delete: 'edr_passenger_app:packages:delete',
publish: 'edr_passenger_app:packages:publish',
}, },
inquiries: { inquiries: {
view: 'edr_passenger_app:inquiries:view', view: 'edr_passenger_app:inquiries:view',
manage: 'edr_passenger_app:inquiries:manage', manage: 'edr_passenger_app:inquiries:manage',
create: 'edr_passenger_app:inquiries:create',
edit: 'edr_passenger_app:inquiries:edit',
delete: 'edr_passenger_app:inquiries:delete',
}, },
tariffRates: { tariffRates: {
view: 'edr_passenger_app:tariff_rates:view', view: 'edr_passenger_app:tariff_rates:view',
manage: 'edr_passenger_app:tariff_rates:manage', manage: 'edr_passenger_app:tariff_rates:manage',
create: 'edr_passenger_app:tariff_rates:create',
edit: 'edr_passenger_app:tariff_rates:edit',
delete: 'edr_passenger_app:tariff_rates:delete',
}, },
reports: { reports: {
/**
* The all-reports umbrella. Kept as a plain string — `reports.controller.ts` and the
* `finance` / `financeManager` / `director` presets depend on it — and kept in every
* per-report guard array so existing holders keep seeing every report.
*/
view: 'edr_passenger_app:reports:view', view: 'edr_passenger_app:reports:view',
// Per-report keys. Grant these instead of `view` to hand out a subset.
overall: { view: 'edr_passenger_app:reports_overall:view' },
finance: {
view: 'edr_passenger_app:reports_finance:view',
export: 'edr_passenger_app:reports_finance:export',
},
coachUtilization: { view: 'edr_passenger_app:reports_coach_utilization:view' },
seatStatus: { view: 'edr_passenger_app:reports_seat_status:view' },
blockedSeats: {
view: 'edr_passenger_app:reports_blocked_seats:view',
export: 'edr_passenger_app:reports_blocked_seats:export',
},
passengers: { view: 'edr_passenger_app:reports_passengers:view' },
boarding: { view: 'edr_passenger_app:reports_boarding:view' },
payments: { view: 'edr_passenger_app:reports_payments:view' },
catalog: { view: 'edr_passenger_app:reports_catalog:view' },
}, },
fraud: { fraud: {
view: 'edr_passenger_app:fraud:view', view: 'edr_passenger_app:fraud:view',
manage: 'edr_passenger_app:fraud:manage', manage: 'edr_passenger_app:fraud:manage',
create: 'edr_passenger_app:fraud:create',
edit: 'edr_passenger_app:fraud:edit',
delete: 'edr_passenger_app:fraud:delete',
}, },
audit: { audit: {
view: 'edr_passenger_app:audit:view', view: 'edr_passenger_app:audit:view',
@@ -154,10 +366,16 @@ export const PASSENGER_PERMS = {
agents: { agents: {
view: 'edr_passenger_app:agents:view', view: 'edr_passenger_app:agents:view',
manage: 'edr_passenger_app:agents:manage', manage: 'edr_passenger_app:agents:manage',
create: 'edr_passenger_app:agents:create',
edit: 'edr_passenger_app:agents:edit',
delete: 'edr_passenger_app:agents:delete',
}, },
currencies: { currencies: {
view: 'edr_passenger_app:currencies:view', view: 'edr_passenger_app:currencies:view',
manage: 'edr_passenger_app:currencies:manage', manage: 'edr_passenger_app:currencies:manage',
create: 'edr_passenger_app:currencies:create',
edit: 'edr_passenger_app:currencies:edit',
delete: 'edr_passenger_app:currencies:delete',
}, },
notifications: { notifications: {
send: 'edr_passenger_app:notifications:send', send: 'edr_passenger_app:notifications:send',

View File

@@ -13,6 +13,9 @@ import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination'; import { usePagination } from '@/lib/use-pagination';
import { formatCurrency, formatDateTime } from '@/lib/utils'; import { formatCurrency, formatDateTime } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3"> <div className="bg-muted/40 rounded-lg p-3">
@@ -27,9 +30,13 @@ const SectionHeader = ({ title }: { title: string }) => (
</h3> </h3>
); );
export default function AgentsPage() { function AgentsPageContent() {
const { user } = useAuthStore(); const { user } = useAuthStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.agents.create, PERMS.agents.manage);
const canEdit = useWritePermission(PERMS.agents.edit, PERMS.agents.manage);
const canDelete = useDeletePermission(PERMS.agents.delete);
const [filters, setFilters] = useState({ search: '', active: '' }); const [filters, setFilters] = useState({ search: '', active: '' });
const [selected, setSelected] = useState<any>(null); const [selected, setSelected] = useState<any>(null);
const [createModal, setCreateModal] = useState(false); const [createModal, setCreateModal] = useState(false);
@@ -131,6 +138,7 @@ export default function AgentsPage() {
const actions = [ const actions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (agent: any) => openEditModal(agent), onClick: (agent: any) => openEditModal(agent),
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, icon: Edit,
@@ -143,6 +151,7 @@ export default function AgentsPage() {
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); }, onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); },
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -156,7 +165,7 @@ export default function AgentsPage() {
<h1 className="text-2xl font-bold">Agents</h1> <h1 className="text-2xl font-bold">Agents</h1>
<p className="text-muted-foreground">Manage agents and their operations</p> <p className="text-muted-foreground">Manage agents and their operations</p>
</div> </div>
<ActionButton icon={Plus} onClick={openCreateModal}>Add Agent</ActionButton> <ActionButton icon={Plus} onClick={openCreateModal} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create agents'}>Add Agent</ActionButton>
</div> </div>
<div className="card"> <div className="card">
@@ -394,3 +403,11 @@ export default function AgentsPage() {
</div> </div>
); );
} }
export default function AgentsPage() {
return (
<PermissionGuard permission={PERMS.agents.view}>
<AgentsPageContent />
</PermissionGuard>
);
}

View File

@@ -10,10 +10,12 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { appReleasesApi } from '@/lib/api'; import { appReleasesApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' }; const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' };
export default function AppReleasesPage() { function AppReleasesPageContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<any>(null); const [editing, setEditing] = useState<any>(null);
@@ -184,3 +186,11 @@ export default function AppReleasesPage() {
</div> </div>
); );
} }
export default function AppReleasesPage() {
return (
<PermissionGuard permission={PERMS.admin}>
<AppReleasesPageContent />
</PermissionGuard>
);
}

View File

@@ -8,6 +8,9 @@ import { useAuthStore } from '@/lib/auth-store';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Header from '@/components/layout/Header'; import Header from '@/components/layout/Header';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { useWritePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
// Add QR Scanner component // Add QR Scanner component
function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) { function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) {
@@ -315,7 +318,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
); );
} }
export default function BoardingPage() { function BoardingPageContent() {
const [qrInput, setQrInput] = useState(''); const [qrInput, setQrInput] = useState('');
const [lastScanned, setLastScanned] = useState<any>(null); const [lastScanned, setLastScanned] = useState<any>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -341,6 +344,8 @@ export default function BoardingPage() {
retry: false, retry: false,
}); });
const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage);
const boardingMutation = useMutation({ const boardingMutation = useMutation({
mutationFn: (qrCodeOrRef: string) => mutationFn: (qrCodeOrRef: string) =>
ticketsApi.scanAndBoard(qrCodeOrRef, { ticketsApi.scanAndBoard(qrCodeOrRef, {
@@ -482,7 +487,8 @@ export default function BoardingPage() {
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={handleButtonClick} onClick={handleButtonClick}
disabled={boardingMutation.isPending || !qrInput.trim()} disabled={boardingMutation.isPending || !qrInput.trim() || !canBoard}
title={canBoard ? undefined : 'You do not have permission to board passengers'}
className="flex-1 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-300 className="flex-1 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-300
text-white font-semibold py-4 px-6 rounded-xl transition-colors text-white font-semibold py-4 px-6 rounded-xl transition-colors
disabled:cursor-not-allowed text-lg" disabled:cursor-not-allowed text-lg"
@@ -605,4 +611,12 @@ export default function BoardingPage() {
</div> </div>
</div> </div>
); );
} }
export default function BoardingPage() {
return (
<PermissionGuard permission={[PERMS.tickets.board, PERMS.tickets.manage]}>
<BoardingPageContent />
</PermissionGuard>
);
}

View File

@@ -9,7 +9,7 @@ import Pagination from '@/components/ui/Pagination';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { usePermission } from '@/lib/use-permission'; import { useDeletePermission, usePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { bookingsApi, apiClient } from '@/lib/api'; import { bookingsApi, apiClient } from '@/lib/api';
@@ -30,7 +30,7 @@ const SectionHeader = ({ title }: { title: string }) => (
); );
function BookingsPageContent() { function BookingsPageContent() {
const canManage = usePermission(PERMS.bookings.manage); const canDeleteBooking = useDeletePermission(PERMS.bookings.delete);
// Mirrors the API guard on POST /payments/:bookingId/force-confirm — // Mirrors the API guard on POST /payments/:bookingId/force-confirm —
// tickets:generate, with the usual super-admin / org-admin bypass. // tickets:generate, with the usual super-admin / org-admin bypass.
const canGenerateTicket = usePermission(PERMS.tickets.generate); const canGenerateTicket = usePermission(PERMS.tickets.generate);
@@ -302,7 +302,7 @@ function BookingsPageContent() {
const actions = [ const actions = [
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
{ label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') }, { label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') },
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2, show: () => canDeleteBooking },
]; ];
return ( return (

View File

@@ -12,6 +12,7 @@ import { seatClassesApi, apiClient } from '@/lib/api';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
function ClassesPageContent() { function ClassesPageContent() {
const [filters, setFilters] = useState({ search: '' }); const [filters, setFilters] = useState({ search: '' });
@@ -21,6 +22,10 @@ function ClassesPageContent() {
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>(''); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.classes.create, PERMS.classes.manage);
const canEdit = useWritePermission(PERMS.classes.edit, PERMS.classes.manage);
const canDelete = useDeletePermission(PERMS.classes.delete);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['classes', filters], queryKey: ['classes', filters],
queryFn: () => seatClassesApi.getAll(), queryFn: () => seatClassesApi.getAll(),
@@ -180,12 +185,14 @@ function ClassesPageContent() {
const actions = [ const actions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (cls: any) => handleOpenModal(cls), onClick: (cls: any) => handleOpenModal(cls),
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, icon: Edit,
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: handleDelete, onClick: handleDelete,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,

View File

@@ -12,6 +12,7 @@ import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination'; import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
type Tab = 'types' | 'coaches'; type Tab = 'types' | 'coaches';
@@ -155,6 +156,10 @@ function CoachesPageContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.coaches.create, PERMS.coaches.manage);
const canEdit = useWritePermission(PERMS.coaches.edit, PERMS.coaches.manage);
const canDelete = useDeletePermission(PERMS.coaches.delete);
// Coach Types Queries // Coach Types Queries
const { data: coachTypesData, isLoading: typesLoading } = useQuery({ const { data: coachTypesData, isLoading: typesLoading } = useQuery({
queryKey: ['coach-types'], queryKey: ['coach-types'],
@@ -438,6 +443,7 @@ function CoachesPageContent() {
const coachTypeActions = [ const coachTypeActions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (item: any) => { onClick: (item: any) => {
setEditingItem({ ...item, isCoachType: true }); setEditingItem({ ...item, isCoachType: true });
setShowModal(true); setShowModal(true);
@@ -447,6 +453,7 @@ function CoachesPageContent() {
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (item: any) => handleDelete(item, true), onClick: (item: any) => handleDelete(item, true),
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -456,6 +463,7 @@ function CoachesPageContent() {
const coachActions = [ const coachActions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (item: any) => { onClick: (item: any) => {
setEditingItem({ ...item, isCoach: true }); setEditingItem({ ...item, isCoach: true });
setSelectedCoachTypeId(item.coachTypeId || ''); setSelectedCoachTypeId(item.coachTypeId || '');
@@ -467,6 +475,7 @@ function CoachesPageContent() {
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (item: any) => handleDelete(item, false), onClick: (item: any) => handleDelete(item, false),
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -489,6 +498,9 @@ function CoachesPageContent() {
setSearch(''); setSearch('');
setShowModal(true); setShowModal(true);
}} }}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create coaches'}
> >
{activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'} {activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'}
</ActionButton> </ActionButton>

View File

@@ -8,6 +8,8 @@ import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
interface ExchangeRate { interface ExchangeRate {
id: string; id: string;
@@ -35,6 +37,10 @@ export default function CurrenciesPage() {
const [deleteConfirm, setDeleteConfirm] = useState<ExchangeRate | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<ExchangeRate | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.currencies.create, PERMS.currencies.manage);
const canEdit = useWritePermission(PERMS.currencies.edit, PERMS.currencies.manage);
const canDelete = useDeletePermission(PERMS.currencies.delete);
const { data: rates = [], isLoading } = useQuery<ExchangeRate[]>({ const { data: rates = [], isLoading } = useQuery<ExchangeRate[]>({
queryKey: ['currencies'], queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'), queryFn: () => apiClient.get('/currencies'),
@@ -124,12 +130,14 @@ export default function CurrenciesPage() {
const actions = [ const actions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); }, onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); },
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, icon: Edit,
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (r: ExchangeRate) => setDeleteConfirm(r), onClick: (r: ExchangeRate) => setDeleteConfirm(r),
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -143,7 +151,7 @@ export default function CurrenciesPage() {
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1> <h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
<p className="text-muted-foreground mt-1">Manage currency exchange rates</p> <p className="text-muted-foreground mt-1">Manage currency exchange rates</p>
</div> </div>
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}> <ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create currency rates'}>
Add Rate Add Rate
</ActionButton> </ActionButton>
</div> </div>

View File

@@ -5,6 +5,9 @@ import { useQuery, useMutation } from '@tanstack/react-query';
import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react'; import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react';
import { seatsApi } from '@/lib/api'; import { seatsApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { useWritePermission } from '@/lib/use-permission';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
// ── Types ───────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────
@@ -300,9 +303,10 @@ interface CoachCardProps {
coach: CoachReport; coach: CoachReport;
schedule: ScheduleReport; schedule: ScheduleReport;
onResolve: () => void; onResolve: () => void;
canResolve?: boolean;
} }
function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { function CoachCard({ coach, schedule, onResolve, canResolve = false }: CoachCardProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const hasDuplicates = coach.duplicates.length > 0; const hasDuplicates = coach.duplicates.length > 0;
@@ -330,7 +334,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
{hasDuplicates && ( {hasDuplicates && (
<button <button
onClick={onResolve} onClick={onResolve}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors" disabled={!canResolve}
title={canResolve ? undefined : 'You do not have permission to resolve duplicate seats'}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
Resolve Resolve
</button> </button>
@@ -379,7 +385,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
// ── Main page ───────────────────────────────────────────────────────────── // ── Main page ─────────────────────────────────────────────────────────────
export default function DiscrepancyPage() { function DiscrepancyPageContent() {
// POST /seats/duplicates/resolve is guarded by seats:edit (it deletes seat rows).
const canResolveDuplicates = useWritePermission(PERMS.seats.edit, PERMS.seats.manage);
const [date, setDate] = useState(today()); const [date, setDate] = useState(today());
const [searchDate, setSearchDate] = useState(''); const [searchDate, setSearchDate] = useState('');
const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null); const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null);
@@ -488,6 +496,7 @@ export default function DiscrepancyPage() {
coach={coach} coach={coach}
schedule={schedule} schedule={schedule}
onResolve={() => setResolveTarget({ schedule, coach })} onResolve={() => setResolveTarget({ schedule, coach })}
canResolve={canResolveDuplicates}
/> />
))} ))}
</div> </div>
@@ -517,3 +526,11 @@ export default function DiscrepancyPage() {
</div> </div>
); );
} }
export default function DiscrepancyPage() {
return (
<PermissionGuard permission={PERMS.seats.view}>
<DiscrepancyPageContent />
</PermissionGuard>
);
}

View File

@@ -10,6 +10,9 @@ import Modal from '@/components/ui/Modal';
import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api'; import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { useAnyPermission, useWritePermission } from '@/lib/use-permission';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
const STATUS_VARIANT: Record<string, any> = { const STATUS_VARIANT: Record<string, any> = {
PENDING: 'PENDING', PENDING: 'PENDING',
@@ -19,8 +22,11 @@ const STATUS_VARIANT: Record<string, any> = {
WAIVED: 'CANCELLED', WAIVED: 'CANCELLED',
}; };
export default function ExcessBaggagePage() { function ExcessBaggagePageContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Logging or resending a luggage charge bills the passenger and sends a pay link.
const canCharge = useAnyPermission([PERMS.excessBaggage.charge, PERMS.payments.manage, PERMS.bookings.manage, PERMS.admin]);
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' }); const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
const [showExtraFilters, setShowExtraFilters] = useState(false); const [showExtraFilters, setShowExtraFilters] = useState(false);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@@ -173,7 +179,7 @@ export default function ExcessBaggagePage() {
icon: Send, icon: Send,
variant: 'secondary' as const, variant: 'secondary' as const,
onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); }, onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); },
show: (c: any) => c.status === 'PENDING', show: (c: any) => canCharge && c.status === 'PENDING',
}, },
{ {
label: 'Waive', label: 'Waive',
@@ -202,7 +208,12 @@ export default function ExcessBaggagePage() {
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1> <h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p> <p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
</div> </div>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}> <ActionButton
icon={Plus}
disabled={!canCharge}
title={canCharge ? undefined : 'You do not have permission to log a luggage charge'}
onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}
>
Log Excess Luggage Log Excess Luggage
</ActionButton> </ActionButton>
</div> </div>
@@ -421,3 +432,11 @@ export default function ExcessBaggagePage() {
</div> </div>
); );
} }
export default function ExcessBaggagePage() {
return (
<PermissionGuard permission={PERMS.bookings.view}>
<ExcessBaggagePageContent />
</PermissionGuard>
);
}

View File

@@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { fraudApi } from '@/lib/api'; import { fraudApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission } from '@/lib/use-permission';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3"> <div className="bg-muted/40 rounded-lg p-3">
@@ -30,11 +33,13 @@ const SEVERITY_GRAD: Record<string, string> = {
LOW: 'from-blue-500 to-blue-600', LOW: 'from-blue-500 to-blue-600',
}; };
export default function FraudDetectionPage() { function FraudDetectionPageContent() {
const [filters, setFilters] = useState({ search: '', severity: '', status: '' }); const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
const [selected, setSelected] = useState<any>(null); const [selected, setSelected] = useState<any>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canManageFraud = useWritePermission(PERMS.fraud.edit, PERMS.fraud.manage);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['fraud-alerts', filters], queryKey: ['fraud-alerts', filters],
queryFn: () => fraudApi.getAlerts(filters), queryFn: () => fraudApi.getAlerts(filters),
@@ -130,10 +135,11 @@ export default function FraudDetectionPage() {
onClick: handleAcknowledge, onClick: handleAcknowledge,
variant: 'primary' as const, variant: 'primary' as const,
icon: CheckCircle, icon: CheckCircle,
show: (alert: any) => !alert.acknowledged, show: (alert: any) => canManageFraud && !alert.acknowledged,
}, },
{ {
label: 'Block User', label: 'Block User',
show: () => canManageFraud,
onClick: handleBlockUser, onClick: handleBlockUser,
variant: 'danger' as const, variant: 'danger' as const,
icon: Ban, icon: Ban,
@@ -301,3 +307,11 @@ export default function FraudDetectionPage() {
</div> </div>
); );
} }
export default function FraudDetectionPage() {
return (
<PermissionGuard permission={PERMS.fraud.view}>
<FraudDetectionPageContent />
</PermissionGuard>
);
}

View File

@@ -1467,7 +1467,7 @@ function GroupBookingPageContent() {
export default function GroupBookingPage() { export default function GroupBookingPage() {
return ( return (
<PermissionGuard permission={PERMS.bookings.manage}> <PermissionGuard permission={[PERMS.bookings.create, PERMS.bookings.manage]}>
<GroupBookingPageContent /> <GroupBookingPageContent />
</PermissionGuard> </PermissionGuard>
); );

View File

@@ -9,10 +9,12 @@ import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import { notificationsApi } from '@/lib/api'; import { notificationsApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP']; const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
export default function NotificationsPage() { function NotificationsPageContent() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<any | null>(null); const [editing, setEditing] = useState<any | null>(null);
const [templateError, setTemplateError] = useState<string | null>(null); const [templateError, setTemplateError] = useState<string | null>(null);
@@ -313,3 +315,11 @@ export default function NotificationsPage() {
</div> </div>
); );
} }
export default function NotificationsPage() {
return (
<PermissionGuard permission={[PERMS.notifications.send, PERMS.admin]}>
<NotificationsPageContent />
</PermissionGuard>
);
}

View File

@@ -9,8 +9,10 @@ import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { reportsApi } from '@/lib/api'; import { reportsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
export default function OperationalReportsPage() { function OperationalReportsPageContent() {
const [filters, setFilters] = useState({ search: '', reportType: '' }); const [filters, setFilters] = useState({ search: '', reportType: '' });
const [selectedReport, setSelectedReport] = useState<any>(null); const [selectedReport, setSelectedReport] = useState<any>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false); const [showDetailsModal, setShowDetailsModal] = useState(false);
@@ -465,3 +467,11 @@ export default function OperationalReportsPage() {
</div> </div>
); );
} }
export default function OperationalReportsPage() {
return (
<PermissionGuard permission={[PERMS.reports.catalog.view, PERMS.reports.view]}>
<OperationalReportsPageContent />
</PermissionGuard>
);
}

View File

@@ -11,6 +11,8 @@ import Pagination from '@/components/ui/Pagination';
import { packagesApi } from '@/lib/api'; import { packagesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
import { getErrorMessage } from '@/lib/api-client'; import { getErrorMessage } from '@/lib/api-client';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3"> <div className="bg-muted/40 rounded-lg p-3">
@@ -25,7 +27,7 @@ const SectionHeader = ({ title }: { title: string }) => (
</h3> </h3>
); );
export default function PackageBookingsPage() { function PackageBookingsPageContent() {
const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 }); const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 });
const [selected, setSelected] = useState<any>(null); const [selected, setSelected] = useState<any>(null);
@@ -264,3 +266,11 @@ export default function PackageBookingsPage() {
</div> </div>
); );
} }
export default function PackageBookingsPage() {
return (
<PermissionGuard permission={PERMS.packages.view}>
<PackageBookingsPageContent />
</PermissionGuard>
);
}

View File

@@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { packageInquiriesApi, packagesApi } from '@/lib/api'; import { packageInquiriesApi, packagesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED']; const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED'];
@@ -19,12 +22,15 @@ const statusVariant: Record<string, string> = {
CLOSED: 'default', CLOSED: 'default',
}; };
export default function PackageInquiriesPage() { function PackageInquiriesPageContent() {
const [filters, setFilters] = useState({ packageId: '', status: '' }); const [filters, setFilters] = useState({ packageId: '', status: '' });
const [deleteConfirm, setDeleteConfirm] = useState<any>(null); const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canEdit = useWritePermission(PERMS.inquiries.edit, PERMS.inquiries.manage);
const canDelete = useDeletePermission(PERMS.inquiries.delete);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['package-inquiries', filters], queryKey: ['package-inquiries', filters],
queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }), queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }),
@@ -112,6 +118,8 @@ export default function PackageInquiriesPage() {
<select <select
className="input py-1 text-xs" className="input py-1 text-xs"
value={row.status} value={row.status}
disabled={!canEdit}
title={canEdit ? undefined : 'You do not have permission to change an inquiry status'}
onChange={(e) => statusMutation.mutate({ id: row.id, status: e.target.value })} onChange={(e) => statusMutation.mutate({ id: row.id, status: e.target.value })}
> >
{STATUSES.map((s) => ( {STATUSES.map((s) => (
@@ -125,6 +133,7 @@ export default function PackageInquiriesPage() {
const actions = [ const actions = [
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); }, onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); },
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -191,3 +200,11 @@ export default function PackageInquiriesPage() {
</div> </div>
); );
} }
export default function PackageInquiriesPage() {
return (
<PermissionGuard permission={PERMS.inquiries.view}>
<PackageInquiriesPageContent />
</PermissionGuard>
);
}

View File

@@ -11,6 +11,9 @@ import Modal from '@/components/ui/Modal';
import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api'; import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
import { getErrorMessage } from '@/lib/api-client'; import { getErrorMessage } from '@/lib/api-client';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is // Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
// rejected instantly client-side instead of round-tripping to the server first. // rejected instantly client-side instead of round-tripping to the server first.
@@ -33,7 +36,7 @@ const emptyForm = {
validFrom: '', validUntil: '', validFrom: '', validUntil: '',
}; };
export default function PackagesPage() { function PackagesPageContent() {
const [page] = useState(1); const [page] = useState(1);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
@@ -65,6 +68,11 @@ export default function PackagesPage() {
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null); const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.packages.create, PERMS.packages.manage);
const canEdit = useWritePermission(PERMS.packages.edit, PERMS.packages.manage);
const canPublish = useWritePermission(PERMS.packages.publish, PERMS.packages.manage);
const canDelete = useDeletePermission(PERMS.packages.delete);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['packages', page], queryKey: ['packages', page],
queryFn: () => packagesApi.getAll({ page, pageSize: 20 }), queryFn: () => packagesApi.getAll({ page, pageSize: 20 }),
@@ -368,23 +376,25 @@ export default function PackagesPage() {
const actions = [ const actions = [
{ label: 'View', onClick: (p: any) => setViewPackage(p), variant: 'secondary' as const, icon: Eye }, { label: 'View', onClick: (p: any) => setViewPackage(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
{ {
label: 'Tiers', icon: Layers, variant: 'secondary' as const, label: 'Tiers', icon: Layers, variant: 'secondary' as const,
show: () => canEdit,
onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }, onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); },
}, },
{ {
label: 'Activate', icon: CheckCircle, variant: 'primary' as const, label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
onClick: (p: any) => setActivateConfirm(p), onClick: (p: any) => setActivateConfirm(p),
show: (p: any) => p.status !== 'ACTIVE', show: (p: any) => canPublish && p.status !== 'ACTIVE',
}, },
{ {
label: 'Deactivate', icon: CheckCircle, variant: 'secondary' as const, label: 'Deactivate', icon: CheckCircle, variant: 'secondary' as const,
onClick: (p: any) => setDeactivateConfirm(p), onClick: (p: any) => setDeactivateConfirm(p),
show: (p: any) => p.status === 'ACTIVE', show: (p: any) => canPublish && p.status === 'ACTIVE',
}, },
{ {
label: 'Delete', icon: Trash2, variant: 'danger' as const, label: 'Delete', icon: Trash2, variant: 'danger' as const,
show: () => canDelete,
onClick: (p: any) => { setDeletePackageError(null); setDeletePackageCascade(false); setDeletePackageConfirm(p); }, onClick: (p: any) => { setDeletePackageError(null); setDeletePackageCascade(false); setDeletePackageConfirm(p); },
}, },
]; ];
@@ -407,7 +417,7 @@ export default function PackagesPage() {
<h1 className="text-2xl font-bold text-foreground">Packages</h1> <h1 className="text-2xl font-bold text-foreground">Packages</h1>
<p className="text-muted-foreground">Manage travel packages and pilgrimages</p> <p className="text-muted-foreground">Manage travel packages and pilgrimages</p>
</div> </div>
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton> <ActionButton icon={Plus} onClick={openCreate} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create packages'}>New Package</ActionButton>
</div> </div>
{/* The package itself may already be saved and this modal closed by the time an image {/* The package itself may already be saved and this modal closed by the time an image
@@ -827,3 +837,11 @@ export default function PackagesPage() {
</div> </div>
); );
} }
export default function PackagesPage() {
return (
<PermissionGuard permission={PERMS.packages.view}>
<PackagesPageContent />
</PermissionGuard>
);
}

View File

@@ -12,6 +12,9 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { passengersApi, apiClient } from '@/lib/api'; import { passengersApi, apiClient } from '@/lib/api';
import { formatDate, formatDateTime } from '@/lib/utils'; import { formatDate, formatDateTime } from '@/lib/utils';
import { PassengerFilters } from '@/types'; import { PassengerFilters } from '@/types';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useDeletePermission } from '@/lib/use-permission';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3"> <div className="bg-muted/40 rounded-lg p-3">
@@ -33,7 +36,7 @@ const TIER_COLORS: Record<string, string> = {
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800', PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
}; };
export default function PassengersPage() { function PassengersPageContent() {
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' }); const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
const [showExtraFilters, setShowExtraFilters] = useState(false); const [showExtraFilters, setShowExtraFilters] = useState(false);
const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' }); const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' });
@@ -51,6 +54,8 @@ export default function PassengersPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canDelete = useDeletePermission(PERMS.passengers.delete);
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade), mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade),
onSuccess: () => { onSuccess: () => {
@@ -156,7 +161,7 @@ export default function PassengersPage() {
const actions = [ const actions = [
{ label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye }, { label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 }, { label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2, show: () => canDelete },
]; ];
return ( return (
@@ -448,3 +453,11 @@ export default function PassengersPage() {
</div> </div>
); );
} }
export default function PassengersPage() {
return (
<PermissionGuard permission={PERMS.passengers.view}>
<PassengersPageContent />
</PermissionGuard>
);
}

View File

@@ -10,9 +10,10 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient, paymentsApi } from '@/lib/api'; import { apiClient, paymentsApi } from '@/lib/api';
import { usePermission } from '@/lib/use-permission'; import { usePermission } from '@/lib/use-permission';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
export default function PaymentMethodsPage() { function PaymentMethodsPageContent() {
const canManageMethods = usePermission(PERMS.paymentMethods.manage); const canManageMethods = usePermission(PERMS.paymentMethods.manage);
const canManageAdmin = usePermission(PERMS.admin); const canManageAdmin = usePermission(PERMS.admin);
const canManage = canManageMethods || canManageAdmin; const canManage = canManageMethods || canManageAdmin;
@@ -417,4 +418,12 @@ export default function PaymentMethodsPage() {
/> />
</div> </div>
); );
} }
export default function PaymentMethodsPage() {
return (
<PermissionGuard permission={PERMS.paymentMethods.view}>
<PaymentMethodsPageContent />
</PermissionGuard>
);
}

View File

@@ -14,6 +14,9 @@ import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination'; import { usePagination } from '@/lib/use-pagination';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
import SupplementaryChargesModal from './SupplementaryChargesModal'; import SupplementaryChargesModal from './SupplementaryChargesModal';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { import {
useSupplementaryCharges, useSupplementaryCharges,
useMarkSupplementaryPaid, useMarkSupplementaryPaid,
@@ -120,6 +123,10 @@ function PaymentsPageContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Raising a supplementary charge bills a passenger and sends a pay link — its own grant.
const canRaiseCharge = useWritePermission(PERMS.payments.supplementary, PERMS.payments.manage);
const canDelete = useDeletePermission(PERMS.payments.delete);
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/payments/${id}`), mutationFn: (id: string) => apiClient.delete(`/payments/${id}`),
onSuccess: () => { onSuccess: () => {
@@ -209,7 +216,7 @@ function PaymentsPageContent() {
const paymentActions = [ const paymentActions = [
{ label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye }, { label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setPaymentToDelete(p); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, { label: 'Delete', onClick: (p: any) => { setDeleteError(null); setPaymentToDelete(p); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2, show: () => canDelete },
]; ];
return ( return (
@@ -221,7 +228,7 @@ function PaymentsPageContent() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{pageTab === 'supplementary' && ( {pageTab === 'supplementary' && (
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)}>Raise Charge</ActionButton> <ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)} disabled={!canRaiseCharge} title={canRaiseCharge ? undefined : 'You do not have permission to raise a supplementary charge'}>Raise Charge</ActionButton>
)} )}
{pageTab === 'payments' && ( {pageTab === 'payments' && (
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton> <ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
@@ -531,8 +538,10 @@ function PaymentsPageContent() {
export default function PaymentsPage() { export default function PaymentsPage() {
return ( return (
<Suspense fallback={null}> <PermissionGuard permission={PERMS.payments.view}>
<PaymentsPageContent /> <Suspense fallback={null}>
</Suspense> <PaymentsPageContent />
</Suspense>
</PermissionGuard>
); );
} }

View File

@@ -45,6 +45,8 @@ import { usePagination } from "@/lib/use-pagination";
import { formatCurrency, formatDateTime } from "@/lib/utils"; import { formatCurrency, formatDateTime } from "@/lib/utils";
import { categoricalColor, getChartPalette } from "@/lib/chart-palette"; import { categoricalColor, getChartPalette } from "@/lib/chart-palette";
import { useTheme } from "@/lib/theme-store"; import { useTheme } from "@/lib/theme-store";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface RouteOption { interface RouteOption {
id: string; id: string;
@@ -71,7 +73,7 @@ function reasonLabel(category: string | null): string {
const TABLE_PAGE_SIZE = 25; const TABLE_PAGE_SIZE = 25;
export default function BlockedSeatRevenueLossPage() { function BlockedSeatRevenueLossPageContent() {
const isDark = useTheme((s) => s.isDark); const isDark = useTheme((s) => s.isDark);
const palette = getChartPalette(isDark); const palette = getChartPalette(isDark);
@@ -917,3 +919,11 @@ function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
</div> </div>
); );
} }
export default function BlockedSeatRevenueLossPage() {
return (
<PermissionGuard permission={[PERMS.reports.blockedSeats.view, PERMS.reports.view]}>
<BlockedSeatRevenueLossPageContent />
</PermissionGuard>
);
}

View File

@@ -9,6 +9,8 @@ import ActionButton from "@/components/ui/ActionButton";
import { formatDateTime } from "@/lib/utils"; import { formatDateTime } from "@/lib/utils";
import Pagination from "@/components/ui/Pagination"; import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination"; import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption { interface ScheduleOption {
id: string; id: string;
@@ -50,7 +52,7 @@ interface BoardingReport {
type Tab = "summary" | "details"; type Tab = "summary" | "details";
export default function BoardingReportPage() { function BoardingReportPageContent() {
const [scheduleId, setScheduleId] = useState(""); const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("summary"); const [tab, setTab] = useState<Tab>("summary");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -380,3 +382,11 @@ export default function BoardingReportPage() {
</div> </div>
); );
} }
export default function BoardingReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.boarding.view, PERMS.reports.view]}>
<BoardingReportPageContent />
</PermissionGuard>
);
}

View File

@@ -7,6 +7,8 @@ import { apiClient } from "@/lib/api-client";
import ActionButton from "@/components/ui/ActionButton"; import ActionButton from "@/components/ui/ActionButton";
import Pagination from "@/components/ui/Pagination"; import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination"; import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption { interface ScheduleOption {
id: string; id: string;
@@ -28,7 +30,7 @@ interface CoachUtilizationRow {
totalBookings: number; totalBookings: number;
} }
export default function CoachUtilizationReportPage() { function CoachUtilizationReportPageContent() {
const [scheduleId, setScheduleId] = useState(""); const [scheduleId, setScheduleId] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -360,3 +362,11 @@ export default function CoachUtilizationReportPage() {
</div> </div>
); );
} }
export default function CoachUtilizationReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.coachUtilization.view, PERMS.reports.view]}>
<CoachUtilizationReportPageContent />
</PermissionGuard>
);
}

View File

@@ -18,6 +18,8 @@ import { usePagination } from '@/lib/use-pagination';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { categoricalColor, getChartPalette } from '@/lib/chart-palette'; import { categoricalColor, getChartPalette } from '@/lib/chart-palette';
import { useTheme } from '@/lib/theme-store'; import { useTheme } from '@/lib/theme-store';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface StationOption { interface StationOption {
id: string; id: string;
@@ -135,7 +137,7 @@ function FinanceReportSkeleton() {
); );
} }
export default function FinanceReportPage() { function FinanceReportPageContent() {
const isDark = useTheme((s) => s.isDark); const isDark = useTheme((s) => s.isDark);
const palette = getChartPalette(isDark); const palette = getChartPalette(isDark);
@@ -840,3 +842,11 @@ export default function FinanceReportPage() {
</div> </div>
); );
} }
export default function FinanceReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.finance.view, PERMS.reports.view]}>
<FinanceReportPageContent />
</PermissionGuard>
);
}

View File

@@ -12,6 +12,8 @@ import { apiClient } from '@/lib/api-client';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444']; const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
@@ -19,7 +21,7 @@ function esc(s: string) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
export default function ReportsPage() { function ReportsPageContent() {
const [dateRange, setDateRange] = useState('30'); const [dateRange, setDateRange] = useState('30');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
@@ -709,3 +711,11 @@ export default function ReportsPage() {
</div> </div>
); );
} }
export default function ReportsPage() {
return (
<PermissionGuard permission={[PERMS.reports.overall.view, PERMS.reports.view]}>
<ReportsPageContent />
</PermissionGuard>
);
}

View File

@@ -9,6 +9,8 @@ import { formatDateTime } from "@/lib/utils";
import ActionButton from "@/components/ui/ActionButton"; import ActionButton from "@/components/ui/ActionButton";
import Pagination from "@/components/ui/Pagination"; import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination"; import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption { interface ScheduleOption {
id: string; id: string;
@@ -70,7 +72,7 @@ interface PassengerRow {
type Tab = "occupancy" | "list"; type Tab = "occupancy" | "list";
export default function PassengersReportPage() { function PassengersReportPageContent() {
const [scheduleId, setScheduleId] = useState(""); const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("occupancy"); const [tab, setTab] = useState<Tab>("occupancy");
const [listSearch, setListSearch] = useState(""); const [listSearch, setListSearch] = useState("");
@@ -592,3 +594,11 @@ export default function PassengersReportPage() {
</div> </div>
); );
} }
export default function PassengersReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.passengers.view, PERMS.reports.view]}>
<PassengersReportPageContent />
</PermissionGuard>
);
}

View File

@@ -11,6 +11,8 @@ import DatePicker from '@/components/ui/DatePicker';
import { parse, isValid } from 'date-fns'; import { parse, isValid } from 'date-fns';
import Pagination from '@/components/ui/Pagination'; import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination'; import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -121,7 +123,7 @@ function BalanceBadge({ row }: { row: DiscrepancyRow }) {
type Applied = { from: string; to: string; sortBy: string; search: string }; type Applied = { from: string; to: string; sortBy: string; search: string };
export default function PaymentDiscrepancyPage() { function PaymentDiscrepancyPageContent() {
const [from, setFrom] = useState(''); const [from, setFrom] = useState('');
const [to, setTo] = useState(''); const [to, setTo] = useState('');
const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance'); const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance');
@@ -479,3 +481,11 @@ export default function PaymentDiscrepancyPage() {
</div> </div>
); );
} }
export default function PaymentDiscrepancyPage() {
return (
<PermissionGuard permission={[PERMS.reports.payments.view, PERMS.reports.view]}>
<PaymentDiscrepancyPageContent />
</PermissionGuard>
);
}

View File

@@ -7,6 +7,8 @@ import {
ChevronDown, ChevronUp, Loader2, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Loader2, ChevronLeft, ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -101,7 +103,7 @@ function downloadCsv(csv: string, filename: string) {
// ── Discrepancy page ────────────────────────────────────────────────────────── // ── Discrepancy page ──────────────────────────────────────────────────────────
export default function PaymentsReportPage() { function PaymentsReportPageContent() {
const [scheduleId, setScheduleId] = useState(''); const [scheduleId, setScheduleId] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [seatClass, setSeatClass] = useState(''); const [seatClass, setSeatClass] = useState('');
@@ -355,3 +357,11 @@ export default function PaymentsReportPage() {
</div> </div>
); );
} }
export default function PaymentsReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.payments.view, PERMS.reports.view]}>
<PaymentsReportPageContent />
</PermissionGuard>
);
}

View File

@@ -10,6 +10,8 @@ import ActionButton from "@/components/ui/ActionButton";
import { formatDateTime, formatCurrency } from "@/lib/utils"; import { formatDateTime, formatCurrency } from "@/lib/utils";
import Pagination from "@/components/ui/Pagination"; import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination"; import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption { interface ScheduleOption {
id: string; id: string;
@@ -56,7 +58,7 @@ interface SeatStatusReport {
type Tab = "seats" | "blocked"; type Tab = "seats" | "blocked";
export default function SeatStatusReportPage() { function SeatStatusReportPageContent() {
const [scheduleId, setScheduleId] = useState(""); const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("seats"); const [tab, setTab] = useState<Tab>("seats");
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL"); const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL");
@@ -383,3 +385,11 @@ export default function SeatStatusReportPage() {
</div> </div>
); );
} }
export default function SeatStatusReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.seatStatus.view, PERMS.reports.view]}>
<SeatStatusReportPageContent />
</PermissionGuard>
);
}

View File

@@ -6,12 +6,12 @@ import { PERMS } from '@/lib/permissions';
/** /**
* Master Data → Reschedule Policies. One policy per fare class (coach type); a class with no * Master Data → Reschedule Policies. One policy per fare class (coach type); a class with no
* policy cannot be rescheduled at all. Gated on bookings:view because that is what * policy cannot be rescheduled at all. Gated on reschedule_policies:view (or :manage) — bookings:view no longer
* `GET /reschedule/policies` requires; creating, editing and deleting are admin-only server-side. * grants it, so the page needs its own grant. Create/edit/delete each have their own key.
*/ */
export default function ReschedulePoliciesPage() { export default function ReschedulePoliciesPage() {
return ( return (
<PermissionGuard permission={PERMS.bookings.view}> <PermissionGuard permission={[PERMS.reschedulePolicies.view, PERMS.reschedulePolicies.manage]}>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Reschedule Policies</h1> <h1 className="text-3xl font-bold text-foreground">Reschedule Policies</h1>

View File

@@ -13,6 +13,7 @@ import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
interface RouteStop { interface RouteStop {
stationId: string; stationId: string;
@@ -193,6 +194,10 @@ function RoutesPageContent() {
}, [error]); }, [error]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.routes.create, PERMS.routes.manage);
const canEdit = useWritePermission(PERMS.routes.edit, PERMS.routes.manage);
const canDelete = useDeletePermission(PERMS.routes.delete);
const { data: routes, isLoading: routesLoading } = useQuery({ const { data: routes, isLoading: routesLoading } = useQuery({
queryKey: ['routes'], queryKey: ['routes'],
queryFn: async () => { queryFn: async () => {
@@ -445,12 +450,14 @@ function RoutesPageContent() {
const routeActions = [ const routeActions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: openEditModal, onClick: openEditModal,
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, icon: Edit,
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: handleDelete, onClick: handleDelete,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -479,6 +486,9 @@ function RoutesPageContent() {
setError(null); setError(null);
setShowModal(true); setShowModal(true);
}} }}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create routes'}
> >
Add Route Add Route
</ActionButton> </ActionButton>

View File

@@ -14,6 +14,7 @@ import { usePagination } from '@/lib/use-pagination';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
import DateTimePicker from '@/components/ui/DateTimePicker'; import DateTimePicker from '@/components/ui/DateTimePicker';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
@@ -80,6 +81,13 @@ function SchedulesPageContent() {
const errorBannerRef = useRef<HTMLDivElement>(null); const errorBannerRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Mirrors the API guards exactly: create/edit/cancel accept the `:manage` umbrella,
// delete does not (see PassengerWrite / PassengerDelete in the API).
const canCreate = useWritePermission(PERMS.schedules.create, PERMS.schedules.manage);
const canEdit = useWritePermission(PERMS.schedules.edit, PERMS.schedules.manage);
const canCancel = useWritePermission(PERMS.schedules.cancel, PERMS.schedules.manage);
const canDelete = useDeletePermission(PERMS.schedules.delete);
// These modals can scroll internally — a submit failure can land silently off-screen with no // These modals can scroll internally — a submit failure can land silently off-screen with no
// visible indication anything went wrong. Scroll the banner into view when a new error appears. // visible indication anything went wrong. Scroll the banner into view when a new error appears.
useEffect(() => { useEffect(() => {
@@ -536,12 +544,16 @@ function SchedulesPageContent() {
const [delayMinutesInput, setDelayMinutesInput] = useState(''); const [delayMinutesInput, setDelayMinutesInput] = useState('');
const [delayError, setDelayError] = useState<string | null>(null); const [delayError, setDelayError] = useState<string | null>(null);
// `hidden:` was silently doing nothing — DataTable's Action type only reads
// `show` — so Report Delay and Cancel were rendering on already-cancelled
// schedules. Folded into `show` alongside the permission check.
const scheduleActions = [ const scheduleActions = [
{ {
label: 'Edit', label: 'Edit',
onClick: handleEditClick, onClick: handleEditClick,
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, icon: Edit,
show: () => canEdit,
}, },
{ {
label: 'Report Delay', label: 'Report Delay',
@@ -552,20 +564,21 @@ function SchedulesPageContent() {
}, },
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Clock, icon: Clock,
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', show: (schedule: Schedule) => canEdit && schedule.status !== 'CANCELLED',
}, },
{ {
label: 'Cancel', label: 'Cancel',
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }), onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
variant: 'danger' as const, variant: 'danger' as const,
icon: X, icon: X,
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', show: (schedule: Schedule) => canCancel && schedule.status !== 'CANCELLED',
}, },
{ {
label: 'Delete', label: 'Delete',
onClick: handleDelete, onClick: handleDelete,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
show: () => canDelete,
}, },
]; ];
@@ -582,6 +595,8 @@ function SchedulesPageContent() {
onClick={handleBulkDelete} onClick={handleBulkDelete}
variant="danger" variant="danger"
loading={bulkDeleteMutation.isPending} loading={bulkDeleteMutation.isPending}
disabled={!canDelete}
title={canDelete ? undefined : 'You do not have permission to delete schedules'}
> >
Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''} Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''}
</ActionButton> </ActionButton>
@@ -590,6 +605,8 @@ function SchedulesPageContent() {
icon={Plus} icon={Plus}
variant="secondary" variant="secondary"
onClick={() => { setError(null); setShowAddModal(true); }} onClick={() => { setError(null); setShowAddModal(true); }}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create schedules'}
> >
Add Schedule Add Schedule
</ActionButton> </ActionButton>
@@ -599,6 +616,8 @@ function SchedulesPageContent() {
setError(null); setError(null);
setShowModal(true); setShowModal(true);
}} }}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create schedules'}
> >
Bulk Generate Bulk Generate
</ActionButton> </ActionButton>

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api'; import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api';
import { routesApi } from '@/lib/api/routes'; import { routesApi } from '@/lib/api/routes';
import { usePermissionStrict } from '@/lib/use-permission'; import { useDeletePermission, usePermissionStrict, useWritePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
@@ -63,6 +63,13 @@ function SeatsPageContent() {
// Strict: being an admin is not enough, the permission has to be granted. // Strict: being an admin is not enough, the permission has to be granted.
const canIssueBooking = usePermissionStrict(PERMS.tickets.generate); const canIssueBooking = usePermissionStrict(PERMS.tickets.generate);
// Blocking takes inventory out of sale, so it is its own grant; removing a seat is
// the app's only soft delete and rides on `seats:delete`; maintenance is an edit.
const canBlockSeats = useWritePermission(PERMS.seats.block, PERMS.seats.manage);
const canEditSeats = useWritePermission(PERMS.seats.edit, PERMS.seats.manage);
const canDeleteSeats = useDeletePermission(PERMS.seats.delete);
const canCancelBooking = useWritePermission(PERMS.bookings.cancel, PERMS.bookings.manage);
const { data: schedulesData } = useQuery({ const { data: schedulesData } = useQuery({
queryKey: ['schedules'], queryKey: ['schedules'],
queryFn: () => schedulesApi.getAll(), queryFn: () => schedulesApi.getAll(),
@@ -478,6 +485,10 @@ function SeatsPageContent() {
handleClearMaintenance={handleClearMaintenance} handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking} handleIssueBooking={handleIssueBooking}
canIssueBooking={canIssueBooking} canIssueBooking={canIssueBooking}
canBlockSeats={canBlockSeats}
canEditSeats={canEditSeats}
canDeleteSeats={canDeleteSeats}
canCancelBooking={canCancelBooking}
hideNumber={true} hideNumber={true}
/> />
))} ))}
@@ -576,6 +587,10 @@ function SeatsPageContent() {
handleClearMaintenance={handleClearMaintenance} handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking} handleIssueBooking={handleIssueBooking}
canIssueBooking={canIssueBooking} canIssueBooking={canIssueBooking}
canBlockSeats={canBlockSeats}
canEditSeats={canEditSeats}
canDeleteSeats={canDeleteSeats}
canCancelBooking={canCancelBooking}
hideNumber={true} hideNumber={true}
/> />
))} ))}
@@ -601,6 +616,10 @@ function SeatsPageContent() {
handleClearMaintenance={handleClearMaintenance} handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking} handleIssueBooking={handleIssueBooking}
canIssueBooking={canIssueBooking} canIssueBooking={canIssueBooking}
canBlockSeats={canBlockSeats}
canEditSeats={canEditSeats}
canDeleteSeats={canDeleteSeats}
canCancelBooking={canCancelBooking}
hideNumber={true} hideNumber={true}
/> />
))} ))}
@@ -846,7 +865,8 @@ function SeatsPageContent() {
size="sm" size="sm"
onClick={() => isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)} onClick={() => isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)}
className="ml-2" className="ml-2"
disabled={!isCoachBlocked(coach) && !isCoachUnblocked(coach)} disabled={!canBlockSeats || (!isCoachBlocked(coach) && !isCoachUnblocked(coach))}
title={canBlockSeats ? undefined : 'You do not have permission to block seats'}
> >
{isCoachBlocked(coach) ? ( {isCoachBlocked(coach) ? (
<> <>
@@ -1326,6 +1346,10 @@ interface SeatIconProps {
handleClearMaintenance: (seat: any) => void; handleClearMaintenance: (seat: any) => void;
handleIssueBooking: (seat: any, coach: any) => void; handleIssueBooking: (seat: any, coach: any) => void;
canIssueBooking?: boolean; canIssueBooking?: boolean;
canBlockSeats?: boolean;
canEditSeats?: boolean;
canDeleteSeats?: boolean;
canCancelBooking?: boolean;
} }
function SeatIcon({ function SeatIcon({
@@ -1345,6 +1369,10 @@ function SeatIcon({
handleClearMaintenance, handleClearMaintenance,
handleIssueBooking, handleIssueBooking,
canIssueBooking = false, canIssueBooking = false,
canBlockSeats = false,
canEditSeats = false,
canDeleteSeats = false,
canCancelBooking = false,
}: SeatIconProps) { }: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-'); const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || ''); const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
@@ -1362,13 +1390,15 @@ function SeatIcon({
<div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat"> <div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat">
</div> </div>
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto"> <div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
<button {canEditSeats && (
onClick={() => handleUndoRemove(seat)} <button
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto" onClick={() => handleUndoRemove(seat)}
title="Undo remove" className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
> title="Undo remove"
<RotateCcw className="h-3 w-3 text-gray-700" /> >
</button> <RotateCcw className="h-3 w-3 text-gray-700" />
</button>
)}
</div> </div>
</div> </div>
); );
@@ -1376,14 +1406,14 @@ function SeatIcon({
const status = getSeatStatus(seat); const status = getSeatStatus(seat);
const color = getSeatColor(status); const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE'; const canBlock = canBlockSeats && status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED'; const canUnblock = canBlockSeats && status === 'BLOCKED';
// A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting // A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting
// payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so // payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so
// it's not reachable via canUnblock anymore; this is the seat's own release path. // it's not reachable via canUnblock anymore; this is the seat's own release path.
const canCancelReservation = status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT'; const canCancelReservation = canCancelBooking && status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT';
const canMaintenance = false; const canMaintenance = false;
const canClearMaintenance = status === 'UNDER_MAINTENANCE'; const canClearMaintenance = canEditSeats && status === 'UNDER_MAINTENANCE';
return ( return (
<div className="relative group flex flex-col items-center"> <div className="relative group flex flex-col items-center">
@@ -1431,13 +1461,15 @@ function SeatIcon({
> >
<Lock className="h-3 w-3 text-gray-700" /> <Lock className="h-3 w-3 text-gray-700" />
</button> </button>
<button {canDeleteSeats && (
onClick={() => handleRemoveSeat(seat)} <button
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto" onClick={() => handleRemoveSeat(seat)}
title="Remove seat" className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
> title="Remove seat"
<X className="h-3 w-3 text-gray-700" /> >
</button> <X className="h-3 w-3 text-gray-700" />
</button>
)}
</> </>
)} )}
{canCancelReservation && ( {canCancelReservation && (

View File

@@ -13,6 +13,7 @@ import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination'; import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
function StationsPageContent() { function StationsPageContent() {
const [filters, setFilters] = useState({ search: '', country: '', operational: '' }); const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
@@ -22,6 +23,10 @@ function StationsPageContent() {
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.stations.create, PERMS.stations.manage);
const canEdit = useWritePermission(PERMS.stations.edit, PERMS.stations.manage);
const canDelete = useDeletePermission(PERMS.stations.delete);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['stations', filters], queryKey: ['stations', filters],
queryFn: () => stationsApi.getAll(filters), queryFn: () => stationsApi.getAll(filters),
@@ -164,6 +169,7 @@ function StationsPageContent() {
const actions = [ const actions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (station: any) => { onClick: (station: any) => {
setEditingStation(station); setEditingStation(station);
setFormError(null); setFormError(null);
@@ -174,6 +180,7 @@ function StationsPageContent() {
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: handleDelete, onClick: handleDelete,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -194,6 +201,8 @@ function StationsPageContent() {
setFormError(null); setFormError(null);
setShowModal(true); setShowModal(true);
}} }}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create stations'}
> >
Add Station Add Station
</ActionButton> </ActionButton>

View File

@@ -9,6 +9,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { useRouteFareRules, useRouteFareRuleMutations, useSeatClasses } from './hooks'; import { useRouteFareRules, useRouteFareRuleMutations, useSeatClasses } from './hooks';
import type { Route, RouteFareRule, SeatClass } from './types'; import type { Route, RouteFareRule, SeatClass } from './types';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
interface Props { interface Props {
routes: Route[]; routes: Route[];
@@ -21,6 +23,12 @@ type OverrideForm = {
}; };
export default function OverridesTab({ routes }: Props) { export default function OverridesTab({ routes }: Props) {
// Fare rules are their own resource on the API (schedule_fares:*), split out of
// schedules:manage so editing a timetable and changing a price are separate grants.
const canCreateFare = useWritePermission(PERMS.scheduleFares.create, PERMS.scheduleFares.manage);
const canEditFare = useWritePermission(PERMS.scheduleFares.edit, PERMS.scheduleFares.manage);
const canDeleteFare = useDeletePermission(PERMS.scheduleFares.delete);
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null); const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ const [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean; isOpen: boolean;
@@ -148,6 +156,9 @@ export default function OverridesTab({ routes }: Props) {
<ActionButton <ActionButton
icon={Plus} icon={Plus}
onClick={() => setForm({ isOpen: true, rule: null, error: null })} onClick={() => setForm({ isOpen: true, rule: null, error: null })}
disabled={!canCreateFare}
title={canCreateFare ? undefined : 'You do not have permission to create fare overrides'}
> >
Add Override Add Override
</ActionButton> </ActionButton>
@@ -165,8 +176,9 @@ export default function OverridesTab({ routes }: Props) {
{ {
label: 'Edit', icon: Edit, variant: 'secondary' as const, label: 'Edit', icon: Edit, variant: 'secondary' as const,
onClick: (r: RouteFareRule) => setForm({ isOpen: true, rule: r, error: null }), onClick: (r: RouteFareRule) => setForm({ isOpen: true, rule: r, error: null }),
show: () => canEditFare,
}, },
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick }, { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick, show: () => canDeleteFare },
]} ]}
loading={isLoading} loading={isLoading}
emptyMessage="No overrides for this route. Click 'Add Override' to create one." emptyMessage="No overrides for this route. Click 'Add Override' to create one."

View File

@@ -10,6 +10,8 @@ import { useSegmentFareRules, useSegmentFareMutations, useSeatClasses, useRoutes
import type { Route, SegmentFareRule, SeatClass } from './types'; import type { Route, SegmentFareRule, SeatClass } from './types';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
interface RouteStop { sequence: number; station?: { name: string; code: string } } interface RouteStop { sequence: number; station?: { name: string; code: string } }
@@ -50,6 +52,12 @@ interface Props { routes: Route[] }
type FormState = { isOpen: boolean; rule: SegmentFareRule | null; error: string | null }; type FormState = { isOpen: boolean; rule: SegmentFareRule | null; error: string | null };
export default function SegmentOverridesTab({ routes }: Props) { export default function SegmentOverridesTab({ routes }: Props) {
// Fare rules are their own resource on the API (schedule_fares:*), split out of
// schedules:manage so editing a timetable and changing a price are separate grants.
const canCreateFare = useWritePermission(PERMS.scheduleFares.create, PERMS.scheduleFares.manage);
const canEditFare = useWritePermission(PERMS.scheduleFares.edit, PERMS.scheduleFares.manage);
const canDeleteFare = useDeletePermission(PERMS.scheduleFares.delete);
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null); const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>({ isOpen: false, rule: null, error: null }); const [form, setForm] = useState<FormState>({ isOpen: false, rule: null, error: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null; name: string; error?: string }>({ isOpen: false, id: null, name: '' }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null; name: string; error?: string }>({ isOpen: false, id: null, name: '' });
@@ -164,7 +172,12 @@ export default function SegmentOverridesTab({ routes }: Props) {
))} ))}
</select> </select>
<ActionButton icon={Plus} onClick={() => setForm({ isOpen: true, rule: null, error: null })} disabled={!selectedRouteId}> <ActionButton
icon={Plus}
onClick={() => setForm({ isOpen: true, rule: null, error: null })}
disabled={!selectedRouteId || !canCreateFare}
title={canCreateFare ? undefined : 'You do not have permission to create fare overrides'}
>
Add Segment Override Add Segment Override
</ActionButton> </ActionButton>
</div> </div>
@@ -176,8 +189,8 @@ export default function SegmentOverridesTab({ routes }: Props) {
data={segmentFares} data={segmentFares}
columns={columns} columns={columns}
actions={[ actions={[
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }) }, { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }), show: () => canEditFare },
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)}${stopLabel(r.destinationStopSequence)}` }) }, { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)}${stopLabel(r.destinationStopSequence)}` }), show: () => canDeleteFare },
]} ]}
loading={isLoading} loading={isLoading}
emptyMessage="No segment overrides for this route." emptyMessage="No segment overrides for this route."

View File

@@ -14,12 +14,14 @@ interface Props {
isLoading: boolean; isLoading: boolean;
onEdit: (cls: SeatClass) => void; onEdit: (cls: SeatClass) => void;
onDelete: (id: string, cascade: boolean) => void; onDelete: (id: string, cascade: boolean) => void;
canEdit?: boolean;
canDelete?: boolean;
isDeleting: boolean; isDeleting: boolean;
deleteError?: string; deleteError?: string;
deleteSuccess?: number; deleteSuccess?: number;
} }
export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess }: Props) { export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess, canEdit = false, canDelete = false }: Props) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ const [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean; isOpen: boolean;
@@ -141,8 +143,8 @@ export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDe
data={displayed} data={displayed}
columns={columns} columns={columns}
actions={[ actions={[
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit }, { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit, show: () => canEdit },
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick }, { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick, show: () => canDelete },
]} ]}
loading={isLoading} loading={isLoading}
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'} emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}

View File

@@ -10,8 +10,11 @@ import BaggageTab from './BaggageTab';
import RateModal from './RateModal'; import RateModal from './RateModal';
import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks'; import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks';
import type { SeatClass, TabType } from './types'; import type { SeatClass, TabType } from './types';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
export default function TariffRatesPage() { function TariffRatesPageContent() {
const [tab, setTab] = useState<TabType>('tariff'); const [tab, setTab] = useState<TabType>('tariff');
const [showRateModal, setShowRateModal] = useState(false); const [showRateModal, setShowRateModal] = useState(false);
const [editingClass, setEditingClass] = useState<SeatClass | null>(null); const [editingClass, setEditingClass] = useState<SeatClass | null>(null);
@@ -20,6 +23,10 @@ export default function TariffRatesPage() {
const [deleteError, setDeleteError] = useState<string | undefined>(undefined); const [deleteError, setDeleteError] = useState<string | undefined>(undefined);
const [deleteSuccess, setDeleteSuccess] = useState(0); const [deleteSuccess, setDeleteSuccess] = useState(0);
const canCreateRate = useWritePermission(PERMS.tariffRates.create, PERMS.tariffRates.manage);
const canEditRate = useWritePermission(PERMS.tariffRates.edit, PERMS.tariffRates.manage);
const canDeleteRate = useDeletePermission(PERMS.tariffRates.delete);
const { allClasses, isLoading } = useSeatClasses(); const { allClasses, isLoading } = useSeatClasses();
const { coachTypes } = useCoachTypes(); const { coachTypes } = useCoachTypes();
const { routes } = useRoutes(); const { routes } = useRoutes();
@@ -71,7 +78,11 @@ export default function TariffRatesPage() {
</p> </p>
</div> </div>
{tab !== 'overrides' && tab !== 'segment-overrides' && ( {tab !== 'overrides' && tab !== 'segment-overrides' && (
<ActionButton icon={Plus} onClick={() => { <ActionButton
icon={Plus}
disabled={!canCreateRate}
title={canCreateRate ? undefined : 'You do not have permission to create tariff rates'}
onClick={() => {
if (tab === 'baggage') { if (tab === 'baggage') {
setShowBaggageModal(true); setShowBaggageModal(true);
} else { } else {
@@ -105,6 +116,8 @@ export default function TariffRatesPage() {
isLoading={isLoading} isLoading={isLoading}
onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }} onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }}
onDelete={handleDelete} onDelete={handleDelete}
canEdit={canEditRate}
canDelete={canDeleteRate}
isDeleting={seatClassMutations.remove.isPending} isDeleting={seatClassMutations.remove.isPending}
deleteError={deleteError} deleteError={deleteError}
deleteSuccess={deleteSuccess} deleteSuccess={deleteSuccess}
@@ -143,3 +156,11 @@ export default function TariffRatesPage() {
</div> </div>
); );
} }
export default function TariffRatesPage() {
return (
<PermissionGuard permission={PERMS.tariffRates.view}>
<TariffRatesPageContent />
</PermissionGuard>
);
}

View File

@@ -14,8 +14,11 @@ import { ticketsApi, apiClient, stationsApi, excessBaggageApi, bookingsApi } fro
import Pagination from '@/components/ui/Pagination'; import Pagination from '@/components/ui/Pagination';
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils'; import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useAnyPermission, useDeletePermission, usePermission, useWritePermission } from '@/lib/use-permission';
export default function TicketsPage() { function TicketsPageContent() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' }); const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
const [ticketPage, setTicketPage] = useState(1); const [ticketPage, setTicketPage] = useState(1);
const resetTicketPage = () => setTicketPage(1); const resetTicketPage = () => setTicketPage(1);
@@ -69,6 +72,13 @@ export default function TicketsPage() {
}); });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canGenerate = usePermission(PERMS.tickets.generate);
const canEditTicket = useWritePermission(PERMS.tickets.edit, PERMS.tickets.manage);
const canDelete = useDeletePermission(PERMS.tickets.delete);
// Logging luggage bills the passenger and sends a pay link, so it is its own grant.
const canLogBaggage = useAnyPermission([PERMS.excessBaggage.charge, PERMS.payments.manage, PERMS.bookings.manage, PERMS.admin]);
const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters, ticketPage], queryKey: ['tickets', filters, ticketPage],
queryFn: () => ticketsApi.getAll({ queryFn: () => ticketsApi.getAll({
@@ -554,7 +564,7 @@ export default function TicketsPage() {
onClick: openExcessModal, onClick: openExcessModal,
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Package, icon: Package,
show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status), show: (ticket: any) => canLogBaggage && !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
}, },
{ {
label: 'Board', label: 'Board',
@@ -562,6 +572,7 @@ export default function TicketsPage() {
variant: 'primary' as const, variant: 'primary' as const,
icon: LogIn, icon: LogIn,
show: (ticket: any) => { show: (ticket: any) => {
if (!canBoard) return false;
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT'; const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
if (isRoundTrip) { if (isRoundTrip) {
const inboundBoarded = !!ticket.booking?.returnBoardedAt; const inboundBoarded = !!ticket.booking?.returnBoardedAt;
@@ -595,10 +606,11 @@ export default function TicketsPage() {
onClick: (ticket: any) => restoreMutation.mutate(ticket.id), onClick: (ticket: any) => restoreMutation.mutate(ticket.id),
variant: 'secondary' as const, variant: 'secondary' as const,
icon: ListCollapse, icon: ListCollapse,
show: (ticket: any) => ticket.status === 'CANCELLED', show: (ticket: any) => canEditTicket && ticket.status === 'CANCELLED',
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: handleDeleteClick, onClick: handleDeleteClick,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -621,6 +633,8 @@ export default function TicketsPage() {
variant="secondary" variant="secondary"
loading={generateMissingMutation.isPending} loading={generateMissingMutation.isPending}
onClick={() => generateMissingMutation.mutate()} onClick={() => generateMissingMutation.mutate()}
disabled={!canGenerate}
title={canGenerate ? undefined : 'You do not have permission to generate tickets'}
> >
Generate Missing Generate Missing
</ActionButton> </ActionButton>
@@ -760,7 +774,13 @@ export default function TicketsPage() {
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel</ActionButton> <ActionButton variant="secondary" onClick={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel</ActionButton>
<ActionButton icon={LogIn} loading={boardMutation.isPending} onClick={handleConfirmBoard}>Board and Print</ActionButton> <ActionButton
icon={LogIn}
loading={boardMutation.isPending}
onClick={handleConfirmBoard}
disabled={!canBoard}
title={canBoard ? undefined : 'You do not have permission to board passengers'}
>Board and Print</ActionButton>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -1103,3 +1123,11 @@ export default function TicketsPage() {
</div> </div>
); );
} }
export default function TicketsPage() {
return (
<PermissionGuard permission={PERMS.tickets.view}>
<TicketsPageContent />
</PermissionGuard>
);
}

View File

@@ -15,6 +15,7 @@ import { Train as TrainType } from '@/types';
import { formatDate } from '@/lib/utils'; import { formatDate } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
function TrainsPageContent() { function TrainsPageContent() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
@@ -24,6 +25,10 @@ function TrainsPageContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.trains.create, PERMS.trains.manage);
const canEdit = useWritePermission(PERMS.trains.edit, PERMS.trains.manage);
const canDelete = useDeletePermission(PERMS.trains.delete);
const { data: trainsData, isLoading: trainsLoading } = useQuery({ const { data: trainsData, isLoading: trainsLoading } = useQuery({
queryKey: ['trains'], queryKey: ['trains'],
queryFn: () => fleetApi.getTrains(), queryFn: () => fleetApi.getTrains(),
@@ -174,6 +179,7 @@ function TrainsPageContent() {
const actions = [ const actions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (train: TrainType) => { onClick: (train: TrainType) => {
setEditingTrain(train); setEditingTrain(train);
setShowModal(true); setShowModal(true);
@@ -186,10 +192,11 @@ function TrainsPageContent() {
onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id), onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id),
variant: 'secondary' as const, variant: 'secondary' as const,
icon: RotateCcw, icon: RotateCcw,
show: (train: TrainType) => !train.isActive, show: (train: TrainType) => canEdit && !train.isActive,
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: handleDelete, onClick: handleDelete,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -209,6 +216,8 @@ function TrainsPageContent() {
setShowModal(true); setShowModal(true);
}} }}
icon={Plus} icon={Plus}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create trains'}
> >
Add Train Add Train
</ActionButton> </ActionButton>

View File

@@ -6,12 +6,12 @@ import { PERMS } from '@/lib/permissions';
/** /**
* Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy * Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy
* can be neither upgraded from nor to. Gated on bookings:view because that is what * can be neither upgraded from nor to. Gated on upgrade_policies:view (or :manage) — bookings:view no longer
* `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side. * grants it, so the page needs its own grant. Create/edit/delete each have their own key.
*/ */
export default function UpgradePoliciesPage() { export default function UpgradePoliciesPage() {
return ( return (
<PermissionGuard permission={PERMS.bookings.view}> <PermissionGuard permission={[PERMS.upgradePolicies.view, PERMS.upgradePolicies.manage]}>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1> <h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1>

View File

@@ -2,17 +2,28 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ShieldOff } from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
interface Props { interface Props {
permission?: string; /**
* A single key, or several of which the user needs **any one**. The any-of form
* is how a report page accepts either its own key or the `reports:view`
* umbrella — mirroring the OR semantics of the API's `PassengerPermissionGuard`.
*/
permission?: string | string[];
children: React.ReactNode; children: React.ReactNode;
} }
/** /**
* Wraps a page to enforce auth + optional permission check. * Wraps a page to enforce auth + optional permission check.
* - Not logged in → redirect to /login * - Not logged in → redirect to /login
* - Missing permission → redirect to /dashboard * - Missing permission → render an explanation (see below)
*
* This used to redirect a user without the permission to /dashboard. That is a
* dead end for anyone lacking `dashboard:view`, since that page renders nothing
* either — they got a blank screen with no explanation. Saying what happened is
* both kinder and easier to support.
*/ */
export function PermissionGuard({ permission, children }: Props) { export function PermissionGuard({ permission, children }: Props) {
const router = useRouter(); const router = useRouter();
@@ -20,17 +31,26 @@ export function PermissionGuard({ permission, children }: Props) {
const hasPermission = useAuthStore((s) => s.hasPermission); const hasPermission = useAuthStore((s) => s.hasPermission);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) router.replace('/login');
router.replace('/login'); }, [isAuthenticated, router]);
return;
}
if (permission && !hasPermission(permission)) {
router.replace('/dashboard');
}
}, [isAuthenticated, permission, hasPermission, router]);
if (!isAuthenticated) return null; if (!isAuthenticated) return null;
if (permission && !hasPermission(permission)) return null;
const keys = permission === undefined ? [] : Array.isArray(permission) ? permission : [permission];
const allowed = keys.length === 0 || keys.some((key) => hasPermission(key));
if (!allowed) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 px-6 text-center">
<ShieldOff className="h-10 w-10 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">You don&apos;t have access to this page</h2>
<p className="max-w-md text-sm text-muted-foreground">
Your account is missing the permission this page requires. Ask an administrator to grant
it if you need access.
</p>
</div>
);
}
return <>{children}</>; return <>{children}</>;
} }

View File

@@ -52,7 +52,8 @@ interface NavItem {
name: string; name: string;
href: string; href: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
permission?: string; /** A single key, or several of which the user needs any one. */
permission?: string | string[];
} }
const navigationSections: { title: string; items: NavItem[] }[] = [ const navigationSections: { title: string; items: NavItem[] }[] = [
@@ -92,8 +93,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view }, { name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: [PERMS.reschedulePolicies.view, PERMS.reschedulePolicies.manage] },
{ name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: PERMS.bookings.view }, { name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: [PERMS.upgradePolicies.view, PERMS.upgradePolicies.manage] },
] ]
}, },
{ {
@@ -128,16 +129,16 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ {
title: 'Analytics & Reports', title: 'Analytics & Reports',
items: [ items: [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view }, { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: [PERMS.reports.overall.view, PERMS.reports.view] },
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view }, { name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: [PERMS.reports.finance.view, PERMS.reports.view] },
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view }, { name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: [PERMS.reports.coachUtilization.view, PERMS.reports.view] },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: [PERMS.reports.seatStatus.view, PERMS.reports.view] },
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view }, { name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: [PERMS.reports.blockedSeats.view, PERMS.reports.view] },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: [PERMS.reports.passengers.view, PERMS.reports.view] },
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view }, { name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: [PERMS.reports.boarding.view, PERMS.reports.view] },
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view }, { name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: [PERMS.reports.payments.view, PERMS.reports.view] },
// { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view }, // { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: [PERMS.reports.payments.view, PERMS.reports.view] },
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: [PERMS.reports.catalog.view, PERMS.reports.view] },
] ]
}, },
{ {
@@ -217,9 +218,11 @@ export default function Sidebar() {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6"> <nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
{navigationSections.map((section) => { {navigationSections.map((section) => {
const visibleItems = section.items.filter( const visibleItems = section.items.filter((item) => {
(item) => !item.permission || hasPermission(item.permission) if (!item.permission) return true;
); const keys = Array.isArray(item.permission) ? item.permission : [item.permission];
return keys.some((key) => hasPermission(key));
});
if (visibleItems.length === 0) return null; if (visibleItems.length === 0) return null;
return ( return (
<div key={section.title}> <div key={section.title}>

View File

@@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
import { import {
reschedulePolicyApi, reschedulePolicyApi,
type ReschedulePolicyCoachType, type ReschedulePolicyCoachType,
@@ -35,6 +37,10 @@ const feeLabel = (percent: number, minMinor: number) =>
* the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all. * the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all.
*/ */
export default function ReschedulePolicyManager() { export default function ReschedulePolicyManager() {
const canCreate = useWritePermission(PERMS.reschedulePolicies.create, PERMS.reschedulePolicies.manage);
const canEdit = useWritePermission(PERMS.reschedulePolicies.edit, PERMS.reschedulePolicies.manage);
const canDelete = useDeletePermission(PERMS.reschedulePolicies.delete);
const [rows, setRows] = useState<ReschedulePolicyRow[]>([]); const [rows, setRows] = useState<ReschedulePolicyRow[]>([]);
const [available, setAvailable] = useState<ReschedulePolicyCoachType[]>([]); const [available, setAvailable] = useState<ReschedulePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -188,9 +194,10 @@ export default function ReschedulePolicyManager() {
]; ];
const actions = [ const actions = [
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (row: ReschedulePolicyRow) => setDeleting(row), onClick: (row: ReschedulePolicyRow) => setDeleting(row),
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -206,7 +213,12 @@ export default function ReschedulePolicyManager() {
not refunded. Same-day = new departure on the same calendar day as the original. A fare class with no not refunded. Same-day = new departure on the same calendar day as the original. A fare class with no
policy here cannot be rescheduled at all. policy here cannot be rescheduled at all.
</p> </p>
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}> <ActionButton
icon={Plus}
onClick={openCreate}
disabled={available.length === 0 || !canCreate}
title={canCreate ? undefined : 'You do not have permission to create reschedule policies'}
>
Add Reschedule Policy Add Reschedule Policy
</ActionButton> </ActionButton>
</div> </div>
@@ -353,7 +365,13 @@ export default function ReschedulePolicyManager() {
<ActionButton variant="secondary" onClick={() => setShowModal(false)}> <ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel Cancel
</ActionButton> </ActionButton>
<ActionButton icon={Save} onClick={submit} loading={saving}> <ActionButton
icon={Save}
onClick={submit}
loading={saving}
disabled={editing ? !canEdit : !canCreate}
title={(editing ? canEdit : canCreate) ? undefined : 'You do not have permission to change reschedule policies'}
>
{editing ? 'Update Policy' : 'Create Policy'} {editing ? 'Update Policy' : 'Create Policy'}
</ActionButton> </ActionButton>
</div> </div>

View File

@@ -14,6 +14,12 @@ interface ActionButtonProps {
loading?: boolean; loading?: boolean;
className?: string; className?: string;
type?: 'button' | 'submit' | 'reset'; type?: 'button' | 'submit' | 'reset';
/**
* Native tooltip. CLAUDE.md asks for a disabled control with a visible reason
* over a silently hidden one, so permission-gated buttons pass the reason here
* alongside `disabled`.
*/
title?: string;
} }
const variants = { const variants = {
@@ -40,6 +46,7 @@ export default function ActionButton({
loading = false, loading = false,
className, className,
type = 'button', type = 'button',
title,
}: ActionButtonProps) { }: ActionButtonProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -63,6 +70,7 @@ export default function ActionButton({
type={type} type={type}
onClick={handleClick} onClick={handleClick}
disabled={isDisabled} disabled={isDisabled}
title={title}
className={cn( className={cn(
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200', 'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[rgb(20,113,76)]', 'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[rgb(20,113,76)]',

View File

@@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
import { import {
upgradePolicyApi, upgradePolicyApi,
type UpgradePolicyCoachType, type UpgradePolicyCoachType,
@@ -38,6 +40,10 @@ const feeLabel = (p: UpgradePolicyRow) =>
* a dialog, the same shape as Reschedule Policies and Coach Management. * a dialog, the same shape as Reschedule Policies and Coach Management.
*/ */
export default function UpgradePolicyManager() { export default function UpgradePolicyManager() {
const canCreate = useWritePermission(PERMS.upgradePolicies.create, PERMS.upgradePolicies.manage);
const canEdit = useWritePermission(PERMS.upgradePolicies.edit, PERMS.upgradePolicies.manage);
const canDelete = useDeletePermission(PERMS.upgradePolicies.delete);
const [rows, setRows] = useState<UpgradePolicyRow[]>([]); const [rows, setRows] = useState<UpgradePolicyRow[]>([]);
const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]); const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -186,9 +192,10 @@ export default function UpgradePolicyManager() {
]; ];
const actions = [ const actions = [
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (row: UpgradePolicyRow) => setDeleting(row), onClick: (row: UpgradePolicyRow) => setDeleting(row),
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -204,7 +211,12 @@ export default function UpgradePolicyManager() {
<em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither <em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither
upgraded from nor to. upgraded from nor to.
</p> </p>
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}> <ActionButton
icon={Plus}
onClick={openCreate}
disabled={available.length === 0 || !canCreate}
title={canCreate ? undefined : 'You do not have permission to create upgrade policies'}
>
Add Upgrade Policy Add Upgrade Policy
</ActionButton> </ActionButton>
</div> </div>
@@ -340,7 +352,13 @@ export default function UpgradePolicyManager() {
<ActionButton variant="secondary" onClick={() => setShowModal(false)}> <ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel Cancel
</ActionButton> </ActionButton>
<ActionButton icon={Save} onClick={submit} loading={saving}> <ActionButton
icon={Save}
onClick={submit}
loading={saving}
disabled={editing ? !canEdit : !canCreate}
title={(editing ? canEdit : canCreate) ? undefined : 'You do not have permission to change upgrade policies'}
>
{editing ? 'Update Policy' : 'Create Policy'} {editing ? 'Update Policy' : 'Create Policy'}
</ActionButton> </ActionButton>
</div> </div>

View File

@@ -4,6 +4,8 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.'; const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.'; const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.';
const FORBIDDEN_MESSAGE =
'You do not have permission to do that. Ask an administrator if you need access.';
/** /**
* Extracts a user-facing message from a failed request. Prefers a real backend-provided message * Extracts a user-facing message from a failed request. Prefers a real backend-provided message
@@ -55,6 +57,21 @@ class ApiClient {
} }
} }
// A 403 from this API is always a permission check, and the server's own text
// names raw permission keys ("Missing permission. Required one of: …") which
// means nothing to a user. Replace it with something actionable, but only when
// the server did not send a more specific message of its own.
if (error.response?.status === 403) {
const body = error.response.data;
const serverMessage = typeof body?.message === 'string' ? body.message : '';
if (!serverMessage || serverMessage.startsWith('Missing permission')) {
const friendly = FORBIDDEN_MESSAGE;
if (body && typeof body === 'object') body.message = friendly;
error.message = friendly;
return Promise.reject(error);
}
}
// Normalize in place so every existing `err?.response?.data?.message || err?.message || // Normalize in place so every existing `err?.response?.data?.message || err?.message ||
// '<fallback>'` call site across the app picks up a friendly message automatically, // '<fallback>'` call site across the app picks up a friendly message automatically,
// instead of raw axios/network text or an unjoined NestJS validation array. // instead of raw axios/network text or an unjoined NestJS validation array.

View File

@@ -4,85 +4,183 @@ export const PERMS = {
view: 'edr_passenger_app:bookings:view', view: 'edr_passenger_app:bookings:view',
manage: 'edr_passenger_app:bookings:manage', manage: 'edr_passenger_app:bookings:manage',
cancel: 'edr_passenger_app:bookings:cancel', cancel: 'edr_passenger_app:bookings:cancel',
reschedule: 'edr_passenger_app:bookings:reschedule',
create: 'edr_passenger_app:bookings:create',
edit: 'edr_passenger_app:bookings:edit',
delete: 'edr_passenger_app:bookings:delete',
},
reschedulePolicies: {
view: 'edr_passenger_app:reschedule_policies:view',
manage: 'edr_passenger_app:reschedule_policies:manage',
create: 'edr_passenger_app:reschedule_policies:create',
edit: 'edr_passenger_app:reschedule_policies:edit',
delete: 'edr_passenger_app:reschedule_policies:delete',
},
upgradePolicies: {
view: 'edr_passenger_app:upgrade_policies:view',
manage: 'edr_passenger_app:upgrade_policies:manage',
create: 'edr_passenger_app:upgrade_policies:create',
edit: 'edr_passenger_app:upgrade_policies:edit',
delete: 'edr_passenger_app:upgrade_policies:delete',
}, },
passengers: { passengers: {
view: 'edr_passenger_app:passengers:view', view: 'edr_passenger_app:passengers:view',
manage: 'edr_passenger_app:passengers:manage', manage: 'edr_passenger_app:passengers:manage',
create: 'edr_passenger_app:passengers:create',
edit: 'edr_passenger_app:passengers:edit',
delete: 'edr_passenger_app:passengers:delete',
}, },
tickets: { tickets: {
view: 'edr_passenger_app:tickets:view', view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage', manage: 'edr_passenger_app:tickets:manage',
generate: 'edr_passenger_app:tickets:generate', generate: 'edr_passenger_app:tickets:generate',
board: 'edr_passenger_app:tickets:board',
create: 'edr_passenger_app:tickets:create',
edit: 'edr_passenger_app:tickets:edit',
delete: 'edr_passenger_app:tickets:delete',
}, },
// ── Master Data ──────────────────────────────────────────────── // ── Master Data ────────────────────────────────────────────────
stations: { stations: {
view: 'edr_passenger_app:stations:view', view: 'edr_passenger_app:stations:view',
manage: 'edr_passenger_app:stations:manage', manage: 'edr_passenger_app:stations:manage',
create: 'edr_passenger_app:stations:create',
edit: 'edr_passenger_app:stations:edit',
delete: 'edr_passenger_app:stations:delete',
}, },
trains: { trains: {
view: 'edr_passenger_app:trains:view', view: 'edr_passenger_app:trains:view',
manage: 'edr_passenger_app:trains:manage', manage: 'edr_passenger_app:trains:manage',
create: 'edr_passenger_app:trains:create',
edit: 'edr_passenger_app:trains:edit',
delete: 'edr_passenger_app:trains:delete',
}, },
coaches: { coaches: {
view: 'edr_passenger_app:coaches:view', view: 'edr_passenger_app:coaches:view',
manage: 'edr_passenger_app:coaches:manage', manage: 'edr_passenger_app:coaches:manage',
create: 'edr_passenger_app:coaches:create',
edit: 'edr_passenger_app:coaches:edit',
delete: 'edr_passenger_app:coaches:delete',
}, },
seats: { seats: {
view: 'edr_passenger_app:seats:view', view: 'edr_passenger_app:seats:view',
manage: 'edr_passenger_app:seats:manage', manage: 'edr_passenger_app:seats:manage',
create: 'edr_passenger_app:seats:create',
edit: 'edr_passenger_app:seats:edit',
delete: 'edr_passenger_app:seats:delete',
block: 'edr_passenger_app:seats:block',
}, },
classes: { classes: {
view: 'edr_passenger_app:classes:view', view: 'edr_passenger_app:classes:view',
manage: 'edr_passenger_app:classes:manage', manage: 'edr_passenger_app:classes:manage',
create: 'edr_passenger_app:classes:create',
edit: 'edr_passenger_app:classes:edit',
delete: 'edr_passenger_app:classes:delete',
}, },
routes: { routes: {
view: 'edr_passenger_app:routes:view', view: 'edr_passenger_app:routes:view',
manage: 'edr_passenger_app:routes:manage', manage: 'edr_passenger_app:routes:manage',
create: 'edr_passenger_app:routes:create',
edit: 'edr_passenger_app:routes:edit',
delete: 'edr_passenger_app:routes:delete',
}, },
schedules: { schedules: {
view: 'edr_passenger_app:schedules:view', view: 'edr_passenger_app:schedules:view',
manage: 'edr_passenger_app:schedules:manage', manage: 'edr_passenger_app:schedules:manage',
create: 'edr_passenger_app:schedules:create',
edit: 'edr_passenger_app:schedules:edit',
delete: 'edr_passenger_app:schedules:delete',
cancel: 'edr_passenger_app:schedules:cancel',
},
scheduleFares: {
view: 'edr_passenger_app:schedule_fares:view',
manage: 'edr_passenger_app:schedule_fares:manage',
create: 'edr_passenger_app:schedule_fares:create',
edit: 'edr_passenger_app:schedule_fares:edit',
delete: 'edr_passenger_app:schedule_fares:delete',
}, },
// ── Tourism ──────────────────────────────────────────────────── // ── Tourism ────────────────────────────────────────────────────
packages: { packages: {
view: 'edr_passenger_app:packages:view', view: 'edr_passenger_app:packages:view',
manage: 'edr_passenger_app:packages:manage', manage: 'edr_passenger_app:packages:manage',
create: 'edr_passenger_app:packages:create',
edit: 'edr_passenger_app:packages:edit',
delete: 'edr_passenger_app:packages:delete',
publish: 'edr_passenger_app:packages:publish',
}, },
inquiries: { inquiries: {
view: 'edr_passenger_app:inquiries:view', view: 'edr_passenger_app:inquiries:view',
manage: 'edr_passenger_app:inquiries:manage', manage: 'edr_passenger_app:inquiries:manage',
create: 'edr_passenger_app:inquiries:create',
edit: 'edr_passenger_app:inquiries:edit',
delete: 'edr_passenger_app:inquiries:delete',
}, },
// ── Finance ──────────────────────────────────────────────────── // ── Finance ────────────────────────────────────────────────────
tariffRates: { tariffRates: {
view: 'edr_passenger_app:tariff_rates:view', view: 'edr_passenger_app:tariff_rates:view',
manage: 'edr_passenger_app:tariff_rates:manage', manage: 'edr_passenger_app:tariff_rates:manage',
create: 'edr_passenger_app:tariff_rates:create',
edit: 'edr_passenger_app:tariff_rates:edit',
delete: 'edr_passenger_app:tariff_rates:delete',
}, },
payments: { payments: {
view: 'edr_passenger_app:payments:view', view: 'edr_passenger_app:payments:view',
manage: 'edr_passenger_app:payments:manage', manage: 'edr_passenger_app:payments:manage',
create: 'edr_passenger_app:payments:create',
supplementary: 'edr_passenger_app:payments:supplementary',
edit: 'edr_passenger_app:payments:edit',
delete: 'edr_passenger_app:payments:delete',
// legacy aliases — still honoured by the backend guards // legacy aliases — still honoured by the backend guards
viewAll: 'edr_passenger_app:payments:view_all', viewAll: 'edr_passenger_app:payments:view_all',
refund: 'edr_passenger_app:payments:refund', refund: 'edr_passenger_app:payments:refund',
manageMethods: 'edr_passenger_app:payments:manage_methods', manageMethods: 'edr_passenger_app:payments:manage_methods',
}, },
excessBaggage: {
charge: 'edr_passenger_app:excess_baggage:charge',
},
paymentMethods: { paymentMethods: {
view: 'edr_passenger_app:payment_methods:view', view: 'edr_passenger_app:payment_methods:view',
manage: 'edr_passenger_app:payment_methods:manage', manage: 'edr_passenger_app:payment_methods:manage',
create: 'edr_passenger_app:payment_methods:create',
edit: 'edr_passenger_app:payment_methods:edit',
delete: 'edr_passenger_app:payment_methods:delete',
}, },
currencies: { currencies: {
view: 'edr_passenger_app:currencies:view', view: 'edr_passenger_app:currencies:view',
manage: 'edr_passenger_app:currencies:manage', manage: 'edr_passenger_app:currencies:manage',
create: 'edr_passenger_app:currencies:create',
edit: 'edr_passenger_app:currencies:edit',
delete: 'edr_passenger_app:currencies:delete',
}, },
reports: { reports: {
// all-reports umbrella — kept, and kept in every per-report guard array
view: 'edr_passenger_app:reports:view', view: 'edr_passenger_app:reports:view',
overall: { view: 'edr_passenger_app:reports_overall:view' },
finance: {
view: 'edr_passenger_app:reports_finance:view',
export: 'edr_passenger_app:reports_finance:export',
},
coachUtilization: { view: 'edr_passenger_app:reports_coach_utilization:view' },
seatStatus: { view: 'edr_passenger_app:reports_seat_status:view' },
blockedSeats: {
view: 'edr_passenger_app:reports_blocked_seats:view',
export: 'edr_passenger_app:reports_blocked_seats:export',
},
passengers: { view: 'edr_passenger_app:reports_passengers:view' },
boarding: { view: 'edr_passenger_app:reports_boarding:view' },
payments: { view: 'edr_passenger_app:reports_payments:view' },
catalog: { view: 'edr_passenger_app:reports_catalog:view' },
}, },
fraud: { fraud: {
view: 'edr_passenger_app:fraud:view', view: 'edr_passenger_app:fraud:view',
manage: 'edr_passenger_app:fraud:manage', manage: 'edr_passenger_app:fraud:manage',
create: 'edr_passenger_app:fraud:create',
edit: 'edr_passenger_app:fraud:edit',
delete: 'edr_passenger_app:fraud:delete',
}, },
audit: { audit: {
view: 'edr_passenger_app:audit:view', view: 'edr_passenger_app:audit:view',
@@ -90,6 +188,9 @@ export const PERMS = {
agents: { agents: {
view: 'edr_passenger_app:agents:view', view: 'edr_passenger_app:agents:view',
manage: 'edr_passenger_app:agents:manage', manage: 'edr_passenger_app:agents:manage',
create: 'edr_passenger_app:agents:create',
edit: 'edr_passenger_app:agents:edit',
delete: 'edr_passenger_app:agents:delete',
}, },
notifications: { notifications: {
send: 'edr_passenger_app:notifications:send', send: 'edr_passenger_app:notifications:send',

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useAuthStore } from './auth-store'; import { useAuthStore } from './auth-store';
import { PERMS } from './permissions';
/** /**
* Returns whether the current user has a given permission key. * Returns whether the current user has a given permission key.
@@ -14,6 +15,20 @@ export function usePermission(key: string): boolean {
return useAuthStore((s) => s.hasPermission(key)); return useAuthStore((s) => s.hasPermission(key));
} }
/**
* True when the user holds **any one** of the keys — the same OR semantics as the
* API's `PassengerPermissionGuard`.
*
* Use it wherever a route accepts a narrow key or a broader one, so the UI agrees
* with the API instead of hiding a control the server would have allowed:
*
* const canCreate = useAnyPermission([PERMS.schedules.create, PERMS.schedules.manage]);
* const canSeeFinance = useAnyPermission([PERMS.reports.finance.view, PERMS.reports.view]);
*/
export function useAnyPermission(keys: string[]): boolean {
return useAuthStore((s) => keys.some((key) => s.hasPermission(key)));
}
/** /**
* Same as usePermission but WITHOUT the super-admin / org-admin bypass — the * Same as usePermission but WITHOUT the super-admin / org-admin bypass — the
* permission must be explicitly granted. Use it wherever the API endpoint is * permission must be explicitly granted. Use it wherever the API endpoint is
@@ -25,3 +40,23 @@ export function usePermission(key: string): boolean {
export function usePermissionStrict(key: string): boolean { export function usePermissionStrict(key: string): boolean {
return useAuthStore((s) => s.hasPermissionStrict(key)); return useAuthStore((s) => s.hasPermissionStrict(key));
} }
/**
* The UI mirror of the API's `@PassengerWrite(narrow, umbrella)`: the narrow key,
* the resource's `:manage` umbrella, or `admin`. Use it for create / edit / domain
* actions so a control is shown exactly when the server would accept the call.
*
* const canCreate = useWritePermission(PERMS.schedules.create, PERMS.schedules.manage);
*/
export function useWritePermission(narrow: string, umbrella: string): boolean {
return useAnyPermission([narrow, umbrella, PERMS.admin]);
}
/**
* The UI mirror of the API's `@PassengerDelete(narrow)`: the narrow `:delete` key
* or `admin`. **`:manage` deliberately does not count** — holding `schedules:manage`
* does not let you delete a schedule, so the button must not appear either.
*/
export function useDeletePermission(narrow: string): boolean {
return useAnyPermission([narrow, PERMS.admin]);
}