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,
|
||||
"scripts": {
|
||||
"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",
|
||||
"start": "node dist/main.js",
|
||||
"start:prod": "node dist/main.js",
|
||||
"lint": "eslint src",
|
||||
"test": "jest",
|
||||
"test:debug": "node --inspect-brk node_modules/jest/bin/jest.js --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"test:e2e:report": "jest --config ./test/jest-e2e.json; open e2e-report/index.html",
|
||||
"test:e2e:all": "bash ../../e2e/run.sh",
|
||||
@@ -18,6 +21,7 @@
|
||||
"type-check": "tsc --noEmit",
|
||||
"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-legacy-users": "node --env-file=.env scripts/seed-legacy-role-users.cjs",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate deploy",
|
||||
"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);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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 { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
||||
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')
|
||||
@Controller('agents')
|
||||
@@ -26,18 +27,20 @@ export class AgentsController {
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create agent profile linked to an IAM user' })
|
||||
@PassengerWrite(PASSENGER_PERMS.agents.create, PASSENGER_PERMS.agents.manage)
|
||||
createAgent(@Body() dto: CreateAgentDto) {
|
||||
return this.service.createAgent(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@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 }) {
|
||||
return this.service.updateAgent(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.agents.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete agent profile' })
|
||||
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
|
||||
* is bypassable by simply never finishing the first payment.
|
||||
*/
|
||||
const ACTIVE_BOOKING_STATUSES: BookingStatus[] = [
|
||||
export const ACTIVE_BOOKING_STATUSES: BookingStatus[] = [
|
||||
BookingStatus.DRAFT,
|
||||
BookingStatus.PENDING_PAYMENT,
|
||||
BookingStatus.CONFIRMED,
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
IssueReservationBookingDto,
|
||||
} from "./guest-booking.dto";
|
||||
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 { SeatsService } from "../seats/seats.service";
|
||||
|
||||
@@ -372,7 +372,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post("group")
|
||||
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
|
||||
@PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
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")
|
||||
@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")
|
||||
@ApiOperation({
|
||||
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")
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.bookings.delete)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
description: "Permanently deletes a booking record",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException, Logger } from '@nes
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
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 { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateBookingDto } from './bookings.dto';
|
||||
@@ -873,15 +873,10 @@ export class BookingsService {
|
||||
return this.createOneWayBooking(dto);
|
||||
}
|
||||
|
||||
private validateSeatIdsAgainstHold(holdId: string, holdSeatIds: string[], requestedSeatIds: string[]) {
|
||||
for (const seatId of requestedSeatIds) {
|
||||
if (!holdSeatIds.includes(seatId)) {
|
||||
throw new BadRequestException(
|
||||
`Seat ${seatId} is not part of hold ${holdId}. Use seat IDs returned from POST /seats/hold.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// validateSeatIdsAgainstHold lived here. Every caller now goes through
|
||||
// SeatsService.assertSeatsClaimable, which performs the same seat-in-hold check alongside
|
||||
// the hold-is-live, hold-is-for-this-schedule and no-conflict checks — and, at write time,
|
||||
// re-runs all of them inside the seat lock.
|
||||
|
||||
/** Resolves contactEmail/contactPhone for an IAM-authenticated passenger booking. */
|
||||
private async resolveIamContact(passengerId?: string): Promise<{ contactEmail: string | null; contactPhone: string | null }> {
|
||||
@@ -896,11 +891,18 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
private async createOneWayBooking(dto: CreateBookingDto) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
|
||||
|
||||
const requestedSeatIds = (dto.passengers as any[]).map(p => p.seatId);
|
||||
this.validateSeatIdsAgainstHold(dto.holdId, hold.seatIds, requestedSeatIds);
|
||||
// Hold live, on this departure, covering these seats, and nobody else holding or booked
|
||||
// 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 claims: SeatClaim[] = [{
|
||||
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({
|
||||
where: { id: dto.scheduleId },
|
||||
@@ -912,14 +914,6 @@ export class BookingsService {
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
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([
|
||||
this.processPassengers(dto.passengers as any[]),
|
||||
this.resolveIamContact(dto.passengerId),
|
||||
@@ -1029,7 +1023,9 @@ export class BookingsService {
|
||||
// still pass; the tolerance absorbs FX-conversion rounding.
|
||||
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: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
@@ -1068,7 +1064,7 @@ export class BookingsService {
|
||||
}
|
||||
},
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
|
||||
});
|
||||
}));
|
||||
|
||||
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
|
||||
if (dto.packageId && dto.priceTierId) {
|
||||
@@ -1087,18 +1083,31 @@ export class BookingsService {
|
||||
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 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([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
@@ -1122,21 +1131,6 @@ export class BookingsService {
|
||||
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([
|
||||
this.processRoundTripPassengers(dto.passengers as any[]),
|
||||
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.
|
||||
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: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
@@ -1314,7 +1309,7 @@ export class BookingsService {
|
||||
},
|
||||
} as any,
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }
|
||||
});
|
||||
}));
|
||||
|
||||
const outboundSeatIds = passengersData.map(p => p.outboundSeatId);
|
||||
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');
|
||||
}
|
||||
|
||||
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');
|
||||
if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired');
|
||||
const leg1SeatIds = (dto.passengers as any[]).map(p => p.seatId).filter(Boolean);
|
||||
const leg2SeatIds = (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId).filter(Boolean);
|
||||
|
||||
const leg1SeatIds = (dto.passengers as any[]).map(p => p.seatId);
|
||||
const leg2SeatIds = (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId);
|
||||
this.validateSeatIdsAgainstHold(dto.holdId, leg1Hold.seatIds, leg1SeatIds);
|
||||
this.validateSeatIdsAgainstHold(dto.leg2HoldId!, leg2Hold.seatIds, leg2SeatIds);
|
||||
// Advisory pass; re-run under the seat lock at the write.
|
||||
const claims: SeatClaim[] = [
|
||||
{
|
||||
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([
|
||||
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 (!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([
|
||||
this.processPassengers(dto.passengers as any[]),
|
||||
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
|
||||
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: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
@@ -1526,7 +1521,7 @@ export class BookingsService {
|
||||
},
|
||||
} as any,
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
|
||||
});
|
||||
}));
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(passengersData.map(p => p.seatId)),
|
||||
@@ -1561,23 +1556,49 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate all 4 holds
|
||||
const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
|
||||
]);
|
||||
const now = new Date();
|
||||
if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 seat hold expired');
|
||||
if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 seat hold expired');
|
||||
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired');
|
||||
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired');
|
||||
|
||||
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));
|
||||
this.validateSeatIdsAgainstHold(dto.returnLeg2HoldId!, retL2Hold.seatIds, (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId));
|
||||
// All 4 holds: live, on their own departure, covering their leg's seats, unconflicted.
|
||||
// Advisory pass; re-run under the seat lock at the write.
|
||||
const seatsOf = (pick: (p: any) => string | undefined) =>
|
||||
(dto.passengers as any[]).map(pick).filter((id): id is string => !!id);
|
||||
const claims: SeatClaim[] = [
|
||||
{
|
||||
holdId: dto.holdId,
|
||||
seatIds: seatsOf(p => p.seatId),
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.transitStationId,
|
||||
journeyDirection: JourneyDirection.OUTBOUND,
|
||||
legLabel: 'Outbound leg-1',
|
||||
},
|
||||
{
|
||||
holdId: dto.leg2HoldId!,
|
||||
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
|
||||
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 (!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([
|
||||
this.processRoundTripPassengers(dto.passengers as any[]),
|
||||
this.resolveIamContact(dto.passengerId),
|
||||
@@ -1718,7 +1710,8 @@ export class BookingsService {
|
||||
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: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
@@ -1759,7 +1752,7 @@ export class BookingsService {
|
||||
},
|
||||
} as any,
|
||||
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
|
||||
});
|
||||
}));
|
||||
|
||||
await Promise.all([
|
||||
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 { DataSource } from 'typeorm';
|
||||
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 { CurrencyService } from '../currency/currency.service';
|
||||
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
||||
@@ -146,11 +146,20 @@ export class GuestBookingService {
|
||||
|
||||
private async createGuestOneWayBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
const authUserId: string | null = req?.user?.id ?? null;
|
||||
// Validate hold
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
if (!hold || hold.expiresAt < new Date()) {
|
||||
throw new BadRequestException('Seat hold expired or not found');
|
||||
}
|
||||
const claimedSeatIds = dto.passengers.map((p) => p.seatId).filter((id): id is string => !!id);
|
||||
// Fail fast, before any fare or identity work: hold is live, belongs to this departure,
|
||||
// covers the seats asked for, and nobody else holds or has booked them. This is a
|
||||
// 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
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
@@ -371,8 +380,15 @@ export class GuestBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
// Create booking
|
||||
const booking = await this.prisma.booking.create({
|
||||
// Create booking.
|
||||
//
|
||||
// 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: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassengerId,
|
||||
@@ -418,6 +434,7 @@ export class GuestBookingService {
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 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
|
||||
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})`);
|
||||
}
|
||||
|
||||
// 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
|
||||
const [outboundSchedule, returnSchedule] = await Promise.all([
|
||||
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 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: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassengerId,
|
||||
@@ -987,7 +1023,7 @@ export class GuestBookingService {
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
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');
|
||||
}
|
||||
|
||||
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) {
|
||||
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([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
@@ -1140,8 +1191,9 @@ export class GuestBookingService {
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
const contact = await this.resolveActorContact(req, passengersData[0]);
|
||||
|
||||
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
|
||||
const booking = await this.prisma.booking.create({
|
||||
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2.
|
||||
// Locked across both legs' seats and re-checked inside the lock.
|
||||
const booking = await this.seatsService.claimSeatsAndWrite(claims, async (tx) => tx.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassengerId,
|
||||
@@ -1204,7 +1256,7 @@ export class GuestBookingService {
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
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}`);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
|
||||
this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
|
||||
]);
|
||||
if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired');
|
||||
if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired');
|
||||
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired');
|
||||
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired');
|
||||
const seatsOf = (pick: (p: (typeof dto.passengers)[number]) => string | undefined) =>
|
||||
dto.passengers.map(pick).filter((id): id is string => !!id);
|
||||
|
||||
const claims: SeatClaim[] = [
|
||||
{
|
||||
holdId: dto.holdId,
|
||||
seatIds: seatsOf((p) => p.seatId),
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.transitStationId,
|
||||
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([
|
||||
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,
|
||||
});
|
||||
|
||||
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: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassengerId,
|
||||
@@ -1410,7 +1494,7 @@ export class GuestBookingService {
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Traveler profiles: guest bookings only (authenticated passengers already have one).
|
||||
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 { Currency } from '@prisma/client';
|
||||
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 {
|
||||
@IsEnum(Currency) fromCurrency: Currency;
|
||||
@@ -30,7 +31,7 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@PassengerAdmin()
|
||||
@PassengerWrite(PASSENGER_PERMS.currencies.create, PASSENGER_PERMS.currencies.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Create exchange rate' })
|
||||
create(@Body() dto: CreateRateDto) {
|
||||
@@ -38,7 +39,7 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@PassengerAdmin()
|
||||
@PassengerWrite(PASSENGER_PERMS.currencies.edit, PASSENGER_PERMS.currencies.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update exchange rate by ID' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateRateDto) {
|
||||
@@ -46,7 +47,7 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.currencies.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete exchange rate by ID' })
|
||||
delete(@Param('id') id: string) {
|
||||
|
||||
@@ -9,7 +9,27 @@ import {
|
||||
ConfirmExcessOtpDto,
|
||||
} from './excess-baggage.dto';
|
||||
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 {
|
||||
@IsString() seatClassId: string;
|
||||
@@ -27,6 +47,7 @@ export class ExcessBaggageAgentController {
|
||||
constructor(private service: ExcessBaggageService) {}
|
||||
|
||||
@Post()
|
||||
@CanCharge()
|
||||
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
|
||||
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
|
||||
dto.agentId = req.user?.id ?? req.user?.sub ?? '';
|
||||
@@ -86,6 +107,7 @@ export class ExcessBaggageAgentController {
|
||||
}
|
||||
|
||||
@Post(':id/resend')
|
||||
@CanCharge()
|
||||
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
|
||||
resendLink(@Param('id') id: string) {
|
||||
return this.service.resendLink(id);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR
|
||||
import { FleetService } from './fleet.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
||||
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';
|
||||
|
||||
@ApiTags('Fleet')
|
||||
@@ -22,7 +22,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiBody({ type: CreateCoachTypeDto })
|
||||
@ApiResponse({ status: 201, description: 'Coach type created' })
|
||||
@@ -31,7 +31,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
||||
@ApiBody({ type: UpdateCoachTypeDto })
|
||||
@@ -42,7 +42,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('coach-types/:id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.coaches.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a coach type' })
|
||||
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
||||
@@ -62,7 +62,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Post('classes')
|
||||
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.classes.create, PASSENGER_PERMS.classes.manage)
|
||||
@ApiOperation({ summary: 'Create a class' })
|
||||
@ApiBody({ type: CreateClassDto })
|
||||
@ApiResponse({ status: 201, description: 'Class created' })
|
||||
@@ -71,7 +71,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@ApiBody({ type: UpdateClassDto })
|
||||
@@ -82,7 +82,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('classes/:id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.classes.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a class' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@@ -103,7 +103,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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)' })
|
||||
@ApiBody({ type: CreateClassDto })
|
||||
@ApiResponse({ status: 201, description: 'Class created' })
|
||||
@@ -112,7 +112,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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)' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@ApiBody({ type: UpdateClassDto })
|
||||
@@ -123,7 +123,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('seat-classes/:id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.classes.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@@ -143,7 +143,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiBody({ type: CreateTrainDto })
|
||||
@ApiResponse({ status: 201, description: 'Train created' })
|
||||
@@ -152,7 +152,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||
@ApiBody({ type: CreateTrainDto })
|
||||
@@ -163,7 +163,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('trains/:id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.trains.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a train service' })
|
||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||
@@ -175,7 +175,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Train restored' })
|
||||
@@ -229,6 +229,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' })
|
||||
@ApiResponse({ status: 200, description: 'Coach utilization data' })
|
||||
@@ -279,7 +280,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiBody({ type: CreateCoachDto })
|
||||
@ApiResponse({
|
||||
@@ -305,7 +306,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiBody({ type: UpdateCoachDto })
|
||||
@@ -332,7 +333,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('coaches/:id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.coaches.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a coach' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@@ -344,7 +345,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiBody({ type: AssignCoachDto })
|
||||
@ApiResponse({ status: 201, description: 'Coach assigned' })
|
||||
@@ -354,7 +355,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Delete('assignments/:id')
|
||||
@PassengerAdmin()
|
||||
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||
@ApiParam({ name: 'id', description: 'Assignment UUID' })
|
||||
@@ -365,6 +366,7 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Post('seatmap/generate')
|
||||
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
|
||||
@ApiOperation({
|
||||
summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED',
|
||||
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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
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';
|
||||
|
||||
@ApiTags('Fraud Detection')
|
||||
@@ -41,7 +41,7 @@ export class FraudController {
|
||||
* Create or update fraud rule
|
||||
*/
|
||||
@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' })
|
||||
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
|
||||
const rule = await this.fraudService.upsertRule(body.type, body.config);
|
||||
@@ -52,7 +52,7 @@ export class FraudController {
|
||||
* Acknowledge a fraud alert
|
||||
*/
|
||||
@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' })
|
||||
async acknowledgeAlert(@Param('id') id: string) {
|
||||
const alert = await this.fraudService.acknowledgeAlert(id);
|
||||
@@ -63,7 +63,7 @@ export class FraudController {
|
||||
* Block user via userId
|
||||
*/
|
||||
@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' })
|
||||
async blockUserById(
|
||||
@Param('userId') userId: string,
|
||||
@@ -77,7 +77,7 @@ export class FraudController {
|
||||
* Block user temporarily
|
||||
*/
|
||||
@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' })
|
||||
async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) {
|
||||
await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes);
|
||||
@@ -88,7 +88,7 @@ export class FraudController {
|
||||
* Unblock user
|
||||
*/
|
||||
@Post('actions/unblock')
|
||||
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
|
||||
@ApiOperation({ summary: 'Unblock user' })
|
||||
async unblockUser(@Body() body: { iamUserId: string }) {
|
||||
await this.fraudService.unblockUser(body.iamUserId);
|
||||
|
||||
@@ -97,6 +97,7 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||
@ApiOperation({ summary: 'Test notification delivery (Admin only)' })
|
||||
async testNotification(@Body() dto: TestNotificationDto) {
|
||||
return this.service.send(
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDt
|
||||
import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options';
|
||||
import { JwtGuard } from '../../common/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';
|
||||
|
||||
@ApiTags('Packages')
|
||||
@@ -36,7 +36,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Patch('inquiries/:id/status')
|
||||
@PassengerStaff([PASSENGER_PERMS.inquiries.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.inquiries.edit, PASSENGER_PERMS.inquiries.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update inquiry status (backoffice)' })
|
||||
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
|
||||
@@ -44,7 +44,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Delete('inquiries/:id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.inquiries.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete inquiry (backoffice)' })
|
||||
deleteInquiry(@Param('id') id: string) {
|
||||
@@ -126,7 +126,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.packages.create, PASSENGER_PERMS.packages.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Create package (admin)' })
|
||||
create(@Body() dto: CreatePackageDto) {
|
||||
@@ -134,7 +134,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update package (admin)' })
|
||||
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
|
||||
@@ -142,7 +142,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.packages.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete package (admin)' })
|
||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' })
|
||||
@@ -151,7 +151,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Post(':id/image')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@@ -166,7 +166,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Delete(':id/image')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' })
|
||||
removeImage(@Param('id') id: string) {
|
||||
@@ -174,7 +174,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Activate package (admin)' })
|
||||
activate(@Param('id') id: string) {
|
||||
@@ -182,7 +182,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Patch(':id/deactivate')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Deactivate package (admin)' })
|
||||
deactivate(@Param('id') id: string) {
|
||||
@@ -190,7 +190,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Post(':id/tiers')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Add price tier to package (admin)' })
|
||||
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
|
||||
@@ -198,7 +198,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Patch('tiers/:tierId')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update price tier (admin)' })
|
||||
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
|
||||
@@ -206,7 +206,7 @@ export class PackagesController {
|
||||
}
|
||||
|
||||
@Delete('tiers/:tierId')
|
||||
@PassengerAdmin()
|
||||
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete price tier (admin)' })
|
||||
deleteTier(@Param('tierId') tierId: string) {
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
RegisterPassengerDto,
|
||||
} from "./passengers.dto";
|
||||
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 { OptionalJwtGuard } from "../verifayda/optional-jwt.guard";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
@@ -566,7 +567,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.passengers.delete)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Delete passenger (admin only)",
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
ForceConfirmDto,
|
||||
ConfirmOtpDto,
|
||||
} 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 { resolveActingUser } from "../../common/acting-user";
|
||||
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
|
||||
@@ -85,7 +85,7 @@ export class PaymentsController {
|
||||
) {}
|
||||
|
||||
@Delete(":id")
|
||||
@PassengerStaff([PASSENGER_PERMS.admin])
|
||||
@PassengerDelete(PASSENGER_PERMS.payments.delete)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Delete a payment intent record (admin only)" })
|
||||
deletePayment(@Param("id") id: string) {
|
||||
@@ -252,6 +252,7 @@ export class PaymentsController {
|
||||
|
||||
@Post("methods")
|
||||
@PassengerStaff([
|
||||
PASSENGER_PERMS.paymentMethods.create,
|
||||
PASSENGER_PERMS.paymentMethods.manage,
|
||||
PASSENGER_PERMS.payments.manageMethods,
|
||||
PASSENGER_PERMS.admin,
|
||||
@@ -266,6 +267,7 @@ export class PaymentsController {
|
||||
|
||||
@Patch("methods/:id")
|
||||
@PassengerStaff([
|
||||
PASSENGER_PERMS.paymentMethods.edit,
|
||||
PASSENGER_PERMS.paymentMethods.manage,
|
||||
PASSENGER_PERMS.payments.manageMethods,
|
||||
PASSENGER_PERMS.admin,
|
||||
@@ -394,7 +396,7 @@ export class PaymentsController {
|
||||
// ── Supplementary Charges ──────────────────────────────────────────────────
|
||||
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
|
||||
createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) {
|
||||
@@ -504,7 +506,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' })
|
||||
markSupplementaryPaid(
|
||||
@@ -515,7 +517,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@Post('supplementary/:id/waive')
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.payments.edit, PASSENGER_PERMS.payments.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Waive a supplementary charge (staff only)' })
|
||||
waiveSupplementaryCharge(
|
||||
@@ -528,7 +530,7 @@ export class PaymentsController {
|
||||
}
|
||||
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' })
|
||||
resendSupplementaryLink(@Param('id') id: string) {
|
||||
|
||||
@@ -12,26 +12,60 @@ import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReport
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
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")
|
||||
@Controller("reports")
|
||||
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
export class ReportsController {
|
||||
constructor(private service: ReportsService) {}
|
||||
|
||||
@Post("generate")
|
||||
@Report(R.catalog.view)
|
||||
@ApiOperation({ summary: "Generate operational report" })
|
||||
generateReport(@Body() dto: GenerateReportDto) {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get('schedules')
|
||||
@AnyReport()
|
||||
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
|
||||
listSchedulesForPicker(@Query('all') all?: string) {
|
||||
return this.service.listSchedulesForPicker(all === 'true');
|
||||
}
|
||||
|
||||
@Get("passengers/list")
|
||||
@Report(R.passengers.view)
|
||||
@ApiOperation({ summary: "Flat passenger list for a specific schedule" })
|
||||
getPassengerList(@Query("scheduleId") scheduleId: string) {
|
||||
return this.service.getPassengerList(scheduleId);
|
||||
@@ -39,6 +73,7 @@ export class ReportsController {
|
||||
|
||||
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
|
||||
@Get("passengers/overview")
|
||||
@Report(R.passengers.view)
|
||||
@ApiOperation({
|
||||
summary: "Fleet-wide passenger mix across a departure window",
|
||||
description:
|
||||
@@ -56,12 +91,14 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get("passengers")
|
||||
@Report(R.passengers.view)
|
||||
@ApiOperation({ summary: "Passengers report for a specific schedule" })
|
||||
getOccupancyReport(@Query("scheduleId") scheduleId: string) {
|
||||
return this.service.getOccupancyBySchedule(scheduleId);
|
||||
}
|
||||
|
||||
@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." })
|
||||
getPaymentDiscrepancy(
|
||||
@Query('from') from?: string,
|
||||
@@ -73,6 +110,7 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get("seat-status")
|
||||
@Report(R.seatStatus.view)
|
||||
@ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" })
|
||||
getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getSeatStatusReport(scheduleId);
|
||||
@@ -80,6 +118,7 @@ export class ReportsController {
|
||||
|
||||
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
|
||||
@Get("seat-status/overview")
|
||||
@Report(R.seatStatus.view)
|
||||
@ApiOperation({
|
||||
summary: "Fleet-wide seat status across a departure window",
|
||||
description:
|
||||
@@ -96,18 +135,21 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get("boarding")
|
||||
@Report(R.boarding.view)
|
||||
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
|
||||
getBoardingReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getBoardingReport(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payments")
|
||||
@Report(R.payments.view)
|
||||
@ApiOperation({ summary: "Payments collected for a schedule" })
|
||||
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getPaymentsReport(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payments/discrepancy")
|
||||
@Report(R.payments.view)
|
||||
@ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" })
|
||||
getPaymentDiscrepancyBySchedule(
|
||||
@Query('scheduleId') scheduleId: string,
|
||||
@@ -121,6 +163,7 @@ export class ReportsController {
|
||||
// ── Finance Summary ──────────────────────────────────────────────────────
|
||||
|
||||
@Get("finance")
|
||||
@Report(R.finance.view)
|
||||
@ApiOperation({
|
||||
summary: "Finance summary — revenue by period, origin/destination segment, trip type, booking type, payment method, and currency",
|
||||
description:
|
||||
@@ -151,6 +194,7 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get("finance/export")
|
||||
@Report(R.finance.export)
|
||||
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
|
||||
@ApiProduces("text/csv")
|
||||
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
|
||||
@@ -167,6 +211,7 @@ export class ReportsController {
|
||||
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
||||
|
||||
@Get("blocked-seats-revenue-loss")
|
||||
@Report(R.blockedSeats.view)
|
||||
@ApiOperation({
|
||||
summary: "Potential revenue lost to blocked seats, per schedule",
|
||||
description:
|
||||
@@ -189,6 +234,7 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get("blocked-seats-revenue-loss/export")
|
||||
@Report(R.blockedSeats.export)
|
||||
@ApiOperation({
|
||||
summary: "Blocked-seat revenue loss as CSV",
|
||||
description:
|
||||
@@ -213,12 +259,14 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get(":reportId")
|
||||
@Report(R.catalog.view)
|
||||
@ApiOperation({ summary: "Get report by ID" })
|
||||
getReport(@Param("reportId") reportId: string) {
|
||||
return this.service.getReport(reportId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Report(R.catalog.view)
|
||||
@ApiOperation({ summary: "List reports" })
|
||||
listReports(@Query("type") type?: string) {
|
||||
return this.service.listReports(type);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
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 { RescheduleService } from './reschedule.service';
|
||||
import {
|
||||
@@ -17,7 +17,7 @@ export class RescheduleController {
|
||||
constructor(private service: RescheduleService) {}
|
||||
|
||||
@Get('reschedule/policies')
|
||||
@PassengerStaff(PASSENGER_PERMS.bookings.view)
|
||||
@PassengerStaff([PASSENGER_PERMS.reschedulePolicies.view, PASSENGER_PERMS.reschedulePolicies.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' })
|
||||
listPolicies() {
|
||||
@@ -25,7 +25,7 @@ export class RescheduleController {
|
||||
}
|
||||
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' })
|
||||
listUnconfiguredCoachTypes() {
|
||||
@@ -33,7 +33,7 @@ export class RescheduleController {
|
||||
}
|
||||
|
||||
@Post('reschedule/policies')
|
||||
@PassengerAdmin()
|
||||
@PassengerWrite(PASSENGER_PERMS.reschedulePolicies.create, PASSENGER_PERMS.reschedulePolicies.manage)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' })
|
||||
createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) {
|
||||
@@ -41,7 +41,7 @@ export class RescheduleController {
|
||||
}
|
||||
|
||||
@Patch('reschedule/policies/:coachTypeId')
|
||||
@PassengerAdmin()
|
||||
@PassengerWrite(PASSENGER_PERMS.reschedulePolicies.edit, PASSENGER_PERMS.reschedulePolicies.manage)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' })
|
||||
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) {
|
||||
@@ -49,7 +49,7 @@ export class RescheduleController {
|
||||
}
|
||||
|
||||
@Delete('reschedule/policies/:coachTypeId')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.reschedulePolicies.delete)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' })
|
||||
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 { RoutesService } from './routes.service';
|
||||
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';
|
||||
|
||||
@ApiTags('Routes')
|
||||
@@ -13,7 +13,7 @@ export class RoutesController {
|
||||
// ── Routes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@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({
|
||||
summary: 'Create a reusable route with its ordered stops',
|
||||
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); }
|
||||
|
||||
@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)' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@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); }
|
||||
|
||||
@Delete(':id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.routes.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a route' })
|
||||
@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); }
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@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); }
|
||||
|
||||
@Delete(':id/stops/:sequence')
|
||||
@PassengerAdmin()
|
||||
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a stop from a route by sequence number' })
|
||||
@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); }
|
||||
|
||||
@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({
|
||||
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.',
|
||||
@@ -122,7 +122,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
}
|
||||
|
||||
@Delete(':id/coaches')
|
||||
@PassengerAdmin()
|
||||
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Clear the default coach lineup for this route' })
|
||||
@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 { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
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';
|
||||
|
||||
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')
|
||||
@Controller('schedules')
|
||||
export class SchedulesController {
|
||||
constructor(private service: SchedulesService) {}
|
||||
|
||||
@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' })
|
||||
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
|
||||
return this.service.bulkGenerateSchedules(dto);
|
||||
}
|
||||
|
||||
@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' })
|
||||
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
||||
|
||||
@@ -42,13 +56,13 @@ export class SchedulesController {
|
||||
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
|
||||
|
||||
@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' })
|
||||
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
||||
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule updated' })
|
||||
@@ -57,7 +71,7 @@ export class SchedulesController {
|
||||
}
|
||||
|
||||
@Delete('fares/:id')
|
||||
@PassengerAdmin()
|
||||
@FareDelete()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||
@@ -65,13 +79,13 @@ export class SchedulesController {
|
||||
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
|
||||
|
||||
@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' })
|
||||
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
||||
|
||||
// Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules'
|
||||
@Delete('routes/fare-rules/:id')
|
||||
@PassengerAdmin()
|
||||
@FareDelete()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a route-level fare override' })
|
||||
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
|
||||
@@ -80,7 +94,7 @@ export class SchedulesController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
|
||||
updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
|
||||
@@ -96,7 +110,7 @@ export class SchedulesController {
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
||||
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); }
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
||||
|
||||
@Delete('segment-fares/:id')
|
||||
@PassengerAdmin()
|
||||
@FareDelete()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
@@ -131,7 +145,7 @@ export class SchedulesController {
|
||||
getSchedule(@Param('id') id: string) { return this.service.getSchedule(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)' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
||||
@@ -139,15 +153,33 @@ export class SchedulesController {
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update schedule status' })
|
||||
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
|
||||
@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' })
|
||||
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);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(S.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a schedule' })
|
||||
@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'); }
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
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); }
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
|
||||
@@ -178,7 +210,7 @@ export class SchedulesController {
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
@Post(':id/delay')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
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
|
||||
@@ -194,7 +226,7 @@ records the accumulated delay on the schedule's live status. Does not change sch
|
||||
}
|
||||
|
||||
@Put(':scheduleId/fares/:seatClassId')
|
||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||
@FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
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.',
|
||||
@@ -242,13 +274,13 @@ records the accumulated delay on the schedule's live status. Does not change sch
|
||||
}
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
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); }
|
||||
|
||||
@Delete(':id/coaches/:coachId')
|
||||
@PassengerAdmin()
|
||||
@PassengerWrite(S.edit, S.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||
@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 { SeatClassesService } from './seat-classes.service';
|
||||
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';
|
||||
|
||||
@ApiTags('Seat Classes')
|
||||
@@ -26,7 +26,7 @@ export class SeatClassesController {
|
||||
getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); }
|
||||
|
||||
@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' })
|
||||
@ApiBody({ type: CreateSeatClassDto })
|
||||
@ApiResponse({ status: 201, description: 'Seat class created' })
|
||||
@@ -34,7 +34,7 @@ export class SeatClassesController {
|
||||
createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); }
|
||||
|
||||
@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' })
|
||||
@ApiParam({ name: 'id', description: 'Seat class UUID' })
|
||||
@ApiBody({ type: UpdateSeatClassDto })
|
||||
@@ -43,7 +43,7 @@ export class SeatClassesController {
|
||||
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); }
|
||||
|
||||
@Delete(':id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.tariffRates.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete a seat class' })
|
||||
@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 { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
|
||||
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";
|
||||
|
||||
@ApiTags("Seats")
|
||||
@@ -35,7 +35,7 @@ export class SeatsController {
|
||||
|
||||
// ── Blocked Seats ─────────────────────────────────────────────────────────
|
||||
@Get('blocks')
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all blocked seats with reason and coach info' })
|
||||
@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")
|
||||
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
|
||||
@PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
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 ───────────────────────────────────────────────────
|
||||
@Post(":seatId/block")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
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")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Unblock a seat" })
|
||||
@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 ───────────────────────────────────────────────────────────
|
||||
@Post(":seatId/maintenance")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
|
||||
@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")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Clear seat maintenance status" })
|
||||
@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 ────────────────────────────────────────────────────────────
|
||||
@Patch(":seatId/remove")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerDelete(PASSENGER_PERMS.seats.delete)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
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")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
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")
|
||||
@UseGuards(JwtGuard)
|
||||
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Export seats as CSV" })
|
||||
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")
|
||||
@UseGuards(JwtGuard)
|
||||
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Preview CSV import" })
|
||||
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")
|
||||
@UseGuards(JwtGuard)
|
||||
@PassengerWrite(PASSENGER_PERMS.seats.create, PASSENGER_PERMS.seats.manage)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Commit CSV import" })
|
||||
importCSV(
|
||||
@@ -367,7 +367,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
||||
// ── Duplicate seat management (backoffice) ────────────────────────────────
|
||||
|
||||
@Get("duplicates")
|
||||
@UseGuards(JwtGuard)
|
||||
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
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")
|
||||
@UseGuards(JwtGuard)
|
||||
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
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 { randomUUID } from 'crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto';
|
||||
import { ActingUser } from '../../common/acting-user';
|
||||
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 { AuditService } from '../../common/audit.service';
|
||||
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.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()
|
||||
export class SeatsService {
|
||||
@@ -206,6 +291,239 @@ export class SeatsService {
|
||||
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
|
||||
// SegmentsService.getSeatAvailabilityMap — the same canonical check search results
|
||||
// (availabilityByClass) use — so the seatmap and search results can never disagree
|
||||
@@ -217,11 +535,28 @@ export class SeatsService {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
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> {
|
||||
const { scheduleId, seatIds, originStationId, destinationStationId, journeyDirection = JourneyDirection.ONE_WAY } = args;
|
||||
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 },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
@@ -245,11 +580,19 @@ export class SeatsService {
|
||||
reqFrom,
|
||||
reqTo,
|
||||
journeyDirection,
|
||||
args.requestingPassengerId,
|
||||
db,
|
||||
);
|
||||
|
||||
if (args.alsoRejectActiveBookings) {
|
||||
await this.assertNoPendingBookingConflict(db, {
|
||||
scheduleId, seatIds, reqFrom, reqTo, legLabel: args.legLabel,
|
||||
});
|
||||
}
|
||||
|
||||
if (availability.size === 0) return;
|
||||
|
||||
const seats = await this.prisma.seat.findMany({
|
||||
const seats = await db.seat.findMany({
|
||||
where: { id: { in: seatIds } },
|
||||
select: { id: true, seatNumber: true },
|
||||
});
|
||||
@@ -413,6 +756,16 @@ export class SeatsService {
|
||||
}
|
||||
|
||||
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({
|
||||
where: { id: { in: seatIds } },
|
||||
select: { id: true, status: true, seatNumber: true },
|
||||
@@ -483,6 +836,16 @@ export class SeatsService {
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
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({
|
||||
@@ -511,7 +874,14 @@ export class SeatsService {
|
||||
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
|
||||
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;
|
||||
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
@@ -1364,12 +1734,15 @@ export class SeatsService {
|
||||
});
|
||||
const occupiedIds = new Set(journeySegments.map(js => js.seatId!));
|
||||
|
||||
// Group BookingSeat rows by seatId::leg to find candidate duplicates,
|
||||
// then filter to only those whose booking segments actually overlap.
|
||||
// Group BookingSeat rows by seatId to find candidate duplicates, then filter to
|
||||
// only those whose booking segments actually overlap.
|
||||
type BS = (typeof bookingSeats)[number];
|
||||
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) {
|
||||
const key = `${bs.seatId}::${bs.leg}`;
|
||||
const key = bs.seatId;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(bs);
|
||||
}
|
||||
@@ -1463,16 +1836,20 @@ export class SeatsService {
|
||||
}
|
||||
if (overlapping.length <= 1) continue;
|
||||
|
||||
const [seatId] = key.split('::');
|
||||
const seatId = key;
|
||||
const seat = coach.seats.find(s => s.id === seatId);
|
||||
duplicates.push({
|
||||
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,
|
||||
bookings: overlapping.map(bs => ({
|
||||
bookingSeatId: bs.id,
|
||||
bookingId: bs.booking.id,
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
leg: bs.leg,
|
||||
passengerName: bs.passengerName,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
createdAt: bs.booking.createdAt,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
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 {
|
||||
fromStationId: string;
|
||||
@@ -73,9 +81,10 @@ export class SegmentsService {
|
||||
* P3: A(1) → D(4) reqFrom=1, reqTo=4
|
||||
* 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
|
||||
* same schedule without blocking each other (see checkDirectionConflict) — omit it
|
||||
* for one-way contexts, where it defaults to ONE_WAY (conflicts with anything).
|
||||
* A round-trip's OUTBOUND and RETURN holds coexist on the same schedule because they
|
||||
* belong to the same traveller — pass requestingPassengerId so that traveller's own
|
||||
* 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:
|
||||
* 1. Active SeatHolds — leg + direction decoded from createdBy JSON
|
||||
@@ -93,7 +102,24 @@ export class SegmentsService {
|
||||
stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
|
||||
reqFrom: 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,
|
||||
/**
|
||||
* 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'>> {
|
||||
const result = new Map<string, 'HELD' | 'BOOKED'>();
|
||||
if (seatIds.length === 0) return result;
|
||||
@@ -105,11 +131,11 @@ export class SegmentsService {
|
||||
const now = new Date();
|
||||
|
||||
const [allHolds, bookedLegs] = await Promise.all([
|
||||
this.prisma.seatHold.findMany({
|
||||
db.seatHold.findMany({
|
||||
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: {
|
||||
scheduleId,
|
||||
seatId: { in: seatIds },
|
||||
@@ -123,22 +149,29 @@ export class SegmentsService {
|
||||
for (const hold of allHolds) {
|
||||
let holdFrom: number | undefined;
|
||||
let holdTo: number | undefined;
|
||||
let holdDirection = JourneyDirection.ONE_WAY;
|
||||
try {
|
||||
if (hold.createdBy) {
|
||||
const meta = JSON.parse(hold.createdBy as string);
|
||||
holdFrom = seqOf(meta.originStationId);
|
||||
holdTo = seqOf(meta.destinationStationId);
|
||||
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
||||
}
|
||||
} 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) {
|
||||
if (!seatIdSet.has(sid)) continue;
|
||||
// Conservative block if leg can't be resolved; otherwise check overlap.
|
||||
const legsOverlap = holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo);
|
||||
if (!legsOverlap) continue;
|
||||
if (!checkDirectionConflict(journeyDirection, holdDirection)) continue;
|
||||
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 { StationsService } from './stations.service';
|
||||
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';
|
||||
|
||||
@ApiTags('Stations')
|
||||
@@ -79,7 +79,7 @@ export class StationsController {
|
||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||
|
||||
@Post()
|
||||
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.stations.create, PASSENGER_PERMS.stations.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Create new station' })
|
||||
@ApiResponse({
|
||||
@@ -105,7 +105,7 @@ export class StationsController {
|
||||
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
|
||||
|
||||
@Patch(':id')
|
||||
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
|
||||
@PassengerWrite(PASSENGER_PERMS.stations.edit, PASSENGER_PERMS.stations.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update station' })
|
||||
@ApiResponse({
|
||||
@@ -134,7 +134,7 @@ export class StationsController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.stations.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Delete station' })
|
||||
@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 { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
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 { resolveActingUser } from '../../common/acting-user';
|
||||
|
||||
@@ -118,7 +118,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('scan-board/:qrCodeOrRef')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
||||
@PassengerWrite(PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Scan QR code or booking ref and automatically board ticket',
|
||||
@@ -146,7 +146,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@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')
|
||||
@ApiOperation({
|
||||
summary: 'Validate ticket at gate with audit logging',
|
||||
@@ -194,7 +194,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@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')
|
||||
@ApiOperation({
|
||||
summary: 'Batch import offline validations',
|
||||
@@ -226,7 +226,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.tickets.delete)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Delete ticket (admin only)',
|
||||
@@ -237,7 +237,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Patch(':id/restore')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
||||
@PassengerWrite(PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
|
||||
restore(@Param('id') id: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
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 { UpgradeService } from './upgrade.service';
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ export class UpgradeController {
|
||||
constructor(private service: UpgradeService) {}
|
||||
|
||||
@Get('upgrade/policies')
|
||||
@PassengerStaff(PASSENGER_PERMS.bookings.view)
|
||||
@PassengerStaff([PASSENGER_PERMS.upgradePolicies.view, PASSENGER_PERMS.upgradePolicies.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' })
|
||||
listPolicies() {
|
||||
@@ -26,7 +26,7 @@ export class UpgradeController {
|
||||
}
|
||||
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' })
|
||||
listUnconfiguredCoachTypes() {
|
||||
@@ -34,7 +34,7 @@ export class UpgradeController {
|
||||
}
|
||||
|
||||
@Post('upgrade/policies')
|
||||
@PassengerAdmin()
|
||||
@PassengerWrite(PASSENGER_PERMS.upgradePolicies.create, PASSENGER_PERMS.upgradePolicies.manage)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' })
|
||||
createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) {
|
||||
@@ -42,7 +42,7 @@ export class UpgradeController {
|
||||
}
|
||||
|
||||
@Patch('upgrade/policies/:coachTypeId')
|
||||
@PassengerAdmin()
|
||||
@PassengerWrite(PASSENGER_PERMS.upgradePolicies.edit, PASSENGER_PERMS.upgradePolicies.manage)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' })
|
||||
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) {
|
||||
@@ -50,7 +50,7 @@ export class UpgradeController {
|
||||
}
|
||||
|
||||
@Delete('upgrade/policies/:coachTypeId')
|
||||
@PassengerAdmin()
|
||||
@PassengerDelete(PASSENGER_PERMS.upgradePolicies.delete)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' })
|
||||
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('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'),
|
||||
];
|
||||
|
||||
@@ -79,74 +176,189 @@ export const PASSENGER_PERMS = {
|
||||
manage: 'edr_passenger_app:bookings:manage',
|
||||
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',
|
||||
},
|
||||
/**
|
||||
* 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: {
|
||||
view: 'edr_passenger_app:passengers:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:tickets:view',
|
||||
manage: 'edr_passenger_app:tickets:manage',
|
||||
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: {
|
||||
view: 'edr_passenger_app:payments:view',
|
||||
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
|
||||
viewAll: 'edr_passenger_app:payments:view_all',
|
||||
refund: 'edr_passenger_app:payments:refund',
|
||||
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: {
|
||||
view: 'edr_passenger_app:payment_methods:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:stations:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:trains:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:coaches:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:seats:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:classes:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:routes:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:schedules:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:packages:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:inquiries:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:tariff_rates:view',
|
||||
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: {
|
||||
/**
|
||||
* 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',
|
||||
|
||||
// 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: {
|
||||
view: 'edr_passenger_app:fraud:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:audit:view',
|
||||
@@ -154,10 +366,16 @@ export const PASSENGER_PERMS = {
|
||||
agents: {
|
||||
view: 'edr_passenger_app:agents:view',
|
||||
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: {
|
||||
view: 'edr_passenger_app:currencies:view',
|
||||
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: {
|
||||
send: 'edr_passenger_app:notifications:send',
|
||||
|
||||
Reference in New Issue
Block a user