mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 15:58:18 +00:00
@@ -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",
|
||||||
|
|||||||
456
apps/edr-passenger-api/scripts/repro-double-booking.cjs
Normal file
456
apps/edr-passenger-api/scripts/repro-double-booking.cjs
Normal 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);
|
||||||
|
});
|
||||||
236
apps/edr-passenger-api/scripts/seed-legacy-role-users.cjs
Normal file
236
apps/edr-passenger-api/scripts/seed-legacy-role-users.cjs
Normal 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);
|
||||||
|
});
|
||||||
359
apps/edr-passenger-api/scripts/stress-booking-concurrency.cjs
Normal file
359
apps/edr-passenger-api/scripts/stress-booking-concurrency.cjs
Normal 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); });
|
||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'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}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)]',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user