From 7c7f5a7a7025eada52c9b5bc62da17ebbdf72e47 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 4 Sep 2026 11:09:06 +0300 Subject: [PATCH 1/5] feat: ( permissions ) add granular CRUD, cancel and per-report keys --- .../seed/passenger-permissions.registry.ts | 172 ++++++++++++++++++ .../backoffice/src/lib/permissions.ts | 82 +++++++++ 2 files changed, 254 insertions(+) diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index d30f37d94..e907ef013 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -68,6 +68,87 @@ 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'), + + // ── 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'), + perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'), ]; @@ -79,19 +160,31 @@ 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', }, 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', + 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', + 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', @@ -100,53 +193,126 @@ export const PASSENGER_PERMS = { 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 +320,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', diff --git a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts index 2c2d54f80..6c8e03dc1 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts @@ -4,65 +4,118 @@ export const PERMS = { view: 'edr_passenger_app:bookings:view', 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', }, 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', + create: 'edr_passenger_app:tickets:create', + edit: 'edr_passenger_app:tickets:edit', + delete: 'edr_passenger_app:tickets:delete', }, // ── Master Data ──────────────────────────────────────────────── 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', + }, + scheduleFares: { + view: 'edr_passenger_app:schedule_fares:view', + manage: 'edr_passenger_app:schedule_fares:manage', + create: 'edr_passenger_app:schedule_fares:create', + edit: 'edr_passenger_app:schedule_fares:edit', + delete: 'edr_passenger_app:schedule_fares:delete', }, // ── Tourism ──────────────────────────────────────────────────── 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', }, // ── Finance ──────────────────────────────────────────────────── 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', }, payments: { view: 'edr_passenger_app:payments:view', manage: 'edr_passenger_app:payments:manage', + create: 'edr_passenger_app:payments:create', + edit: 'edr_passenger_app:payments:edit', + delete: 'edr_passenger_app:payments:delete', // legacy aliases — still honoured by the backend guards viewAll: 'edr_passenger_app:payments:view_all', refund: 'edr_passenger_app:payments:refund', @@ -71,18 +124,44 @@ export const PERMS = { 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', }, 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', }, reports: { + // all-reports umbrella — kept, and kept in every per-report guard array view: 'edr_passenger_app:reports:view', + + overall: { view: 'edr_passenger_app:reports_overall:view' }, + finance: { + view: 'edr_passenger_app:reports_finance:view', + export: 'edr_passenger_app:reports_finance:export', + }, + coachUtilization: { view: 'edr_passenger_app:reports_coach_utilization:view' }, + seatStatus: { view: 'edr_passenger_app:reports_seat_status:view' }, + blockedSeats: { + view: 'edr_passenger_app:reports_blocked_seats:view', + export: 'edr_passenger_app:reports_blocked_seats:export', + }, + passengers: { view: 'edr_passenger_app:reports_passengers:view' }, + boarding: { view: 'edr_passenger_app:reports_boarding:view' }, + payments: { view: 'edr_passenger_app:reports_payments:view' }, + catalog: { view: 'edr_passenger_app:reports_catalog:view' }, }, fraud: { 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', @@ -90,6 +169,9 @@ export const 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', }, notifications: { send: 'edr_passenger_app:notifications:send', From 7a61ebcb277efea74f6aec2776183117946d168f Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 4 Sep 2026 15:44:28 +0300 Subject: [PATCH 2/5] feat: ( permissions ) enforce specific permission keys across API and backoffice --- .../src/common/passenger-guards.ts | 27 ++++++ .../src/common/passenger-permission.util.ts | 28 +++++++ .../src/modules/agents/agents.controller.ts | 7 +- .../modules/bookings/bookings.controller.ts | 15 +++- .../modules/currency/currency.controller.ts | 9 +- .../excess-baggage.controller.ts | 17 +++- .../src/modules/fleet/fleet.controller.ts | 40 ++++----- .../src/modules/fraud/fraud.controller.ts | 12 +-- .../notifications/notifications.controller.ts | 1 + .../modules/packages/packages.controller.ts | 26 +++--- .../passengers/passengers.controller.ts | 5 +- .../modules/payments/payments.controller.ts | 14 ++-- .../src/modules/reports/reports.controller.ts | 50 ++++++++++- .../reschedule/reschedule.controller.ts | 12 +-- .../modules/schedules/routes.controller.ts | 16 ++-- .../modules/schedules/schedules.controller.ts | 82 +++++++++++++------ .../seat-classes/seat-classes.controller.ts | 8 +- .../src/modules/seats/seats.controller.ts | 28 +++---- .../modules/stations/stations.controller.ts | 8 +- .../src/modules/tickets/tickets.controller.ts | 12 +-- .../src/modules/upgrade/upgrade.controller.ts | 12 +-- .../seed/passenger-permissions.registry.ts | 46 +++++++++++ .../backoffice/src/app/agents/page.tsx | 21 ++++- .../backoffice/src/app/app-releases/page.tsx | 12 ++- .../backoffice/src/app/boarding/page.tsx | 20 ++++- .../backoffice/src/app/bookings/page.tsx | 6 +- .../backoffice/src/app/classes/page.tsx | 7 ++ .../backoffice/src/app/coaches/page.tsx | 12 +++ .../backoffice/src/app/currencies/page.tsx | 10 ++- .../backoffice/src/app/discrepancy/page.tsx | 23 +++++- .../src/app/excess-baggage/page.tsx | 25 +++++- .../backoffice/src/app/fraud/page.tsx | 18 +++- .../backoffice/src/app/notifications/page.tsx | 12 ++- .../src/app/operational-reports/page.tsx | 12 ++- .../src/app/package-bookings/page.tsx | 12 ++- .../src/app/package-inquiries/page.tsx | 19 ++++- .../backoffice/src/app/packages/page.tsx | 28 +++++-- .../backoffice/src/app/passengers/page.tsx | 17 +++- .../src/app/payment-methods/page.tsx | 13 ++- .../backoffice/src/app/payments/page.tsx | 19 +++-- .../src/app/reports/blocked-seats/page.tsx | 12 ++- .../src/app/reports/boarding/page.tsx | 12 ++- .../app/reports/coach-utilization/page.tsx | 12 ++- .../src/app/reports/finance/page.tsx | 12 ++- .../src/app/reports/overall/page.tsx | 12 ++- .../src/app/reports/passengers/page.tsx | 12 ++- .../app/reports/payment-discrepancy/page.tsx | 12 ++- .../src/app/reports/payments/page.tsx | 12 ++- .../backoffice/src/app/reports/seats/page.tsx | 12 ++- .../src/app/reschedule-policies/page.tsx | 6 +- .../backoffice/src/app/routes/page.tsx | 10 +++ .../backoffice/src/app/schedules/page.tsx | 23 +++++- .../backoffice/src/app/seats/page.tsx | 72 +++++++++++----- .../backoffice/src/app/stations/page.tsx | 9 ++ .../src/app/tariff-rates/OverridesTab.tsx | 14 +++- .../app/tariff-rates/SegmentOverridesTab.tsx | 19 ++++- .../src/app/tariff-rates/TariffTab.tsx | 8 +- .../backoffice/src/app/tariff-rates/page.tsx | 25 +++++- .../backoffice/src/app/tickets/page.tsx | 36 +++++++- .../backoffice/src/app/trains/page.tsx | 11 ++- .../src/app/upgrade-policies/page.tsx | 6 +- .../src/components/layout/PermissionGuard.tsx | 42 +++++++--- .../src/components/layout/Sidebar.tsx | 35 ++++---- .../reschedule/ReschedulePolicyManager.tsx | 24 +++++- .../src/components/ui/ActionButton.tsx | 8 ++ .../upgrade/UpgradePolicyManager.tsx | 24 +++++- .../backoffice/src/lib/api-client.ts | 17 ++++ .../backoffice/src/lib/permissions.ts | 19 +++++ .../backoffice/src/lib/use-permission.ts | 35 ++++++++ 69 files changed, 1061 insertions(+), 251 deletions(-) diff --git a/apps/edr-passenger-api/src/common/passenger-guards.ts b/apps/edr-passenger-api/src/common/passenger-guards.ts index 936ee73ef..93c9e1ee7 100644 --- a/apps/edr-passenger-api/src/common/passenger-guards.ts +++ b/apps/edr-passenger-api/src/common/passenger-guards.ts @@ -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 `: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]); diff --git a/apps/edr-passenger-api/src/common/passenger-permission.util.ts b/apps/edr-passenger-api/src/common/passenger-permission.util.ts index 628d39dfe..69a2320a5 100644 --- a/apps/edr-passenger-api/src/common/passenger-permission.util.ts +++ b/apps/edr-passenger-api/src/common/passenger-permission.util.ts @@ -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(', ')}`, + ); +} diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index 21f71a8c6..5ed724df2 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -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 & { 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) { diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 36f2b7250..935741b6f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -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", diff --git a/apps/edr-passenger-api/src/modules/currency/currency.controller.ts b/apps/edr-passenger-api/src/modules/currency/currency.controller.ts index 79f8f2c3c..1487c09f7 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.controller.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.controller.ts @@ -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) { diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 0255c8a2b..4e5478093 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -9,7 +9,20 @@ 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, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]); class UpsertBaggageAllowanceDto { @IsString() seatClassId: string; @@ -27,6 +40,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 +100,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); diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index a8172e223..791268aea 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -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. diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts index 87007f16a..e92d4b98a 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts @@ -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); diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts index 99ce32b08..767605ae1 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts @@ -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( diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index 6ef20bf40..197a6b4e9 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -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) { @@ -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) { diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index e6c4d2fbc..53e338eb2 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -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)", diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 956de8e06..e5a3f5985 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -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) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 6cf12a9f6..cc6a04c91 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -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, payment method, and currency", description: @@ -138,6 +181,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" } }) @@ -154,6 +198,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: @@ -176,6 +221,7 @@ export class ReportsController { } @Get("blocked-seats-revenue-loss/export") + @Report(R.blockedSeats.export) @ApiOperation({ summary: "Blocked-seat revenue loss as CSV", description: @@ -200,12 +246,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); diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts index 0aba110d1..0d944dd3e 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts @@ -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) { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index 5e3237e93..ef4f4b48d 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -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' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 3f6228bf3..48df9e548 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -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' }) diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts index 5b75d4b3b..776e01560 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts @@ -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' }) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 87bb026d3..519042854 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -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", diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index 5ddd64383..036056531 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -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' }) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index a835a3e67..b6b96b87d 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -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) { diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts index f52abcbc4..738dc3cc9 100644 --- a/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts @@ -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) { diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index e907ef013..e32f343c7 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -128,6 +128,9 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ 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'), @@ -149,6 +152,19 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ 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'), ]; @@ -164,6 +180,25 @@ export const PASSENGER_PERMS = { 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', @@ -175,6 +210,8 @@ export const PASSENGER_PERMS = { 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', @@ -183,6 +220,8 @@ export const PASSENGER_PERMS = { 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 @@ -190,6 +229,13 @@ export const PASSENGER_PERMS = { 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', diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx index b2967c58d..02c6eb18f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -13,6 +13,9 @@ import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { formatCurrency, formatDateTime } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
@@ -27,9 +30,13 @@ const SectionHeader = ({ title }: { title: string }) => ( ); -export default function AgentsPage() { +function AgentsPageContent() { const { user } = useAuthStore(); const queryClient = useQueryClient(); + + const canCreate = useWritePermission(PERMS.agents.create, PERMS.agents.manage); + const canEdit = useWritePermission(PERMS.agents.edit, PERMS.agents.manage); + const canDelete = useDeletePermission(PERMS.agents.delete); const [filters, setFilters] = useState({ search: '', active: '' }); const [selected, setSelected] = useState(null); const [createModal, setCreateModal] = useState(false); @@ -131,6 +138,7 @@ export default function AgentsPage() { const actions = [ { label: 'Edit', + show: () => canEdit, onClick: (agent: any) => openEditModal(agent), variant: 'secondary' as const, icon: Edit, @@ -143,6 +151,7 @@ export default function AgentsPage() { }, { label: 'Delete', + show: () => canDelete, onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); }, variant: 'danger' as const, icon: Trash2, @@ -156,7 +165,7 @@ export default function AgentsPage() {

Agents

Manage agents and their operations

- Add Agent + Add Agent
@@ -394,3 +403,11 @@ export default function AgentsPage() {
); } + +export default function AgentsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx index c5d5d7fa9..0974737c1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx @@ -10,10 +10,12 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { appReleasesApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' }; -export default function AppReleasesPage() { +function AppReleasesPageContent() { const queryClient = useQueryClient(); const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -184,3 +186,11 @@ export default function AppReleasesPage() { ); } + +export default function AppReleasesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx index 75f85cc90..2aeeb421b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx @@ -8,6 +8,9 @@ import { useAuthStore } from '@/lib/auth-store'; import { formatDateTime } from '@/lib/utils'; import { useRouter } from 'next/navigation'; import Header from '@/components/layout/Header'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { useWritePermission } from '@/lib/use-permission'; +import { PERMS } from '@/lib/permissions'; // Add QR Scanner component function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) { @@ -315,7 +318,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro ); } -export default function BoardingPage() { +function BoardingPageContent() { const [qrInput, setQrInput] = useState(''); const [lastScanned, setLastScanned] = useState(null); const [error, setError] = useState(null); @@ -341,6 +344,8 @@ export default function BoardingPage() { retry: false, }); + const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage); + const boardingMutation = useMutation({ mutationFn: (qrCodeOrRef: string) => ticketsApi.scanAndBoard(qrCodeOrRef, { @@ -482,7 +487,8 @@ export default function BoardingPage() {
); -} \ No newline at end of file +} + +export default function BoardingPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index fffe3722c..188526c27 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -9,7 +9,7 @@ import Pagination from '@/components/ui/Pagination'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; -import { usePermission } from '@/lib/use-permission'; +import { useDeletePermission, usePermission } from '@/lib/use-permission'; import { PERMS } from '@/lib/permissions'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { bookingsApi, apiClient } from '@/lib/api'; @@ -30,7 +30,7 @@ const SectionHeader = ({ title }: { title: string }) => ( ); function BookingsPageContent() { - const canManage = usePermission(PERMS.bookings.manage); + const canDeleteBooking = useDeletePermission(PERMS.bookings.delete); // Mirrors the API guard on POST /payments/:bookingId/force-confirm — // tickets:generate, with the usual super-admin / org-admin bypass. const canGenerateTicket = usePermission(PERMS.tickets.generate); @@ -302,7 +302,7 @@ function BookingsPageContent() { const actions = [ { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, { label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') }, - { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, + { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2, show: () => canDeleteBooking }, ]; return ( diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index ed6f71acb..73b6f90e9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -12,6 +12,7 @@ import { seatClassesApi, apiClient } from '@/lib/api'; import { formatCurrency } from '@/lib/utils'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; function ClassesPageContent() { const [filters, setFilters] = useState({ search: '' }); @@ -21,6 +22,10 @@ function ClassesPageContent() { const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); const queryClient = useQueryClient(); + const canCreate = useWritePermission(PERMS.classes.create, PERMS.classes.manage); + const canEdit = useWritePermission(PERMS.classes.edit, PERMS.classes.manage); + const canDelete = useDeletePermission(PERMS.classes.delete); + const { data, isLoading } = useQuery({ queryKey: ['classes', filters], queryFn: () => seatClassesApi.getAll(), @@ -180,12 +185,14 @@ function ClassesPageContent() { const actions = [ { label: 'Edit', + show: () => canEdit, onClick: (cls: any) => handleOpenModal(cls), variant: 'secondary' as const, icon: Edit, }, { label: 'Delete', + show: () => canDelete, onClick: handleDelete, variant: 'danger' as const, icon: Trash2, diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 0b836f141..aee8fe68a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -12,6 +12,7 @@ import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; type Tab = 'types' | 'coaches'; @@ -155,6 +156,10 @@ function CoachesPageContent() { const queryClient = useQueryClient(); + const canCreate = useWritePermission(PERMS.coaches.create, PERMS.coaches.manage); + const canEdit = useWritePermission(PERMS.coaches.edit, PERMS.coaches.manage); + const canDelete = useDeletePermission(PERMS.coaches.delete); + // Coach Types Queries const { data: coachTypesData, isLoading: typesLoading } = useQuery({ queryKey: ['coach-types'], @@ -438,6 +443,7 @@ function CoachesPageContent() { const coachTypeActions = [ { label: 'Edit', + show: () => canEdit, onClick: (item: any) => { setEditingItem({ ...item, isCoachType: true }); setShowModal(true); @@ -447,6 +453,7 @@ function CoachesPageContent() { }, { label: 'Delete', + show: () => canDelete, onClick: (item: any) => handleDelete(item, true), variant: 'danger' as const, icon: Trash2, @@ -456,6 +463,7 @@ function CoachesPageContent() { const coachActions = [ { label: 'Edit', + show: () => canEdit, onClick: (item: any) => { setEditingItem({ ...item, isCoach: true }); setSelectedCoachTypeId(item.coachTypeId || ''); @@ -467,6 +475,7 @@ function CoachesPageContent() { }, { label: 'Delete', + show: () => canDelete, onClick: (item: any) => handleDelete(item, false), variant: 'danger' as const, icon: Trash2, @@ -489,6 +498,9 @@ function CoachesPageContent() { setSearch(''); setShowModal(true); }} + + disabled={!canCreate} + title={canCreate ? undefined : 'You do not have permission to create coaches'} > {activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'} diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index 7be021368..56b0f7d9c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -8,6 +8,8 @@ import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; +import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; interface ExchangeRate { id: string; @@ -35,6 +37,10 @@ export default function CurrenciesPage() { const [deleteConfirm, setDeleteConfirm] = useState(null); const queryClient = useQueryClient(); + const canCreate = useWritePermission(PERMS.currencies.create, PERMS.currencies.manage); + const canEdit = useWritePermission(PERMS.currencies.edit, PERMS.currencies.manage); + const canDelete = useDeletePermission(PERMS.currencies.delete); + const { data: rates = [], isLoading } = useQuery({ queryKey: ['currencies'], queryFn: () => apiClient.get('/currencies'), @@ -124,12 +130,14 @@ export default function CurrenciesPage() { const actions = [ { label: 'Edit', + show: () => canEdit, onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); }, variant: 'secondary' as const, icon: Edit, }, { label: 'Delete', + show: () => canDelete, onClick: (r: ExchangeRate) => setDeleteConfirm(r), variant: 'danger' as const, icon: Trash2, @@ -143,7 +151,7 @@ export default function CurrenciesPage() {

Exchange Rates

Manage currency exchange rates

- { setError(null); setShowAddModal(true); }}> + { setError(null); setShowAddModal(true); }} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create currency rates'}> Add Rate diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx index 9545c1d2a..d36decba9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx @@ -5,6 +5,9 @@ import { useQuery, useMutation } from '@tanstack/react-query'; import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react'; import { seatsApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +import { useWritePermission } from '@/lib/use-permission'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; // ── Types ───────────────────────────────────────────────────────────────── @@ -300,9 +303,10 @@ interface CoachCardProps { coach: CoachReport; schedule: ScheduleReport; onResolve: () => void; + canResolve?: boolean; } -function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { +function CoachCard({ coach, schedule, onResolve, canResolve = false }: CoachCardProps) { const [expanded, setExpanded] = useState(false); const hasDuplicates = coach.duplicates.length > 0; @@ -330,7 +334,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { {hasDuplicates && ( @@ -379,7 +385,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { // ── Main page ───────────────────────────────────────────────────────────── -export default function DiscrepancyPage() { +function DiscrepancyPageContent() { + // POST /seats/duplicates/resolve is guarded by seats:edit (it deletes seat rows). + const canResolveDuplicates = useWritePermission(PERMS.seats.edit, PERMS.seats.manage); const [date, setDate] = useState(today()); const [searchDate, setSearchDate] = useState(''); const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null); @@ -488,6 +496,7 @@ export default function DiscrepancyPage() { coach={coach} schedule={schedule} onResolve={() => setResolveTarget({ schedule, coach })} + canResolve={canResolveDuplicates} /> ))} @@ -517,3 +526,11 @@ export default function DiscrepancyPage() { ); } + +export default function DiscrepancyPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index 6e3c98340..5386f8f4c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -10,6 +10,9 @@ import Modal from '@/components/ui/Modal'; import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; +import { useWritePermission } from '@/lib/use-permission'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; const STATUS_VARIANT: Record = { PENDING: 'PENDING', @@ -19,8 +22,11 @@ const STATUS_VARIANT: Record = { WAIVED: 'CANCELLED', }; -export default function ExcessBaggagePage() { +function ExcessBaggagePageContent() { const queryClient = useQueryClient(); + + // Logging or resending a luggage charge bills the passenger and sends a pay link. + const canCharge = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage); const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' }); const [showExtraFilters, setShowExtraFilters] = useState(false); const user = useAuthStore((s) => s.user); @@ -173,7 +179,7 @@ export default function ExcessBaggagePage() { icon: Send, variant: 'secondary' as const, onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); }, - show: (c: any) => c.status === 'PENDING', + show: (c: any) => canCharge && c.status === 'PENDING', }, { label: 'Waive', @@ -202,7 +208,12 @@ export default function ExcessBaggagePage() {

Excess Lugagge

Track and manage excess luggage charges at boarding

- { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}> + { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }} + > Log Excess Luggage @@ -421,3 +432,11 @@ export default function ExcessBaggagePage() { ); } + +export default function ExcessBaggagePage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx b/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx index cc8ac9e7b..3d04f9a6f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx @@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import { fraudApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { useWritePermission } from '@/lib/use-permission'; const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
@@ -30,11 +33,13 @@ const SEVERITY_GRAD: Record = { LOW: 'from-blue-500 to-blue-600', }; -export default function FraudDetectionPage() { +function FraudDetectionPageContent() { const [filters, setFilters] = useState({ search: '', severity: '', status: '' }); const [selected, setSelected] = useState(null); const queryClient = useQueryClient(); + const canManageFraud = useWritePermission(PERMS.fraud.edit, PERMS.fraud.manage); + const { data, isLoading } = useQuery({ queryKey: ['fraud-alerts', filters], queryFn: () => fraudApi.getAlerts(filters), @@ -130,10 +135,11 @@ export default function FraudDetectionPage() { onClick: handleAcknowledge, variant: 'primary' as const, icon: CheckCircle, - show: (alert: any) => !alert.acknowledged, + show: (alert: any) => canManageFraud && !alert.acknowledged, }, { label: 'Block User', + show: () => canManageFraud, onClick: handleBlockUser, variant: 'danger' as const, icon: Ban, @@ -301,3 +307,11 @@ export default function FraudDetectionPage() {
); } + +export default function FraudDetectionPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx index d277eb3de..3f19a52fc 100644 --- a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx @@ -9,10 +9,12 @@ import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; import { notificationsApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP']; -export default function NotificationsPage() { +function NotificationsPageContent() { const [showModal, setShowModal] = useState(false); const [editing, setEditing] = useState(null); const [templateError, setTemplateError] = useState(null); @@ -313,3 +315,11 @@ export default function NotificationsPage() { ); } + +export default function NotificationsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx index f7a0c1887..3be651b39 100644 --- a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx @@ -9,8 +9,10 @@ import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import { reportsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; -export default function OperationalReportsPage() { +function OperationalReportsPageContent() { const [filters, setFilters] = useState({ search: '', reportType: '' }); const [selectedReport, setSelectedReport] = useState(null); const [showDetailsModal, setShowDetailsModal] = useState(false); @@ -465,3 +467,11 @@ export default function OperationalReportsPage() { ); } + +export default function OperationalReportsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx index 183611374..4925ebaa4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx @@ -11,6 +11,8 @@ import Pagination from '@/components/ui/Pagination'; import { packagesApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; import { getErrorMessage } from '@/lib/api-client'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
@@ -25,7 +27,7 @@ const SectionHeader = ({ title }: { title: string }) => ( ); -export default function PackageBookingsPage() { +function PackageBookingsPageContent() { const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 }); const [selected, setSelected] = useState(null); @@ -264,3 +266,11 @@ export default function PackageBookingsPage() {
); } + +export default function PackageBookingsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx b/apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx index 73f0ad658..39855185d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx @@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { packageInquiriesApi, packagesApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED']; @@ -19,12 +22,15 @@ const statusVariant: Record = { CLOSED: 'default', }; -export default function PackageInquiriesPage() { +function PackageInquiriesPageContent() { const [filters, setFilters] = useState({ packageId: '', status: '' }); const [deleteConfirm, setDeleteConfirm] = useState(null); const [deleteError, setDeleteError] = useState(null); const queryClient = useQueryClient(); + const canEdit = useWritePermission(PERMS.inquiries.edit, PERMS.inquiries.manage); + const canDelete = useDeletePermission(PERMS.inquiries.delete); + const { data, isLoading } = useQuery({ queryKey: ['package-inquiries', filters], queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }), @@ -112,6 +118,8 @@ export default function PackageInquiriesPage() { - setForm({ isOpen: true, rule: null, error: null })} disabled={!selectedRouteId}> + setForm({ isOpen: true, rule: null, error: null })} + disabled={!selectedRouteId || !canCreateFare} + title={canCreateFare ? undefined : 'You do not have permission to create fare overrides'} + > Add Segment Override @@ -176,8 +189,8 @@ export default function SegmentOverridesTab({ routes }: Props) { data={segmentFares} columns={columns} actions={[ - { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }) }, - { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)} → ${stopLabel(r.destinationStopSequence)}` }) }, + { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }), show: () => canEditFare }, + { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)} → ${stopLabel(r.destinationStopSequence)}` }), show: () => canDeleteFare }, ]} loading={isLoading} emptyMessage="No segment overrides for this route." diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx index 63c9b5d38..d0b16c2d9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx @@ -14,12 +14,14 @@ interface Props { isLoading: boolean; onEdit: (cls: SeatClass) => void; onDelete: (id: string, cascade: boolean) => void; + canEdit?: boolean; + canDelete?: boolean; isDeleting: boolean; deleteError?: string; deleteSuccess?: number; } -export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess }: Props) { +export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess, canEdit = false, canDelete = false }: Props) { const [search, setSearch] = useState(''); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; @@ -141,8 +143,8 @@ export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDe data={displayed} columns={columns} actions={[ - { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit }, - { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick }, + { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit, show: () => canEdit }, + { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick, show: () => canDelete }, ]} loading={isLoading} emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'} diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index 7239809f0..baf43e098 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -10,8 +10,11 @@ import BaggageTab from './BaggageTab'; import RateModal from './RateModal'; import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks'; import type { SeatClass, TabType } from './types'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; -export default function TariffRatesPage() { +function TariffRatesPageContent() { const [tab, setTab] = useState('tariff'); const [showRateModal, setShowRateModal] = useState(false); const [editingClass, setEditingClass] = useState(null); @@ -20,6 +23,10 @@ export default function TariffRatesPage() { const [deleteError, setDeleteError] = useState(undefined); const [deleteSuccess, setDeleteSuccess] = useState(0); + const canCreateRate = useWritePermission(PERMS.tariffRates.create, PERMS.tariffRates.manage); + const canEditRate = useWritePermission(PERMS.tariffRates.edit, PERMS.tariffRates.manage); + const canDeleteRate = useDeletePermission(PERMS.tariffRates.delete); + const { allClasses, isLoading } = useSeatClasses(); const { coachTypes } = useCoachTypes(); const { routes } = useRoutes(); @@ -71,7 +78,11 @@ export default function TariffRatesPage() {

{tab !== 'overrides' && tab !== 'segment-overrides' && ( - { + { if (tab === 'baggage') { setShowBaggageModal(true); } else { @@ -105,6 +116,8 @@ export default function TariffRatesPage() { isLoading={isLoading} onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }} onDelete={handleDelete} + canEdit={canEditRate} + canDelete={canDeleteRate} isDeleting={seatClassMutations.remove.isPending} deleteError={deleteError} deleteSuccess={deleteSuccess} @@ -143,3 +156,11 @@ export default function TariffRatesPage() { ); } + +export default function TariffRatesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 74f1ec9b8..ed2ad79cb 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -14,8 +14,11 @@ import { ticketsApi, apiClient, stationsApi, excessBaggageApi, bookingsApi } fro import Pagination from '@/components/ui/Pagination'; import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { usePermission, useWritePermission, useDeletePermission } from '@/lib/use-permission'; -export default function TicketsPage() { +function TicketsPageContent() { const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' }); const [ticketPage, setTicketPage] = useState(1); const resetTicketPage = () => setTicketPage(1); @@ -69,6 +72,13 @@ export default function TicketsPage() { }); const queryClient = useQueryClient(); + const canGenerate = usePermission(PERMS.tickets.generate); + const canEditTicket = useWritePermission(PERMS.tickets.edit, PERMS.tickets.manage); + const canDelete = useDeletePermission(PERMS.tickets.delete); + // Logging luggage bills the passenger and sends a pay link, so it is its own grant. + const canLogBaggage = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage); + const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage); + const { data, isLoading, error } = useQuery({ queryKey: ['tickets', filters, ticketPage], queryFn: () => ticketsApi.getAll({ @@ -554,7 +564,7 @@ export default function TicketsPage() { onClick: openExcessModal, variant: 'secondary' as const, icon: Package, - show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status), + show: (ticket: any) => canLogBaggage && !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status), }, { label: 'Board', @@ -562,6 +572,7 @@ export default function TicketsPage() { variant: 'primary' as const, icon: LogIn, show: (ticket: any) => { + if (!canBoard) return false; const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT'; if (isRoundTrip) { const inboundBoarded = !!ticket.booking?.returnBoardedAt; @@ -595,10 +606,11 @@ export default function TicketsPage() { onClick: (ticket: any) => restoreMutation.mutate(ticket.id), variant: 'secondary' as const, icon: ListCollapse, - show: (ticket: any) => ticket.status === 'CANCELLED', + show: (ticket: any) => canEditTicket && ticket.status === 'CANCELLED', }, { label: 'Delete', + show: () => canDelete, onClick: handleDeleteClick, variant: 'danger' as const, icon: Trash2, @@ -621,6 +633,8 @@ export default function TicketsPage() { variant="secondary" loading={generateMissingMutation.isPending} onClick={() => generateMissingMutation.mutate()} + disabled={!canGenerate} + title={canGenerate ? undefined : 'You do not have permission to generate tickets'} > Generate Missing @@ -760,7 +774,13 @@ export default function TicketsPage() {
{ setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel - Board and Print + Board and Print
@@ -1103,3 +1123,11 @@ export default function TicketsPage() { ); } + +export default function TicketsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index e1be87ff3..7d228e270 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -15,6 +15,7 @@ import { Train as TrainType } from '@/types'; import { formatDate } from '@/lib/utils'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; function TrainsPageContent() { const [showModal, setShowModal] = useState(false); @@ -24,6 +25,10 @@ function TrainsPageContent() { const queryClient = useQueryClient(); + const canCreate = useWritePermission(PERMS.trains.create, PERMS.trains.manage); + const canEdit = useWritePermission(PERMS.trains.edit, PERMS.trains.manage); + const canDelete = useDeletePermission(PERMS.trains.delete); + const { data: trainsData, isLoading: trainsLoading } = useQuery({ queryKey: ['trains'], queryFn: () => fleetApi.getTrains(), @@ -174,6 +179,7 @@ function TrainsPageContent() { const actions = [ { label: 'Edit', + show: () => canEdit, onClick: (train: TrainType) => { setEditingTrain(train); setShowModal(true); @@ -186,10 +192,11 @@ function TrainsPageContent() { onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id), variant: 'secondary' as const, icon: RotateCcw, - show: (train: TrainType) => !train.isActive, + show: (train: TrainType) => canEdit && !train.isActive, }, { label: 'Delete', + show: () => canDelete, onClick: handleDelete, variant: 'danger' as const, icon: Trash2, @@ -209,6 +216,8 @@ function TrainsPageContent() { setShowModal(true); }} icon={Plus} + disabled={!canCreate} + title={canCreate ? undefined : 'You do not have permission to create trains'} > Add Train
diff --git a/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx index 5be9108b9..fefdc9105 100644 --- a/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx @@ -6,12 +6,12 @@ import { PERMS } from '@/lib/permissions'; /** * Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy - * can be neither upgraded from nor to. Gated on bookings:view because that is what - * `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side. + * can be neither upgraded from nor to. Gated on upgrade_policies:view (or :manage) — bookings:view no longer + * grants it, so the page needs its own grant. Create/edit/delete each have their own key. */ export default function UpgradePoliciesPage() { return ( - +

Upgrade Policies

diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx index 2da55e37f..0a1668bf8 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx @@ -2,17 +2,28 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import { ShieldOff } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; interface Props { - permission?: string; + /** + * A single key, or several of which the user needs **any one**. The any-of form + * is how a report page accepts either its own key or the `reports:view` + * umbrella — mirroring the OR semantics of the API's `PassengerPermissionGuard`. + */ + permission?: string | string[]; children: React.ReactNode; } /** * Wraps a page to enforce auth + optional permission check. * - Not logged in → redirect to /login - * - Missing permission → redirect to /dashboard + * - Missing permission → render an explanation (see below) + * + * This used to redirect a user without the permission to /dashboard. That is a + * dead end for anyone lacking `dashboard:view`, since that page renders nothing + * either — they got a blank screen with no explanation. Saying what happened is + * both kinder and easier to support. */ export function PermissionGuard({ permission, children }: Props) { const router = useRouter(); @@ -20,17 +31,26 @@ export function PermissionGuard({ permission, children }: Props) { const hasPermission = useAuthStore((s) => s.hasPermission); useEffect(() => { - if (!isAuthenticated) { - router.replace('/login'); - return; - } - if (permission && !hasPermission(permission)) { - router.replace('/dashboard'); - } - }, [isAuthenticated, permission, hasPermission, router]); + if (!isAuthenticated) router.replace('/login'); + }, [isAuthenticated, router]); if (!isAuthenticated) return null; - if (permission && !hasPermission(permission)) return null; + + const keys = permission === undefined ? [] : Array.isArray(permission) ? permission : [permission]; + const allowed = keys.length === 0 || keys.some((key) => hasPermission(key)); + + if (!allowed) { + return ( +
+ +

You don't have access to this page

+

+ Your account is missing the permission this page requires. Ask an administrator to grant + it if you need access. +

+
+ ); + } return <>{children}; } diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 58f20b17c..26715f077 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -52,7 +52,8 @@ interface NavItem { name: string; href: string; icon: React.ComponentType<{ className?: string }>; - permission?: string; + /** A single key, or several of which the user needs any one. */ + permission?: string | string[]; } const navigationSections: { title: string; items: NavItem[] }[] = [ @@ -92,8 +93,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, - { name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view }, - { name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: PERMS.bookings.view }, + { name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: [PERMS.reschedulePolicies.view, PERMS.reschedulePolicies.manage] }, + { name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: [PERMS.upgradePolicies.view, PERMS.upgradePolicies.manage] }, ] }, { @@ -128,16 +129,16 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Analytics & Reports', items: [ - { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view }, - { name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view }, - { name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view }, - { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, - { name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view }, - { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, - { name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view }, - { name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view }, - // { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view }, - // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, + { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: [PERMS.reports.overall.view, PERMS.reports.view] }, + { name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: [PERMS.reports.finance.view, PERMS.reports.view] }, + { name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: [PERMS.reports.coachUtilization.view, PERMS.reports.view] }, + { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: [PERMS.reports.seatStatus.view, PERMS.reports.view] }, + { name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: [PERMS.reports.blockedSeats.view, PERMS.reports.view] }, + { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: [PERMS.reports.passengers.view, PERMS.reports.view] }, + { name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: [PERMS.reports.boarding.view, PERMS.reports.view] }, + { name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: [PERMS.reports.payments.view, PERMS.reports.view] }, + // { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: [PERMS.reports.payments.view, PERMS.reports.view] }, + // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: [PERMS.reports.catalog.view, PERMS.reports.view] }, ] }, { @@ -217,9 +218,11 @@ export default function Sidebar() { {/* Navigation */}
diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/ActionButton.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/ActionButton.tsx index 1b70303ec..e0f4acfc6 100644 --- a/apps/edr-passenger-web/backoffice/src/components/ui/ActionButton.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/ui/ActionButton.tsx @@ -14,6 +14,12 @@ interface ActionButtonProps { loading?: boolean; className?: string; type?: 'button' | 'submit' | 'reset'; + /** + * Native tooltip. CLAUDE.md asks for a disabled control with a visible reason + * over a silently hidden one, so permission-gated buttons pass the reason here + * alongside `disabled`. + */ + title?: string; } const variants = { @@ -40,6 +46,7 @@ export default function ActionButton({ loading = false, className, type = 'button', + title, }: ActionButtonProps) { const [isLoading, setIsLoading] = useState(false); @@ -63,6 +70,7 @@ export default function ActionButton({ type={type} onClick={handleClick} disabled={isDisabled} + title={title} className={cn( 'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200', 'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[rgb(20,113,76)]', diff --git a/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx b/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx index 49e66f607..c6f30d82e 100644 --- a/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx @@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; +import { PERMS } from '@/lib/permissions'; import { upgradePolicyApi, type UpgradePolicyCoachType, @@ -38,6 +40,10 @@ const feeLabel = (p: UpgradePolicyRow) => * a dialog, the same shape as Reschedule Policies and Coach Management. */ export default function UpgradePolicyManager() { + const canCreate = useWritePermission(PERMS.upgradePolicies.create, PERMS.upgradePolicies.manage); + const canEdit = useWritePermission(PERMS.upgradePolicies.edit, PERMS.upgradePolicies.manage); + const canDelete = useDeletePermission(PERMS.upgradePolicies.delete); + const [rows, setRows] = useState([]); const [available, setAvailable] = useState([]); const [loading, setLoading] = useState(true); @@ -186,9 +192,10 @@ export default function UpgradePolicyManager() { ]; const actions = [ - { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, + { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit }, { label: 'Delete', + show: () => canDelete, onClick: (row: UpgradePolicyRow) => setDeleting(row), variant: 'danger' as const, icon: Trash2, @@ -204,7 +211,12 @@ export default function UpgradePolicyManager() { to and charged per upgraded passenger. A fare class with no policy here can be neither upgraded from nor to.

- + Add Upgrade Policy
@@ -340,7 +352,13 @@ export default function UpgradePolicyManager() { setShowModal(false)}> Cancel - + {editing ? 'Update Policy' : 'Create Policy'} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api-client.ts b/apps/edr-passenger-web/backoffice/src/lib/api-client.ts index 54d339583..29cb9efe8 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api-client.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api-client.ts @@ -4,6 +4,8 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.'; const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.'; +const FORBIDDEN_MESSAGE = + 'You do not have permission to do that. Ask an administrator if you need access.'; /** * Extracts a user-facing message from a failed request. Prefers a real backend-provided message @@ -55,6 +57,21 @@ class ApiClient { } } + // A 403 from this API is always a permission check, and the server's own text + // names raw permission keys ("Missing permission. Required one of: …") which + // means nothing to a user. Replace it with something actionable, but only when + // the server did not send a more specific message of its own. + if (error.response?.status === 403) { + const body = error.response.data; + const serverMessage = typeof body?.message === 'string' ? body.message : ''; + if (!serverMessage || serverMessage.startsWith('Missing permission')) { + const friendly = FORBIDDEN_MESSAGE; + if (body && typeof body === 'object') body.message = friendly; + error.message = friendly; + return Promise.reject(error); + } + } + // Normalize in place so every existing `err?.response?.data?.message || err?.message || // ''` call site across the app picks up a friendly message automatically, // instead of raw axios/network text or an unjoined NestJS validation array. diff --git a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts index 6c8e03dc1..4983d1dff 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts @@ -9,6 +9,20 @@ export const PERMS = { edit: 'edr_passenger_app:bookings:edit', delete: 'edr_passenger_app:bookings:delete', }, + reschedulePolicies: { + view: 'edr_passenger_app:reschedule_policies:view', + manage: 'edr_passenger_app:reschedule_policies:manage', + create: 'edr_passenger_app:reschedule_policies:create', + edit: 'edr_passenger_app:reschedule_policies:edit', + delete: 'edr_passenger_app:reschedule_policies:delete', + }, + upgradePolicies: { + view: 'edr_passenger_app:upgrade_policies:view', + manage: 'edr_passenger_app:upgrade_policies:manage', + create: 'edr_passenger_app:upgrade_policies:create', + edit: 'edr_passenger_app:upgrade_policies:edit', + delete: 'edr_passenger_app:upgrade_policies:delete', + }, passengers: { view: 'edr_passenger_app:passengers:view', manage: 'edr_passenger_app:passengers:manage', @@ -20,6 +34,7 @@ export const PERMS = { view: 'edr_passenger_app:tickets:view', manage: 'edr_passenger_app:tickets:manage', generate: 'edr_passenger_app:tickets:generate', + board: 'edr_passenger_app:tickets:board', create: 'edr_passenger_app:tickets:create', edit: 'edr_passenger_app:tickets:edit', delete: 'edr_passenger_app:tickets:delete', @@ -114,6 +129,7 @@ export const PERMS = { view: 'edr_passenger_app:payments:view', manage: 'edr_passenger_app:payments:manage', create: 'edr_passenger_app:payments:create', + supplementary: 'edr_passenger_app:payments:supplementary', edit: 'edr_passenger_app:payments:edit', delete: 'edr_passenger_app:payments:delete', // legacy aliases — still honoured by the backend guards @@ -121,6 +137,9 @@ export const PERMS = { refund: 'edr_passenger_app:payments:refund', manageMethods: 'edr_passenger_app:payments:manage_methods', }, + excessBaggage: { + charge: 'edr_passenger_app:excess_baggage:charge', + }, paymentMethods: { view: 'edr_passenger_app:payment_methods:view', manage: 'edr_passenger_app:payment_methods:manage', diff --git a/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts index d064c78f2..6342d27a4 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts @@ -1,6 +1,7 @@ 'use client'; import { useAuthStore } from './auth-store'; +import { PERMS } from './permissions'; /** * Returns whether the current user has a given permission key. @@ -14,6 +15,20 @@ export function usePermission(key: string): boolean { return useAuthStore((s) => s.hasPermission(key)); } +/** + * True when the user holds **any one** of the keys — the same OR semantics as the + * API's `PassengerPermissionGuard`. + * + * Use it wherever a route accepts a narrow key or a broader one, so the UI agrees + * with the API instead of hiding a control the server would have allowed: + * + * const canCreate = useAnyPermission([PERMS.schedules.create, PERMS.schedules.manage]); + * const canSeeFinance = useAnyPermission([PERMS.reports.finance.view, PERMS.reports.view]); + */ +export function useAnyPermission(keys: string[]): boolean { + return useAuthStore((s) => keys.some((key) => s.hasPermission(key))); +} + /** * Same as usePermission but WITHOUT the super-admin / org-admin bypass — the * permission must be explicitly granted. Use it wherever the API endpoint is @@ -25,3 +40,23 @@ export function usePermission(key: string): boolean { export function usePermissionStrict(key: string): boolean { return useAuthStore((s) => s.hasPermissionStrict(key)); } + +/** + * The UI mirror of the API's `@PassengerWrite(narrow, umbrella)`: the narrow key, + * the resource's `:manage` umbrella, or `admin`. Use it for create / edit / domain + * actions so a control is shown exactly when the server would accept the call. + * + * const canCreate = useWritePermission(PERMS.schedules.create, PERMS.schedules.manage); + */ +export function useWritePermission(narrow: string, umbrella: string): boolean { + return useAnyPermission([narrow, umbrella, PERMS.admin]); +} + +/** + * The UI mirror of the API's `@PassengerDelete(narrow)`: the narrow `:delete` key + * or `admin`. **`:manage` deliberately does not count** — holding `schedules:manage` + * does not let you delete a schedule, so the button must not appear either. + */ +export function useDeletePermission(narrow: string): boolean { + return useAnyPermission([narrow, PERMS.admin]); +} From b899037151297af8356d80e9fe0fb9d4ec2fbf76 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 4 Sep 2026 16:25:46 +0300 Subject: [PATCH 3/5] fix: ( permissions ) match page guards to their API guards so umbrella keys pass --- apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx | 2 +- .../edr-passenger-web/backoffice/src/app/group-booking/page.tsx | 2 +- .../edr-passenger-web/backoffice/src/app/notifications/page.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx index 2aeeb421b..19b29550f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx @@ -615,7 +615,7 @@ function BoardingPageContent() { export default function BoardingPage() { return ( - + ); diff --git a/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx b/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx index 3f888771b..4dc173a22 100644 --- a/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx @@ -1467,7 +1467,7 @@ function GroupBookingPageContent() { export default function GroupBookingPage() { return ( - + ); diff --git a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx index 3f19a52fc..26909d607 100644 --- a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx @@ -318,7 +318,7 @@ function NotificationsPageContent() { export default function NotificationsPage() { return ( - + ); From 70b1f751589fb97d457b418348315dddcbcc5102 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sat, 5 Sep 2026 09:23:22 +0300 Subject: [PATCH 4/5] fix: ( permissions) accept payments:manage for luggage charges --- .../modules/excess-baggage/excess-baggage.controller.ts | 9 ++++++++- .../backoffice/src/app/excess-baggage/page.tsx | 4 ++-- .../backoffice/src/app/tickets/page.tsx | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 4e5478093..f5866c5bd 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -22,7 +22,14 @@ import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; * included — could raise a charge. */ const CanCharge = () => - PassengerStaff([PASSENGER_PERMS.excessBaggage.charge, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]); + 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; diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index 5386f8f4c..38763da7c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -10,7 +10,7 @@ import Modal from '@/components/ui/Modal'; import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; -import { useWritePermission } from '@/lib/use-permission'; +import { useAnyPermission, useWritePermission } from '@/lib/use-permission'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; @@ -26,7 +26,7 @@ function ExcessBaggagePageContent() { const queryClient = useQueryClient(); // Logging or resending a luggage charge bills the passenger and sends a pay link. - const canCharge = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage); + const canCharge = useAnyPermission([PERMS.excessBaggage.charge, PERMS.payments.manage, PERMS.bookings.manage, PERMS.admin]); const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' }); const [showExtraFilters, setShowExtraFilters] = useState(false); const user = useAuthStore((s) => s.user); diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index ed2ad79cb..df23f9ee1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -16,7 +16,7 @@ import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils import { useAuthStore } from '@/lib/auth-store'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; -import { usePermission, useWritePermission, useDeletePermission } from '@/lib/use-permission'; +import { useAnyPermission, useDeletePermission, usePermission, useWritePermission } from '@/lib/use-permission'; function TicketsPageContent() { const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' }); @@ -76,7 +76,7 @@ function TicketsPageContent() { const canEditTicket = useWritePermission(PERMS.tickets.edit, PERMS.tickets.manage); const canDelete = useDeletePermission(PERMS.tickets.delete); // Logging luggage bills the passenger and sends a pay link, so it is its own grant. - const canLogBaggage = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage); + const canLogBaggage = useAnyPermission([PERMS.excessBaggage.charge, PERMS.payments.manage, PERMS.bookings.manage, PERMS.admin]); const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage); const { data, isLoading, error } = useQuery({ From 888bf200726406abecdede12450966b3a56c7f75 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 8 Sep 2026 10:05:47 +0300 Subject: [PATCH 5/5] fix: ( bookings ) prevent double booking by claiming seats under a row lock --- apps/edr-passenger-api/package.json | 4 + .../scripts/repro-double-booking.cjs | 456 ++++++++++++++++++ .../scripts/seed-legacy-role-users.cjs | 236 +++++++++ .../scripts/stress-booking-concurrency.cjs | 359 ++++++++++++++ .../modules/bookings/booking-identity.util.ts | 2 +- .../src/modules/bookings/bookings.service.ts | 247 +++++----- .../modules/bookings/guest-booking.service.ts | 166 +++++-- .../seats.service.double-booking.spec.ts | 217 +++++++++ .../src/modules/seats/seats.service.ts | 393 ++++++++++++++- .../src/modules/segments/segments.service.ts | 53 +- 10 files changed, 1946 insertions(+), 187 deletions(-) create mode 100644 apps/edr-passenger-api/scripts/repro-double-booking.cjs create mode 100644 apps/edr-passenger-api/scripts/seed-legacy-role-users.cjs create mode 100644 apps/edr-passenger-api/scripts/stress-booking-concurrency.cjs create mode 100644 apps/edr-passenger-api/src/modules/seats/seats.service.double-booking.spec.ts diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index c048ef1e9..d05d44c3f 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -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", diff --git a/apps/edr-passenger-api/scripts/repro-double-booking.cjs b/apps/edr-passenger-api/scripts/repro-double-booking.cjs new file mode 100644 index 000000000..d4964045d --- /dev/null +++ b/apps/edr-passenger-api/scripts/repro-double-booking.cjs @@ -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 --origin --destination [--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 --origin --destination '); + 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 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); +}); diff --git a/apps/edr-passenger-api/scripts/seed-legacy-role-users.cjs b/apps/edr-passenger-api/scripts/seed-legacy-role-users.cjs new file mode 100644 index 000000000..f174a2f81 --- /dev/null +++ b/apps/edr-passenger-api/scripts/seed-legacy-role-users.cjs @@ -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); +}); diff --git a/apps/edr-passenger-api/scripts/stress-booking-concurrency.cjs b/apps/edr-passenger-api/scripts/stress-booking-concurrency.cjs new file mode 100644 index 000000000..28e08b595 --- /dev/null +++ b/apps/edr-passenger-api/scripts/stress-booking-concurrency.cjs @@ -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 --origin --destination + * [--mode ladder|edge|all] [--levels 2,5,10,25,50,100] + * [--return-schedule --return-origin --return-destination ] + * + * 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); }); diff --git a/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.ts b/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.ts index cd5385765..236d019a6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.ts +++ b/apps/edr-passenger-api/src/modules/bookings/booking-identity.util.ts @@ -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, diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 004aca024..12ea1f9d8 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -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)), diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index cf5d92b82..b33c2d8fe 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -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); diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.double-booking.spec.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.double-booking.spec.ts new file mode 100644 index 000000000..3d03e8dd4 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.double-booking.spec.ts @@ -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); + 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); + }); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index e54c085a1..c0c4420b1 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -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 { + 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 { + 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( + seatIds: string[], + work: (tx: Prisma.TransactionClient) => Promise, + ): Promise { + // 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( + claims: SeatClaim[], + write: (tx: Prisma.TransactionClient) => Promise, + ): Promise { + 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 { + 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 { + 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(); + 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 { 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(); + // 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, diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts index aaeb51e03..953fd705d 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts @@ -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> { const result = new Map(); 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'); } }