diff --git a/.gitignore b/.gitignore index 24b935a3d..b9dc16c3c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,14 @@ e2e/**/cypress/downloads/ # e2e launcher state (ports of the running stack) e2e/freight/.e2e-ports.json + +# local run scripts (contain personal DB credentials — never commit) +run-passenger-local.sh +run-passenger-web.sh + +# generated test output +e2e-ui-report/ +test-results/ +playwright-report/ +blob-report/ +RUNNING_LOCALLY.md diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3ca5f6cb1..26afdc2d0 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,7 +12,7 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; -import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; +import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import appConfig from "./config/app.config"; @@ -29,6 +29,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module"; // import { TrainsModule } from "./modules/trains/trains.module"; import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; +import { TruckTypesModule } from "./modules/truck-types/truck-types.module"; import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; @@ -77,8 +78,8 @@ import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-l import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { VerifaydaModule } from './modules/verifayda/verifayda.module'; -import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module'; +import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; +import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; import { CargoesModule } from "./modules/cargoes/cargoes.module"; @@ -104,7 +105,13 @@ import { LoggerMiddleware } from "./logger.middleware"; imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig], + load: [ + appConfig, + databaseConfig, + telebirrConfig, + rabbitmqConfig, + faydaConfig, + ], }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), @@ -152,6 +159,7 @@ import { LoggerMiddleware } from "./logger.middleware"; FilesModule, ConsignmentsModule, LocomotivesModule, + TruckTypesModule, WagonTypesModule, TrainSetsModule, TrainSchedulesModule, @@ -223,7 +231,7 @@ import { LoggerMiddleware } from "./logger.middleware"; }) export class AppModule implements OnApplicationBootstrap { constructor( - private readonly seeder: DataSeeder, + // private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, @@ -258,7 +266,7 @@ export class AppModule implements OnApplicationBootstrap { // freightPositionsSeeder → seeds Position + PositionPermission rows // (depends on edrOrgSeeder, must run after) await this.freightPermissionKeyMigrationSeeder.run(); - await this.seeder.run(); + // await this.seeder.run(); await this.edrOrgSeeder.run(); await this.freightPositionsSeeder.run(); diff --git a/apps/edr-freight-api/src/common/mile-financials.util.ts b/apps/edr-freight-api/src/common/mile-financials.util.ts index 2f5288048..f22e86925 100644 --- a/apps/edr-freight-api/src/common/mile-financials.util.ts +++ b/apps/edr-freight-api/src/common/mile-financials.util.ts @@ -8,6 +8,8 @@ type MileRecord = { bookingContainers?: Array<{ units?: Array<{ vgmTons?: number | string | null }> | null; }> | null; + /** Attached here: the train schedule the booking rides, for mile alignment. */ + trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null; } | null; }; @@ -36,6 +38,38 @@ export async function attachMileFinancials( if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3)); } + // Train alignment: which schedule each booking rides (mile pickups/deliveries + // are planned against the train's departure). + const bookingIds = [...new Set(records.map((r) => r.bookingId).filter(Boolean))] as string[]; + if (bookingIds.length) { + const schedules: Array<{ + bookingId: string; + trainNumber: string | null; + departureDate: string | null; + }> = await dataSource.query( + `SELECT DISTINCT ON (tsb.booking_id) + tsb.booking_id AS "bookingId", + ts.train_number AS "trainNumber", + COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)::text AS "departureDate" + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts + ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL + WHERE tsb.booking_id = ANY($1::uuid[]) AND tsb.deleted_at IS NULL + ORDER BY tsb.booking_id, tsb.created_at DESC`, + [bookingIds], + ); + const byBookingSchedule = new Map(schedules.map((s) => [s.bookingId, s])); + for (const r of records) { + const s = r.bookingId ? byBookingSchedule.get(r.bookingId) : undefined; + if (r.booking && s) { + r.booking.trainSchedule = { + trainNumber: s.trainNumber, + departureDate: s.departureDate, + }; + } + } + } + const needAdvance = records.filter( (r) => r.bookingId && !(Number(r.advancedPayment) > 0), ); diff --git a/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts new file mode 100644 index 000000000..fadacda69 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the + * vehicle link is optional and only for acquisitions that ARE a fleet vehicle. + */ +export class AddAcquisitionItemName2470000000000 implements MigrationInterface { + name = 'AddAcquisitionItemName2470000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + ADD COLUMN IF NOT EXISTS item_name varchar(200) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + DROP COLUMN IF EXISTS item_name + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts b/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts new file mode 100644 index 000000000..61431c5bc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Dedup stamp for the km/date-due maintenance alert — without it the daily + * cron would re-notify every day a schedule stays due. + */ +export class AddMaintenanceDueNotifiedAt2480000000000 implements MigrationInterface { + name = 'AddMaintenanceDueNotifiedAt2480000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.maintenance_schedules + ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts new file mode 100644 index 000000000..f4f125eaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * KM-based maintenance scheduling: per-vehicle service intervals (by km + * and/or days) driving the maintenance due engine. Raw schema-qualified SQL — + * the builder API resolved bare table names against the default schema and + * failed on boot ("Table maintenance_intervals does not exist"). + */ +export class AddMaintenanceIntervals2800000000000 implements MigrationInterface { + name = 'AddMaintenanceIntervals2800000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.maintenance_intervals ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id) ON DELETE CASCADE, + maintenance_type varchar NOT NULL, + interval_km numeric(14,2), + interval_days integer, + description text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.maintenance_intervals;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts b/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts new file mode 100644 index 000000000..679846484 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Persist the signer's saved-signature image on the handover record, so the + * signed handover document can render the actual signature (not just the + * typed name) — parity with the booking-contract signing flow. + */ +export class AddSignatureToHandover2800000000001 implements MigrationInterface { + name = 'AddSignatureToHandover2800000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_handovers ADD COLUMN IF NOT EXISTS signature_image_url text;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS signature_image_url;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts new file mode 100644 index 000000000..983aa6e64 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Named service items for KM-based maintenance ("oil change", "tires", …). + * The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval + * per type per vehicle, so oil and tire intervals could not coexist. Interval + * identity becomes (vehicle, maintenance_type, service_item); schedules carry + * the item so completion re-finds the right interval for auto-scheduling. + */ +export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface { + name = 'AddMaintenanceServiceItem2810000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + // Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the + // item-less legacy rows into one slot; soft-deleted rows are ignored. + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item" + ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, '')) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts b/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts new file mode 100644 index 000000000..e1e02c418 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bulk tonnage at assignment time. First-mile trucks and export self-haul + * trucks carry a planned load (tonnes + optional item count) so bulk bookings + * draw down as vehicles are assigned — not only at the weighbridge. + */ +export class AddMileTonsQuantity2820000000000 implements MigrationInterface { + name = 'AddMileTonsQuantity2820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS tons numeric(14,3);`, + ); + await queryRunner.query( + `ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS quantity integer;`, + ); + await queryRunner.query( + `ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_tons numeric(14,3);`, + ); + await queryRunner.query( + `ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_quantity integer;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_quantity;`, + ); + await queryRunner.query( + `ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_tons;`, + ); + await queryRunner.query( + `ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS quantity;`, + ); + await queryRunner.query( + `ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS tons;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts b/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts new file mode 100644 index 000000000..fb22cbee9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts @@ -0,0 +1,121 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Truck types become back-office data instead of a hardcoded `VehicleType` enum, + * so EDR can add a configuration without a code change. + * + * `vehicles.vehicle_type` is deliberately LEFT IN PLACE as a denormalised code. + * Truck-detention billing groups trucks with raw SQL over that column + * (`SELECT v.vehicle_type ... GROUP BY`, warehouse-fee.service.ts) and matches + * the result against `warehouse_fee_rules.vehicle_type`. Swapping it for the FK + * outright would silently drop detention charges, so the FK is additive and the + * service writes the type's code through on every save. + * + * Raw SQL, `freight.`-qualified, IF NOT EXISTS throughout — the TypeORM builder + * API resolves bare names against `public` and crash-loops boot. + */ +export class AddTruckTypes2840000000000 implements MigrationInterface { + name = "AddTruckTypes2840000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.truck_types ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(32) NOT NULL, + name varchar(100) NOT NULL, + capacity_tons numeric(10,3), + has_trailer boolean NOT NULL DEFAULT false, + description text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_truck_types_code + ON freight.truck_types (code) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_truck_types_is_active + ON freight.truck_types (is_active) + `); + + // Seed one row per legacy enum value so vehicles already carrying that code + // keep resolving, plus CASONI as the first rigid (no-trailer) configuration. + // has_trailer is true only for the articulated configurations. + await queryRunner.query(` + INSERT INTO freight.truck_types (code, name, has_trailer) + VALUES + ('TRUCK', 'Truck', true), + ('TRAILER', 'Trailer', true), + ('TANKER', 'Tanker', true), + ('FLATBED', 'Flatbed', true), + ('VAN', 'Van', false), + ('CAR', 'Car', false), + ('BUS', 'Bus', false), + ('CASONI', 'Casoni (rigid, no trailer)', false) + ON CONFLICT (code) DO NOTHING + `); + + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS truck_type_id uuid + `); + + // Separate DO block: ADD CONSTRAINT has no IF NOT EXISTS in Postgres. + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_vehicles_truck_type' + ) THEN + ALTER TABLE freight.vehicles + ADD CONSTRAINT fk_vehicles_truck_type + FOREIGN KEY (truck_type_id) REFERENCES freight.truck_types (id) + ON DELETE SET NULL; + END IF; + END $$ + `); + + // Backfill the FK from the code already stored on each vehicle. + await queryRunner.query(` + UPDATE freight.vehicles v + SET truck_type_id = t.id + FROM freight.truck_types t + WHERE v.truck_type_id IS NULL + AND upper(trim(v.vehicle_type)) = t.code + `); + + // Truck-type codes are varchar(32); the fee-rule column they are matched + // against was varchar(20) and would truncate/reject longer codes. + await queryRunner.query(` + ALTER TABLE freight.warehouse_fee_rules + ALTER COLUMN vehicle_type TYPE varchar(32) + `); + + // A VIN identifies exactly one vehicle worldwide. Partial index so the many + // existing rows without a VIN do not collide. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_vehicles_vin + ON freight.vehicles (vin) + WHERE vin IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_vehicles_vin`); + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP CONSTRAINT IF EXISTS fk_vehicles_truck_type + `); + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS truck_type_id + `); + await queryRunner.query(`DROP TABLE IF EXISTS freight.truck_types`); + // warehouse_fee_rules.vehicle_type is left widened: narrowing it back would + // fail on any row that stored a code longer than 20 characters. + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts index cf324a501..cc7507dd6 100644 --- a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; +import { Type } from "class-transformer"; +import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator"; class CreateOrganizationUserNameDto { @ApiProperty() @@ -29,7 +30,8 @@ export class CreateOrganizationUserDto { phoneNumber?: string; @ApiProperty({ type: CreateOrganizationUserNameDto }) - @IsObject() + @ValidateNested() + @Type(() => CreateOrganizationUserNameDto) name!: CreateOrganizationUserNameDto; @ApiProperty({ required: false, default: false }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index bc8c80982..c292a0395 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -252,8 +252,11 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } + // Powers the customer-detail bookings tab, so `customers:view` reaches it too + // — otherwise a staffer granted only the customer permission gets a page whose + // tabs 403 individually. @Get("by-company/:companyId/customer-view") - @BookingView() + @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view]) @ApiOperation({ summary: "List bookings for a company (customer-view shape, backoffice)", }) @@ -436,18 +439,26 @@ export class BookingsController { } @Get(':id/customer-truck-assignment/freight-order') - @ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' }) + @ApiOperation({ + summary: + 'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).', + }) async customerTruckFreightOrder( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, + @Query('copies') copies?: string, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } + const extraCopyIndexes = (copies ?? '') + .split(',') + .map((n) => Number(n.trim())) + .filter((n) => Number.isInteger(n) && n >= 1 && n <= 8); const { filename, buffer } = - await this.bookingsService.customerTruckFreightOrderCopies(id); + await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(buffer); @@ -480,6 +491,20 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Post(':id/customer-trucks/bulk') + @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + async bulkAddCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @Body() payload: { trucks: AddCustomerTruckDto[] }, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addBulkTrucks(id, payload.trucks); + } + @Patch(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) async updateCustomerTruck( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index da7779026..d40433f4e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -142,8 +142,21 @@ export class BookingsService { return this.findById(bookingId); } + /** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */ + static readonly FREIGHT_ORDER_EXTRA_COPIES = [ + 'Original 1 (for Issuing Carrier)', + 'Original 2 (for Consignee)', + 'Original 3 (for Shipper)', + 'Copy 4 (Delivery Receipt)', + 'Copy 5 (Extra Copy)', + 'Copy 6 (Extra Copy)', + 'Copy 7 (Extra Copy)', + 'Copy 8 (for Agent)', + ] as const; + async customerTruckFreightOrderCopies( bookingId: string, + extraCopyIndexes: number[] = [], ): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); if (!booking.customerTruckAssignedAt) { @@ -171,7 +184,12 @@ export class BookingsService { [bookingId], ); - const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); + // The 2 gate copies are ALWAYS printed; the waybill-style copies are + // whatever the customer ticked (indexes into the fixed catalog). + const extraCopies = [...new Set(extraCopyIndexes)] + .map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1]) + .filter(Boolean); + const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies); // Chromium when available; otherwise the styled tabular fallback (never the // generic text dump — the freight order is an outward-facing gate document). const buffer = await this.pdfRender.htmlToPdfBuffer(html, { @@ -268,6 +286,7 @@ export class BookingsService { arrivedAt: string | null; containers: string | null; }>, + extraCopies: string[] = [], ): string { const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); const assignedAt = booking.customerTruckAssignedAt @@ -386,6 +405,7 @@ export class BookingsService { ${copy('Copy 1: Port Operations Copy')} ${copy('Copy 2: Gate Security & Carrier Copy')} + ${extraCopies.map((label) => copy(label)).join('')} `; } diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index eb4699008..3df681d9c 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -85,6 +85,24 @@ export class CustomerTruckService { if (isBulk) { const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId); assertBulkTonnageRemains(totalTons, remainingTons); + + // Assignment-time drawdown: planned tonnage across live trucks (weighed + // net once departed, planned before) may not exceed the declared total. + if (totalTons > 0) { + const [p]: Array<{ planned: string | null }> = await this.dataSource.query( + `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned + FROM freight.customer_truck_assignments a + WHERE a.booking_id = $1 AND a.deleted_at IS NULL`, + [bookingId], + ); + const alreadyPlanned = Number(p?.planned ?? 0); + const requestedTons = Number(dto.plannedTons ?? 0); + if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) { + throw new BadRequestException( + `Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`, + ); + } + } } if (requested.length) { @@ -108,6 +126,8 @@ export class CustomerTruckService { plateNumber: dto.truckPlateNumber.trim().toUpperCase(), driverName: dto.driverName.trim(), truckType: dto.truckType.trim(), + plannedTons: isBulk ? (dto.plannedTons ?? null) : null, + plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null, }), ); await manager.getRepository(CustomerTruckContainer).save( @@ -186,23 +206,52 @@ export class CustomerTruckService { throw new ConflictException('Cannot edit a truck that has already arrived'); } - const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); - if (requested.length < 1) { + // Bulk trucks carry loose tonnage, not containers — planned tonnage is + // editable instead, capped by what the other trucks haven't claimed. + const isBulk = booking.freightType === 'BULK'; + const requested = isBulk + ? [] + : (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!isBulk && requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } - assertTruckLoad({ - containers: requested, - bookingContainers: await this.bookingContainerNumbers(bookingId), - sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), - // Exclude THIS truck's own containers so re-saving the same set is allowed. - assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), - }); + if (!isBulk) { + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + // Exclude THIS truck's own containers so re-saving the same set is allowed. + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); + } else if (dto.plannedTons != null) { + const { totalTons } = await remainingBulkTons(this.dataSource, bookingId); + if (totalTons > 0) { + const [p]: Array<{ planned: string | null }> = await this.dataSource.query( + `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned + FROM freight.customer_truck_assignments a + WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`, + [bookingId, assignmentId], + ); + const others = Number(p?.planned ?? 0); + if (others + Number(dto.plannedTons) > totalTons + 0.001) { + throw new BadRequestException( + `Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`, + ); + } + } + } await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { plateNumber: dto.truckPlateNumber.trim().toUpperCase(), driverName: dto.driverName.trim(), truckType: dto.truckType.trim(), + ...(isBulk + ? { + plannedTons: dto.plannedTons ?? null, + plannedQuantity: dto.plannedQuantity ?? null, + } + : {}), }); await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); await manager.getRepository(CustomerTruckContainer).save( @@ -576,4 +625,35 @@ export class CustomerTruckService { } /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + + async addBulkTrucks( + bookingId: string, + dtos: AddCustomerTruckDto[], + ): Promise<{ + success: number; + failed: number; + errors: Array<{ row: number; truck: string; reason: string }>; + }> { + const errors: Array<{ row: number; truck: string; reason: string }> = []; + let successCount = 0; + + for (let i = 0; i < dtos.length; i++) { + try { + await this.addTruck(bookingId, dtos[i]); + successCount++; + } catch (err: any) { + errors.push({ + row: i + 2, // Row 1 is header + truck: dtos[i].truckPlateNumber, + reason: err.message || 'Unknown error', + }); + } + } + + return { + success: successCount, + failed: errors.length, + errors, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts index 4356d66ec..9816b3405 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -4,10 +4,12 @@ import { IsArray, IsIn, IsNotEmpty, + IsNumber, IsOptional, IsString, Matches, MaxLength, + Min, } from 'class-validator'; import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; @@ -44,4 +46,16 @@ export class AddCustomerTruckDto { message: 'each container number must match ISO container format, e.g. ABCD1234567', }) containerNumbers?: string[]; + + /** Bulk: planned tonnage this truck hauls — draws down the booking total at assignment. */ + @IsOptional() + @IsNumber() + @Min(0) + plannedTons?: number; + + /** Bulk: optional item/piece count on this truck. */ + @IsOptional() + @IsNumber() + @Min(0) + plannedQuantity?: number; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts new file mode 100644 index 000000000..5e03c7bc4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -0,0 +1,48 @@ +import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +export class BulkCustomerTruckRow { + @IsString() + @IsNotEmpty() + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container must be ISO format (e.g. ABCD1234567)', + }) + containerNumbers?: (string | null)[]; +} + +export class BulkCustomerTrucksDto { + @IsArray() + @ArrayMaxSize(100) + trucks!: BulkCustomerTruckRow[]; +} + +export interface BulkTruckUploadResult { + success: number; + failed: number; + errors: Array<{ + row: number; + truck: string; + reason: string; + }>; + created: Array<{ + truckPlateNumber: string; + driverName: string; + containers: number; + }>; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts index 3892d2a97..94ab3b212 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -51,6 +51,14 @@ export class CustomerTruckAssignment extends BaseEntity { @Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) netWeightTons?: number | null; + /** Bulk: planned tonnage at assignment — draws down the booking before weigh-out. */ + @Column({ name: 'planned_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + plannedTons?: number | null; + + /** Bulk: optional item/piece count planned on this truck. */ + @Column({ name: 'planned_quantity', type: 'integer', nullable: true }) + plannedQuantity?: number | null; + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) departedAt?: Date | null; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 43d70ce19..8e3be4d1a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -11,13 +11,22 @@ import { HttpCode, HttpStatus, UseInterceptors, + UseGuards, UploadedFiles, BadRequestException, + NotFoundException, } from "@nestjs/common"; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { CurrentUser } from "@edr/api-common"; -import { FreightAdmin } from "../../common/booking-guards"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import { BookingStaff } from "../../common/booking-guards"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FilesService } from "../files/files.service"; import { CompaniesService } from "./companies.service"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -59,6 +68,23 @@ interface CurrentIamUser { phoneNumber?: string; } +/** + * Which permission a status write needs. Approving/reactivating is a different + * authority from suspending, but both arrive on the same route with the target + * in the BODY — a route-level guard can't tell them apart, so the handlers + * assert against this map instead. + * + * Keyed by string so it serves both `CompanyStatus` and `ProfileStatus` + * (a superset: it adds `rejected`). + */ +const STATUS_PERM: Record = { + active: FREIGHT_PERMS.customers.verify, + pending: FREIGHT_PERMS.customers.verify, + rejected: FREIGHT_PERMS.customers.verify, + suspended: FREIGHT_PERMS.customers.deactivate, + blacklisted: FREIGHT_PERMS.customers.deactivate, +}; + @ApiTags("Companies") @Controller("companies") export class CompaniesController { @@ -410,7 +436,7 @@ export class CompaniesController { // Used by backoffice @Post() - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.create) @ApiOperation({ summary: "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", @@ -421,12 +447,14 @@ export class CompaniesController { } @Get("stats") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Company counts by status (KPI strip)" }) async getStats(): Promise { return this.companiesService.getCompanyStats(); } @Get() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List companies (paginated, filterable)" }) async findAll( @Query() query: ListCompaniesQueryDto, @@ -436,6 +464,7 @@ export class CompaniesController { } @Get(":id") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get company by ID" }) async findById( @Param("id", ParseUUIDPipe) id: string, @@ -446,30 +475,77 @@ export class CompaniesController { return dto; } + /** + * Edits fields AND carries `status`, so it spans two authorities. The route + * guard is one-of (a status-only caller must get in); the asserts below are + * what actually authorize: touching `status` needs the permission + * {@link STATUS_PERM} maps it to, touching anything else needs + * `customers:update`. Both checks are required — without the second, a + * caller holding only `customers:deactivate` could rename the company. + */ @Patch(":id") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.update, + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company" }) async update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCompanyDto, + @CurrentUser() user: TCurrentUser, ): Promise { + const { status, ...fields } = dto; + if (status) assertFreightPermission(user, STATUS_PERM[status]); + if (Object.keys(fields).length > 0) { + assertFreightPermission(user, FREIGHT_PERMS.customers.update); + } const company = await this.companiesService.updateCompany(id, dto); return new ResponseCompanyDto(company); } @Delete(":id") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.deactivate) @ApiOperation({ summary: "Soft-delete a company" }) @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param("id", ParseUUIDPipe) id: string): Promise { await this.companiesService.deleteCompany(id); } + /** + * Dual-audience: staff read any customer's documents, and the portal reads + * its OWN during onboarding (`companiesService.getDocuments`). So the route + * is authenticated-only and the split happens here — same shape as + * `GET /contracts/:id`. Gating it on a staff permission alone would 403 every + * customer on their own documents. + * + * The staff arm is one-of because two pages consume it: the customer detail + * page (`customers:view`) and the contract-request detail page, whose route + * is gated on `contracts:view` — a contract reviewer without the customer + * permission still needs the applicant's documents. + */ @Get(":companyId/documents") + @UseGuards(JwtGuard) @ApiOperation({ summary: "List documents uploaded for a company" }) async listDocuments( @Param("companyId", ParseUUIDPipe) companyId: string, + @CurrentUser() user: TCurrentUser, ) { + const isStaff = [ + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.bookings.view, + ].some((p) => hasFreightPermission(user, p)); + + if (!isStaff) { + const { company } = await this.companiesService.getCompanyInfoByUserId( + user.id, + ); + // Hidden as NotFound rather than Forbidden so company ids can't be probed. + if (company.id !== companyId) { + throw new NotFoundException(`Company ${companyId} not found`); + } + } const files = await this.filesService.findByResource(companyId, "companies"); return Promise.all( files.map(async (f) => ({ @@ -490,7 +566,7 @@ export class CompaniesController { } @Post("documents/:fileId/request-change") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Ask the customer to correct one uploaded document", description: @@ -532,14 +608,23 @@ export class CompaniesController { return this.companiesService.uploadCompanyDocuments(companyId, files, user.id); } + /** + * Approve / reject / suspend / blacklist all arrive here with the target in + * the body, so authorization is per-status via {@link STATUS_PERM} rather + * than on the route (the guard is only the one-of gate). + */ @Patch("company-profiles/:profileId/status") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company profile's approval status" }) async updateCompanyProfileStatus( - @CurrentUser() user: CurrentIamUser, + @CurrentUser() user: TCurrentUser, @Param("profileId", ParseUUIDPipe) profileId: string, @Body() dto: UpdateCompanyProfileStatusDto, ): Promise { + assertFreightPermission(user, STATUS_PERM[dto.status]); const profile = await this.companiesService.setCompanyProfileStatus( profileId, dto.status, @@ -550,7 +635,7 @@ export class CompaniesController { } @Get(":companyId/change-requests") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List a company's profile change requests" }) async listChangeRequests( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -560,7 +645,7 @@ export class CompaniesController { } @Post("change-requests/:id/approve") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Approve a pending profile change request (applies the changes)", }) @@ -576,7 +661,7 @@ export class CompaniesController { } @Post("change-requests/:id/reject") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Reject a pending profile change request with a note", }) @@ -594,7 +679,7 @@ export class CompaniesController { } @Post(":companyId/profiles") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.update) @ApiOperation({ summary: "Add a profile (employee) to a company" }) async createProfile( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -608,6 +693,7 @@ export class CompaniesController { } @Get(":companyId/profiles") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List profiles for a company" }) async listProfiles( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -618,6 +704,7 @@ export class CompaniesController { } @Get("profile/user/:userId") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get profile by IAM user ID" }) async findProfileByUser( @Param("userId", ParseUUIDPipe) userId: string, diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts index 8656b2109..512c22566 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts @@ -1,4 +1,4 @@ -import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; export class FirstMileVehicleInput { @@ -8,6 +8,18 @@ export class FirstMileVehicleInput { @IsOptional() @IsString() containerNumber?: string; + + /** Bulk: tonnage this truck hauls. */ + @IsOptional() + @IsNumber() + @Min(0) + tons?: number; + + /** Bulk: optional item/piece count. */ + @IsOptional() + @IsNumber() + @Min(0) + quantity?: number; } /** Replace the full set of vehicles (with their container numbers) on a pickup. */ diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts index 39bf51a50..5c9f02c42 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts @@ -36,4 +36,12 @@ export class FirstMileVehicleAssignment extends BaseEntity { /** Actual distance driven by this truck (km), entered per vehicle. */ @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) distanceKm?: number | null; + + /** Bulk: tonnage this truck hauls — assigned tonnage draws down the booking total. */ + @Column({ name: 'tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + tons?: number | null; + + /** Bulk: optional item/piece count on this truck. */ + @Column({ name: 'quantity', type: 'integer', nullable: true }) + quantity?: number | null; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 948853e22..b11912b2c 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -532,17 +532,47 @@ export class FirstMileService { */ async setVehicles( id: string, - inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + inputs: Array<{ + vehicleId: string; + containerNumber?: string | null; + tons?: number | null; + quantity?: number | null; + }>, ): Promise { const existing = await this.findById(id); - // Dedupe by vehicleId, keeping the container number; preserve order. - const desiredMap = new Map(); + // Dedupe by vehicleId, keeping the load details; preserve order. + const desiredMap = new Map< + string, + { containerNumber: string | null; tons: number | null; quantity: number | null } + >(); for (const inp of inputs) { - if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + if (inp.vehicleId) { + desiredMap.set(inp.vehicleId, { + containerNumber: inp.containerNumber ?? null, + tons: inp.tons ?? null, + quantity: inp.quantity ?? null, + }); + } } const desired = [...desiredMap.keys()]; const desiredSet = new Set(desired); + // Bulk drawdown: assigned tonnage may not exceed what the booking declares. + const totalTons = [...desiredMap.values()].reduce((s, v) => s + (Number(v.tons) || 0), 0); + if (totalTons > 0 && existing.bookingId) { + const [b]: Array<{ vgm: string | null }> = await this.dataSource.query( + `SELECT cargo_total_weight_vgm AS vgm FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [existing.bookingId], + ); + const declared = Number(b?.vgm ?? 0); + if (declared > 0 && totalTons > declared + 0.001) { + throw new BadRequestException( + `Assigned tonnage (${totalTons} t) exceeds the booking's declared ${declared} t`, + ); + } + } + const manager = this.dataSource.manager; const current = await manager.find(FirstMileVehicleAssignment, { where: { firstMileId: id }, @@ -555,12 +585,16 @@ export class FirstMileService { )]; const added = desired.filter((v) => !junctionSet.has(v)); const removed = releaseIds.filter((v) => !desiredSet.has(v)); - // Vehicles that stay but whose container number changed. - const changed = current.filter( - (a) => - desiredMap.has(a.vehicleId) && - (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), - ); + // Vehicles that stay but whose load details changed. + const changed = current.filter((a) => { + const want = desiredMap.get(a.vehicleId); + if (!want) return false; + return ( + (a.containerNumber ?? null) !== want.containerNumber || + (a.tons == null ? null : Number(a.tons)) !== want.tons || + (a.quantity ?? null) !== want.quantity + ); + }); await this.dataSource.transaction(async (tx) => { if (removed.length) { @@ -570,17 +604,25 @@ export class FirstMileService { }); } for (const vehicleId of added) { + const want = desiredMap.get(vehicleId); await tx.insert(FirstMileVehicleAssignment, { firstMileId: id, vehicleId, - containerNumber: desiredMap.get(vehicleId) ?? null, + containerNumber: want?.containerNumber ?? null, + tons: want?.tons ?? null, + quantity: want?.quantity ?? null, }); } for (const row of changed) { + const want = desiredMap.get(row.vehicleId); await tx.update( FirstMileVehicleAssignment, { firstMileId: id, vehicleId: row.vehicleId }, - { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + { + containerNumber: want?.containerNumber ?? null, + tons: want?.tons ?? null, + quantity: want?.quantity ?? null, + }, ); } }); diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts index 03797f3b7..61a13744e 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts @@ -54,11 +54,4 @@ export class InterchangeDocumentsController { dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) { return this.service.dispute(id, dto); } - - @Patch(':id/cancel') - @BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel) - @ApiOperation({ summary: 'Cancel a draft/generated interchange document' }) - cancel(@Param('id', ParseUUIDPipe) id: string) { - return this.service.cancel(id); - } } diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts index f91e942c0..e5fb5dc4d 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts @@ -203,11 +203,12 @@ export class InterchangeDocumentsService { async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise { const document = await this.findOne(id); - // A dispute can only be raised on a live handover — a GENERATED or already - // ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here. - if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) { + // A dispute can only be raised BEFORE the handover is acknowledged — an + // acknowledged document is settled. DISPUTED itself is terminal and + // read-only: the registered dispute cannot be re-raised or overwritten. + if (document.status !== 'GENERATED') { throw new BadRequestException( - `Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`, + `Interchange document in ${document.status} status cannot be disputed (must be GENERATED — an acknowledged handover is settled, a registered dispute is read-only)`, ); } await this.dataSource.getRepository(InterchangeDocument).update(id, { @@ -217,15 +218,6 @@ export class InterchangeDocumentsService { return this.findOne(id); } - async cancel(id: string): Promise { - const document = await this.findOne(id); - if (!['DRAFT', 'GENERATED'].includes(document.status)) { - throw new BadRequestException(`Interchange document ${document.status} cannot be cancelled`); - } - await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'CANCELLED' }); - return this.findOne(id); - } - private async getScheduleSnapshot(scheduleId: string): Promise { const [schedule] = await this.dataSource.query( `SELECT ts.id, diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts index d3e70acff..129262ef0 100644 --- a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts @@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto { @IsEnum(MaintenanceType) maintenanceType!: MaintenanceType; + /** What is serviced — matched against the interval for auto-scheduling. */ + @IsOptional() + @IsString() + serviceItem?: string; + @IsString() description!: string; @@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto { @IsNumber() actualCost?: number; + /** Odometer at completion — drives KM-based auto-scheduling of the next service. */ + @IsOptional() + @IsNumber() + odometerReading?: number; + @IsOptional() @IsString() notes?: string; } + +export class UpsertMaintenanceIntervalDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(MaintenanceType) + maintenanceType!: MaintenanceType; + + /** What is serviced — "oil change", "tires", … Distinguishes intervals of the same type. */ + @IsOptional() + @IsString() + serviceItem?: string; + + @IsOptional() + @IsNumber() + intervalKm?: number; + + @IsOptional() + @IsNumber() + intervalDays?: number; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts new file mode 100644 index 000000000..640c3e26e --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { MaintenanceType } from './maintenance-schedule.entity'; + +/** + * Maintenance interval configuration. Defines how often a vehicle needs a + * given service. Identity is (vehicle, maintenanceType, serviceItem) — a + * vehicle carries several intervals of the same coarse type with different + * items (oil every 10k km, tires every 50k km, both PREVENTIVE). Uniqueness + * is enforced by a COALESCE expression index in the migration (nullable + * service_item), not a TypeORM @Unique. + */ +@Entity({ name: 'maintenance_intervals', schema: 'freight' }) +@Index(['vehicleId', 'maintenanceType']) +export class MaintenanceInterval extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_type', type: 'varchar' }) + maintenanceType!: MaintenanceType; + + /** What is serviced — "oil change", "tires", … Null = generic for the type. */ + @Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true }) + serviceItem?: string | null; + + /** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */ + @Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true }) + intervalKm?: number | null; + + /** Maintenance interval in days. E.g., 365 for annual inspection. */ + @Column({ name: 'interval_days', type: 'integer', nullable: true }) + intervalDays?: number | null; + + /** Human-readable description. E.g., "Oil and filter change". */ + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + /** Is this interval active? Can be disabled without deleting historical data. */ + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts index a4d4d60a0..5a0cc074a 100644 --- a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts @@ -30,6 +30,10 @@ export class MaintenanceSchedule extends BaseEntity { @Column({ name: 'maintenance_type', type: 'varchar' }) maintenanceType!: MaintenanceType; + /** What is serviced — matches the interval's service_item for auto-scheduling. */ + @Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true }) + serviceItem?: string | null; + @Column({ name: 'description' }) description!: string; @@ -62,4 +66,8 @@ export class MaintenanceSchedule extends BaseEntity { @Column({ name: 'next_due_date', type: 'timestamptz', nullable: true }) nextDueDate?: Date; + + /** Stamped once the km/date-due alert has fired, so the daily check doesn't repeat it. */ + @Column({ name: 'due_notified_at', type: 'timestamptz', nullable: true }) + dueNotifiedAt?: Date; } diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts new file mode 100644 index 000000000..1b020455c --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts @@ -0,0 +1,99 @@ +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceStatus } from './entities/maintenance-schedule.entity'; + +/** + * KM-based auto-scheduling: completing a maintenance with an odometer reading + * creates the next SCHEDULED item at completedKm + intervalKm, matched on the + * schedule's (type, serviceItem) interval. Re-completing must not duplicate. + */ +function makeService(opts: { + before: Record | null; + after: Record | null; + interval: Record | null; +}) { + const saved: Array> = []; + const service = Object.create(MaintenanceService.prototype) as Record; + service.scheduleRepository = { + findOneBy: jest + .fn() + .mockResolvedValueOnce(opts.before) + .mockResolvedValueOnce(opts.after), + update: jest.fn(), + create: jest.fn((v: Record) => v), + save: jest.fn(async (v: Record) => { + saved.push(v); + return v; + }), + }; + service.intervalRepository = { + getByVehicleAndType: jest.fn().mockResolvedValue(opts.interval), + }; + service.dataSource = { + getRepository: jest.fn().mockReturnValue({ update: jest.fn() }), + }; + service.logger = { error: jest.fn() }; + return { service: service as unknown as MaintenanceService, saved }; +} + +const base = { + id: 's-1', + vehicleId: 'v-1', + maintenanceType: 'PREVENTIVE', + serviceItem: 'oil change', + description: 'Oil and filter', +}; + +describe('MaintenanceService auto-next scheduling', () => { + it('completing at 50,000 km with a 10,000 km interval schedules the next at 60,000', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.SCHEDULED }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null, description: 'Oil and filter' }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 50000, + }); + + expect(saved).toHaveLength(1); + expect(saved[0]).toMatchObject({ + vehicleId: 'v-1', + serviceItem: 'oil change', + nextDueKm: 60000, + status: MaintenanceStatus.SCHEDULED, + }); + }); + + it('re-completing an already COMPLETED schedule does not duplicate the next one', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 50000, + }); + + expect(saved).toHaveLength(0); + }); + + it('a km + days interval produces ONE next schedule carrying both thresholds', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.IN_PROGRESS }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 20000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: 180 }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 20000, + }); + + expect(saved).toHaveLength(1); + expect(saved[0].nextDueKm).toBe(30000); + expect(saved[0].nextDueDate).toBeInstanceOf(Date); + }); +}); diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts new file mode 100644 index 000000000..7aac5e863 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts @@ -0,0 +1,76 @@ +import { NotificationAudience } from '@edr/types'; + +import { MaintenanceService } from './maintenance.service'; + +/** + * The daily due-alert: a SCHEDULED item that crossed its km or date threshold + * gets one BACKOFFICE notification, then is stamped so it isn't repeated. + */ +function makeService(due: Array>) { + const update = jest.fn(); + const notify = jest.fn(); + const service = Object.create(MaintenanceService.prototype) as Record; + service.maintenanceRepository = { getUnnotifiedDue: jest.fn().mockResolvedValue(due) }; + service.scheduleRepository = { update }; + service.inbox = { notify }; + service.logger = { error: jest.fn() }; + return { service: service as unknown as MaintenanceService, update, notify }; +} + +describe('MaintenanceService.sendDueAlerts', () => { + it('reports the km reason when the km threshold was crossed', async () => { + const { service, notify, update } = makeService([ + { + id: 'sched-1', + vehicleId: 'v-1', + plateNumber: 'ET-9875', + maintenanceType: 'PREVENTIVE', + description: 'Oil change', + nextDueKm: 50000, + nextDueDate: null, + currentKm: 50200, + }, + ]); + + await service.sendDueAlerts(); + + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + audience: NotificationAudience.BACKOFFICE, + title: 'Maintenance due — ET-9875', + body: expect.stringContaining('driven 50200 km (due at 50000 km)'), + }), + ); + expect(update).toHaveBeenCalledWith('sched-1', { dueNotifiedAt: expect.any(Date) }); + }); + + it('reports the date reason when only the due date has passed', async () => { + const { service, notify } = makeService([ + { + id: 'sched-2', + vehicleId: 'v-2', + plateNumber: 'AA-8642', + maintenanceType: 'INSPECTION', + description: 'Annual inspection', + nextDueKm: null, + nextDueDate: new Date('2026-01-01'), + currentKm: 1000, + }, + ]); + + await service.sendDueAlerts(); + + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.stringContaining('due 1/1/2026') }), + ); + }); + + it('does nothing when nothing is due', async () => { + const { service, notify, update } = makeService([]); + + await service.sendDueAlerts(); + + expect(notify).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts new file mode 100644 index 000000000..d884cdf55 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { IsNull, Repository } from 'typeorm'; +import { MaintenanceInterval } from './entities/maintenance-interval.entity'; +import { MaintenanceType } from './entities/maintenance-schedule.entity'; + +@Injectable() +export class MaintenanceIntervalRepository extends BaseRepository { + constructor( + @InjectRepository(MaintenanceInterval) + private readonly intervalRepository: Repository, + ) { + super(intervalRepository); + } + + /** + * Resolve the interval for a completed service. Prefers the exact + * (type, serviceItem) match; a completion without an item falls back to the + * type's item-less interval only, so "oil" completions never consume the + * "tires" interval. + */ + async getByVehicleAndType( + vehicleId: string, + maintenanceType: MaintenanceType, + serviceItem?: string | null, + ): Promise { + return this.intervalRepository.findOne({ + where: { + vehicleId, + maintenanceType, + isActive: true, + serviceItem: serviceItem?.trim() ? serviceItem.trim() : IsNull(), + }, + }); + } + + async getActiveIntervals(vehicleId: string): Promise { + return this.intervalRepository.find({ + where: { vehicleId, isActive: true }, + order: { maintenanceType: 'ASC', serviceItem: 'ASC' }, + }); + } + + async upsertInterval( + vehicleId: string, + maintenanceType: MaintenanceType, + serviceItem?: string | null, + intervalKm?: number | null, + intervalDays?: number | null, + description?: string | null, + ): Promise { + const item = serviceItem?.trim() || null; + const existing = await this.getByVehicleAndType(vehicleId, maintenanceType, item); + + if (existing) { + await this.intervalRepository.update(existing.id, { + intervalKm: intervalKm ?? existing.intervalKm, + intervalDays: intervalDays ?? existing.intervalDays, + description: description ?? existing.description, + }); + const updated = await this.intervalRepository.findOneBy({ id: existing.id }); + return updated!; + } + + return this.intervalRepository.save( + this.intervalRepository.create({ + vehicleId, + maintenanceType, + serviceItem: item, + intervalKm, + intervalDays, + description, + isActive: true, + }), + ); + } + + /** Soft-disable: history keeps pointing at it, auto-scheduling stops. */ + async deactivate(id: string): Promise { + await this.intervalRepository.update(id, { isActive: false }); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts index 4ad8026f2..32e5dfba6 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -4,7 +4,12 @@ import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { MaintenanceService } from './maintenance.service'; import { MaintenanceDepthService } from './maintenance-depth.service'; -import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; +import { + CreateMaintenanceScheduleDto, + CreateMaintenanceCostDto, + UpdateMaintenanceScheduleDto, + UpsertMaintenanceIntervalDto, +} from './dto/create-maintenance.dto'; import { CreateWorkOrderDto, UpdateWorkOrderDto, @@ -44,6 +49,34 @@ export class MaintenanceController { return this.maintenanceService.updateMaintenanceSchedule(id, dto); } + @Get('due-board') + @BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetDashboard.view]) + @ApiOperation({ summary: 'Fleet-wide next-due maintenance board (by date and km)' }) + async getDueBoard() { + return this.maintenanceService.getDueBoard(); + } + + @Post('intervals') + @BookingStaff(FREIGHT_PERMS.maintenance.create) + @ApiOperation({ summary: 'Define/adjust a service interval (e.g. oil change every 10,000 km)' }) + async upsertInterval(@Body() dto: UpsertMaintenanceIntervalDto) { + return this.maintenanceService.upsertInterval(dto); + } + + @Get('intervals/:vehicleId') + @BookingStaff(FREIGHT_PERMS.maintenance.view) + @ApiOperation({ summary: "A vehicle's active service intervals" }) + async getIntervals(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getIntervals(vehicleId); + } + + @Delete('intervals/:id') + @BookingStaff(FREIGHT_PERMS.maintenance.delete) + @ApiOperation({ summary: 'Deactivate a service interval (stops auto-scheduling)' }) + async deactivateInterval(@Param('id') id: string) { + return this.maintenanceService.deactivateInterval(id); + } + @Get('upcoming/:vehicleId') @BookingStaff(FREIGHT_PERMS.maintenance.view) @ApiOperation({ summary: 'Get upcoming maintenance' }) diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts index 8f4fe1d0b..b64b77bae 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -2,25 +2,37 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { MaintenanceInterval } from './entities/maintenance-interval.entity'; import { WorkOrder } from './entities/work-order.entity'; import { Part } from './entities/part.entity'; import { Warranty } from './entities/warranty.entity'; import { MaintenanceService } from './maintenance.service'; import { MaintenanceDepthService } from './maintenance-depth.service'; import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceIntervalRepository } from './maintenance-interval.repository'; import { WorkOrderRepository } from './work-order.repository'; import { PartRepository } from './part.repository'; import { WarrantyRepository } from './warranty.repository'; import { MaintenanceController } from './maintenance.controller'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; @Module({ imports: [ - TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]), + TypeOrmModule.forFeature([ + MaintenanceSchedule, + MaintenanceCost, + MaintenanceInterval, + WorkOrder, + Part, + Warranty, + ]), + NotificationInboxModule, ], providers: [ MaintenanceService, MaintenanceDepthService, MaintenanceRepository, + MaintenanceIntervalRepository, WorkOrderRepository, PartRepository, WarrantyRepository, diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts index 9e8cf972e..b417e72cf 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -47,4 +47,109 @@ export class MaintenanceRepository extends BaseRepository { .getRawOne(); return result?.total || 0; } + + /** + * Fleet-wide "next due" board: one row per vehicle with a SCHEDULED + * maintenance item, driven by time AND km — whichever is soonest. Current km + * is the vehicle's latest fuel-up odometer reading (how mileage is actually + * captured today), falling back to vehicle.actual_distance_km when the + * vehicle has no fuel purchase on file yet. + */ + async getDueBoard(): Promise< + Array<{ + scheduleId: string; + vehicleId: string; + plateNumber: string; + maintenanceType: string; + serviceItem: string | null; + description: string; + scheduledDate: Date; + nextDueDate: Date | null; + nextDueKm: number | null; + currentKm: number | null; + kmRemaining: number | null; + daysRemaining: number | null; + overdue: boolean; + }> + > { + // Every SCHEDULED item, not one per vehicle — a truck legitimately holds + // several (oil vs tires intervals differ). + return this.scheduleRepository.manager.query(` + SELECT + s.id AS "scheduleId", + s.vehicle_id AS "vehicleId", + v.plate_number AS "plateNumber", + s.maintenance_type AS "maintenanceType", + s.service_item AS "serviceItem", + s.description, + s.scheduled_date AS "scheduledDate", + s.next_due_date AS "nextDueDate", + s.next_due_km AS "nextDueKm", + COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm", + CASE WHEN s.next_due_km IS NOT NULL + THEN s.next_due_km - COALESCE(fp.max_odometer, v.actual_distance_km, 0) + ELSE NULL END AS "kmRemaining", + CASE WHEN s.next_due_date IS NOT NULL + THEN EXTRACT(DAY FROM s.next_due_date - now()) + ELSE NULL END AS "daysRemaining", + ( + (s.next_due_date IS NOT NULL AND s.next_due_date <= now()) + OR (s.next_due_km IS NOT NULL + AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km) + ) AS overdue + FROM freight.maintenance_schedules s + JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT MAX(odometer_reading) AS max_odometer + FROM freight.fuel_purchases fp2 + WHERE fp2.vehicle_id = s.vehicle_id + ) fp ON true + WHERE s.status = 'SCHEDULED' AND s.deleted_at IS NULL + ORDER BY s.vehicle_id, s.scheduled_date ASC + `); + } + + /** + * SCHEDULED items that have crossed their km or date due-point and have not + * yet been notified. Backs the daily km/date maintenance alert. + */ + async getUnnotifiedDue(): Promise< + Array<{ + id: string; + vehicleId: string; + plateNumber: string; + maintenanceType: string; + description: string; + nextDueKm: number | null; + nextDueDate: Date | null; + currentKm: number | null; + }> + > { + return this.scheduleRepository.manager.query(` + SELECT + s.id, + s.vehicle_id AS "vehicleId", + v.plate_number AS "plateNumber", + s.maintenance_type AS "maintenanceType", + s.description, + s.next_due_km AS "nextDueKm", + s.next_due_date AS "nextDueDate", + COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm" + FROM freight.maintenance_schedules s + JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT MAX(odometer_reading) AS max_odometer + FROM freight.fuel_purchases fp2 + WHERE fp2.vehicle_id = s.vehicle_id + ) fp ON true + WHERE s.status = 'SCHEDULED' + AND s.deleted_at IS NULL + AND s.due_notified_at IS NULL + AND ( + (s.next_due_date IS NOT NULL AND s.next_due_date <= now()) + OR (s.next_due_km IS NOT NULL + AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km) + ) + `); + } } diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index 8ef4800b6..dd9da55a8 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -1,16 +1,28 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, Repository } from 'typeorm'; import { MaintenanceRepository } from './maintenance.repository'; -import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity'; +import { MaintenanceIntervalRepository } from './maintenance-interval.repository'; +import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity'; import { MaintenanceCost } from './entities/maintenance-cost.entity'; import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity'; -import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; +import { + CreateMaintenanceScheduleDto, + CreateMaintenanceCostDto, + UpdateMaintenanceScheduleDto, + UpsertMaintenanceIntervalDto, +} from './dto/create-maintenance.dto'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; @Injectable() export class MaintenanceService { + private readonly logger = new Logger(MaintenanceService.name); + constructor( private readonly maintenanceRepository: MaintenanceRepository, + private readonly intervalRepository: MaintenanceIntervalRepository, @InjectRepository(MaintenanceSchedule) private readonly scheduleRepository: Repository, @InjectRepository(MaintenanceCost) @@ -18,8 +30,44 @@ export class MaintenanceService { // Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we // reach it through the global DataSource rather than @InjectRepository. private readonly dataSource: DataSource, + private readonly inbox: NotificationInboxService, ) {} + /** Fleet-wide next-due board — see MaintenanceRepository.getDueBoard. */ + async getDueBoard() { + return this.maintenanceRepository.getDueBoard(); + } + + /** + * Daily check: a vehicle's driven km (latest fuel-up odometer reading, since + * that's the only place mileage is actually recorded) or its due date has + * reached a SCHEDULED item's threshold → alert backoffice once. + */ + @Cron(CronExpression.EVERY_DAY_AT_7AM, { name: 'maintenance-due-alert' }) + async sendDueAlerts(): Promise { + try { + const due = await this.maintenanceRepository.getUnnotifiedDue(); + for (const item of due) { + const reason = + item.nextDueKm != null && (item.currentKm ?? 0) >= item.nextDueKm + ? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)` + : `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`; + await this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.GENERIC, + title: `Maintenance due — ${item.plateNumber}`, + body: `${item.plateNumber} (${item.maintenanceType}) is due for maintenance — ${reason}. ${item.description}`, + link: `/dashboard/maintenance?vehicleId=${item.vehicleId}`, + data: { vehicleId: item.vehicleId, scheduleId: item.id, action: 'MAINTENANCE_DUE' }, + }); + await this.scheduleRepository.update(item.id, { dueNotifiedAt: new Date() }); + } + } catch (err) { + this.logger.error(`sendDueAlerts failed: ${(err as Error).message}`, (err as Error).stack); + } + } + /** * Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle * under maintenance is taken out of service (MAINTENANCE + BUSY); once the @@ -64,6 +112,10 @@ export class MaintenanceService { id: string, dto: UpdateMaintenanceScheduleDto, ): Promise { + // Status BEFORE the write: completing an already-COMPLETED schedule again + // must not auto-create a second "next" schedule. + const before = await this.scheduleRepository.findOneBy({ id }); + await this.scheduleRepository.update(id, { ...dto, completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined, @@ -78,6 +130,15 @@ export class MaintenanceService { ) { // Maintenance finished/aborted → vehicle back in service. await this.setVehicleMaintenanceState(updated.vehicleId, false); + + // First transition into COMPLETED with an odometer → auto-schedule next. + if ( + dto.status === MaintenanceStatus.COMPLETED && + before?.status !== MaintenanceStatus.COMPLETED && + updated.odometerReading != null + ) { + await this.scheduleNextMaintenance(updated); + } } else if (dto.status === MaintenanceStatus.IN_PROGRESS) { // Maintenance started → keep the vehicle out of service. await this.setVehicleMaintenanceState(updated.vehicleId, true); @@ -87,6 +148,83 @@ export class MaintenanceService { return updated!; } + /** Define/adjust how often a vehicle needs a service ("oil change every 10,000 km"). */ + async upsertInterval(dto: UpsertMaintenanceIntervalDto) { + return this.intervalRepository.upsertInterval( + dto.vehicleId, + dto.maintenanceType, + dto.serviceItem ?? null, + dto.intervalKm ?? null, + dto.intervalDays ?? null, + dto.description ?? null, + ); + } + + async getIntervals(vehicleId: string) { + return this.intervalRepository.getActiveIntervals(vehicleId); + } + + async deactivateInterval(id: string): Promise<{ id: string; deactivated: boolean }> { + await this.intervalRepository.deactivate(id); + return { id, deactivated: true }; + } + + /** + * Auto-schedule the next service after a completion: matched on the + * completed schedule's (type, serviceItem) interval; one SCHEDULED row + * carrying BOTH thresholds when the interval defines km and days — + * whichever is crossed first makes it due. + */ + private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise { + try { + const interval = await this.intervalRepository.getByVehicleAndType( + completed.vehicleId, + completed.maintenanceType as MaintenanceType, + completed.serviceItem, + ); + + if (!interval) return; // No interval defined, skip auto-scheduling + + const now = new Date(); + const completedKm = Number(completed.odometerReading ?? 0); + const intervalKm = Number(interval.intervalKm ?? 0); + const intervalDays = Number(interval.intervalDays ?? 0); + if (intervalKm <= 0 && intervalDays <= 0) return; + + const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined; + const nextDueDate = + intervalDays > 0 + ? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000) + : undefined; + + const label = interval.serviceItem ? `${interval.serviceItem}: ` : ''; + const due = [ + nextDueKm != null ? `${nextDueKm} km` : null, + nextDueDate != null ? nextDueDate.toISOString().slice(0, 10) : null, + ] + .filter(Boolean) + .join(' / '); + + await this.scheduleRepository.save( + this.scheduleRepository.create({ + vehicleId: completed.vehicleId, + maintenanceType: completed.maintenanceType, + serviceItem: completed.serviceItem ?? interval.serviceItem ?? null, + description: `${label}${interval.description || completed.description} (next due: ${due})`, + scheduledDate: now, + nextDueKm, + nextDueDate, + status: MaintenanceStatus.SCHEDULED, + }), + ); + } catch (err) { + this.logger.error( + `Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + async getUpcomingMaintenance(vehicleId: string) { return this.maintenanceRepository.getUpcomingMaintenance(vehicleId); } diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 50856c3d7..2ae62d4f3 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -16,7 +16,8 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { BookingView } from "../../common/booking-guards"; +import { BookingStaff, BookingView } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { PaymentService } from "./payment.service"; import { IntentStatusDto } from "./payments.dto"; @@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto"; export class PaymentController { constructor(private readonly paymentService: PaymentService) { } + // Customer-detail payments tab — same one-of rule as the bookings tab. @Get("by-company/:companyId/customer-view") + @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.payments.view]) @ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" }) findByCompanyCustomerView( @Param("companyId", ParseUUIDPipe) companyId: string, diff --git a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts index 943d79296..cfab53a5d 100644 --- a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts +++ b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts @@ -7,6 +7,7 @@ import { IsOptional, IsEnum, IsBoolean, + MinLength, } from 'class-validator'; import { VendorType } from '../entities/vendor.entity'; import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity'; @@ -72,6 +73,11 @@ export class UpdateVendorDto { } export class CreateAcquisitionDto { + /** WHAT was acquired — required so an acquisition can't be saved empty. */ + @IsString() + @MinLength(2) + itemName!: string; + @IsOptional() @IsUUID() vehicleId?: string; @@ -120,6 +126,11 @@ export class CreateAcquisitionDto { } export class UpdateAcquisitionDto { + @IsOptional() + @IsString() + @MinLength(2) + itemName?: string; + @IsOptional() @IsUUID() vehicleId?: string; diff --git a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts index d4f781c15..e1a9f4ff5 100644 --- a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts +++ b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts @@ -18,6 +18,12 @@ export enum AcquisitionStatus { @Entity({ name: 'asset_acquisitions', schema: 'freight' }) @Index(['vehicleId', 'acquisitionDate']) export class AssetAcquisition extends BaseEntity { + /** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */ + @Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true }) + itemName?: string; + + /** Optional link — only when the acquisition IS a fleet vehicle. Parts and + * general procurement stay unlinked so reports don't misattribute them. */ @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) vehicleId?: string; diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts b/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts new file mode 100644 index 000000000..8004d9d9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ProcurementService } from './procurement.service'; +import { AcquisitionType } from './entities/asset-acquisition.entity'; + +// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may. +describe('ProcurementService acquisition lease-field guard', () => { + const repo = { + createAcquisition: jest.fn(async (dto) => dto), + findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })), + updateAcquisition: jest.fn(async (_id, dto) => dto), + }; + const svc = new ProcurementService(repo as never); + + it('rejects a PURCHASE with lease dates', async () => { + await expect( + svc.createAcquisition({ + itemName: 'Brake pads', + acquisitionType: AcquisitionType.PURCHASE, + acquisitionDate: '2026-07-22', + leaseStart: '2026-07-01', + } as never), + ).rejects.toThrow(BadRequestException); + }); + + it('accepts a LEASE with lease dates and a plain PURCHASE', async () => { + await expect( + svc.createAcquisition({ + itemName: 'Rented crane', + acquisitionType: AcquisitionType.LEASE, + acquisitionDate: '2026-07-22', + leaseStart: '2026-07-01', + leaseEnd: '2027-07-01', + } as never), + ).resolves.toBeDefined(); + await expect( + svc.createAcquisition({ + itemName: 'Brake pads', + acquisitionType: AcquisitionType.PURCHASE, + acquisitionDate: '2026-07-22', + } as never), + ).resolves.toBeDefined(); + }); + + it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => { + await expect( + svc.updateAcquisition('a1', { monthlyPayment: 500 } as never), + ).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts index e799d5ff9..7095a6a09 100644 --- a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts +++ b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { ProcurementRepository } from './procurement.repository'; import { Vendor } from './entities/vendor.entity'; -import { AssetAcquisition } from './entities/asset-acquisition.entity'; +import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity'; import { AssetDisposal } from './entities/asset-disposal.entity'; import { CreateVendorDto, @@ -51,7 +51,23 @@ export class ProcurementService { } // ---- Acquisitions ---- + /** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */ + private assertLeaseFieldsValid(dto: { + acquisitionType?: string; + leaseStart?: string; + leaseEnd?: string; + monthlyPayment?: number; + }): void { + if (dto.acquisitionType !== AcquisitionType.PURCHASE) return; + if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) { + throw new BadRequestException( + 'Lease start/end and monthly payment are not valid for a PURCHASE acquisition', + ); + } + } + async createAcquisition(dto: CreateAcquisitionDto): Promise { + this.assertLeaseFieldsValid(dto); return this.procurementRepository.createAcquisition(dto); } @@ -64,6 +80,20 @@ export class ProcurementService { } async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise { + // Validate against the resulting record, not just the patch — switching an + // acquisition to PURCHASE must also shed any stored lease terms. + const existing = await this.procurementRepository.findAcquisitionById(id); + if (existing) { + const next = { ...existing, ...dto }; + if (next.acquisitionType === AcquisitionType.PURCHASE) { + this.assertLeaseFieldsValid({ + acquisitionType: next.acquisitionType, + leaseStart: dto.leaseStart, + leaseEnd: dto.leaseEnd, + monthlyPayment: dto.monthlyPayment, + }); + } + } return this.procurementRepository.updateAcquisition(id, dto); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index b2bfc2842..614afe275 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2831,13 +2831,12 @@ export class TrainSchedulingService { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); - const company = booking?.company as Record | null | undefined; const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; const containerItems = allocation.containerItems ?? []; const firstContainer = containerItems[0]; @@ -2846,8 +2845,6 @@ export class TrainSchedulingService { const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` ${wagonCells} - ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} - ${esc(booking?.companyId)} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} @@ -2929,8 +2926,6 @@ export class TrainSchedulingService { Equated Length Tare Weight Load Capacity - Customer Name - Customer ID Cargo Type Container No Chassis No @@ -2938,7 +2933,7 @@ export class TrainSchedulingService { - ${rows || 'No wagons on this train set.'} + ${rows || 'No wagons on this train set.'} diff --git a/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts b/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts new file mode 100644 index 000000000..ec673a471 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +const toBoolean = ({ value }: { value: unknown }) => { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return value; +}; + +export class CreateTruckTypeDto { + @ApiProperty({ maxLength: 32, example: 'CASONI' }) + @IsString() + @MaxLength(32) + code!: string; + + @ApiProperty({ maxLength: 100, example: 'Casoni (rigid, no trailer)' }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiPropertyOptional({ + description: 'Payload capacity in metric tons — pre-fills a vehicle registered against this type', + example: 30, + }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0) + capacityTons?: number; + + @ApiPropertyOptional({ + description: 'Whether this configuration pulls a trailer. False (e.g. Casoni) forbids a trailer plate.', + default: false, + }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + hasTrailer?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts b/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts new file mode 100644 index 000000000..269bce685 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateTruckTypeDto } from './create-truck-type.dto'; + +export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts b/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts new file mode 100644 index 000000000..4a09dcd40 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts @@ -0,0 +1,40 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * A truck configuration EDR registers vehicles against — back-office managed so + * new configurations arrive without a code change. + * + * Two fields drive vehicle registration: + * - `capacityTons` pre-fills a vehicle's capacity (capacity belongs to the type, + * not to each individual truck). + * - `hasTrailer` decides whether a trailer plate applies at all. A rigid truck + * (e.g. Casoni) has none, and registering one with a trailer plate is rejected. + */ +@Entity({ schema: 'freight', name: 'truck_types' }) +@Index(['code']) +@Index(['isActive']) +export class TruckType extends BaseEntity { + /** + * Matching key, upper-case. Denormalised onto `vehicles.vehicle_type`, which + * truck-detention billing groups and matches fee rules by — so a code change + * here is a billing-visible change. + */ + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name!: string; + + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) + capacityTons?: number | null; + + @Column({ name: 'has_trailer', type: 'boolean', default: false }) + hasTrailer!: boolean; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts new file mode 100644 index 000000000..516d1d7d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + RuleEngineCreate, + RuleEngineDelete, + RuleEngineUpdate, + RuleEngineView, +} from '../../common/rule-engine-guards'; + +import { CreateTruckTypeDto } from './dto/create-truck-type.dto'; +import { UpdateTruckTypeDto } from './dto/update-truck-type.dto'; +import { TruckTypesService } from './truck-types.service'; + +@ApiTags('truck-types') +@Controller('truck-types') +@ApiBearerAuth() +export class TruckTypesController { + constructor(private readonly truckTypesService: TruckTypesService) {} + + @Get() + @RuleEngineView('truck-types') + @ApiOperation({ summary: 'List truck types' }) + findAll(@Query() query: Record) { + return this.truckTypesService.findAll({ + isActive: + query.isActive === 'all' + ? undefined + : query.isActive !== undefined + ? query.isActive === 'true' + : true, + page: query.page ? parseInt(query.page, 10) : undefined, + pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }); + } + + @Get(':id') + @RuleEngineView('truck-types') + @ApiOperation({ summary: 'Get a truck type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.truckTypesService.findById(id); + } + + @Post() + @RuleEngineCreate('truck-types') + @ApiOperation({ summary: 'Create a truck type' }) + create(@Body() dto: CreateTruckTypeDto) { + return this.truckTypesService.create(dto); + } + + @Patch(':id') + @RuleEngineUpdate('truck-types') + @ApiOperation({ summary: 'Update a truck type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTruckTypeDto) { + return this.truckTypesService.update(id, dto); + } + + @Delete(':id') + @RuleEngineDelete('truck-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a truck type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.truckTypesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts new file mode 100644 index 000000000..466caa91f --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TruckType } from './entities/truck-type.entity'; +import { TruckTypesController } from './truck-types.controller'; +import { TruckTypesRepository } from './truck-types.repository'; +import { TruckTypesService } from './truck-types.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([TruckType])], + controllers: [TruckTypesController], + providers: [TruckTypesRepository, TruckTypesService], + exports: [TruckTypesRepository, TruckTypesService], +}) +export class TruckTypesModule {} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts new file mode 100644 index 000000000..bb803bd8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts @@ -0,0 +1,20 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TruckType } from './entities/truck-type.entity'; + +@Injectable() +export class TruckTypesRepository extends BaseRepository { + constructor( + @InjectRepository(TruckType) + repository: Repository, + ) { + super(repository); + } + + findByCode(code: string): Promise { + return this.repository.findOne({ where: { code } }); + } +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts new file mode 100644 index 000000000..1cc6421d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts @@ -0,0 +1,116 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindOptionsOrder } from 'typeorm'; + +import { CreateTruckTypeDto } from './dto/create-truck-type.dto'; +import { UpdateTruckTypeDto } from './dto/update-truck-type.dto'; +import { TruckType } from './entities/truck-type.entity'; +import { TruckTypesRepository } from './truck-types.repository'; + +type TruckTypeListFilter = { + isActive?: boolean; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: string; +}; + +@Injectable() +export class TruckTypesService { + constructor(private readonly truckTypesRepository: TruckTypesRepository) {} + + async findAll(filter: TruckTypeListFilter = {}): Promise<{ + data: TruckType[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 500; + const sortBy = ['code', 'name', 'capacityTons', 'hasTrailer', 'isActive'].includes( + filter.sortBy ?? '', + ) + ? (filter.sortBy as keyof TruckType) + : 'code'; + const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + const [data, total] = await this.truckTypesRepository.findAndCount({ + where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data, + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + async findById(id: string): Promise { + const truckType = await this.truckTypesRepository.findById(id); + + if (!truckType) { + throw new NotFoundException(`Truck type ${id} not found`); + } + + return truckType; + } + + async findByCode(code: string): Promise { + const truckType = await this.truckTypesRepository.findByCode(code); + if (!truckType) { + throw new NotFoundException(`Truck type ${code} not found`); + } + return truckType; + } + + async create(dto: CreateTruckTypeDto): Promise { + const code = dto.code.trim().toUpperCase(); + const existing = await this.truckTypesRepository.findByCode(code); + + if (existing) { + throw new ConflictException(`Truck type code "${code}" already exists`); + } + + return this.truckTypesRepository.create({ + code, + name: dto.name.trim(), + capacityTons: dto.capacityTons ?? null, + hasTrailer: dto.hasTrailer ?? false, + description: dto.description?.trim() ?? null, + isActive: dto.isActive ?? true, + }); + } + + async update(id: string, dto: UpdateTruckTypeDto): Promise { + const truckType = await this.findById(id); + const nextCode = dto.code?.trim().toUpperCase(); + + if (nextCode && nextCode !== truckType.code) { + const existing = await this.truckTypesRepository.findByCode(nextCode); + if (existing) { + throw new ConflictException(`Truck type code "${nextCode}" already exists`); + } + } + + const updated = await this.truckTypesRepository.update(id, { + ...dto, + ...(nextCode ? { code: nextCode } : {}), + ...(dto.name ? { name: dto.name.trim() } : {}), + }); + + if (!updated) { + throw new NotFoundException(`Truck type ${id} not found`); + } + + return updated; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.truckTypesRepository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 4dda3c053..79ecc76c5 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,6 +1,11 @@ import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator'; import { Transform } from 'class-transformer'; -import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; +import { + FuelType, + VehicleAvailability, + VehicleOwnership, + VehicleStatus, +} from '../entities/vehicle.entity'; /** * A vehicle plate is two or three letters, a hyphen, then two to six digits — @@ -28,8 +33,9 @@ export class CreateVehicleDto { @IsString() plateNumber!: string; - @IsEnum(VehicleType) - vehicleType!: VehicleType; + /** Truck configuration from `freight.truck_types` — drives capacity and whether a trailer plate applies. */ + @IsUUID() + truckTypeId!: string; @IsString() manufacturer!: string; @@ -43,8 +49,18 @@ export class CreateVehicleDto { @IsEnum(FuelType) fuelType!: FuelType; + /** Defaults to the truck type's capacity when omitted. */ + @IsOptional() @IsNumber() - capacity!: number; + capacity?: number; + + @IsOptional() + @IsString() + vin?: string; + + @IsOptional() + @IsEnum(VehicleOwnership) + ownership?: VehicleOwnership; @IsEnum(VehicleStatus) status!: VehicleStatus; diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 534019bc4..59725fc5e 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -1,6 +1,17 @@ import { Entity, Column } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; +/** + * Legacy classification. Truck configurations are now back-office data in + * `freight.truck_types` — register a vehicle with `truckTypeId`, not this. + * + * The `vehicle_type` COLUMN survives as a denormalised copy of the truck type's + * code because truck-detention billing groups by it in raw SQL and matches it + * against `warehouse_fee_rules.vehicle_type`. The service writes it through on + * every save; nothing should set it by hand. + * + * @deprecated use `truckTypeId` / `freight.truck_types` + */ export enum VehicleType { TRUCK = 'TRUCK', VAN = 'VAN', @@ -11,6 +22,12 @@ export enum VehicleType { FLATBED = 'FLATBED', } +/** Who supplies the truck. Supplier selection is deferred until EDR commits to outsourcing. */ +export enum VehicleOwnership { + OWNED = 'OWNED', + OUTSOURCED = 'OUTSOURCED', +} + export enum FuelType { PETROL = 'PETROL', DIESEL = 'DIESEL', @@ -47,8 +64,12 @@ export class Vehicle extends BaseEntity { @Column({ name: 'registration_number', unique: true, nullable: true }) registrationNumber?: string; + /** Denormalised `truck_types.code` — written through by the service, never set by hand. */ @Column({ name: 'vehicle_type', type: 'varchar', nullable: true }) - vehicleType?: VehicleType; + vehicleType?: string; + + @Column({ name: 'truck_type_id', type: 'uuid', nullable: true }) + truckTypeId?: string | null; @Column({ nullable: true }) manufacturer?: string; @@ -101,7 +122,7 @@ export class Vehicle extends BaseEntity { @Column({ name: 'vin', type: 'varchar', nullable: true }) vin?: string; - /** Owned | Leased | Rented */ + /** OWNED | OUTSOURCED — see {@link VehicleOwnership}. */ @Column({ name: 'ownership', type: 'varchar', nullable: true }) ownership?: string; diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts index cb810abaf..6171602d7 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts @@ -11,6 +11,7 @@ describe('VehiclesService driver assignment guard', () => { new VehiclesService( { findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any, { record: jest.fn() } as any, + { findById: jest.fn(async () => ({ code: 'TRUCK', name: 'Truck', hasTrailer: true })) } as any, ); it('rejects create when the driver is on another truck', async () => { @@ -18,7 +19,7 @@ describe('VehiclesService driver assignment guard', () => { const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck); const svc = makeService(findOne); await expect( - svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any), + svc.create({ plateNumber: '3-22222', truckTypeId: 'tt1', assignedDriverId: 'd1' } as any), ).rejects.toThrow(ConflictException); }); diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts index 07aa4bd2f..a3febac70 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Vehicle } from './entities/vehicle.entity'; import { VehiclesService } from './vehicles.service'; import { VehiclesController } from './vehicles.controller'; +import { TruckTypesModule } from '../truck-types/truck-types.module'; @Module({ - imports: [TypeOrmModule.forFeature([Vehicle])], + imports: [TypeOrmModule.forFeature([Vehicle]), TruckTypesModule], providers: [VehiclesService], controllers: [VehiclesController], exports: [VehiclesService], diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 98d38cbce..30c72e1ba 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -1,9 +1,16 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity'; +import { TruckType } from '../truck-types/entities/truck-type.entity'; +import { TruckTypesService } from '../truck-types/truck-types.service'; import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity'; import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity'; import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; @@ -18,8 +25,26 @@ export class VehiclesService { @InjectRepository(Vehicle) private readonly vehicleRepo: Repository, private readonly history: FleetHistoryService, + private readonly truckTypes: TruckTypesService, ) {} + /** + * A trailer plate only exists on a configuration that pulls a trailer — a + * rigid truck (Casoni) has none. Checked against the RESULTING record, not + * just the patch, so switching an articulated truck to a rigid type cannot + * leave its old trailer plate stranded on the row. + */ + private assertTrailerPlateAllowed( + truckType: TruckType, + trailerPlateNo?: string | null, + ): void { + if (!truckType.hasTrailer && trailerPlateNo) { + throw new BadRequestException( + `${truckType.name} has no trailer — remove the trailer plate number`, + ); + } + } + /** * A driver holds one truck at a time — reassignment requires detaching them * from their current truck first. @@ -54,10 +79,17 @@ export class VehiclesService { await this.assertDriverUnassigned(dto.assignedDriverId); } - const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`; + const truckType = await this.truckTypes.findById(dto.truckTypeId); + this.assertTrailerPlateAllowed(truckType, dto.trailerPlateNo); + + const registrationNumber = `REG-${truckType.code}-${Date.now()}`; const vehicle = this.vehicleRepo.create({ ...dto, registrationNumber, + // Denormalised for truck-detention billing, which groups on this column. + vehicleType: truckType.code, + // Capacity belongs to the type; an explicit value still wins for one-offs. + capacity: dto.capacity ?? truckType.capacityTons ?? undefined, }); const saved = await this.vehicleRepo.save(vehicle); @@ -148,6 +180,17 @@ export class VehiclesService { await this.assertDriverUnassigned(dto.assignedDriverId, id); } + // Re-resolve the truck type whenever the type OR the trailer plate moves — + // either edit can produce a rigid truck holding a trailer plate. + const nextTruckTypeId = dto.truckTypeId ?? vehicle.truckTypeId; + let nextTruckType: TruckType | null = null; + if (nextTruckTypeId && (dto.truckTypeId !== undefined || dto.trailerPlateNo !== undefined)) { + nextTruckType = await this.truckTypes.findById(nextTruckTypeId); + const nextTrailerPlate = + dto.trailerPlateNo !== undefined ? dto.trailerPlateNo : vehicle.trailerPlateNo; + this.assertTrailerPlateAllowed(nextTruckType, nextTrailerPlate); + } + const prev = { assignedDriverId: vehicle.assignedDriverId, assignedDriverName: vehicle.assignedDriverName, @@ -156,6 +199,11 @@ export class VehiclesService { }; Object.assign(vehicle, dto); + // After the patch is applied, so the denormalised billing code always + // reflects the type the vehicle actually ends up on. + if (nextTruckType) { + vehicle.vehicleType = nextTruckType.code; + } const saved = await this.vehicleRepo.save(vehicle); // Driver (re)assignment — emit an unassign for the old driver and/or an diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts new file mode 100644 index 000000000..e8f73a38f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts @@ -0,0 +1,81 @@ +import { BadRequestException } from '@nestjs/common'; + +import { VehiclesService } from './vehicles.service'; + +// A trailer plate only exists on a configuration that pulls a trailer. A rigid +// truck (Casoni) has none, so registering or editing one into a trailer plate +// must be refused server-side — the form hiding the field is not enforcement. +describe('VehiclesService trailer plate guard', () => { + const CASONI = { code: 'CASONI', name: 'Casoni (rigid, no trailer)', hasTrailer: false, capacityTons: 30 }; + const ARTIC = { code: 'TRUCK', name: 'Truck', hasTrailer: true, capacityTons: 40 }; + + const makeService = (findOne: jest.Mock, truckType: unknown) => { + const save = jest.fn(async (x) => x); + const svc = new VehiclesService( + { findOne, create: jest.fn((x) => x), save } as any, + { record: jest.fn() } as any, + { findById: jest.fn(async () => truckType) } as any, + ); + return { svc, save }; + }; + + it('rejects creating a rigid truck that carries a trailer plate', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); // plate is free + const { svc } = makeService(findOne, CASONI); + await expect( + svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni', trailerPlateNo: 'ET-1234' } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('accepts a rigid truck with no trailer plate, and takes capacity from the type', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); + const { svc } = makeService(findOne, CASONI); + const saved = await svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni' } as any); + expect(saved.capacity).toBe(30); + // Denormalised code is what truck-detention billing groups on. + expect(saved.vehicleType).toBe('CASONI'); + }); + + it('keeps an explicit capacity over the type default', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); + const { svc } = makeService(findOne, CASONI); + const saved = await svc.create({ + plateNumber: 'ET-9875', + truckTypeId: 'tt-casoni', + capacity: 25, + } as any); + expect(saved.capacity).toBe(25); + }); + + it('allows a trailer plate on an articulated type', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); + const { svc } = makeService(findOne, ARTIC); + await expect( + svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-truck', trailerPlateNo: 'ET-1234' } as any), + ).resolves.toBeDefined(); + }); + + // The regression that motivated validating the RESULT rather than the patch: + // switching type alone leaves the stored trailer plate behind. + it('rejects switching an existing truck to a rigid type while its trailer plate stands', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' }); + const { svc } = makeService(findOne, CASONI); + await expect(svc.update('v1', { truckTypeId: 'tt-casoni' } as any)).rejects.toThrow( + BadRequestException, + ); + }); + + it('allows the switch when the trailer plate is cleared in the same edit', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' }); + const { svc } = makeService(findOne, CASONI); + const saved = await svc.update('v1', { + truckTypeId: 'tt-casoni', + trailerPlateNo: null, + } as any); + expect(saved.vehicleType).toBe('CASONI'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts index c90fb5a16..85f66b74f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -52,4 +52,8 @@ export class BookingHandover extends BaseEntity { /** EDR last-mile: when the goods were delivered to the customer. */ @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) deliveredAt?: Date | null; + + /** URL to the signer's saved signature image, if available at sign time. */ + @Column({ name: 'signature_image_url', type: 'text', nullable: true }) + signatureImageUrl?: string | null; } diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index 81f83a57a..d7bb881cd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -287,6 +287,7 @@ export class HandoverService { handoverId: string, userId?: string | null, signerName?: string | null, + signatureImageUrl?: string | null, ): Promise { const repo = this.dataSource.getRepository(BookingHandover); const handover = await repo.findOne({ where: { id: handoverId } }); @@ -297,6 +298,7 @@ export class HandoverService { handover.signedAt = new Date(); handover.signedByUserId = userId ?? null; handover.signerName = signerName?.trim() || null; + handover.signatureImageUrl = signatureImageUrl ?? null; return repo.save(handover); } @@ -305,6 +307,7 @@ export class HandoverService { bookingId: string, userId?: string | null, signerName?: string | null, + signatureImageUrl?: string | null, ): Promise { await this.dataSource .getRepository(BookingHandover) @@ -314,6 +317,7 @@ export class HandoverService { signedAt: new Date(), signedByUserId: userId ?? null, signerName: signerName?.trim() || null, + signatureImageUrl: signatureImageUrl ?? null, }, ); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 3b61d7ff0..a60261c1e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -791,15 +791,22 @@ export class WarehouseFeeService { }; } - // Group the leg's vehicles by type so each truck type is billed by its own - // matching rule (rates differ by truck type). Falls back to one untyped group. + // Group the leg's vehicles by CANONICAL truck type so each type is billed + // by its own matching rule (rates differ by truck type). The FK to + // truck_types is the source of truth — renaming a type's label no longer + // silently unmatches its rule; the normalized legacy vehicle_type code is + // only a fallback for vehicles without the FK (LEFT JOIN keeps them billed + // instead of dropping them). Falls back to one untyped group. const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = await this.dataSource.query( - `SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount" + `SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", + count(*)::int AS "truckCount" FROM freight.last_mile_vehicle_assignments va JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN freight.truck_types t + ON t.id = v.truck_type_id AND t.deleted_at IS NULL WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL - GROUP BY v.vehicle_type`, + GROUP BY 1`, [lastMileId], ); const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts index 7bd56e593..cdf34cd66 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -20,8 +20,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service'; @ApiTags('warehouse-inspection') @ApiBearerAuth() +// Baseline read: inspection reports are opened from inventory screens too — +// either view permission grants reads; writes stack their own per route. @Controller() -@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view) +@BookingStaff([ + FREIGHT_PERMS.warehouseInspectionReports.view, + FREIGHT_PERMS.warehouseInventory.view, +]) export class WarehouseInspectionController { constructor(private readonly inspectionService: WarehouseInspectionService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 0174ef3e9..b3868b465 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { actorLabel } from './current-actor.util'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, StaffReference } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; @@ -456,6 +456,7 @@ export class WarehouseInventoryController { } @Get(':id/handover-document') + @StaffReference() @ApiOperation({ summary: 'View import goods handover document PDF' }) async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.handoverDocument(id); @@ -466,6 +467,7 @@ export class WarehouseInventoryController { } @Post('bookings/:bookingId/approve-delivery') + @StaffReference() @ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" }) approveDeliveryForBooking( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -481,12 +483,14 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/handovers') + @StaffReference() @ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' }) bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.handoverService.list(bookingId); } @Post('handovers/:handoverId/sign') + @StaffReference() @ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' }) signHandover( @Param('handoverId', ParseUUIDPipe) handoverId: string, @@ -502,12 +506,14 @@ export class WarehouseInventoryController { } @Post('bookings/:bookingId/request-handover-signature') + @StaffReference() @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.handoverService.requestSignature(bookingId); } @Get('bookings/:bookingId/grn-document') + @StaffReference() @ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' }) async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId); @@ -518,6 +524,7 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/release-document') + @StaffReference() @ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' }) async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId); @@ -528,6 +535,7 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/handover-document') + @StaffReference() @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' }) async bookingHandoverDocument( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -545,18 +553,21 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/container-items') + @StaffReference() @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.containerItems(bookingId); } @Get('bookings/:bookingId/container-weights') + @StaffReference() @ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" }) containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.bookingContainerWeights(bookingId); } @Get('bookings/:bookingId/location') + @StaffReference() @ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" }) bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.bookingLocation(bookingId); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index bd702542b..582face93 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1578,6 +1578,14 @@ export class WarehouseInventoryService { notes: `Bulk received (${dto.direction})`, truckEntrance, }); + + // Validate capacity before saving + const weight = Number(booking.weight) || 0; + const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0; + this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); + this.assertCapacity('Yard', yard, weight, 0, containerCount); + this.assertCapacity('Zone', zone, weight, 0, containerCount); + const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, @@ -1585,7 +1593,7 @@ export class WarehouseInventoryService { zoneId: dto.zoneId, bookingId, quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, - weight: Number(booking.weight) || 0, + weight, grnNumber, status: 'RECEIVED', arrivedAt: now, @@ -1593,6 +1601,9 @@ export class WarehouseInventoryService { }), ); + // Update warehouse/yard/zone capacity counters + await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); + // Receiving the booking flags every container unit as received into the // port (self-haul export: the delivering truck's goods are now in) so // staff can raise the per-container GRN over what's received. @@ -2493,27 +2504,37 @@ export class WarehouseInventoryService { }); if (result.unloadedCount > 0) { - let document = await this.interchangeDocuments.generateFromSchedule({ - scheduleId, - direction: 'EXPORT', - handoverLocation: schedule.destinationName ?? 'Djibouti Port', - handoverFrom: 'EDR', - handoverTo: 'Djibouti Port Operator', - portOperatorName: 'Doraleh Multipurpose Port', - generatedBy: performedBy ?? 'EDR Operations', - remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', - }); - if (document.status !== 'ACKNOWLEDGED') { - document = await this.interchangeDocuments.acknowledge(document.id, { - acknowledgedBy: 'Djibouti Port Operator', - remarks: 'Auto acknowledged after Djibouti export unloading.', + // Best-effort: the unload is already committed — a paperwork failure must + // not fail the response (it did once: items unloaded, request 500'd, and + // the document only appeared after a manual retry days later). The doc + // backfills on any retry since already-unloaded items count as unloaded. + try { + let document = await this.interchangeDocuments.generateFromSchedule({ + scheduleId, + direction: 'EXPORT', + handoverLocation: schedule.destinationName ?? 'Djibouti Port', + handoverFrom: 'EDR', + handoverTo: 'Djibouti Port Operator', + portOperatorName: 'Doraleh Multipurpose Port', + generatedBy: performedBy ?? 'EDR Operations', + remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', }); + if (document.status !== 'ACKNOWLEDGED') { + document = await this.interchangeDocuments.acknowledge(document.id, { + acknowledgedBy: 'Djibouti Port Operator', + remarks: 'Auto acknowledged after Djibouti export unloading.', + }); + } + result.interchangeDocument = { + id: document.id, + documentNo: document.documentNo, + status: document.status, + }; + } catch (err) { + this.logger.warn( + `Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`, + ); } - result.interchangeDocument = { - id: document.id, - documentNo: document.documentNo, - status: document.status, - }; } return result; @@ -4076,10 +4097,11 @@ export class WarehouseInventoryService { await this.invoices.assertClearanceAllowed(item.id); const approvedAt = new Date(); + const signatureImageUrl = signature?.signatureImageUrl ?? null; const approval = { approvedAt: approvedAt.toISOString(), signerDisplayName: name, - signatureImageUrl: signature?.signatureImageUrl ?? null, + signatureImageUrl, userId, }; const existingNotes = this.stripCustomerDeliveryApproval(item.notes); @@ -4103,7 +4125,7 @@ export class WarehouseInventoryService { // Sign the structured handover record(s) for this booking (self-haul: before // the truck leaves). Kept alongside the legacy approval note. - await this.handover.signForBooking(bookingId, userId, name); + await this.handover.signForBooking(bookingId, userId, name, signatureImageUrl); return { bookingId, @@ -4138,6 +4160,8 @@ export class WarehouseInventoryService { throw new BadRequestException('Please enter your full name to sign the handover'); } + const signature = await this.signatures.getForUser(userId).catch(() => null); + const [h]: Array<{ bookingId: string; reference: string; @@ -4170,7 +4194,7 @@ export class WarehouseInventoryService { ); if (inv) await this.invoices.assertClearanceAllowed(inv.id); - const signed = await this.handover.sign(handoverId, userId, name); + const signed = await this.handover.sign(handoverId, userId, name, signature?.signatureImageUrl ?? null); const allSigned = await this.handover.isFullySigned(h.bookingId); if (inv) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index 4ad469ce0..c2f5cec9c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { actorLabel } from './current-actor.util'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, StaffReference } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; @@ -43,6 +43,7 @@ export class WarehouseInvoiceController { } @Get('bookings/:id/warehouse-fee-invoices') + @StaffReference() @ApiOperation({ summary: 'List warehouse fee invoices for a booking' }) listForBooking(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.listForBooking(id); @@ -70,12 +71,14 @@ export class WarehouseInvoiceController { } @Get('warehouse-fee-invoices/:id') + @StaffReference() @ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.findById(id); } @Get('warehouse-fee-invoices/:id/document') + @StaffReference() @ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' }) async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.invoiceService.document(id); @@ -86,6 +89,7 @@ export class WarehouseInvoiceController { } @Get('warehouse-fee-invoices/:id/receipt') + @StaffReference() @ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' }) async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.invoiceService.receipt(id); @@ -110,6 +114,7 @@ export class WarehouseInvoiceController { } @Post('warehouse-fee-invoices/:id/pay-online') + @StaffReference() @ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' }) payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) { return this.invoiceService.initiatePayment(id, dto); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index 5f4205815..7e658d9db 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { BookingStaff, StaffReference } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; @@ -10,8 +10,8 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-yards') @ApiBearerAuth() -// No class-level guard: the two reference GETs are open to any signed-in -// staff (StaffReference), every other route carries its own permission. +// No class-level guard: every route carries its own permission (reads accept +// yard-view OR inventory-view so inventory flows can populate yard pickers). @Controller('warehouse-yards') export class WarehouseYardsController { constructor( @@ -20,14 +20,14 @@ export class WarehouseYardsController { ) {} @Get() - @StaffReference() + @BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view]) @ApiOperation({ summary: 'List all warehouse yards' }) findAll() { return this.yardsService.findAll(); } @Get(':id') - @StaffReference() + @BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view]) @ApiOperation({ summary: 'Get warehouse yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.yardsService.findById(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 3279e9092..5b5e2b227 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; @@ -44,6 +44,7 @@ export class WarehouseYardsService { // Ensure the parent warehouse exists. await this.warehousesService.findById(warehouseId); await this.assertCodeUnique(warehouseId, dto.code.trim()); + await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null); return this.yardsRepository.create({ warehouseId, @@ -69,14 +70,22 @@ export class WarehouseYardsService { await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id); } + const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null; + const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null; + + // Validate updated capacity doesn't exceed warehouse limits + if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) { + await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id); + } + const status = dto.status ?? existing.status; const updated = await this.yardsRepository.update(id, { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + capacityWeight: newCapacityWeight, + capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, @@ -97,4 +106,39 @@ export class WarehouseYardsService { throw new ConflictException(`Yard code ${code} already exists in this warehouse`); } } + + private async assertCapacityWithinWarehouse( + warehouseId: string, + newCapacityWeight: number | null, + newCapacityContainers: number | null, + excludeYardId?: string, + ): Promise { + const warehouse = await this.warehousesService.findById(warehouseId); + const yards = await this.findByWarehouse(warehouseId); + + // Sum existing yard capacities, excluding the yard being updated if provided + const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards; + const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0); + const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0); + + // Check weight capacity + if (newCapacityWeight !== null && warehouse.capacityWeight != null) { + const totalWeight = totalExistingWeight + newCapacityWeight; + if (totalWeight > warehouse.capacityWeight) { + throw new BadRequestException( + `Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`, + ); + } + } + + // Check container capacity + if (newCapacityContainers !== null && warehouse.capacityContainers != null) { + const totalContainers = totalExistingContainers + newCapacityContainers; + if (totalContainers > warehouse.capacityContainers) { + throw new BadRequestException( + `Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index b0371cbcc..594fd7a6f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -8,8 +8,11 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-zones') @ApiBearerAuth() +// Baseline read: zone reference data also serves inventory flows (allocation, +// receive/move pickers) — either view permission grants reads; writes stack +// their specific permission per route. @Controller('warehouse-zones') -@BookingStaff(FREIGHT_PERMS.warehouseZones.view) +@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view]) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index b4ae2e0de..367a5a75e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; @@ -43,6 +43,7 @@ export class WarehouseZonesService { // Ensure the parent yard exists. await this.yardsService.findById(yardId); await this.assertCodeUnique(yardId, dto.code.trim()); + await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null); return this.zonesRepository.create({ yardId, @@ -68,14 +69,22 @@ export class WarehouseZonesService { await this.assertCodeUnique(existing.yardId, dto.code.trim(), id); } + const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null; + const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null; + + // Validate updated capacity doesn't exceed yard limits + if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) { + await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id); + } + const status = dto.status ?? existing.status; const updated = await this.zonesRepository.update(id, { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + capacityWeight: newCapacityWeight, + capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, @@ -96,4 +105,39 @@ export class WarehouseZonesService { throw new ConflictException(`Zone code ${code} already exists in this yard`); } } + + private async assertCapacityWithinYard( + yardId: string, + newCapacityWeight: number | null, + newCapacityContainers: number | null, + excludeZoneId?: string, + ): Promise { + const yard = await this.yardsService.findById(yardId); + const zones = await this.findByYard(yardId); + + // Sum existing zone capacities, excluding the zone being updated if provided + const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones; + const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0); + const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0); + + // Check weight capacity + if (newCapacityWeight !== null && yard.capacityWeight != null) { + const totalWeight = totalExistingWeight + newCapacityWeight; + if (totalWeight > yard.capacityWeight) { + throw new BadRequestException( + `Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`, + ); + } + } + + // Check container capacity + if (newCapacityContainers !== null && yard.capacityContainers != null) { + const totalContainers = totalExistingContainers + newCapacityContainers; + if (totalContainers > yard.capacityContainers) { + throw new BadRequestException( + `Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts index 63c40de94..3ee381a8c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -13,8 +13,15 @@ import { WarehousesService } from './warehouses.service'; @ApiTags('warehouses') @ApiBearerAuth() +// Baseline read: warehouse reference data is consumed by inventory/dashboard +// flows too, so any of the three view permissions grants reads. Writes stack +// their specific create/update permission per route on top. @Controller('warehouses') -@BookingStaff(FREIGHT_PERMS.warehouses.view) +@BookingStaff([ + FREIGHT_PERMS.warehouses.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseDashboard.view, +]) export class WarehousesController { constructor( private readonly warehousesService: WarehousesService, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 64f164f6d..537bac332 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -19,6 +19,9 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ 'rates', 'approval-rules', 'yard-distances', + // Keep new slugs at the END: ruleEngineCrudId derives ids from list index, + // so a mid-list insert would shift ids already seeded for later slugs. + 'truck-types', ] as const; export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; @@ -101,6 +104,7 @@ const RULE_ENGINE_VIEW_IDS: Record = { 'cargo-types': 'b2000001-0001-4000-8000-000000000001', 'container-types': 'b2000001-0001-4000-8000-000000000003', 'wagon-types': 'b2000001-0001-4000-8000-000000000015', + 'truck-types': 'b2000001-0001-4000-8000-00000000001a', 'service-types': 'b2000001-0001-4000-8000-000000000005', yards: 'b2000001-0001-4000-8000-000000000007', 'shipping-lines': 'b2000001-0001-4000-8000-000000000009', @@ -123,7 +127,7 @@ const ruleEngineCrudId = ( const n = RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 + RULE_ENGINE_CRUD_ACTIONS.indexOf(action) + - 1; // 1..33 + 1; // 1..36 return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`; }; @@ -902,41 +906,41 @@ export const POSITION_PERMISSION_PRESETS = { // permission catalog (all CRUD across bookings, contracts, scheduling, // fleet, warehouse, mile, finance, settings, staff). operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]), - // Dispatcher: warehouse floor operations — receive/GRN, move, load/unload, - // inspect, dispatch, gate, release/deliver, interchange docs, fee invoices, - // plus truck dispatch on the mile legs and read-only operational context. - // Allocation & fee rules are VIEW-ONLY — never create/update/delete. + // Dispatcher: full CRUD on warehouse management (incl. import/export/intercity + // inventory flows) and fleet management, plus truck dispatch on the mile legs + // and operational context. The ONE carve-out: allocation & fee rules stay + // VIEW-ONLY — a dispatcher never creates/updates/deletes those rules. dispatcher: dedupe([ + // Warehouse management — full CRUD. FREIGHT_PERMS.warehouseDashboard.view, - FREIGHT_PERMS.warehouses.view, - FREIGHT_PERMS.warehouseYards.view, - FREIGHT_PERMS.warehouseZones.view, - FREIGHT_PERMS.warehouseInventory.view, - FREIGHT_PERMS.warehouseInventory.receive, - FREIGHT_PERMS.warehouseInventory.move, - FREIGHT_PERMS.warehouseInventory.load, - FREIGHT_PERMS.warehouseInventory.unload, - FREIGHT_PERMS.warehouseInventory.dispatch, - FREIGHT_PERMS.warehouseInventory.gatePass, - FREIGHT_PERMS.warehouseInventory.release, - FREIGHT_PERMS.warehouseInventory.deliver, - FREIGHT_PERMS.warehouseInventory.inspect, - FREIGHT_PERMS.warehouseInspectionReports.view, - FREIGHT_PERMS.warehouseInspectionReports.create, - FREIGHT_PERMS.warehouseInspectionReports.update, - FREIGHT_PERMS.interchangeDocuments.view, - FREIGHT_PERMS.interchangeDocuments.generate, - FREIGHT_PERMS.interchangeDocuments.acknowledge, - FREIGHT_PERMS.warehouseFeeInvoices.view, - FREIGHT_PERMS.warehouseFeeInvoices.generate, + ...Object.values(FREIGHT_PERMS.warehouses), + ...Object.values(FREIGHT_PERMS.warehouseYards), + ...Object.values(FREIGHT_PERMS.warehouseZones), + ...Object.values(FREIGHT_PERMS.warehouseInventory), + ...Object.values(FREIGHT_PERMS.warehouseInspectionReports), + ...Object.values(FREIGHT_PERMS.interchangeDocuments), + ...Object.values(FREIGHT_PERMS.warehouseFeeInvoices), // View-only on the rules that govern allocation and fees. FREIGHT_PERMS.warehouseAllocationRules.view, FREIGHT_PERMS.warehouseFeeRules.view, + // Fleet management — full CRUD. + ...Object.values(FREIGHT_PERMS.fleet), + FREIGHT_PERMS.fleetDashboard.view, + ...Object.values(FREIGHT_PERMS.fleetReports), + ...Object.values(FREIGHT_PERMS.vehicles), + ...Object.values(FREIGHT_PERMS.drivers), + ...Object.values(FREIGHT_PERMS.tracking), + ...Object.values(FREIGHT_PERMS.fuel), + ...Object.values(FREIGHT_PERMS.maintenance), + ...Object.values(FREIGHT_PERMS.locomotives), + ...Object.values(FREIGHT_PERMS.wagons), + ...Object.values(FREIGHT_PERMS.trains), + ...Object.values(FREIGHT_PERMS.routes), + ...Object.values(FREIGHT_PERMS.containers), + ...Object.values(FREIGHT_PERMS.cargoes), // Truck dispatch on the EDR mile legs + operational context. - FREIGHT_PERMS.firstMile.view, - FREIGHT_PERMS.firstMile.assignVehicles, - FREIGHT_PERMS.lastMile.view, - FREIGHT_PERMS.lastMile.assignVehicles, + ...Object.values(FREIGHT_PERMS.firstMile), + ...Object.values(FREIGHT_PERMS.lastMile), FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.bookings.operations, ]), diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index af49ff263..bb4d024c8 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -632,7 +632,9 @@ const filterSidebarByPermission = ( const filterItems = (items: SidebarItem[]): SidebarItem[] => items .map((item) => - item.children ? { ...item, children: filterItems(item.children) } : item, + item.children + ? { ...item, children: filterItems(item.children) } + : item, ) .filter((item) => { if (etGl || djGl) { @@ -800,8 +802,22 @@ const App = () => { } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> Pickup date setPickupDate(e.target.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx new file mode 100644 index 000000000..92ab514ad --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx @@ -0,0 +1,96 @@ +import { Button, Group, TextInput } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import { Search, X } from "lucide-react"; +import type { ReactNode } from "react"; + +export interface ListControlsProps { + search: string; + onSearchChange: (value: string) => void; + searchPlaceholder?: string; + /** `YYYY-MM-DD`, matching Mantine 9's date inputs. */ + dateFrom: string | null; + onDateFromChange: (value: string | null) => void; + dateTo: string | null; + onDateToChange: (value: string | null) => void; + /** Label above the range, naming the date being filtered (e.g. "Arrival date"). */ + dateLabel?: string; + hasFilters?: boolean; + onReset?: () => void; + /** Page-specific selects (status, warehouse…) rendered after the date range. */ + children?: ReactNode; + showSearch?: boolean; + showDateRange?: boolean; +} + +/** + * Search box + inclusive date range + clear, shared by every freight list so the + * controls sit in the same place and behave the same way on all of them. + * Pair with `useListControls`, which owns the state and does the filtering. + */ +const ListControls = ({ + search, + onSearchChange, + searchPlaceholder = "Search…", + dateFrom, + onDateFromChange, + dateTo, + onDateToChange, + dateLabel, + hasFilters, + onReset, + children, + showSearch = true, + showDateRange = true, +}: ListControlsProps) => ( + + {showSearch && ( + onSearchChange(e.currentTarget.value)} + leftSection={} + style={{ flex: "1 1 240px", minWidth: 200 }} + /> + )} + + {showDateRange && ( + <> + + + + )} + + {children} + + {hasFilters && onReset && ( + + )} + +); + +export default ListControls; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index c7893659b..ff99be149 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -23,6 +23,8 @@ import { import { useState } from "react"; import { useFileViewer } from "@edr/ui-common"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { fetchViewableFile } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company, CompanyChangeRequest } from "@/types/customer"; @@ -128,6 +130,8 @@ function DiffRow({ * (with note) actions, plus a short history of past decisions. */ export function ChangeRequestReview({ company }: { company: Company }) { + const { user } = useAuth(); + const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify); const query = useQuery( api.customers.changeRequests.queryOptions({ input: { id: company.id } }), ); @@ -323,25 +327,30 @@ export function ChangeRequestReview({ company }: { company: Company }) { )} - - - - + {/* Reviewing the diff is `customers:view`; deciding on it is + `customers:verify`. Without it the request stays readable but + un-actionable. */} + {canReview && ( + + + + + )} )} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 04267cc7b..112026ca6 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -11,6 +11,8 @@ import { } from "@mantine/core"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import type { @@ -286,6 +288,19 @@ export function InvoiceStatusBadge({ * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so * an already-active profile is still managable. */ +/** + * Which permission each status write needs. Mirrors `STATUS_PERM` in the API's + * `companies.controller.ts` — approving is a different authority from + * suspending, and both go through the same endpoint. Keep the two in step. + */ +const STATUS_PERM: Record = { + active: FREIGHT_PERMS.customers.verify, + pending: FREIGHT_PERMS.customers.verify, + rejected: FREIGHT_PERMS.customers.verify, + suspended: FREIGHT_PERMS.customers.deactivate, + blacklisted: FREIGHT_PERMS.customers.deactivate, +}; + export function ProfileApprovalActions({ profileId, status, @@ -295,6 +310,10 @@ export function ProfileApprovalActions({ status: ProfileStatus; locked?: boolean; }) { + const { user } = useAuth(); + /** The API rejects these anyway — hide rather than offer a button that 403s. */ + const canSet = (next: ProfileStatus) => + hasPermission(user, STATUS_PERM[next]); const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), ); @@ -414,35 +433,41 @@ export function ProfileApprovalActions({ } if (status === "pending") { + if (!canSet("active") && !canSet("rejected")) return null; return ( <> {decisionModal} - - + {canSet("active") && ( + + )} + {canSet("rejected") && ( + + )} ); } if (status === "rejected") { + if (!canSet("active")) return null; return ( - + {canSet("active") && ( + + )} + {canSet("blacklisted") && ( + + )} ); } if (status === "blacklisted") { + if (!canSet("pending")) return null; return ( diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index d8b370de0..88c6de18c 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -18,6 +18,11 @@ import { LoadInventoryModal } from './LoadInventoryModal'; import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { WarehouseInventoryTable } from './WarehouseInventoryTable'; +import ListControls from '@/components/common/ListControls'; +// Generic list footer — already shared by the fleet and train-scheduling lists +// despite the ruleEngine path; reused here rather than adding a second one. +import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter'; +import { useListControls } from '@/hooks/useListControls'; import { extractDownloadErrorMessage, extractErrorMessage } from './options'; import { openPdfBlob, saveBlob } from './pdf'; @@ -56,8 +61,17 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo api.warehouses.bulkMarkInspected.mutationOptions(), ); + const controls = useListControls(items, { + searchKeys: ['grnNumber', 'bookingReference', 'customerName', 'status', 'releaseOrderReference', 'notes'], + dateKey: 'arrivedAt', + }); + const visible = controls.filteredRows; + const [selected, setSelected] = useState>(new Set()); - const allSelected = items.length > 0 && selected.size === items.length; + // Select-all spans everything matching the current filters, not just the rows + // on screen — bulk "mark inspected" over one page of a filtered set would be a + // surprise. Counts compare against the filtered set for the same reason. + const allSelected = visible.length > 0 && selected.size === visible.length; const someSelected = selected.size > 0 && !allSelected; const toggleSelect = (id: string) => setSelected((prev) => { @@ -66,7 +80,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo return next; }); const toggleSelectAll = () => - setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id))); + setSelected(allSelected ? new Set() : new Set(visible.map((i) => i.id))); const markInspected = async () => { if (selected.size === 0) { @@ -268,8 +282,21 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo + + + + setMoveItem(null)} item={moveItem} /> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 9e66d9861..7633e4dcd 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate'; import { warehouseService } from '@/services/warehouse.service'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; @@ -204,6 +205,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea label: `Last-mile · ${truckPrefill.truckPlateNumber}`, trailerPlate: truckPrefill.trailerPlateNumber ?? '', driverName: truckPrefill.driverName ?? '', + driverLicense: truckPrefill.driverLicense ?? '', driverPhone: truckPrefill.driverPhone ?? '', truckType: truckPrefill.truckType ?? '', containerNumbers: splitContainerNumbers(truckPrefill.containerNumber), @@ -217,6 +219,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea label: `Customer · ${t.plateNumber} — ${t.driverName}`, trailerPlate: '', driverName: t.driverName, + driverLicense: '', driverPhone: '', truckType: t.truckType, containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean), @@ -230,6 +233,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`, trailerPlate: t.trailerPlateNumber ?? '', driverName: t.driverName ?? '', + driverLicense: t.driverLicense ?? '', driverPhone: t.driverPhone ?? '', truckType: t.truckType ?? '', containerNumbers: splitContainerNumbers(t.containerNumber), @@ -280,6 +284,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea // way. A walk-in truck (typed plate, no assignment) stays editable at arrival. const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption); const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName); + // The freight order's truck details are the customer's / fleet's record — the + // gate may FILL blanks (walk-in license, phone) but never edit shown values. + const isTrailerLocked = isEntranceLocked || Boolean(selectedOption?.trailerPlate); + const isDriverLicenseLocked = isEntranceLocked || Boolean(selectedOption?.driverLicense); + const isDriverPhoneLocked = isEntranceLocked || Boolean(selectedOption?.driverPhone); const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0; /** Load a truck into the form: its saved block if any, else its assignment. */ @@ -292,7 +301,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea setTruckPlateNumber(plate); setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || ''); setDriverName(block?.driverName || option?.driverName || ''); - setDriverLicense(block?.driverLicense || ''); + setDriverLicense(block?.driverLicense || option?.driverLicense || ''); setDriverPhone(block?.driverPhone || option?.driverPhone || ''); setTruckType(block?.truckType || option?.truckType || ''); const loaded = block @@ -440,6 +449,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }); return; } + // No backdating: gate times are recorded as they happen. The locked + // entrance (exit step) keeps its original past gate-in untouched. + if (!isEntranceLocked && isBackdated(gateInTime)) { + toast({ variant: 'destructive', title: 'Gate in time cannot be in the past' }); + return; + } if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) { toast({ variant: 'destructive', @@ -447,6 +462,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }); return; } + if (isExitStep && isBackdated(gateOutTime)) { + toast({ variant: 'destructive', title: 'Gate out time cannot be in the past' }); + return; + } if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) { toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' }); return; @@ -600,15 +619,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea label="Trailer plate number" value={trailerPlateNumber} onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)} - readOnly={isEntranceLocked} + readOnly={isTrailerLocked} /> setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} /> - setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} /> + setDriverLicense(e.currentTarget.value)} readOnly={isDriverLicenseLocked} /> - setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} /> + setDriverPhone(e.currentTarget.value)} readOnly={isDriverPhoneLocked} /> setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} /> @@ -646,7 +665,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} - setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> + setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> {hasContainerWeights && ( @@ -679,7 +698,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea Computed net: {computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`} - setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} /> + setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} /> {weightMismatch && ( } color="red" variant="light"> diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 2f65e110e..c57a607d5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -199,6 +199,7 @@ export const QUERY_KEYS = { MAINTENANCE: { ROOT: ["maintenance"] as const, + dueBoard: () => ["maintenance", "due-board"] as const, schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const, upcoming: (vehicleId?: string) => @@ -207,6 +208,8 @@ export const QUERY_KEYS = { ["maintenance", "history", vehicleId ?? "all"] as const, stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const, + intervals: (vehicleId?: string) => + ["maintenance", "intervals", vehicleId ?? "all"] as const, }, FINANCIAL_REPORTS: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index ade491a7b..d08cac603 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -417,6 +417,9 @@ export const URL_CONSTANTS = { WAGON_TYPES: "/wagon-types", WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`, + TRUCK_TYPES: "/truck-types", + TRUCK_TYPE_BY_ID: (id: string) => `/truck-types/${id}`, + PRIORITY_CONFIGS: "/priority-configs", PRIORITY_CONFIG_BY_ID: (id: string) => `/priority-configs/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts b/apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts index 67cf70f24..ba1f10b73 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts @@ -67,10 +67,3 @@ export function useDisputeInterchangeDocument() { }); } -export function useCancelInterchangeDocument() { - const onSuccess = useInterchangeInvalidation(); - return useMutation({ - mutationFn: (id: string) => interchangeDocumentsService.cancel(id), - onSuccess, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts b/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts new file mode 100644 index 000000000..11e0124eb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useState } from "react"; +import { usePagination } from "@edr/ui-common"; + +/** + * Search + date-range + pagination over an already-fetched array. + * + * Client-side on purpose: the freight lists are hundreds of rows (largest table + * is ~1.1k), so filtering in the browser avoids paginating ~20 API endpoints — + * several of which sit on billing paths. If a list ever outgrows this (roughly + * 5k rows, where the per-keystroke filter starts to feel slow), move that ONE + * page to a server-side query; the component API here stays the same. + * + * Dates are `YYYY-MM-DD` strings, matching Mantine 9's date inputs. Comparing + * them lexically keeps the range on calendar days and sidesteps timezone drift + * entirely — a UTC timestamp is truncated to its date before the comparison. + * + * ponytail: linear scan per keystroke, no debounce — fine at this size; add + * a debounce (or server-side filtering) if a list gets big enough to stutter. + */ +export interface ListControlsOptions { + /** + * Fields matched against the search box. Constrained to real keys of the row + * so a typo is a compile error rather than a filter that silently matches + * nothing. For nested or derived values, pass `searchValue` instead. + */ + searchKeys?: (keyof T)[]; + /** + * Row's meaningful business date (arrival, invoice, dispatch…), which is what + * staff actually filter by. Falls back to `createdAt` when the row has no + * value for it, so a record is never silently invisible to a date range. + */ + dateKey?: keyof T; + /** Rows per page. */ + pageSize?: number; + /** Custom search extractor when the value isn't a top-level field. */ + searchValue?: (row: T) => string; +} + +const readField = (row: unknown, key: string): unknown => + row && typeof row === "object" ? (row as Record)[key] : undefined; + +/** + * Reduce any stored date to its `YYYY-MM-DD` calendar day. ISO strings are cut + * directly rather than parsed, so a timestamp is never shifted into the + * previous/next day by the viewer's timezone. + */ +export const toDayString = (raw: unknown): string | null => { + if (!raw) return null; + if (raw instanceof Date) { + return Number.isNaN(raw.getTime()) ? null : raw.toISOString().slice(0, 10); + } + const text = String(raw); + if (/^\d{4}-\d{2}-\d{2}/.test(text)) return text.slice(0, 10); + const parsed = new Date(text); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10); +}; + +/** + * Does a stored date fall inside an inclusive `YYYY-MM-DD` range? Exported for + * lists that already own their filtering (e.g. FleetResourcePage, which folds + * server-side filters and search together) so the range semantics — inclusive + * ends, undated rows excluded — stay defined in exactly one place. + */ +export const matchesDayRange = ( + raw: unknown, + dateFrom: string | null, + dateTo: string | null, +): boolean => { + if (!dateFrom && !dateTo) return true; + const day = toDayString(raw); + if (!day) return false; + if (dateFrom && day < dateFrom) return false; + if (dateTo && day > dateTo) return false; + return true; +}; + +export const useListControls = (rows: T[], options: ListControlsOptions = {}) => { + const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options; + + const [search, setSearch] = useState(""); + const [dateFrom, setDateFrom] = useState(null); + const [dateTo, setDateTo] = useState(null); + const { pagination, setPagination } = usePagination({ pageSize }); + + const keys = searchKeys.map(String); + const keySignature = keys.join("|"); + const dateKeyStr = dateKey ? String(dateKey) : undefined; + + const filteredRows = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term && !dateFrom && !dateTo) return rows; + + return rows.filter((row) => { + if (term) { + const haystack = searchValue + ? searchValue(row) + : keys.map((key) => String(readField(row, key) ?? "")).join(" "); + if (!haystack.toLowerCase().includes(term)) return false; + } + if (dateFrom || dateTo) { + const raw = dateKeyStr + ? (readField(row, dateKeyStr) ?? readField(row, "createdAt")) + : null; + if (!matchesDayRange(raw, dateFrom, dateTo)) return false; + } + return true; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows, search, dateFrom, dateTo, keySignature, dateKeyStr, searchValue]); + + // Narrowing the result set can strand the user on a page that no longer + // exists (filter to 3 rows while on page 5 → empty table). Snap back to the + // first page whenever the filters change. + useEffect(() => { + setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 })); + }, [search, dateFrom, dateTo, setPagination]); + + const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize)); + + const pagedRows = useMemo(() => { + const start = pagination.pageIndex * pagination.pageSize; + return filteredRows.slice(start, start + pagination.pageSize); + }, [filteredRows, pagination.pageIndex, pagination.pageSize]); + + const hasFilters = Boolean(search || dateFrom || dateTo); + + const reset = () => { + setSearch(""); + setDateFrom(null); + setDateTo(null); + }; + + return { + search, + setSearch, + dateFrom, + setDateFrom, + dateTo, + setDateTo, + hasFilters, + reset, + filteredRows, + pagedRows, + pageCount, + pagination, + setPagination, + totalCount: filteredRows.length, + /** Spread straight onto so every list paginates identically. */ + tableProps: { + pagination: { + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: filteredRows.length, + }, + tableOptions: { + manualPagination: true as const, + pageCount, + state: { pagination }, + onPaginationChange: setPagination, + }, + }, + }; +}; diff --git a/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts b/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts index 758e2a75a..f8fd1f47d 100644 --- a/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts +++ b/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts @@ -88,8 +88,8 @@ export const resolveModuleConfig = (config: TenantConfig): ModuleConfig => ({ }); const defaultConfig: TenantConfig = { - appName: "Smart Office", - organizationName: "Smart Office", + appName: "EDR Freight", + organizationName: "Ethio-Djibouti Railways", canUseAttachmentFromDMS: false, logo: "/assets/TriaTradinglogo.png", primaryColor: "#1b354d", @@ -116,8 +116,8 @@ const defaultConfig: TenantConfig = { const tenantConfigs: Record = { localhost: { - appName: "Smart Office", - organizationName: "Addis Ababa City Administration", + appName: "EDR Freight", + organizationName: "Ethio-Djibouti Railways", logo: "", primaryColor: "#0EA371", moduleConfig: { diff --git a/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts b/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts new file mode 100644 index 000000000..0ce0541ee --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts @@ -0,0 +1,20 @@ +/** + * Backdating guard for operational time entries (gate in/out, mile truck + * times, delivery pickups): times must be recorded as they happen, never + * dated back. A one-hour grace covers real-world lag (weighbridge queue, + * operator finishing the form after the event). + */ +export const BACKDATE_GRACE_MS = 60 * 60 * 1000; + +/** Local-time "YYYY-MM-DDTHH:mm" for a datetime-local input's `min`. */ +export const nowLocalDateTimeInput = (): string => + new Date(Date.now() - new Date().getTimezoneOffset() * 60_000) + .toISOString() + .slice(0, 16); + +/** True when the value is more than the grace period in the past. */ +export const isBackdated = (value: string | Date | null | undefined): boolean => { + if (!value) return false; + const t = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(t) && t < Date.now() - BACKDATE_GRACE_MS; +}; diff --git a/apps/edr-freight-web/backoffice/src/locales/am/translation.json b/apps/edr-freight-web/backoffice/src/locales/am/translation.json index a37525807..99c1e0329 100644 --- a/apps/edr-freight-web/backoffice/src/locales/am/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/am/translation.json @@ -2039,6 +2039,7 @@ "setting": "ቅንብሮች", "loadingAdmins": "አስተዳዳሪዎችን በመጫን ላይ...", "errorLoadingAdmins": "የአስተዳዳሪ መረጃን ማጫን ላይ ስህተት ተፈጥሯል", + "errorLoadingUnits": "ክፍሎችን ማጫን ላይ ስህተት ተፈጥሯል", "retry": "ደግመው ይሞክሩ", "assignAdmin": "አስተዳዳሪ መመደብ", "addAdmin": "አስተዳዳሪ ያክሉ", @@ -2585,7 +2586,6 @@ "archiveDepartment": "የስራ መደብ መጠርያ አርክብ አድርግ", "deleteDepartment": "የስራ መደብ መጠርያ ሰርዝ", "deleteConfirm": "የስራ መደብ መጠርያ ይሰርዝ?", - "delete": "ሰርዝ", "deleteFailed": "የስራ መደብ መጠርያ ሰረዝ ወደ ተሳክቶ", "cannotDeleteWithEmployees": "ተመድቦ ካለበት ሰራተኞች ጋር የስራ መደብ መጠርያ መሰረዝ አይቻልም", "reassignEmployeesFirst": "እባክዎ ሁሉንም ሰራተኞች በዚህ ክፍል ውስጥ ዳግም ይሰጧቸው ወይም ያስወግዱ።", @@ -2652,6 +2652,18 @@ "selectApplicationToLoadPermissions": "ፍቃዶቹን ለማስገንዘብ አፕሊኬሽኑን ይምረጡ", "copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።", "copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም", + "selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ", + "cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።", + "permissionsSelected": "{{count}} ተመርጠዋል", + "positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል", + "positionTypeUpdated": "የቦታ ዓይነት ተሻሽሏል", + "positionTypeDeleted": "የቦታ ዓይነት ተሰርዟል", + "positionTypeMigrated": "የቦታ ዓይነት ዝውውር ተሻሽሏል", + "positionTypeNotFound": "የቦታ ዓይነት አልተገኘም", + "permissionsAssignFailed": "የቦታ ዓይነቱ ተቀምጧል፣ ነገር ግን ፍቃዶቹን መመደብ አልተቻለም። እንደገና ለመሞከር ደግመው ይክፈቱት።", + "failedToLoadPermissions": "ፍቃዶችን መጫን አልተቻለም", + "failedToLoadPositionTypes": "የቦታ ዓይነቶችን መጫን አልተቻለም", + "exportFailed": "የቦታ ዓይነት ቁልፎችን መላክ አልተቻለም", "perFailed": "ፍቃድ መፍጠር አልተቻለም", "perSuccess": "የፍቃድ አይነት ተፈጠረና ፍቃዶች ተመደቡ", "updatePerSuccess": "ፍቃድ በትክክል ተዘምኗል", @@ -3117,7 +3129,7 @@ "referenceNumberStyles": "የማጣቀሻ ቁጥር ስታይሎች", "other": "ሌላ", "styleCategories": "የስታይል ምድቦች", - "styleEditor": "ስታይል አርታኢ", + "styleEditor": "የስታይል አርታዒ", "livePreview": "የቀጥታ ቅድመ-እይታ", "fontsHint": "አማራጭ የቅርጸ-ቁምፊ ሪሶርስ ይምረጡ።", "fontResource": "የቅርጸ-ቁምፊ ፋይል", @@ -3209,11 +3221,9 @@ "previewLanguage": "የቅድመ-እይታ ቋንቋ", "amharic": "አማርኛ", "english": "እንግሊዝኛ", - "styleCategories": "የስታይል ምድቦች", "categoryHint": "ለማርትዕ ክፍል ይምረጡ", "expandSidebar": "የጎን ማውጫን ዘርጋ", "collapseSidebar": "የጎን ማውጫን ጠቅልል", - "styleEditor": "የስታይል አርታዒ", "headerFooterSelection": "ራስጌ እና ግርጌ", "noSettings": "ምንም የስታይል ቅንብሮች የሉም", "visible": "የሚታይ", @@ -7393,5 +7403,100 @@ "department": "ዲፓርትመንት", "unit": "ክፍል", "notAvailable": "ያልዋቀረ" + }, + "orgAdmins": { + "title": "የድርጅት አስተዳዳሪዎች", + "subtitle": "አስተዳዳሪዎቹን ለማየት እና ለማስተዳደር ድርጅት ይምረጡ።", + "tableName": "የድርጅት አስተዳዳሪዎች", + "selectOrg": "ድርጅት ይምረጡ", + "searchOrgs": "ድርጅቶችን ይፈልጉ...", + "noOrgsFound": "ምንም ድርጅት አልተገኘም።", + "adminsCount": "{{count}} አስተዳዳሪ", + "adminsCount_other": "{{count}} አስተዳዳሪዎች", + "noAdmins": "አስተዳዳሪ የለም", + "activeEmployees": "{{count}} ንቁ ሰራተኞች", + "selectOrgPrompt": "አስተዳዳሪዎቹን ለማስተዳደር ድርጅት ይምረጡ", + "selectOrgPromptHint": "ከላይ ያለውን መምረጫ ተጠቅመው ድርጅት ይፈልጉ እና ይምረጡ።", + "noAdminsHint": "{{name}} እስካሁን አስተዳዳሪ የለውም። አዲስ አስተዳዳሪ ይጋብዙ ወይም ነባር ሰራተኛ ይመድቡ።", + "assignExisting": "ነባር ሰራተኛ ይመድቡ", + "roleOrgAdmin": "የድርጅት አስተዳዳሪ", + "roleUnitAdmin": "የክፍል አስተዳዳሪ", + "statusInvited": "የተጋበዘ", + "statusActive": "ንቁ", + "statusInactive": "ንቁ ያልሆነ", + "columns": { + "name": "ስም", + "email": "ኢሜይል", + "phone": "ስልክ", + "role": "ሚና", + "status": "ሁኔታ", + "addedOn": "የተጨመረበት ቀን", + "actions": "እርምጃዎች" + }, + "actions": { + "edit": "መገለጫ ያስተካክሉ", + "resend": "ግብዣ እንደገና ይላኩ", + "activate": "መለያ ያንቁ", + "deactivate": "መለያ ያቦዝኑ", + "remove": "አስተዳዳሪ ያስወግዱ" + }, + "form": { + "nameEn": "ስም (እንግሊዝኛ)", + "nameAm": "ስም (አማርኛ)", + "username": "የተጠቃሚ ስም", + "email": "ኢሜይል", + "phoneNumber": "ስልክ ቁጥር", + "unit": "ክፍል", + "selectUnit": "ክፍል ይምረጡ", + "loadingUnits": "ክፍሎች በመጫን ላይ...", + "noUnit": "ምንም — የድርጅት አስተዳዳሪ", + "unitRequired": "ክፍል ያስፈልጋል" + }, + "edit": { + "title": "የአስተዳዳሪ መገለጫ ያስተካክሉ", + "description": "የዚህን አስተዳዳሪ የመገለጫ ዝርዝሮች ያዘምኑ።", + "submit": "ለውጦችን ያስቀምጡ" + }, + "assign": { + "title": "ነባር ሰራተኛ ይመድቡ", + "description": "የዚህን ድርጅት ሰራተኛ ወደ አስተዳዳሪነት ያሳድጉ።", + "searchUsers": "ሰራተኞችን በስም ወይም በኢሜይል ይፈልጉ...", + "noUsersFound": "ምንም ሰራተኛ አልተገኘም።", + "alreadyAdmin": "አስቀድሞ አስተዳዳሪ ነው", + "submit": "እንደ አስተዳዳሪ ይመድቡ", + "loadError": "ሰራተኞችን መጫን አልተሳካም።", + "users": "ተጠቃሚዎች" + }, + "confirmRemove": { + "title": "አስተዳዳሪ ይወገድ?", + "description": "ይህ የ{{name}}ን የአስተዳዳሪነት ሚና ከ{{org}} ያስወግዳል። የተጠቃሚው መለያ ራሱ ይቀራል።", + "removing": "በማስወገድ ላይ..." + }, + "confirmToggle": { + "activateTitle": "መለያ ይንቃ?", + "deactivateTitle": "መለያ ይቦዝን?", + "description": "ይህ የ{{name}}ን የመለያ ሁኔታ በመላው ስርዓቱ ላይ ይቀይራል፣ ለዚህ ድርጅት ብቻ አይደለም።" + }, + "toasts": { + "assigned": "አስተዳዳሪ በተሳካ ሁኔታ ተመድቧል!", + "removed": "አስተዳዳሪ በተሳካ ሁኔታ ተወግዷል!", + "resent": "ግብዣው እንደገና ተልኳል!", + "activated": "መለያው ነቅቷል!", + "deactivated": "መለያው ቦዝኗል!", + "profileUpdated": "መገለጫው ተዘምኗል!", + "resending": "ግብዣ በመላክ ላይ...", + "missingContact": "ይህ አስተዳዳሪ ኢሜይል ወይም ስልክ ቁጥር የለውም።", + "added": "አስተዳዳሪ በተሳካ ሁኔታ ተጨምሯል!" + }, + "loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።", + "pickerError": "ድርጅቶችን መጫን አልተሳካም።", + "addAdmin": "አስተዳዳሪ ጨምር", + "add": { + "title": "አስተዳዳሪ ጨምር", + "description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።", + "submit": "አስተዳዳሪ ጨምር", + "inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።", + "noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።" + } } } diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json index 8cf8582b7..f1357c2bf 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -2057,6 +2057,7 @@ "setting": "Setting", "loadingAdmins": "Loading admins...", "errorLoadingAdmins": "Error loading admin data", + "errorLoadingUnits": "Error loading units", "retry": "Retry", "assignAdmin": "Assign Admin", "addAdmin": "Add Admin", @@ -2760,6 +2761,18 @@ "selectApplicationToLoadPermissions": "Select an application to load its permissions", "copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.", "copyPermissionsFailed": "Failed to copy permissions", + "selectOrganizationToCopy": "Select an organization to see the position types you can copy from", + "cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.", + "permissionsSelected": "{{count}} selected", + "positionTypeCreated": "Position type created", + "positionTypeUpdated": "Position type updated", + "positionTypeDeleted": "Position type deleted", + "positionTypeMigrated": "Position type migration updated", + "positionTypeNotFound": "Position type not found", + "permissionsAssignFailed": "Position type saved, but assigning its permissions failed. Reopen it to try again.", + "failedToLoadPermissions": "Failed to load permissions", + "failedToLoadPositionTypes": "Failed to load position types", + "exportFailed": "Failed to export position type keys", "perFailed": "Failed To Create Permission", "perSuccess": "Permission type created and permissions assigned", "updatePerSuccess": "Permission updated successfully", @@ -7391,5 +7404,100 @@ "department": "Department", "unit": "Unit", "notAvailable": "Not Available" + }, + "orgAdmins": { + "title": "Organization Admins", + "subtitle": "Pick an organization to view and manage its administrators.", + "tableName": "Organization Admins", + "selectOrg": "Select an organization", + "searchOrgs": "Search organizations...", + "noOrgsFound": "No organizations found.", + "adminsCount": "{{count}} admin", + "adminsCount_other": "{{count}} admins", + "noAdmins": "No admins", + "activeEmployees": "{{count}} active employees", + "selectOrgPrompt": "Select an organization to manage its admins", + "selectOrgPromptHint": "Use the selector above to search and pick an organization.", + "noAdminsHint": "{{name}} has no administrators yet. Invite a new admin or assign an existing employee.", + "assignExisting": "Assign Existing", + "roleOrgAdmin": "Org Admin", + "roleUnitAdmin": "Unit Admin", + "statusInvited": "Invited", + "statusActive": "Active", + "statusInactive": "Inactive", + "columns": { + "name": "Name", + "email": "Email", + "phone": "Phone", + "role": "Role", + "status": "Status", + "addedOn": "Added On", + "actions": "Actions" + }, + "actions": { + "edit": "Edit Profile", + "resend": "Resend Invite", + "activate": "Activate Account", + "deactivate": "Deactivate Account", + "remove": "Remove Admin" + }, + "form": { + "nameEn": "Name (English)", + "nameAm": "Name (Amharic)", + "username": "Username", + "email": "Email", + "phoneNumber": "Phone Number", + "unit": "Unit", + "selectUnit": "Select a unit", + "loadingUnits": "Loading units...", + "noUnit": "None — organization admin", + "unitRequired": "Unit is required" + }, + "edit": { + "title": "Edit Admin Profile", + "description": "Update this administrator's profile details.", + "submit": "Save Changes" + }, + "assign": { + "title": "Assign Existing Employee", + "description": "Promote an employee of this organization to administrator.", + "searchUsers": "Search employees by name or email...", + "noUsersFound": "No employees found.", + "alreadyAdmin": "Already admin", + "submit": "Assign as Admin", + "loadError": "Failed to load employees.", + "users": "Users" + }, + "confirmRemove": { + "title": "Remove Admin?", + "description": "This removes the admin role of {{name}} for {{org}}. The user account itself is kept.", + "removing": "Removing..." + }, + "confirmToggle": { + "activateTitle": "Activate Account?", + "deactivateTitle": "Deactivate Account?", + "description": "This changes the account status of {{name}} across the whole platform, not just for this organization." + }, + "toasts": { + "assigned": "Admin assigned successfully!", + "removed": "Admin removed successfully!", + "resent": "Invitation re-sent successfully!", + "activated": "Account activated successfully!", + "deactivated": "Account deactivated successfully!", + "profileUpdated": "Profile updated successfully!", + "resending": "Sending invite...", + "missingContact": "This admin has no email or phone number on file.", + "added": "Admin added successfully!" + }, + "loadError": "Failed to load admins.", + "pickerError": "Failed to load organizations.", + "addAdmin": "Add Admin", + "add": { + "title": "Add Admin", + "description": "Create a user account and grant admin access in this organization.", + "submit": "Add Admin", + "inviteNote": "The user is created and receives an SMS invitation to set their password.", + "noUnitsOrgAdmin": "This organization has no units — the admin will be added as an organization admin." + } } } diff --git a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json index 7f3d4e679..bd5e17fae 100644 --- a/apps/edr-freight-web/backoffice/src/locales/fr/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/fr/translation.json @@ -1525,6 +1525,7 @@ "setting": "Paramètre", "loadingAdmins": "Chargement des administrateurs...", "errorLoadingAdmins": "Erreur lors du chargement des données administrateur", + "errorLoadingUnits": "Erreur lors du chargement des unités", "retry": "Réessayer", "assignAdmin": "Assigner un administrateur", "addAdmin": "Ajouter un administrateur", @@ -1886,6 +1887,18 @@ "selectApplicationToLoadPermissions": "Sélectionner une application pour charger ses autorisations", "copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.", "copyPermissionsFailed": "Échec de la copie des autorisations", + "selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier", + "cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.", + "permissionsSelected": "{{count}} sélectionné(s)", + "positionTypeCreated": "Type de poste créé", + "positionTypeUpdated": "Type de poste mis à jour", + "positionTypeDeleted": "Type de poste supprimé", + "positionTypeMigrated": "Migration du type de poste mise à jour", + "positionTypeNotFound": "Type de poste introuvable", + "permissionsAssignFailed": "Type de poste enregistré, mais l'attribution de ses autorisations a échoué. Rouvrez-le pour réessayer.", + "failedToLoadPermissions": "Échec du chargement des autorisations", + "failedToLoadPositionTypes": "Échec du chargement des types de poste", + "exportFailed": "Échec de l'exportation des clés de type de poste", "perFailed": "Échec de la création de l’autorisation", "perSuccess": "Type d’autorisation créé et autorisations assignées", "updatePerSuccess": "Autorisation mise à jour avec succès", diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx index eacd05459..aad3bbb89 100644 --- a/apps/edr-freight-web/backoffice/src/main.tsx +++ b/apps/edr-freight-web/backoffice/src/main.tsx @@ -11,6 +11,7 @@ import "../index.css"; import "@edr/ui-common/theme.css"; import { Toaster } from "react-hot-toast"; +import { Toaster as SonnerToaster } from "./shared/common/ui/sonner"; // Initialize i18next before first paint so the detected/persisted language // applies immediately (the vendored IAM UI also imports this via @/i18n). @@ -70,6 +71,9 @@ createRoot(rootElement).render( message (suppressed on warehouse / mile / onboarding pages). */} + {/* sonner toasts (used across super-admin & user-management) + rendered nowhere without this mount */} + diff --git a/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx index 701400310..2df952af8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx @@ -67,7 +67,8 @@ const DashboardPage = () => { refetch(); refetchAdmins(); }} - className="bg-primary hover:bg-primary/90 text-primary-foreground"> + className="bg-primary hover:bg-primary/90 text-primary-foreground" + > {t("organization.retry")} @@ -140,7 +141,8 @@ const DashboardPage = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx index 6fce0ec39..0503baad8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx @@ -1,7 +1,7 @@ -import OrganizationsAdmins from "@/super-admin/components/organizationAdmins/OrganizationAdmins"; +import OrgAdminsPage from "@/super-admin/components/org-admins/OrgAdminsPage"; const OrganizationAdminsPage = () => { - return ; + return ; }; export default OrganizationAdminsPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 79e2f3b6e..35488b94f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -56,6 +56,8 @@ import { humanize, } from "@/components/customers"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { downloadBookingFile, fetchViewableFile, @@ -108,6 +110,7 @@ export default function CustomerDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { view, viewer } = useFileViewer(); + const { user } = useAuth(); const { data: company, isLoading } = useQuery( api.customers.getById.queryOptions({ @@ -180,6 +183,11 @@ export default function CustomerDetailPage() { // API's rule exactly, so no button is offered that the server would reject. const stillOnboarding = company ? isOnboardingDraft(company) : false; const canReview = company ? hasSubmittedOnboarding(company) : true; + // Workflow gate (above) AND authority: asking the customer to correct a + // document is a `customers:verify` action, so a view-only reviewer reads the + // documents but is not offered the request-change control. + const canRequestDocChange = + canReview && hasPermission(user, FREIGHT_PERMS.customers.verify); /** Document the reviewer is asking the customer to correct; null = closed. */ const [changeRequestDoc, setChangeRequestDoc] = @@ -446,7 +454,7 @@ export default function CustomerDetailPage() { > - {canReview && ( + {canRequestDocChange && ( [] = useMemo( diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx index 008fee25b..f31bf5e76 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx @@ -18,6 +18,11 @@ import { } from "@mantine/core"; import { Plus, AlertTriangle } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import ListControls from "@/components/common/ListControls"; +// Generic list footer — already shared by the fleet and train-scheduling lists +// despite the ruleEngine path. +import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; +import { useListControls } from "@/hooks/useListControls"; import { useToast } from "@/hooks/use-toast"; import { complianceService, @@ -86,6 +91,11 @@ export default function CompliancePage() { }, }); + const controls = useListControls(records as ComplianceRecord[], { + searchKeys: ["type", "status", "documentNumber"], + dateKey: "expiryDate", + }); + const createMutation = useMutation({ mutationFn: async (data: typeof formData) => { const res = await complianceService.create({ @@ -210,6 +220,18 @@ export default function CompliancePage() { Compliance Records + @@ -239,7 +261,7 @@ export default function CompliancePage() { ) : null} - {(records as ComplianceRecord[]).map((record) => ( + {controls.pagedRows.map((record) => ( {vehicleLabel(record)} @@ -259,6 +281,13 @@ export default function CompliancePage() { ))}
+
{/* Modal */} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index f99494929..d50a3217b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -1,5 +1,6 @@ import type { ColumnDef } from "@edr/ui-common"; import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -15,6 +16,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog"; import FleetHistoryModal from "@/components/fleet/FleetHistoryModal"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; import FleetToolbar from "@/components/fleet/FleetToolbar"; +import { matchesDayRange } from "@/hooks/useListControls"; import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal"; import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal"; import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal"; @@ -53,6 +55,10 @@ const FleetResourcePage = () => { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); + // Registration date range. Server-side list filters (status/yard/train) are + // applied by the API; this narrows what comes back, alongside search. + const [dateFrom, setDateFrom] = useState(null); + const [dateTo, setDateTo] = useState(null); const [listFilterValues, setListFilterValues] = useState>({}); const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -100,6 +106,9 @@ const FleetResourcePage = () => { const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery( api.wagonTypes.list.queryOptions(), ); + const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery( + api.truckTypes.list.queryOptions(), + ); const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery( api.containerTypes.list.queryOptions({ staleTime: Infinity }), ); @@ -128,7 +137,7 @@ const FleetResourcePage = () => { useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); - }, [search, listFilterValues, setPagination]); + }, [search, listFilterValues, dateFrom, dateTo, setPagination]); const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status")); const usesServerListFilters = Boolean(config?.listFilters?.length); @@ -175,17 +184,34 @@ const FleetResourcePage = () => { (y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }), ); + // Carries capacity + trailer configuration so picking a truck type can + // pre-fill the vehicle's capacity and drop the trailer plate on a rigid type. + const truckTypeOpts = ( + truckTypes as Array<{ + id: string; + code: string; + name?: string; + capacityTons?: number | null; + hasTrailer?: boolean; + }> + ).map((t) => ({ + value: t.id, + label: t.name ? `${t.name} (${t.code})` : t.code, + meta: { capacityTons: t.capacityTons, hasTrailer: t.hasTrailer }, + })); + registerFleetOptionLabels("currentYardId", yardOpts); return { wagonTypes: wagonTypeOpts, containerTypes: containerTypeOpts, cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts], + truckTypes: truckTypeOpts, wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts], containers: containerOpts, yards: yardOpts, }; - }, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]); + }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]); const listFilterSelects = useMemo(() => { if (!config?.listFilters?.length) return null; @@ -218,6 +244,7 @@ const FleetResourcePage = () => { registerFleetOptionLabels("containerId", dynamicOptions.containers); registerFleetOptionLabels("currentYardId", dynamicOptions.yards); registerFleetOptionLabels("locationId", dynamicOptions.yards); + registerFleetOptionLabels("truckTypeId", dynamicOptions.truckTypes); }, [dynamicOptions]); const formFields = useMemo((): FleetFormFieldDef[] => { @@ -233,16 +260,20 @@ const FleetResourcePage = () => { wagonTypesLoading || containerTypesLoading || cargoTypesLoading || + truckTypesLoading || wagonsLoading || containersLoading || yardsLoading; const filteredRows = useMemo(() => { if (!config) return allRows; - if (usesServerListFilters) return allRows; const term = search.trim().toLowerCase(); return allRows.filter((row) => { const record = row as unknown as Record; + // The date range applies even when the API already filtered the list — + // it is not one of the server-side filters. + if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false; + if (usesServerListFilters) return true; if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) { return false; } @@ -253,7 +284,7 @@ const FleetResourcePage = () => { .includes(term), ); }); - }, [allRows, search, statusFilter, config, usesServerListFilters]); + }, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo]); const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize)); const pagedRows = useMemo(() => { @@ -452,7 +483,30 @@ const FleetResourcePage = () => { viewMode={viewMode} onViewModeChange={setViewMode} filters={ - listFilterSelects ? ( + + + + {listFilterSelects ? ( {listFilterSelects.map((filter) => ( ) : ( - - - Upcoming Maintenance - - - {isLoading ? ( - Loading... - ) : upcomingList.length > 0 ? ( - - - - Type - Description - Scheduled - Est. Cost - Status - - - - {upcomingList.map((m) => ( - - {m.maintenanceType} - {m.description} - {new Date(m.scheduledDate).toLocaleDateString()} - - {m.estimatedCost != null - ? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}` - : '—'} - - - {m.status} - + <> + + + Service Intervals — drives auto-scheduling + + e.g. oil change every 10,000 km. On completion with an odometer reading, the + next service is scheduled automatically at reading + interval. + + + + + {intervalList.length > 0 && ( +
+ + + Type + Service Item + Every (km) + Every (days) + Description + + + + + {intervalList.map((i) => ( + + {i.maintenanceType} + {i.serviceItem ?? '—'} + {i.intervalKm ?? '—'} + {i.intervalDays ?? '—'} + {i.description ?? '—'} + + + deactivateIntervalMutation.mutate(i.id)} + > + + + + + + ))} + +
+ )} + + setAcqForm({ ...acqForm, vehicleId: val || "" })} searchable clearable /> - setAcqForm({ ...acqForm, vendorId: val || "" })} + searchable + clearable + /> + + createAcquisition.mutate()} loading={createAcquisition.isPending} - disabled={!acqForm.acquisitionDate} + disabled={!acqForm.acquisitionDate || acqForm.itemName.trim().length < 2} > Save Acquisition diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx index 6a3956685..ae785a708 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx @@ -1,14 +1,18 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ActionIcon, Badge, + Button, Card, Center, Container, Group, Loader, + NumberInput, + Radio, + Select, SimpleGrid, Stack, Table, @@ -20,6 +24,7 @@ import { import { ArrowLeft, Fuel, + Gauge, History, Route, Truck, @@ -28,7 +33,13 @@ import { } from "lucide-react"; import { api } from "@/auth/http"; -import { vehiclesService } from "@/services/vehicles.service"; +import { api as apiClient2 } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import { + vehiclesService, + type SaveVehiclePayload, + type Vehicle, +} from "@/services/vehicles.service"; import { driversService } from "@/services/drivers.service"; import { fleetHistoryService } from "@/services/fleet-history.service"; @@ -132,6 +143,7 @@ const VehicleDetailPage = () => { }>History }>Maintenance }>Fuel + }>Operations }>First/Last mile @@ -142,6 +154,8 @@ const VehicleDetailPage = () => { + + @@ -172,6 +186,10 @@ const VehicleDetailPage = () => { + + + + @@ -181,6 +199,103 @@ const VehicleDetailPage = () => { ); }; +/** + * Where a truck is and what it costs to run are per-trip operational facts, not + * part of registering the vehicle — so they are edited here rather than on the + * Add Vehicle form. `pricePerKm` is live billing input: first/last-mile charges + * are `distance × pricePerKm`. + */ +const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [form, setForm] = useState({ + locationId: vehicle.locationId ?? "", + estimatedDistanceKm: vehicle.estimatedDistanceKm ?? "", + actualDistanceKm: vehicle.actualDistanceKm ?? "", + pricePerKm: vehicle.pricePerKm ?? "", + currency: vehicle.currency ?? "ETB", + }); + + const { data: yards = [], isLoading: yardsLoading } = useQuery( + apiClient2.routes.yards.queryOptions(), + ); + + const save = useMutation({ + mutationFn: () => + vehiclesService.update(vehicle.id, { + locationId: form.locationId || null, + // Empty means "not recorded" — send null so the column is unset rather + // than coerced to 0, which would read as a real measurement. + estimatedDistanceKm: form.estimatedDistanceKm === "" ? null : Number(form.estimatedDistanceKm), + actualDistanceKm: form.actualDistanceKm === "" ? null : Number(form.actualDistanceKm), + pricePerKm: form.pricePerKm === "" ? null : Number(form.pricePerKm), + currency: form.currency || null, + } as Partial & { locationId?: string | null }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["vehicle", vehicle.id] }); + toast({ title: "Operational details saved" }); + }, + onError: () => + toast({ title: "Could not save operational details", variant: "destructive" }), + }); + + return ( + + + + - n === row.containerNumber || - !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), - ), - // keep a manual/legacy value selectable even if not in the booking - ...(row.containerNumber && !containerOptions.includes(row.containerNumber) - ? [row.containerNumber] - : []), - ]} - value={row.containerNumber || null} - onChange={(value) => - setVehicleRows((prev) => - prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)), - ) - } - searchable - clearable - /> + {!bulkMode && activeRecord && isBulkBooking(activeRecord) ? ( + <> + + setVehicleRows((prev) => + prev.map((x, idx) => + idx === i ? { ...x, tons: v === "" ? "" : Number(v) } : x, + ), + ) + } + /> + + setVehicleRows((prev) => + prev.map((x, idx) => + idx === i ? { ...x, quantity: v === "" ? "" : Number(v) } : x, + ), + ) + } + /> + + ) : ( + ({ value: s, label: s.replace(/_/g, ' ') }))} - value={status} - onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)} - clearable - w={200} - /> -
+ + ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))} + placeholder={truckTypesLoading ? 'Loading truck types...' : 'Any truck type'} + data={truckTypes.map((t) => ({ value: t.code, label: `${t.name} (${t.code})` }))} + disabled={truckTypesLoading} value={form.vehicleType || null} onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))} clearable diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/Header.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/Header.tsx deleted file mode 100644 index 901001c93..000000000 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/Header.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import React, { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { NavLink, useLocation } from "react-router-dom"; -import { cn } from "@/shared/lib/utils"; -import { - LayoutGrid, - Upload, - Download, - CheckSquare, - UserCheck, - FileText, - LucideIcon, - Settings, - Handshake, - BellRing, - BarChart3, -} from "lucide-react"; -import { useUserDetail } from "../hooks/useUserDetail"; -import { useAuthUser } from "@/shared/hooks/useAuthUser"; -import Cookies from "js-cookie"; -import { useDelegations } from "../hooks/useDelegations"; -import Top from "./Top"; - -import { useReport } from "../hooks/useReport"; -import { hasApprovalPermission } from "@/record-management/routes/routes"; -import { useReportByHooks } from "../hooks/useReportByHooks"; -import { useMyCollaborations } from "../hooks/useMyCollaborations"; - -interface HeaderProps { - onToggleSidebar?: () => void; -} - -export interface INavTabs { - icon: LucideIcon; - isVissible?: boolean; - label?: string; - title?: string; - href?: string; - url?: string; - isActive?: boolean; - count?: boolean; - isPrimary?: boolean; - countBadge?: number; - isUrgent?: boolean; -} - -const Header: React.FC = ({ onToggleSidebar }) => { - const baseParams = { - skip: 0, - take: 10, - orderBy: "employeePosition.createdAt:DESC", - }; - const { t } = useTranslation(); - const location = useLocation(); - const { hasDelegated } = useDelegations(baseParams); - const { userDetails, selectedPositionPermissionKeys, selectedPosition } = - useAuthUser(); - const { permissionKeys } = useUserDetail(userDetails); - const useParentCounts = - permissionKeys.includes("can:viewParentPositionRecord") && - new URLSearchParams(location.search).get("view") === "parent"; - const currentPosition = Cookies.get("current-position-id"); - const canViewApprovalTab = hasApprovalPermission( - selectedPositionPermissionKeys, - ); - - // Reuse the auth layer's already-normalized active position instead of - // re-deriving it here. useAuthUser resolves it by matching BOTH `id` and - // `employeePositionId` (and honors the delegated-position cookie), so a - // delegate's position is found and `isDelegate` is reliable. The previous - // local lookup matched `pos.id === selectedPositionId` only — but - // `selectedPositionId` is normalized to `employeePositionId`, so the find - // returned undefined and `!undefined` left every tab visible for delegates. - const canViewDelegationTab = !selectedPosition?.isDelegate; - const userRoles = - userDetails?.roles?.map((r: { key: string }) => r.key) || []; - const showUserManagementShortcut = - userRoles.includes("admin") || - userRoles.includes("unit_admin") || - userRoles.includes("super_admin"); - - const { - dashboard, - ROdashboard, - TotalDraftExternal, - TotalDraftInternal, - TotalDraftCC, - ROTotalDraftIncoming, - ROPending, - TotalUrgentDraftInternal, - TotalUrgentDraftExternal, - ROPendingUrgent, - } = useReport(""); - const { breakdownCounts } = useReportByHooks({ - isSecretary: useParentCounts, - }); - const { data: draftCollaborations } = useMyCollaborations({ - skip: 0, - take: 1, - signStatus: "draft", - }); - const collaborationDraftCount = - draftCollaborations?.count ?? dashboard?.collaborationCount?.draft ?? 0; - const incomingHref = useParentCounts - ? "/record-management/userIncoming?view=parent" - : "/record-management/userIncoming"; - - const navigationTabs = useMemo( - (): INavTabs[] => [ - { - icon: LayoutGrid, - label: t("header.navigation.dashboard"), - href: "/record-management/dashboard", - isActive: location.pathname.includes("/record-management/dashboard"), - isPrimary: true, - // Delegates see only Outgoing, Incoming, Approval — hide Dashboard. - isVissible: canViewDelegationTab, - }, - { - icon: BarChart3, - label: "Reports", - href: "/record-management/sector-reports", - isActive: location.pathname.startsWith( - "/record-management/sector-reports", - ), - isPrimary: false, - isVissible: false, - }, - { - icon: Upload, - label: t("header.navigation.outgoing"), - href: "/record-management/userRecords", - isActive: - location.pathname.startsWith("/record-management/userRecords") && - new URLSearchParams(location.search).get("from") !== "collaborations", - isPrimary: true, - isVissible: true, - }, - { - icon: Download, - label: t("header.navigation.incoming"), - href: incomingHref, - isActive: - location.pathname.startsWith("/record-management/userIncoming") || - location.pathname.startsWith( - "/record-management/viewIncoming/incoming/", - ) || - location.pathname.startsWith( - "/record-management/viewIncoming/internal", - ) || - location.pathname.startsWith("/record-management/viewIncoming/cc"), - isPrimary: true, - isVissible: true, - countBadge: - breakdownCounts.external + - breakdownCounts.externalSmart + - breakdownCounts.internal + - breakdownCounts.ccUnseen + - breakdownCounts.forYourReference, - isUrgent: TotalUrgentDraftInternal > 0 || TotalUrgentDraftExternal > 0, - }, - { - icon: CheckSquare, - label: t("header.navigation.approval"), - href: "/record-management/approval", - isActive: - location.pathname.startsWith("/record-management/approval") || - location.pathname.startsWith("/record-management/viewApproval"), - isPrimary: false, - isVissible: canViewApprovalTab, - countBadge: breakdownCounts?.approval || 0, - isUrgent: - (dashboard?.activeApprovalCount?.myUrgentWorkflowCount || 0) > 0, - }, - { - icon: UserCheck, - label: t("header.navigation.delegation"), - href: "/record-management/delegation", - isActive: location.pathname.startsWith("/record-management/delegation"), - isPrimary: false, - isVissible: canViewDelegationTab, - }, - { - icon: Handshake, - label: t("header.navigation.collaborations"), - href: "/record-management/collaborations", - isActive: - location.pathname.startsWith("/record-management/collaborations") || - new URLSearchParams(location.search).get("from") === "collaborations", - isPrimary: false, - isVissible: canViewDelegationTab, - countBadge: collaborationDraftCount, - }, - { - icon: Settings, - label: t("header.navigation.settings"), - href: "/record-management/uploadTeeterandSignature", - isActive: location.pathname.startsWith( - "/record-management/uploadTeeterandSignature", - ), - isPrimary: false, - isVissible: canViewDelegationTab, - }, - ], - [ - t, - location.pathname, - location.search, - TotalDraftCC, - TotalDraftInternal, - TotalDraftExternal, - TotalUrgentDraftInternal, - TotalUrgentDraftExternal, - canViewApprovalTab, - canViewDelegationTab, - useParentCounts, - incomingHref, - breakdownCounts.approval, - breakdownCounts.ccUnseen, - breakdownCounts.external, - breakdownCounts.externalSmart, - breakdownCounts.forYourReference, - breakdownCounts.internal, - dashboard?.activeApprovalCount?.myNotUrgentWorkflowsCount, - dashboard?.activeApprovalCount?.myUrgentWorkflowCount, - collaborationDraftCount, - ], - ); - - const recordOfficerNavs = useMemo( - (): INavTabs[] => [ - { - icon: LayoutGrid, - label: t("header.navigation.dashboard"), - href: "/record-management/dashboard", - isActive: location.pathname.includes("/dashboard"), - isVissible: true, - }, - { - icon: Upload, - label: t("header.navigation.outgoing"), - href: "/record-management/recordOfficer/outgoing", - isActive: - location.pathname.startsWith( - "/record-management/recordOfficer/outgoing", - ) || - location.pathname.includes("/record-management/view/") || - location.pathname.includes("/record-management/outgoingview/"), - isVissible: true, - }, - { - icon: Download, - label: t("header.navigation.incoming"), - href: "/record-management/recordOfficer/incoming", - isActive: - location.pathname.startsWith( - "/record-management/recordOfficer/incoming", - ) || - location.pathname.includes("/record-management/viewIncoming/") || - location.pathname.includes("/record-management/recordViewIncoming/"), - isVissible: true, - countBadge: ROTotalDraftIncoming, - }, - { - icon: FileText, - label: t("header.navigation.pending"), - href: "/record-management/pending", - isActive: - location.pathname.startsWith("/record-management/pending") || - location.pathname.includes("/record-management/viewPending/"), - isVissible: true, - countBadge: ROPending, - isUrgent: ROPendingUrgent > 0, - }, - ], - [t, location.pathname, ROTotalDraftIncoming, ROPending, ROPendingUrgent], - ); - - const activeNavigationTabs = useMemo(() => { - if (permissionKeys.includes("can:dispatchRecords")) { - return recordOfficerNavs; - } - return navigationTabs.filter((tab) => tab.isVissible); - }, [permissionKeys, navigationTabs, recordOfficerNavs]); - - // const { user } = useAuth(); - // console.log("User Info:", user); - // const organizationId = - // user?.employee && user.employee.length > 0 - // ? user.employee[0].unitId - // : undefined; - - // const unitId = organizationId; - return ( -
- - - {/* Navigation Tabs */} - {currentPosition ? ( -
- {/* Mobile layout: Simple horizontal scrollable (up to sm) */} -
-
- {activeNavigationTabs.map((tab, index) => ( - - cn( - "flex items-center gap-1 px-3 py-2 mr-2 text-xs font-medium transition-colors whitespace-nowrap relative", - isActive - ? "text-primary-800 dark:text-white border-b-2 border-primary-600 dark:border-primary-400" - : "text-gray-700 dark:text-gray-300 hover:text-primary-800 dark:hover:text-white", - ) - } - > - - {tab.label} - - {/* Count Badge - positioned on tab */} - {typeof tab?.countBadge === "number" && - tab.countBadge > 0 && ( - - {tab.countBadge > 99 ? "99+" : tab.countBadge} - - )} - - {/* Urgent Bell - positioned on tab */} - {typeof tab?.isUrgent === "boolean" && tab.isUrgent && ( - - - - - )} - - {/* Live indicator for Delegation */} - {hasDelegated && - (tab.label?.toLowerCase() === "delegation" || - tab.label?.toLowerCase() === "ዉክልና") && ( - - - - - )} - - ))} -
-
- - {/* Desktop layout: All tabs in one row (sm and up) */} -
-
- {activeNavigationTabs.map((tab, index) => ( - - cn( - "flex items-center justify-center px-2.5 py-2.5 rounded-sm text-sm whitespace-nowrap flex-1 font-Urbanist relative", - tab.isActive - ? "bg-primary-50 dark:bg-primary-900/40 text-primary-800 dark:text-white font-medium" - : "text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700", - ) - } - > - - - {tab.label} - - {/* Count Badge (inline) */} - {typeof tab?.countBadge === "number" && - tab.countBadge > 0 && ( - - {tab.countBadge > 99 ? "99+" : tab.countBadge} - - )} - - {/* Urgent Bell (pulsing) */} - {typeof tab?.isUrgent === "boolean" && tab.isUrgent && ( - - {/* Pulse effect */} - - {/* Bell icon */} - - - - - )} - - - {/* Live indicator for Delegation */} - {hasDelegated && - (tab.label?.toLowerCase() === "delegation" || - tab.label?.toLowerCase() === "ዉክልና") && ( - - - - - )} - - ))} -
-
-
- ) : null} -
- ); -}; - -export default Header; diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/NavigationHeader/Profile.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/NavigationHeader/Profile.tsx deleted file mode 100644 index 6d128954a..000000000 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/NavigationHeader/Profile.tsx +++ /dev/null @@ -1,1049 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/shared/common/ui/card"; -import { Badge } from "@/shared/common/ui/badge"; -import { - ChevronLeft, - Mail, - User, - Briefcase, - Key, - CheckCircle2, - Shield, - Lock, - CircleUser, - Star, - ShieldCheck, - Clock, - UserPen, - ShieldOff, - IdCard, - Settings, - Building, - Users, - FileText, - ChevronDown, - LogOut, - Monitor, -} from "lucide-react"; -import { Button } from "@/shared/common/ui/button"; -import { NavLink, useNavigate } from "react-router-dom"; -import { useAuthUser } from "../../../hooks/useAuthUser"; -import { useTranslation } from "react-i18next"; -import { motion } from "framer-motion"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import Top from "../Top"; -import { Label } from "@/shared/common/ui/label"; -import { Switch } from "@/shared/common/ui/switch"; -import { useSessions } from "@/shared/hooks/useSession"; -import { useQueryClient } from "@tanstack/react-query"; -import Loader from "@/record-management/components/Loader/loader"; - -// Define TypeScript interfaces for the props -interface PositionDetailItemProps { - icon: React.ReactNode; - label: string; - value: string; -} - -interface InfoBoxProps { - icon: React.ReactNode; - label: string; - value: string; - color?: "blue" | "purple" | "green" | "gray"; - fullWidth?: boolean; - amharic?: boolean; - capitalize?: boolean; -} - -interface ProfileInfoItemProps { - icon: React.ReactNode; - label: string; - value: string; - capitalize?: boolean; -} - -interface SecurityItemProps { - title: string; - description: string; - action: React.ReactNode; - status?: "secure" | "warning" | "inactive"; - - twoFactorStatus?: "on" | "off"; -} - -interface ActivityItemProps { - icon: React.ReactNode; - title: string; - time: string; - type: "success" | "info" | "warning"; -} - -const ProfilePage = () => { - const navigate = useNavigate(); - const { t } = useTranslation(); - const queryClient = useQueryClient(); - - const { - userDetails, - isLoading, - isError, - refetch, - logout, - setTwoFactorAuth, - twoFactorData, - editTwoFA, - isLoadingStatus, - } = useAuthUser(); - const userRoles = userDetails?.roles?.map((r: { key: string }) => r.key) || []; - const showUserManagementShortcut = - userRoles.includes("admin") || - userRoles.includes("unit_admin") || - userRoles.includes("super_admin"); - const localizedName = useLocalizedName(); - const [isSessionsExpanded, setIsSessionsExpanded] = useState(false); - const [twoFactor, setTwoFactor] = useState(false); - const [permissionsExpanded, setPermissionsExpanded] = useState(false); - const PERMISSIONS_INITIAL_SHOW = 4; - useEffect(() => { - if (!userDetails) return; - if (twoFactorData?.[0]) { - setTwoFactor(twoFactorData?.[0].isMFARequired); - } - }, [twoFactorData, userDetails]); - - const { - data: sessionsQuery, - deleteSession, - deleteAllSessions, - } = useSessions({ - skip: 0, - take: 10, - orderBy: "CreatedAt:DESC", - }); - const userSessions = sessionsQuery?.sessions || []; - const handleLogoutSession = (sessionId: string) => { - deleteSession(sessionId); - queryClient.invalidateQueries({ queryKey: ["my-sessions"] }); - }; - const handleLogoutAllSessions = () => { - const otherSessions = userSessions.filter((s) => s.id); - - if (otherSessions.length > 0) { - deleteAllSessions({ sessionIds: otherSessions.map((s) => s.id) }); - queryClient.invalidateQueries({ queryKey: ["my-sessions"] }); - } - }; - const handleFactorAuthentication = () => { - if (isLoading || isLoadingStatus) return; // prevent duplicate clicks - if (!userDetails) return; - - const item = twoFactorData?.[0]; - const hasExistingRecord = !!item?.id; - if (!hasExistingRecord) { - // ✅ POST only if no record exists at all - setTwoFactorAuth({ isMFARequired: true }); - } else { - // Toggle using PUT/edit if a record already exists - const newStatus = !twoFactor; - editTwoFA({ id: item.id, isEnabled: newStatus }); - } - }; - - const handleLogout = () => { - logout(); - }; - - if (isLoading) { - return ; - } - - if (isError || !userDetails) { - return ( -
- -
- - - -

- {t("profile.error")} -

-
- - - -
-
- ); - } - - const employeeData = userDetails.employee?.[0] || {}; - const position = employeeData.positions?.[0] || {}; - const positionIsDeletation = employeeData.positions.filter( - (item: any) => item.isDelegate - ); - const positionHasDeletation = employeeData.positions.filter( - (item: any) => item.hasDelegated - ); - // Animation variants - const container = { - hidden: { opacity: 0 }, - show: { - opacity: 1, - transition: { - staggerChildren: 0.1, - }, - }, - }; - - const item = { - hidden: { opacity: 0, y: 20 }, - show: { opacity: 1, y: 0 }, - }; - - // New reusable components for the improved layout - const PositionDetailItem: React.FC = ({ - icon, - label, - value, - }) => ( - -
- {icon} -
-
-

- {label} -

-

- {value} -

-
-
- ); - - const InfoBox: React.FC = ({ - icon, - label, - value, - color = "gray", - fullWidth = false, - amharic = false, - capitalize = false, - }) => ( - -
-
- {icon} -
-

- {label} -

-
-

- {value || "Not specified"} -

-
- ); - - // Reusable Component: ProfileInfoItem - const ProfileInfoItem: React.FC = ({ - icon, - label, - value, - capitalize = false, - }) => ( - -
- {icon} -
-
-

{label}

-

- {value} -

-
-
- ); - - // Reusable Component: SecurityItem - const SecurityItem: React.FC = ({ - title, - description, - status, - action, - twoFactorStatus, - }) => ( - -
-
-
-

- {title} -

-

- {description} -

-
-
- {action} -
- ); - - // Reusable Component: ActivityItem - const ActivityItem: React.FC = ({ - icon, - title, - time, - type, - }) => ( - -
- {icon} -
-
-

{title}

-

{time}

-
-
- ); - - return ( -
- -
- {/* Enhanced Header */} - -
- - - - -
- - -
- - {/* User Quick Stats */} - -
-
- - Online - -
-
- - {t(`profile.userTypes.${userDetails.userType}`)} - -
-
- - {/* Main Content Grid - Reorganized */} - - {/* Left Sidebar - Profile & Account Status */} -
- {/* Enhanced Profile Card */} - - - {/* Profile Header with Gradient */} -
-
-
- - {userDetails.status === "accepted" - ? "Verified" - : "Pending"} - -
- - {/* Profile Avatar */} -
- -
- -
-
-
-
-
- - - {/* User Info */} -
- - {localizedName(userDetails.name) || t("profile.noName")} - -
- - @{userDetails.username} -
-
- - {/* Status Indicators */} -
- -
- {positionIsDeletation ? "Yes" : "No"} -
-
Delegate
-
- -
- {position.permissions?.length || 0} -
-
Permissions
-
-
- - {/* Profile Details */} -
- } - label={t("profile.email")} - value={userDetails.email} - /> - } - label={t("profile.userType")} - value={t(`profile.userTypes.${userDetails.userType}`)} - capitalize - /> - } - label={t("profile.passwordSet")} - value={ - userDetails.hasSetPassword - ? t("common.yes") - : t("common.no") - } - /> -
- - {/* Action Buttons */} -
- - - - - - -
-
-
-
- - {/* Account Status Card */} - - - - - - Account Status - - - - {/* Verification Status */} -
-
-
- {userDetails.status === "accepted" ? ( - - ) : ( - - )} -
-
-

- Account Status -

-

- {userDetails.status === "accepted" - ? "Verified and active" - : "Pending approval"} -

-
-
- - {userDetails.status === "accepted" ? "Active" : "Pending"} - -
- - {/* Password Status */} -
-
-
- -
-
-

- Password -

-

- {userDetails.hasSetPassword - ? "Secured with password" - : "No password set"} -

-
-
- - {userDetails.hasSetPassword ? "Set" : "Not Set"} - -
- - {/* Quick Action */} -
- - - -
-
-
-
-
- - {/* Right Column - Main Content */} -
- - - - -
- -
-
- Employee Information -

- Personal & organizational details -

-
-
-
- -
- {/* Basic Info */} -
- } - label="Employee ID" - value={employeeData?.id || "Not specified"} - color="blue" - /> - } - label="Organization ID" - value={employeeData?.organizationId || "Not specified"} - color="purple" - /> - } - label="User Type" - value={userDetails.userType} - color="green" - capitalize - /> -
- - {/* Names */} -
- } - label="English Name" - value={employeeData?.name?.en || "Not specified"} - fullWidth - /> - } - label="Amharic Name" - value={employeeData?.name?.am || "Not specified"} - fullWidth - amharic - /> -
- - {/* Position Summary */} - {userDetails.employee[0]?.positions?.[0] && ( -
-

- - Current Position -

-
-
-

- Position -

-

- {userDetails.employee[0].positions[0].name?.en || - "Not specified"} -

-
-
-

- Delegation -

- - {positionHasDeletation.length ? "Active" : "None"} - -
-
-
- )} -
-
-
-
- {/* Top Row: Position Details & Security */} -
- {/* Position Details */} - - - - -
- -
-
- {t("profile.positionDetails")} -

- Your current role -

-
-
-
- -
- } - label={t("profile.positionName")} - value={localizedName(position.name) || t("common.na")} - /> - } - label={t("profile.positionKey")} - value={position.key || t("common.na")} - /> - } - label={t("profile.employeeId")} - value={employeeData.id || t("common.na")} - /> -
-
-
-
- - {/* Security Card */} - - - - -
- -
-
- {t("profile.accountSecurity")} -

- Account safety -

-
-
-
- -
- - - - } - /> - - - -
- } - /> -
-
-
- - - {t("profile.sessions")} - - {userSessions.length > 0 && ( - - {userSessions.length} active - - )} -
- -
- - {/* Expandable Sessions Content */} - {isSessionsExpanded && ( - -
- {userSessions.length === 0 ? ( -
- -

No active sessions

-
- ) : ( - <> - {userSessions.map((session) => ( -
-
-
- -
-
-

- Device: {session.device} -

-

- Email: {session.email} -

-

- Created:{" "} - {new Date( - session.createdAt - ).toLocaleString()} -

-
-
- -
- ))} - - - - )} -
-
- )} -
-
- - - -
- - {/* Middle Row: Permissions */} - - - - -
- -
-
- {t("profile.permissions")} -

- Access rights and privileges -

-
-
-
- - {position.permissions?.length > 0 ? ( - - {(permissionsExpanded ? position.permissions : position.permissions.slice(0, PERMISSIONS_INITIAL_SHOW)).map((perm: any, index: number) => ( - -
- -
- - {perm.key} - -
- ))} - {position.permissions.length > PERMISSIONS_INITIAL_SHOW && ( - - )} -
- ) : ( -
-
- -
-

- {t("profile.noPermissions")} -

-

- No special access rights assigned -

-
- )} -
-
-
-
- -
- - ); -}; - -export default ProfilePage; diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx index 507e7da74..82e716d66 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx @@ -17,7 +17,6 @@ import { ClipboardList, Clock, FileText, - Home, Languages, Key, LogOut, @@ -104,9 +103,9 @@ const Top: React.FC = ({ const activePositionName = currentLanguage === "am" ? userDetails?.employee?.[0]?.positions?.[0]?.name?.am || - userDetails?.employee?.[0]?.positions?.[0]?.name?.en + userDetails?.employee?.[0]?.positions?.[0]?.name?.en : userDetails?.employee?.[0]?.positions?.[0]?.name?.en || - userDetails?.employee?.[0]?.positions?.[0]?.name?.am; + userDetails?.employee?.[0]?.positions?.[0]?.name?.am; const normalizedUserType = userDetails?.userType?.trim().toLowerCase() || ""; const userTypeLabel = @@ -114,8 +113,8 @@ const Top: React.FC = ({ ? t("header.user") : normalizedUserType ? normalizedUserType - .replace(/[_-]/g, " ") - .replace(/\b\w/g, (char) => char.toUpperCase()) + .replace(/[_-]/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) : ""; const roleLabel = @@ -123,8 +122,8 @@ const Top: React.FC = ({ userTypeLabel || (normalizedUserType ? userDetails?.roles?.[0]?.key - ?.replace(/[:_]/g, " ") - .replace(/\b\w/g, (char) => char.toUpperCase()) + ?.replace(/[:_]/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) : ""); const { permissionKeys } = useUserDetail(userDetails as MeDto); @@ -159,57 +158,57 @@ const Top: React.FC = ({ }, ...(moduleConfig.recordManagement ? [ - { - id: "recordManagement", - label: t("nav.Record Management", "Record Management"), - path: "/record-management/dashboard", - }, - ] + { + id: "recordManagement", + label: t("nav.Record Management", "Record Management"), + path: "/record-management/dashboard", + }, + ] : []), ...(moduleConfig.performance ? [ - { - id: "performanceManagement", - label: t("nav.PerformanceManagement", "Performance Management"), - path: "/performance-management/plan-years", - }, - ] + { + id: "performanceManagement", + label: t("nav.PerformanceManagement", "Performance Management"), + path: "/performance-management/plan-years", + }, + ] : []), ...(moduleConfig.objective ? [ - { - id: "objectiveManagement", - label: t("nav.objectiveManagement", "Objective Management"), - path: "/objective-management/plan-years", - }, - ] + { + id: "objectiveManagement", + label: t("nav.objectiveManagement", "Objective Management"), + path: "/objective-management/plan-years", + }, + ] : []), ...(moduleConfig.dms ? [ - { - id: "documentManagement", - label: t("nav.DocumentManagement", "Document Management"), - path: "/dms/dashboard", - }, - ] + { + id: "documentManagement", + label: t("nav.DocumentManagement", "Document Management"), + path: "/dms/dashboard", + }, + ] : []), ...(isOrgAdmin && moduleConfig.siteManagement ? [ - { - id: "orgAdmin", - label: t("nav.admin", "Admin"), - path: "/user-management/user_management-dashboard", - }, - ] + { + id: "orgAdmin", + label: t("nav.admin", "Admin"), + path: "/user-management/user_management-dashboard", + }, + ] : []), ...(isSuperAdmin ? [ - { - id: "superAdmin", - label: t("OrganizationAdmin", "Super Admin"), - path: "/user-management/dashboard", - }, - ] + { + id: "superAdmin", + label: t("OrganizationAdmin", "Super Admin"), + path: "/user-management/dashboard", + }, + ] : []), ]; @@ -256,10 +255,28 @@ const Top: React.FC = ({ }; return ( -
-
-
-
+
+
+
+ {onToggleSidebar ? ( + + + + + + Toggle sidebar + + + ) : ( @@ -320,191 +337,183 @@ const Top: React.FC = ({ ))} + )} -
- -
- - {showUserManagementShortcut && ( - - )} +
+
-
+ {showUserManagementShortcut && ( + + )} +
+ +
+ + + {canActivateUsers && ( +
+ + + {pendingUsersCount > 0 && ( + + {pendingUsersCount > 99 ? "99+" : pendingUsersCount} + + )} + + {openPendingUsers && ( +
+ +
+ )} +
+ )} + +
- {canActivateUsers && ( -
- - - {pendingUsersCount > 0 && ( - - {pendingUsersCount > 99 ? "99+" : pendingUsersCount} - - )} - - {openPendingUsers && ( -
- -
- )} + {openNotifications && ( +
+
)} +
-
- + {openReminders && ( +
+ +
+ )} +
+ + + + + + + + {UI_LANGUAGE_OPTIONS.map((lang) => ( + changeLanguage(lang.value)} className={cn( - "h-4 w-4 transition-colors", - openNotifications && - "fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300", + "flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/30", + currentLanguage === lang.value && + "bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white", )} - /> - {unseenCount > 0 && ( - - {unseenCount > 99 ? "99+" : unseenCount} - - )} - + > +
+ + {getUiLanguageShortLabel(lang.value, t)} + + {getUiLanguageLabel(lang.value, t)} +
+ {currentLanguage === lang.value && ( + + )} +
+ ))} +
+
- {openNotifications && ( -
- -
- )} -
- - {/* Reminders */} -
+ + - {openReminders && ( -
- -
- )} -
- - - - - - - - {UI_LANGUAGE_OPTIONS.map((lang) => ( - changeLanguage(lang.value)} - className={cn( - "flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/30", - currentLanguage === lang.value && - "bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white", - )} - > -
- - {getUiLanguageShortLabel(lang.value, t)} - - {getUiLanguageLabel(lang.value, t)} -
- {currentLanguage === lang.value && ( - - )} -
- ))} -
-
- - - - - - - -
-

{fullName}

-

- {roleLabel} -

+ > + {initials.toUpperCase()}
+
+ + {fullName} + + + {roleLabel} + +
+ + + + +
+

{fullName}

+

+ {roleLabel} +

+
+ + navigate("/profile")} + > + + {t("header.viewProfile")} + + + navigate("/update-profile")} + > + + {t("header.editProfile")} + + + navigate("/change-password")} + > + + {t("header.changePassword")} + + + {showRecordManagementShortcut && ( navigate("/profile")} + onClick={() => navigate("/record-management/dashboard")} > - - {t("header.viewProfile")} + + {t("nav.Record Management")} + )} + {showUserManagementShortcut && ( navigate("/update-profile")} + onClick={() => navigate("/user-management")} > - - {t("header.editProfile")} + + + {t("dashboard.userManagement", "User Management")} + + )} - navigate("/change-password")} - > - - {t("header.changePassword")} - + - {showRecordManagementShortcut && ( - navigate("/record-management/dashboard")} - > - - {t("nav.Record Management")} - - )} - - {showUserManagementShortcut && ( - navigate("/user-management")} - > - - - {t("dashboard.userManagement", "User Management")} - - - )} - - - - - - {t("header.signOut")} - -
-
-
+ + + {t("header.signOut")} + + +
-
-
+
+
); }; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 0adeb3a82..c0d324b83 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -195,6 +195,7 @@ import { type UsedTrainNumbers, } from "./trainBuilder.service"; import { trainSchedulingService } from "./trainScheduling.service"; +import { truckTypesService, type TruckType } from "./truck-types.service"; import { wagonTypesService, type WagonType } from "./wagon-types.service"; import { wagonService, @@ -2082,6 +2083,36 @@ export const api = { ), }, + truckTypes: { + list: endpoint("truck-types", "list", () => + truckTypesService.getTruckTypes(), + ), + + create: endpoint, TruckType>( + "truck-types", + "create", + (payload) => truckTypesService.create(payload).then((r) => r.data), + undefined, + () => [["truck-types"]], + ), + + update: endpoint<{ id: string; data: Partial }, TruckType>( + "truck-types", + "update", + ({ id, data }) => truckTypesService.update(id, data).then((r) => r.data), + undefined, + () => [["truck-types"]], + ), + + remove: endpoint( + "truck-types", + "remove", + (id) => truckTypesService.delete(id).then(() => undefined), + undefined, + () => [["truck-types"]], + ), + }, + wagonTypes: { list: endpoint("wagon-types", "list", () => wagonTypesService.getWagonTypes(), diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index 275bf84ea..5992fa96f 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -22,7 +22,10 @@ export interface FirstMileBooking { serviceType?: { id: string; label?: string } | null; originYard?: { id: string; label?: string } | null; destinationYard?: { id: string; label?: string } | null; - cargoType?: { id: string; label?: string } | null; + cargoType?: { id: string; label?: string; cargoTypeName?: string; name?: string } | null; + freightType?: string | null; + /** Attached server-side: the train schedule this booking rides. */ + trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null; /** Container lines — total container count drives how many trucks are needed. */ bookingContainers?: Array<{ id: string; @@ -68,6 +71,8 @@ export interface FirstMileRecord { vehicleId: string; containerNumber?: string | null; distanceKm?: number | null; + tons?: number | null; + quantity?: number | null; vehicle?: FirstMileVehicle | null; }>; /** Present only when an invoice has actually been generated (not on distance). */ @@ -95,7 +100,12 @@ export const firstMileService = { api.delete(FM.BY_ID(id)), setVehicles: ( id: string, - vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>, + vehicles: Array<{ + vehicleId: string; + containerNumber?: string | null; + tons?: number | null; + quantity?: number | null; + }>, ) => api.post(`${FM.BASE}/${id}/vehicles`, { vehicles }), setDistances: ( id: string, diff --git a/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts b/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts index 6e2fd3474..0996b053d 100644 --- a/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts @@ -28,6 +28,4 @@ export const interchangeDocumentsService = { apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.ACKNOWLEDGE(id), payload), dispute: (id: string, payload: { remarks: string }) => apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.DISPUTE(id), payload), - cancel: (id: string) => - apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.CANCEL(id), {}), }; diff --git a/apps/edr-freight-web/backoffice/src/services/procurement.service.ts b/apps/edr-freight-web/backoffice/src/services/procurement.service.ts index 684dcd37f..8113db7f3 100644 --- a/apps/edr-freight-web/backoffice/src/services/procurement.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/procurement.service.ts @@ -20,6 +20,7 @@ export interface Vendor { export interface AssetAcquisition { id: string; + itemName?: string | null; vehicleId?: string | null; vendorId?: string | null; acquisitionType: AcquisitionType; diff --git a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts index 4e646e426..3fcd6a7e8 100644 --- a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts @@ -85,6 +85,7 @@ const RESOURCE_BASE: Record = { "cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES, "container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES, "wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES, + "truck-types": URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPES, "priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS, "service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES, "weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES, @@ -103,6 +104,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => { return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id); case "wagon-types": return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id); + case "truck-types": + return URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPE_BY_ID(id); case "priority-configs": return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id); case "service-types": diff --git a/apps/edr-freight-web/backoffice/src/services/truck-types.service.ts b/apps/edr-freight-web/backoffice/src/services/truck-types.service.ts new file mode 100644 index 000000000..a48fa569f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/truck-types.service.ts @@ -0,0 +1,30 @@ +import { api } from "../auth/http"; + +type ListResponse = T[] | { data: T[] }; + +export interface TruckType { + id: string; + code: string; + name: string; + /** Pre-fills a vehicle's capacity — capacity belongs to the type, not each truck. */ + capacityTons: number | null; + /** False for a rigid truck (e.g. Casoni), which has no trailer plate at all. */ + hasTrailer: boolean; + description?: string | null; + isActive: boolean; +} + +const asList = (payload: ListResponse): T[] => + Array.isArray(payload) ? payload : payload.data; + +export const truckTypesService = { + async getTruckTypes() { + const response = await api.get>('/truck-types', { + params: { isActive: 'all', pageSize: 500 }, + }); + return asList(response.data); + }, + create: (data: Partial) => api.post('/truck-types', data), + update: (id: string, data: Partial) => api.patch(`/truck-types/${id}`, data), + delete: (id: string) => api.delete(`/truck-types/${id}`), +}; diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts index 749e7656c..c72efa264 100644 --- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts @@ -20,7 +20,14 @@ export interface Vehicle { id: string; plateNumber: string; registrationNumber: string; + /** Denormalised truck-type code, written server-side. Register with `truckTypeId`. */ vehicleType: VehicleType; + /** Truck configuration from the managed truck types. */ + truckTypeId?: string | null; + /** Vehicle Identification Number — unique across the fleet. */ + vin?: string | null; + /** OWNED | OUTSOURCED. */ + ownership?: string | null; manufacturer: string; model: string; year: number; diff --git a/apps/edr-freight-web/backoffice/src/shared/services/organizationsService.ts b/apps/edr-freight-web/backoffice/src/shared/services/organizationsService.ts index 3a560a08d..f0bd42337 100644 --- a/apps/edr-freight-web/backoffice/src/shared/services/organizationsService.ts +++ b/apps/edr-freight-web/backoffice/src/shared/services/organizationsService.ts @@ -37,7 +37,7 @@ export enum FilterEnum { } export const getOrganizations = async ( - params?: OrgQueryParams + params?: OrgQueryParams, ): Promise => { return axiosInstance.get("/organizations/filter", { ...{ headers: withHeaders() }, @@ -45,29 +45,27 @@ export const getOrganizations = async ( }); }; -export const getMyAdminOrganizations = async (): Promise => { - //my-admin-organizations - return axiosInstance.get("/organizations/with-admin-flag", { - headers: withHeaders(), - }); -}; export const getOrganizationsWithAdminFlag = async ( - params?: OrgQueryParams + params?: OrgQueryParams, ): Promise => { return axiosInstance.get("/organizations/with-admin-flag", { ...{ headers: withHeaders() }, - params, + params: { + take: 100, + ...params, + }, }); }; +export const getMyAdminOrganizations = getOrganizationsWithAdminFlag; export const getOrganizationById = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.get(`/organizations/${id}`, { headers: withHeaders() }); }; export const getChildren = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.get(`/organizations/${id}/children`, { headers: withHeaders(), @@ -76,7 +74,7 @@ export const getChildren = async ( export const getEmployeesUnderOrg = async ( id: string | number, - params?: OrgQueryParams + params?: OrgQueryParams, ): Promise => { return axiosInstance.get(`/organizations/current/${id}/employees`, { headers: withHeaders(), @@ -89,7 +87,7 @@ export const getEmployeesUnderOrg = async ( }; export const getEmployeeCountByOrgId = async ( - id: string | number + id: string | number, ): Promise => { // Try the endpoint from your curl example return axiosInstance.get(`/organizations/${id}/employees/count`, { @@ -98,13 +96,13 @@ export const getEmployeeCountByOrgId = async ( }; export const createOrganization = async ( - data: OrganizationPayload + data: OrganizationPayload, ): Promise => { return axiosInstance.post("/organizations", data, { headers: withHeaders() }); }; export const activateOrganization = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.patch(`/organizations/${id}/activate`, null, { headers: withHeaders(), @@ -112,7 +110,7 @@ export const activateOrganization = async ( }; export const deActivateOrganization = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.patch(`/organizations/${id}/debar`, null, { headers: withHeaders(), @@ -121,7 +119,7 @@ export const deActivateOrganization = async ( export const updateOrganization = async ( id: string | number, - data: OrganizationPayload + data: OrganizationPayload, ): Promise => { return axiosInstance.put(`/organizations/${id}`, data, { headers: withHeaders(), @@ -129,7 +127,7 @@ export const updateOrganization = async ( }; export const deleteOrganization = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.delete(`/organizations/${id}`, { headers: withHeaders(), @@ -137,7 +135,7 @@ export const deleteOrganization = async ( }; export const softDeleteOrganization = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.delete(`/organizations/${id}/soft`, { headers: withHeaders(), @@ -149,7 +147,7 @@ export const softDeleteOrganization = async ( // GET /organizations/archived // PATCH /organizations/{id}/restore export const getArchivedOrganizations = async ( - params?: OrgQueryParams + params?: OrgQueryParams, ): Promise => { return axiosInstance.get(`/organizations/archived`, { headers: withHeaders(), @@ -158,7 +156,7 @@ export const getArchivedOrganizations = async ( }; export const restoreOrganization = async ( - id: string | number + id: string | number, ): Promise => { return axiosInstance.patch(`/organizations/${id}/restore`, null, { headers: withHeaders(), @@ -171,20 +169,20 @@ export const getDocumentRequirements = async (): Promise => { }); }; export const getDocumentRequirementsById = async ( - id: string + id: string, ): Promise => { return axiosInstance.get(`/documentary-requirements/${id}`, { headers: withHeaders(), }); }; export const getDocumentRequirementsByFilter = async ( - filter: FilterEnum + filter: FilterEnum, ): Promise => { return axiosInstance.get(`/documentary-requirements/${filter}/type`); }; export const postDocumentRequirements = async ( - data: DocumentRequirementDto + data: DocumentRequirementDto, ): Promise => { return axiosInstance.post(`/documentary-requirements`, data, { headers: withHeaders(), @@ -193,7 +191,7 @@ export const postDocumentRequirements = async ( export const giveResponse = async ( id: string, - data: ResponseActionDto + data: ResponseActionDto, ): Promise => { return axiosInstance.post(`user-documents/${id}/response`, data, { headers: withHeaders(), @@ -202,7 +200,7 @@ export const giveResponse = async ( export const getArchivedUserId = async ( unitId: string, - params?: OrgQueryParams + params?: OrgQueryParams, ): Promise => { return axiosInstance.get(`/employees/archived/${unitId}/with-unit`, { headers: withHeaders(), diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx new file mode 100644 index 000000000..44211f6c3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx @@ -0,0 +1,383 @@ +import { useEffect } from "react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { t } from "i18next"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shared/common/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { Input } from "@/shared/common/ui/input"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; +import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins"; + +const adminSchema = z.object({ + name: z.object({ + en: z.string().min(1, t("organization.englishNameRequired")), + am: z.string().min(1, t("organization.amharicNameRequired")), + }), + username: z.string().min(3, t("organization.usernameMinLength")), + email: z.string().email(t("organization.invalidEmail")), + phoneNumber: z + .string() + .regex(/^(\+251|0)?9\d{8}$/, t("organization.invalidPhoneNumber")), + organizationId: z.string().min(1, t("organization.organizationRequired")), + /** required when the org has units (unit admin); empty only when the org + * has no units → org admin. Enforced at submit, not in the schema. */ + unitId: z.string().optional(), +}); + +export type AdminFormValues = z.infer; + +const EMPTY_VALUES: AdminFormValues = { + name: { en: "", am: "" }, + username: "", + email: "", + phoneNumber: "", + organizationId: "", + unitId: "", +}; + +const RequiredMark = () => *; + +interface AdminFormModalProps { + isOpen: boolean; + onClose: () => void; + /** null → add a new admin; set → edit this admin's profile */ + admin: OrgAdminUser | null; + /** org whose units feed the unit-admin scope picker */ + organizationId?: string; + /** server-side error from the last submit, shown inline */ + apiError: string | null; + onSubmit: (values: AdminFormValues) => void; + isSubmitting: boolean; +} + +/** Add-admin (org or unit scope) / edit-admin-profile modal (one form). */ +export default function AdminFormModal({ + isOpen, + onClose, + admin, + organizationId, + apiError, + onSubmit, + isSubmitting, +}: AdminFormModalProps) { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + const isEdit = !!admin; + + const form = useForm({ + resolver: zodResolver(adminSchema), + defaultValues: EMPTY_VALUES, + }); + + const { organizationsResponse } = useOrganizations("Org", { take: 3000 }); + const activeOrgs = (organizationsResponse?.items ?? []).filter( + (org) => org.status === "Active", + ); + + const selectedOrgId = form.watch("organizationId"); + const { data: unitsResponse, isLoading: isLoadingUnits } = + useUnit().getList( + selectedOrgId || "", + { take: 300, skip: 0 }, + isOpen && !isEdit, + ); + const units = unitsResponse?.data?.items ?? []; + + useEffect(() => { + if (isOpen) { + form.reset( + admin + ? { + ...EMPTY_VALUES, + name: { + en: admin.name?.en ?? "", + am: admin.name?.am ?? "", + }, + username: admin.username ?? "", + email: admin.email ?? "", + phoneNumber: admin.phoneNumber ?? "", + } + : { ...EMPTY_VALUES, organizationId: organizationId ?? "" }, + ); + } + }, [isOpen, admin, organizationId, form]); + + // an org with units gets a unit admin — unit is mandatory then; only a + // unit-less org falls through to an org admin + const submit = form.handleSubmit((values) => { + if (!isEdit && units.length > 0 && !values.unitId) { + form.setError("unitId", { + message: t("orgAdmins.form.unitRequired"), + }); + return; + } + onSubmit(values); + }); + + const handleClose = () => { + if (isSubmitting) return; + onClose(); + }; + + return ( + !open && handleClose()}> + + + + {isEdit ? t("orgAdmins.edit.title") : t("orgAdmins.add.title")} + + + {isEdit + ? t("orgAdmins.edit.description") + : t("orgAdmins.add.description")} + + + +
+ +
+ ( + + + {t("orgAdmins.form.nameEn")} + + + + + + + + )} + /> + ( + + + {t("orgAdmins.form.nameAm")} + + + + + + + + )} + /> +
+ ( + + + {t("orgAdmins.form.username")} + + + + + + + + )} + /> + ( + + + {t("orgAdmins.form.email")} + + + + + + + + )} + /> + ( + + + {t("orgAdmins.form.phoneNumber")} + + + + + + + + )} + /> + + {!isEdit && ( + <> + ( + + + {t("organization.organization")} + + + + + + )} + /> + {(isLoadingUnits || units.length > 0 || !selectedOrgId) && ( + ( + + + {t("orgAdmins.form.unit")} + + + + + + )} + /> + )} +

+ {!isLoadingUnits && selectedOrgId && units.length === 0 + ? t("orgAdmins.add.noUnitsOrgAdmin") + : t("orgAdmins.add.inviteNote")} +

+ + )} + + {apiError && ( +
+ {apiError} +
+ )} + + + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx new file mode 100644 index 000000000..870d75486 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx @@ -0,0 +1,334 @@ +import { useEffect, useMemo, useState } from "react"; +import { Check, Loader2, UserPlus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/common/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; +import { Input } from "@/shared/common/ui/input"; +import { Label } from "@/shared/common/ui/label"; +import { ScrollArea } from "@/shared/common/ui/scroll-area"; +import { Badge } from "@/shared/common/ui/badge"; +import { cn } from "@/super-admin/lib/utils"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useEmployees } from "@/user-management/hooks/useEmployees"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; + +interface AssignExistingAdminModalProps { + isOpen: boolean; + onClose: () => void; + /** org preselected in the panel (the page's current org) */ + organizationId: string; + /** user ids that are already admins of the page's org — shown disabled */ + existingAdminIds: string[]; + /** server-side error from the last assign attempt, shown inline */ + apiError: string | null; + /** unitId set → grant unit-admin of that unit instead of org-admin */ + onAssign: (userId: string, organizationId: string, unitId?: string) => void; + isAssigning: boolean; +} + +/** + * Promote an existing employee to admin. Three panels like the old + * AssignAdminDialog: pick an org (page org preselected), pick a unit (or + * none → org admin), then pick a user — unit selection also filters the + * employee list to that unit. + */ +export default function AssignExistingAdminModal({ + isOpen, + onClose, + organizationId, + existingAdminIds, + apiError, + onAssign, + isAssigning, +}: AssignExistingAdminModalProps) { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + const [selectedUserId, setSelectedUserId] = useState(""); + const [search, setSearch] = useState(""); + const [orgId, setOrgId] = useState(organizationId); + // "" → org admin (all org users listed); set → unit admin of that unit + const [unitId, setUnitId] = useState(""); + + const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( + "Org", + { take: 300 }, + ); + const orgs = organizationsResponse?.items ?? []; + + const { data: unitsResponse, isLoading: isLoadingUnits } = + useUnit().getList(orgId, { take: 300, skip: 0 }, isOpen); + const units = unitsResponse?.data?.items ?? []; + + const { + employeesResponseByOrg, + isLoadingEmployeesByOrg, + isErrorEmployeesByOrg, + refetchEmployeesByOrg, + } = useEmployees({ + organizationId: isOpen ? orgId : undefined, + unitId: unitId || undefined, + params: { take: 3000, skip: 0 }, + }); + + const filteredEmployees = useMemo(() => { + const query = search.trim().toLowerCase(); + const employees = employeesResponseByOrg?.items ?? []; + if (!query) return employees; + return employees.filter((employee: any) => { + const name = localizedName(employee.user?.name).toLowerCase(); + const email = employee.user?.email?.toLowerCase() ?? ""; + return name.includes(query) || email.includes(query); + }); + }, [employeesResponseByOrg, localizedName, search]); + + const selectOrg = (id: string) => { + setOrgId(id); + setUnitId(""); + setSelectedUserId(""); + }; + + const selectUnit = (id: string) => { + setUnitId(id); + setSelectedUserId(""); + }; + + const handleClose = () => { + if (isAssigning) return; + setSelectedUserId(""); + setSearch(""); + setOrgId(organizationId); + setUnitId(""); + onClose(); + }; + + // already-admin info only covers the page's org + const knownAdminIds = orgId === organizationId ? existingAdminIds : []; + + useEffect(() => { + if (isOpen) { + setOrgId(organizationId); + setUnitId(""); + setSelectedUserId(""); + setSearch(""); + } + }, [isOpen, organizationId]); + + return ( + !open && handleClose()}> + + + {t("orgAdmins.assign.title")} + + {t("orgAdmins.assign.description")} + + + +
+ {/* Step 1: organization (page org preselected) */} +
+ + {isLoadingOrgs ? ( +
+ +
+ ) : ( + +
+ {orgs.map((org) => ( + + ))} +
+
+ )} +
+ + {/* Step 2: unit (or none → org admin) */} +
+ + {isLoadingUnits ? ( +
+ +
+ ) : ( + +
+ + {units.map((unit: any) => ( + + ))} +
+
+ )} +
+ + {/* Step 3: user */} +
+ + setSearch(event.target.value)} + placeholder={t("orgAdmins.assign.searchUsers")} + /> + {isLoadingEmployeesByOrg ? ( +
+ +
+ ) : isErrorEmployeesByOrg ? ( +
+ {t("orgAdmins.assign.loadError")} + +
+ ) : ( + +
+ {filteredEmployees.map((employee: any) => { + const userId = employee.user?.id; + if (!userId) return null; + const isAlreadyAdmin = knownAdminIds.includes(userId); + return ( + + ); + })} + {filteredEmployees.length === 0 && ( +

+ {t("orgAdmins.assign.noUsersFound")} +

+ )} +
+
+ )} +
+
+ + {apiError && ( +
+ {apiError} +
+ )} + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx new file mode 100644 index 000000000..97c3bfbc6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx @@ -0,0 +1,228 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { + ArrowUpDown, + MoreHorizontal, + Pencil, + Send, + Trash2, + UserCheck, + UserX, +} from "lucide-react"; +import { t } from "i18next"; +import { Button } from "@/shared/common/ui/button"; +import { Badge } from "@/shared/common/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/common/ui/dropdown-menu"; +import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins"; + +export const ORG_ADMIN_ROLE_KEY = "organization_admin"; +export const UNIT_ADMIN_ROLE_KEY = "unit_admin"; + +export interface AdminRoleInfo { + isOrgAdmin: boolean; + isUnitAdmin: boolean; + unitId?: string; +} + +/** + * all-admins/:id returns users who are org admins of the org OR unit admins of + * one of its units; userRoles carries every role of the user, so match the org + * explicitly for the org-admin grant. + */ +// ponytail: unit relation isn't loaded, so a unit_admin grant from another org +// can't be told apart — acceptable, the server only returns admins of this org. +export function getAdminRoleInfo( + admin: OrgAdminUser, + selectedOrgId: string, +): AdminRoleInfo { + const roles = admin.userRoles ?? []; + const isOrgAdmin = roles.some( + (r) => + r.role?.key === ORG_ADMIN_ROLE_KEY && + r.organizationId === selectedOrgId, + ); + const unitRole = roles.find( + (r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId, + ); + return { + isOrgAdmin, + isUnitAdmin: !!unitRole, + unitId: unitRole?.unitId ?? undefined, + }; +} + +interface ColumnCallbacks { + selectedOrgId: string; + localizedName: (name?: { am?: string; en?: string }) => string; + onEdit: (admin: OrgAdminUser) => void; + onResend: (admin: OrgAdminUser) => void; + onToggleActive: (admin: OrgAdminUser) => void; + onRemove: (admin: OrgAdminUser, roleInfo: AdminRoleInfo) => void; +} + +export function getOrgAdminsColumnDefn({ + selectedOrgId, + localizedName, + onEdit, + onResend, + onToggleActive, + onRemove, +}: ColumnCallbacks): ColumnDef[] { + return [ + { + id: "name", + accessorFn: (row) => localizedName(row.name), + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+

+ {localizedName(row.original.name) || "—"} +

+

+ {row.original.username} +

+
+ ), + }, + { + id: "email", + accessorFn: (row) => row.email ?? "", + header: () => t("orgAdmins.columns.email"), + cell: ({ row }) => ( + {row.original.email || "—"} + ), + }, + { + id: "phoneNumber", + accessorFn: (row) => row.phoneNumber ?? "", + header: () => t("orgAdmins.columns.phone"), + cell: ({ row }) => ( + {row.original.phoneNumber || "—"} + ), + }, + { + id: "role", + header: () => t("orgAdmins.columns.role"), + cell: ({ row }) => { + const info = getAdminRoleInfo(row.original, selectedOrgId); + return ( +
+ {info.isOrgAdmin && ( + + {t("orgAdmins.roleOrgAdmin")} + + )} + {info.isUnitAdmin && ( + + {t("orgAdmins.roleUnitAdmin")} + + )} +
+ ); + }, + }, + { + id: "status", + accessorFn: (row) => + !row.hasSetPassword + ? "invited" + : row.isActive + ? "active" + : "inactive", + header: () => t("orgAdmins.columns.status"), + cell: ({ row }) => { + const admin = row.original; + if (!admin.hasSetPassword) { + return ( + + {t("orgAdmins.statusInvited")} + + ); + } + return admin.isActive ? ( + + {t("orgAdmins.statusActive")} + + ) : ( + + {t("orgAdmins.statusInactive")} + + ); + }, + }, + { + id: "createdAt", + accessorFn: (row) => row.createdAt ?? "", + header: () => t("orgAdmins.columns.addedOn"), + cell: ({ row }) => + row.original.createdAt + ? new Date(row.original.createdAt).toLocaleDateString() + : "—", + }, + { + id: "actions", + header: () => t("orgAdmins.columns.actions"), + enableHiding: false, + cell: ({ row }) => { + const admin = row.original; + const roleInfo = getAdminRoleInfo(admin, selectedOrgId); + return ( + + + + + + onEdit(admin)}> + + {t("orgAdmins.actions.edit")} + + {!admin.hasSetPassword && ( + onResend(admin)}> + + {t("orgAdmins.actions.resend")} + + )} + onToggleActive(admin)}> + {admin.isActive ? ( + <> + + {t("orgAdmins.actions.deactivate")} + + ) : ( + <> + + {t("orgAdmins.actions.activate")} + + )} + + + onRemove(admin, roleInfo)} + > + + {t("orgAdmins.actions.remove")} + + + + ); + }, + }, + ]; +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx new file mode 100644 index 000000000..d5844a603 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx @@ -0,0 +1,430 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Building2, Loader2, Plus, UserPlus, Users2 } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/common/ui/alert-dialog"; +import { Badge } from "@/shared/common/ui/badge"; +import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { + OrgAdminUser, + useOrgAdmins, +} from "@/super-admin/hooks/useOrgAdmins"; +import { OrgPicker } from "./OrgPicker"; +import { + AdminRoleInfo, + getOrgAdminsColumnDefn, +} from "./OrgAdminsColumnDefn"; +import AdminFormModal, { AdminFormValues } from "./AdminFormModal"; +import AssignExistingAdminModal from "./AssignExistingAdminModal"; + +interface RemoveTarget { + admin: OrgAdminUser; + roleInfo: AdminRoleInfo; +} + +export default function OrgAdminsPage() { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + + const [selectedOrg, setSelectedOrg] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + // modals & confirms + const [formOpen, setFormOpen] = useState(false); + const [editAdmin, setEditAdmin] = useState(null); + const [assignOpen, setAssignOpen] = useState(false); + const [removeTarget, setRemoveTarget] = useState(null); + const [toggleTarget, setToggleTarget] = useState(null); + + const { + adminsResponse, + isLoading, + isError, + refetch, + addAdmin, + isAdding, + assignAdmin, + isAssigning, + removeAdmin, + isRemoving, + resendInvite, + toggleActive, + isToggling, + updateAdminProfile, + isUpdatingProfile, + formError, + assignError, + removeError, + toggleError, + clearErrors, + } = useOrgAdmins(selectedOrg?.id, { + take: pageSize, + skip: pageIndex * pageSize, + }); + + useEffect(() => { + setPageIndex(0); + }, [selectedOrg?.id, pageSize]); + + const admins = adminsResponse?.items ?? []; + const adminCount = adminsResponse?.count ?? 0; + const existingAdminIds = useMemo( + () => admins.map((admin) => admin.id), + [admins], + ); + + const handleFormSubmit = (values: AdminFormValues) => { + if (!selectedOrg) return; + const person = { + name: values.name, + username: values.username, + email: values.email, + phoneNumber: values.phoneNumber, + }; + if (editAdmin) { + updateAdminProfile( + { id: editAdmin.id, payload: person }, + { + onSuccess: () => { + setFormOpen(false); + setEditAdmin(null); + }, + }, + ); + } else { + addAdmin( + { + organizationId: values.organizationId, + unitId: values.unitId || undefined, + ...person, + }, + { onSuccess: () => setFormOpen(false) }, + ); + } + }; + + const handleAssign = ( + userId: string, + organizationId: string, + unitId?: string, + ) => { + assignAdmin( + { organizationId, userId, unitId }, + { onSuccess: () => setAssignOpen(false) }, + ); + }; + + const handleRemoveConfirm = () => { + if (!removeTarget || !selectedOrg) return; + const { admin, roleInfo } = removeTarget; + removeAdmin( + // unit-admin-only rows go through the unit endpoint; everything else + // defaults to org removal so a role anomaly never sends unitId: undefined + !roleInfo.isOrgAdmin && roleInfo.unitId + ? { userId: admin.id, unitId: roleInfo.unitId } + : { userId: admin.id, organizationId: selectedOrg.id }, + { onSuccess: () => setRemoveTarget(null) }, + ); + }; + + const handleResend = (admin: OrgAdminUser) => { + if (!admin.email || !admin.phoneNumber) { + toast.error(t("orgAdmins.toasts.missingContact")); + return; + } + const toastId = toast.loading(t("orgAdmins.toasts.resending")); + resendInvite( + { email: admin.email, phoneNumber: admin.phoneNumber }, + { onSettled: () => toast.dismiss(toastId) }, + ); + }; + + const handleToggleConfirm = () => { + if (!toggleTarget) return; + toggleActive( + { id: toggleTarget.id, activate: !toggleTarget.isActive }, + { onSuccess: () => setToggleTarget(null) }, + ); + }; + + const columns = useMemo( + () => + getOrgAdminsColumnDefn({ + selectedOrgId: selectedOrg?.id ?? "", + localizedName: localizedName as (name?: { + am?: string; + en?: string; + }) => string, + onEdit: (admin) => { + setEditAdmin(admin); + setFormOpen(true); + }, + onResend: handleResend, + onToggleActive: setToggleTarget, + onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedOrg?.id], + ); + + return ( +
+ + + + {t("orgAdmins.title")} + +

+ {t("orgAdmins.subtitle")} +

+
+ + {/* Org selector + summary */} +
+ + {selectedOrg && ( +
+ + + {t("orgAdmins.adminsCount", { count: adminCount })} + + {selectedOrg.activeEmployeeCount !== undefined && ( + + {t("orgAdmins.activeEmployees", { + count: selectedOrg.activeEmployeeCount, + })} + + )} + + {selectedOrg.status} + +
+ )} +
+ + {!selectedOrg ? ( +
+ +

+ {t("orgAdmins.selectOrgPrompt")} +

+

+ {t("orgAdmins.selectOrgPromptHint")} +

+
+ ) : ( + <> + {isError && ( +
+ {t("orgAdmins.loadError")} + +
+ )} + {!isLoading && !isError && adminCount === 0 && ( +
+ {t("orgAdmins.noAdminsHint", { + name: localizedName(selectedOrg.name), + })} +
+ )} + setPageIndex(pageIndex + 1)} + prevFunction={() => setPageIndex(Math.max(pageIndex - 1, 0))} + refresh={refetch} + isLoading={isLoading} + extraToolbar={ +
+ + +
+ } + /> + + )} +
+
+ + {/* Add / Edit modal */} + { + setFormOpen(false); + setEditAdmin(null); + clearErrors(); + }} + admin={editAdmin} + organizationId={selectedOrg?.id} + apiError={formError} + onSubmit={handleFormSubmit} + isSubmitting={isAdding || isUpdatingProfile} + /> + + {/* Assign existing employee modal */} + {selectedOrg && ( + { + setAssignOpen(false); + clearErrors(); + }} + organizationId={selectedOrg.id} + existingAdminIds={existingAdminIds} + apiError={assignError} + onAssign={handleAssign} + isAssigning={isAssigning} + /> + )} + + {/* Remove admin confirm */} + { + if (!open) { + setRemoveTarget(null); + clearErrors(); + } + }} + > + + + + {t("orgAdmins.confirmRemove.title")} + + + {t("orgAdmins.confirmRemove.description", { + name: + localizedName(removeTarget?.admin.name) || + removeTarget?.admin.email, + org: localizedName(selectedOrg?.name), + })} + + + {removeError && ( +
+ {removeError} +
+ )} + + + {t("common.cancel")} + + + {isRemoving + ? t("orgAdmins.confirmRemove.removing") + : t("orgAdmins.actions.remove")} + + +
+
+ + {/* Activate / Deactivate confirm */} + { + if (!open) { + setToggleTarget(null); + clearErrors(); + } + }} + > + + + + {toggleTarget?.isActive + ? t("orgAdmins.confirmToggle.deactivateTitle") + : t("orgAdmins.confirmToggle.activateTitle")} + + + {t("orgAdmins.confirmToggle.description", { + name: + localizedName(toggleTarget?.name) || toggleTarget?.email, + })} + + + {toggleError && ( +
+ {toggleError} +
+ )} + + + {t("common.cancel")} + + + {isToggling && } + {toggleTarget?.isActive + ? t("orgAdmins.actions.deactivate") + : t("orgAdmins.actions.activate")} + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx new file mode 100644 index 000000000..cc32d4a5d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useDebouncedValue } from "@mantine/hooks"; +import { Building2, Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/common/ui/button"; +import { Badge } from "@/shared/common/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/common/ui/popover"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@/shared/common/ui/command"; +import { cn } from "@/super-admin/lib/utils"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { getOrganizationsWithAdminFlag } from "@/shared/services/organizationsService"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { ORG_PICKER_KEY } from "@/super-admin/hooks/useOrgAdmins"; + +interface OrgPickerProps { + value: OrganizationDto | null; + onChange: (org: OrganizationDto) => void; +} + +/** Searchable organization combobox — server-side name search, shows admin counts. */ +export function OrgPicker({ value, onChange }: OrgPickerProps) { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); + + const { + data: orgsResponse, + isLoading, + isError, + refetch, + } = useQuery({ + queryKey: [ORG_PICKER_KEY, debouncedSearch], + queryFn: async () => { + const { data } = await getOrganizationsWithAdminFlag({ + take: 50, + skip: 0, + name: debouncedSearch || undefined, + }); + return { + count: (data?.count ?? 0) as number, + items: (data?.items ?? []) as OrganizationDto[], + }; + }, + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const orgs = orgsResponse?.items ?? []; + + // default to the first org that already has admins (initial load only, + // never while the user is searching) + useEffect(() => { + if (value || debouncedSearch || !orgs.length) return; + const firstWithAdmins = orgs.find((org) => (org.adminsCount ?? 0) > 0); + if (firstWithAdmins) onChange(firstWithAdmins); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgsResponse]); + + return ( + + + + + + + + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ {t("orgAdmins.pickerError")} + +
+ ) : ( + <> + {t("orgAdmins.noOrgsFound")} + {orgs.map((org) => ( + { + onChange(org); + setOpen(false); + }} + className="flex items-center justify-between gap-2" + > + + + + {localizedName(org.name)} + + + {(org.adminsCount ?? 0) > 0 ? ( + + {t("orgAdmins.adminsCount", { + count: org.adminsCount ?? 0, + })} + + ) : ( + + {t("orgAdmins.noAdmins")} + + )} + + ))} + + )} +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx deleted file mode 100644 index 2ac9ef71d..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { FormEvent, useMemo, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { Check, Loader2 } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; - -import { Button } from "@/shared/common/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/shared/common/ui/dialog"; -import { Input } from "@/shared/common/ui/input"; -import { Label } from "@/shared/common/ui/label"; -import { ScrollArea } from "@/shared/common/ui/scroll-area"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { cn } from "@/super-admin/lib/utils"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { - assignOrgAdminRole, - RemoveOrAssignOrgAdminPayload, -} from "@/super-admin/services/api/userRoleService"; -import { useEmployees } from "@/user-management/hooks/useEmployees"; - -interface AssignOrgAdminDialogProps { - isOpen: boolean; - onClose: () => void; - onSuccess: () => void; -} - -export function AssignOrgAdminDialog({ - isOpen, - onClose, - onSuccess, -}: AssignOrgAdminDialogProps) { - const queryClient = useQueryClient(); - const { t } = useTranslation(); - const localizedName = useLocalizedName(); - const [selectedOrg, setSelectedOrg] = useState(""); - const [selectedUser, setSelectedUser] = useState(""); - const [search, setSearch] = useState(""); - const [isAssigning, setIsAssigning] = useState(false); - - const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( - "Org", - { take: 300 } - ); - - const { - employeesResponseByOrg, - isLoadingEmployeesByOrg: isLoadingEmployees, - } = useEmployees({ - organizationId: selectedOrg || undefined, - params: { take: 3000, skip: 0 }, - }); - - const filteredEmployees = useMemo(() => { - const query = search.trim().toLowerCase(); - const employees = employeesResponseByOrg?.items ?? []; - - if (!query) return employees; - - return employees.filter((employee) => { - const name = localizedName(employee.user.name).toLowerCase(); - const email = employee.user.email?.toLowerCase() ?? ""; - - return name.includes(query) || email.includes(query); - }); - }, [employeesResponseByOrg, localizedName, search]); - - const resetForm = () => { - setSelectedOrg(""); - setSelectedUser(""); - setSearch(""); - }; - - const handleClose = () => { - if (isAssigning) return; - resetForm(); - onClose(); - }; - - const handleOrganizationChange = (organizationId: string) => { - setSelectedOrg(organizationId); - setSelectedUser(""); - setSearch(""); - }; - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - - if (!selectedOrg || !selectedUser) { - toast.error(t("organization.allFieldsRequired", "All fields are required")); - return; - } - - const payload: RemoveOrAssignOrgAdminPayload = { - organizationId: selectedOrg, - userId: selectedUser, - }; - - setIsAssigning(true); - - try { - await assignOrgAdminRole(payload); - await queryClient.invalidateQueries({ - queryKey: ["organizationAdmins"], - }); - resetForm(); - onClose(); - onSuccess(); - } catch (error: any) { - toast.error(t("organization.userAssignFailed"), { - description: error?.response?.data?.message, - }); - } finally { - setIsAssigning(false); - } - }; - - return ( - { - if (!open) handleClose(); - }} - > - - - - {t( - "organization.assignAdminToOrganization", - "Assign admin to organization" - )} - - - {t( - "organization.assignOrgAdminInstructions", - "Select an organization and a user to assign as its administrator." - )} - - - -
-
-
- - {isLoadingOrgs ? ( -
- -
- ) : ( - -
- {organizationsResponse?.items?.map((organization) => ( - - ))} -
-
- )} -
- -
- - {!selectedOrg ? ( -
-

- {t("organization.selectOrganizationFirst")} -

-
- ) : isLoadingEmployees ? ( -
- -
- ) : ( -
- setSearch(event.target.value)} - placeholder={t( - "organization.searchOrganizationUsers", - "Search organization users" - )} - /> - -
- {filteredEmployees.map((employee) => ( - - ))} - {filteredEmployees.length === 0 && ( -

- {t("organization.noUsersFound")} -

- )} -
-
-
- )} -
-
- - - - - -
-
-
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx deleted file mode 100644 index 81fcaff80..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useState } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { Link } from "react-router-dom"; -import { Plus, Loader2, UserPlus } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "../../../shared/common/ui/card"; -import { toast } from "sonner"; -import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable"; -import { OrganizationAdminsColumnDefn } from "./OrganizationAdminsColumnDefn"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { AssignOrgAdminDialog } from "./AssignOrgAdminDialog"; -import { useTranslation } from "react-i18next"; -import { useLocalizedName } from "@/shared/common/localizedName"; - -export default function OrganizationAdmins() { - const [pageIndex, setPageIndex] = useState(0); // starts at 0 - const pageSize = 10; - const { t } = useTranslation(); - const localizedName = useLocalizedName(); - const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const { organizationsAdminsResponse, isLoading, isError, refetch } = - useOrganizations("Admin", { - take: pageSize, - skip: pageIndex * pageSize, - orderBy: "createdAt", - order: "createdAt:Desc", - name: searchTerm || undefined, - }); - - const handlePageChange = (newPage: number) => { - setPageIndex(newPage); - }; - - const handleSearchChange = (term: string) => { - setSearchTerm(term); - setPageIndex(0); // Reset to first page when search term changes - }; - - const handleRefresh = () => { - refetch(); - }; - - if (isLoading) { - return ( -
-
- -
- {t("organization.loadingAdmins")} -
-
-
- ); - } - - if (isError) { - return ( -
-
-
- {t("organization.errorLoadingAdmins")} -
- -
-
- ); - } - - return ( - <> -
- - - - {t("organization.organizationAdmins")} - - - - string - )} - data={organizationsAdminsResponse?.items || []} - tableName="Organization Admins" - toolBarPosition="right" - refresh={handleRefresh} - onGlobalFilterChange={handleSearchChange} - disableClientFiltering={true} - extraToolbar={ -
- - - - -
- } - itemCount={organizationsAdminsResponse?.count || 0} - pageIndex={pageIndex} - onPageChange={handlePageChange} - nextFunction={() => handlePageChange(pageIndex + 1)} - prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} - /> -
-
-
- - {/* Assign Admin Dialog */} - setIsAssignDialogOpen(false)} - onSuccess={() => { - refetch(); - toast.success(t("organization.adminAssignedSuccess")); - }} - /> - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx deleted file mode 100644 index 812b0cb29..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../../../shared/common/ui/dropdown-menu"; -import { Edit, MoreHorizontal, Trash, UserCheck, UserX } from "lucide-react"; -import { Button } from "../../../shared/common/ui/button"; -import { Link } from "react-router-dom"; -import { OrganizationAdmin } from "@/super-admin/services/api/organizationAdminService"; -import { toast } from "sonner"; - -interface OrganizationAdminsActionsProps { - rowData: OrganizationAdmin; -} -const OrganizationAdminsActions: React.FC = ({ - rowData, -}) => { - const handleDeleteClick = () => { - toast.warning("Delete functionality not implemented yet"); - }; - - const handleStatusChange = (status: string) => { - toast.info(`Admin status change to ${status} not implemented yet`); - }; - - return ( - - - - - - Actions - - - - - Edit - - - {rowData.status !== "active" && ( - handleStatusChange("active")} - className="flex items-center" - > - - Activate - - )} - {rowData.status === "active" && ( - handleStatusChange("inactive")} - className="flex items-center" - > - - Deactivate - - )} - handleDeleteClick()} - className="flex items-center" - > - - Delete - - - - ); -}; - -export default OrganizationAdminsActions; diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx deleted file mode 100644 index 4a1e4d33f..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { ColumnDef } from "@tanstack/react-table"; -import { Button } from "../../../shared/common/ui/button"; -import { ArrowUpDown, Eye } from "lucide-react"; -import { OrganizationAdminsDto } from "@/shared/dto/organization/organizationDto"; -import { StatusCell } from "./StatusCell"; -import { t } from "i18next"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { Link } from "react-router-dom"; -import OrganizationAdminsActions from "./OrganizationAdminsActions"; - -export const OrganizationAdminsColumnDefn = ( - localizedName: (name?: { am?: string; en?: string }) => string, -): ColumnDef[] => { - return [ - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => ( -
{localizedName(row.original.name)}
- ), - }, - { - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const id = row.original.id; - return ( - - ); - }, - }, - { - accessorKey: "actions", - header: t("organization.actions") || "Actions", - cell: ({ row }) => ( -
- - - - -
- ), - }, - ]; -}; diff --git a/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts new file mode 100644 index 000000000..57c5cddc2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts @@ -0,0 +1,259 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { + assignOrganizationAdmin, + assignUnitAdmin, + fetchAllUnitAdminById, +} from "@/super-admin/services/api/organizationAdminService"; +import { + assignOrgAdminRole, + assignUnitAdminRole, + removeOrgAdminRole, + removeUnitAdminRole, +} from "@/super-admin/services/api/userRoleService"; +import { + activateUser, + deactivateUser, +} from "@/super-admin/services/api/userService"; +import { resendVerificationCode } from "@/shared/services/authService"; +import { + updateProfile, + UpdateProfilePayload, +} from "@/user-management/services/api/employeePositionsService"; + +export interface OrgAdminUserRole { + id: string; + organizationId?: string | null; + unitId?: string | null; + role?: { key?: string } | null; +} + +/** User row returned by GET /organizations/all-admins/:id (userRoles.role relation included). */ +export interface OrgAdminUser { + id: string; + name?: { am?: string; en?: string }; + username?: string; + email?: string; + phoneNumber?: string; + isActive: boolean; + hasSetPassword: boolean; + status?: string; + createdAt?: string; + userRoles?: OrgAdminUserRole[]; +} + +export const ORG_ADMINS_KEY = "orgAdmins"; +export const ORG_PICKER_KEY = "orgAdminsOrgPicker"; + +export interface RemoveAdminInput { + userId: string; + /** set for org-admin removal */ + organizationId?: string; + /** set for unit-admin removal (wins over organizationId) */ + unitId?: string; +} + +export interface AddAdminInput { + organizationId: string; + /** set → create as unit admin of this unit instead of org admin */ + unitId?: string; + name: { am: string; en: string }; + username: string; + email: string; + phoneNumber: string; +} + +export const useOrgAdmins = ( + orgId?: string, + params?: { take?: number; skip?: number }, +) => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError, getErrorMessage } = useErrorHandler(t); + + // dialog mutations surface their API errors inline, not via toast + const [formError, setFormError] = useState(null); + const [assignError, setAssignError] = useState(null); + const [removeError, setRemoveError] = useState(null); + const [toggleError, setToggleError] = useState(null); + + const inlineError = + (set: (message: string | null) => void) => async (err: unknown) => { + console.error(err); + set(await getErrorMessage(err)); + }; + + const clearErrors = () => { + setFormError(null); + setAssignError(null); + setRemoveError(null); + setToggleError(null); + }; + + const { + data: adminsResponse, + isLoading, + isError, + refetch, + } = useQuery({ + queryKey: [ORG_ADMINS_KEY, orgId, params], + queryFn: async () => { + const { data } = await fetchAllUnitAdminById(orgId as string, params); + return { + count: (data?.count ?? 0) as number, + items: (data?.items ?? []) as OrgAdminUser[], + }; + }, + enabled: !!orgId, + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: [ORG_ADMINS_KEY] }); + // picker + org tables show adminsCount — keep them fresh + queryClient.invalidateQueries({ queryKey: [ORG_PICKER_KEY] }); + queryClient.invalidateQueries({ queryKey: ["organizations"] }); + queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] }); + }; + + const { mutate: addAdmin, isPending: isAdding } = useMutation({ + mutationFn: async ({ + organizationId, + unitId, + ...person + }: AddAdminInput) => { + // both iam invite endpoints create the user (employee + role + + // SET_PASSWORD OTP in one tx); unitId decides the admin scope + const { data } = unitId + ? await assignUnitAdmin({ unitId, ...person }) + : await assignOrganizationAdmin({ organizationId, ...person }); + return data; + }, + onMutate: () => setFormError(null), + onSuccess: () => { + toast.success(t("orgAdmins.toasts.added")); + invalidate(); + }, + onError: inlineError(setFormError), + }); + + const { mutate: assignAdmin, isPending: isAssigning } = useMutation({ + mutationFn: async ({ + organizationId, + userId, + unitId, + }: { + organizationId: string; + userId: string; + /** set → grant unit-admin of this unit instead of org-admin */ + unitId?: string; + }) => { + const { data } = unitId + ? await assignUnitAdminRole({ unitId, userId }) + : await assignOrgAdminRole({ organizationId, userId }); + return data; + }, + onMutate: () => setAssignError(null), + onSuccess: () => { + toast.success(t("orgAdmins.toasts.assigned")); + invalidate(); + }, + onError: inlineError(setAssignError), + }); + + const { mutate: removeAdmin, isPending: isRemoving } = useMutation({ + mutationFn: async ({ userId, organizationId, unitId }: RemoveAdminInput) => { + const { data } = unitId + ? await removeUnitAdminRole({ unitId, userId }) + : await removeOrgAdminRole({ + organizationId: organizationId as string, + userId, + }); + return data; + }, + onMutate: () => setRemoveError(null), + onSuccess: () => { + toast.success(t("orgAdmins.toasts.removed")); + invalidate(); + }, + onError: inlineError(setRemoveError), + }); + + const { mutate: resendInvite, isPending: isResending } = useMutation({ + mutationFn: async (payload: { email: string; phoneNumber: string }) => { + const { data } = await resendVerificationCode(payload); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.resent")); + }, + onError: handleError, + }); + + const { mutate: toggleActive, isPending: isToggling } = useMutation({ + mutationFn: async ({ id, activate }: { id: string; activate: boolean }) => { + const { data } = activate + ? await activateUser(id) + : await deactivateUser(id); + return data; + }, + onMutate: () => setToggleError(null), + onSuccess: (_data, variables) => { + toast.success( + variables.activate + ? t("orgAdmins.toasts.activated") + : t("orgAdmins.toasts.deactivated"), + ); + invalidate(); + }, + onError: inlineError(setToggleError), + }); + + const { mutate: updateAdminProfile, isPending: isUpdatingProfile } = + useMutation({ + mutationFn: async ({ + id, + payload, + }: { + id: string; + payload: UpdateProfilePayload; + }) => { + const { data } = await updateProfile(payload, id); + return data; + }, + onMutate: () => setFormError(null), + onSuccess: () => { + toast.success(t("orgAdmins.toasts.profileUpdated")); + invalidate(); + }, + onError: inlineError(setFormError), + }); + + return { + adminsResponse, + isLoading, + isError, + refetch, + addAdmin, + isAdding, + assignAdmin, + isAssigning, + removeAdmin, + isRemoving, + resendInvite, + isResending, + toggleActive, + isToggling, + updateAdminProfile, + isUpdatingProfile, + formError, + assignError, + removeError, + toggleError, + clearErrors, + }; +}; diff --git a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts index da454e55c..bed483a90 100644 --- a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts +++ b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts @@ -2,6 +2,7 @@ export type RuleEngineResourceSlug = | "cargo-types" | "container-types" | "wagon-types" + | "truck-types" | "priority-configs" | "service-types" | "weight-limit-rules" diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 1ca31e886..37d2897c2 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -756,8 +756,10 @@ export interface AllocationRule { targetZoneCode?: string | null; storageType?: string | null; isActive: boolean; + /** Set by the API (BaseEntity); used for the list date filter. */ + createdAt?: string; } -export type SaveAllocationRulePayload = Omit; +export type SaveAllocationRulePayload = Omit; export const FEE_RULE_TYPES = [ 'STORAGE_FEE', @@ -813,8 +815,10 @@ export interface FeeRule { tiers?: FeeRuleTier[]; currency: string; isActive: boolean; + /** Set by the API (BaseEntity); used for the list date filter. */ + createdAt?: string; } -export type SaveFeeRulePayload = Omit; +export type SaveFeeRulePayload = Omit; export interface FeeRuleTier { fromDay: number; diff --git a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx index fb523fc06..127806334 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx @@ -1,4 +1,4 @@ -import { NavLink } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; import { Archive, BarChart, @@ -8,28 +8,48 @@ import { FileText, Globe, Settings, - ShieldAlert, Users2, UsersRound, } from "lucide-react"; -import { useAuth } from "@/shared/context/AuthContext"; -import { usePermissions } from "@/shared/context/PermissionContext"; - import { useTranslation } from "react-i18next"; +import { useAuth } from "@/shared/context/AuthContext"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/shared/common/ui/sidebar"; + export interface MenuItem { label: string; href: string; icon: React.ReactNode; roles?: string[]; - permissions?: string[]; - isPrimary?: boolean; - displayLabel?: string; - children?: MenuItem[]; + /** Sidebar section this item is bucketed under. */ + group: string; } +// Section render order; groups with no role-visible items are skipped. +const GROUP_ORDER = [ + "Overview", + "Organizations", + "Content", + "Records", + "Configuration", + "Archive", + "System", +]; + export const AppMenuTabs = () => { const { user } = useAuth(); + const { pathname } = useLocation(); + const { setOpenMobile } = useSidebar(); + const { t } = useTranslation(); + const userRoles = user?.roles.map((role) => role.key) || []; const menuItems: MenuItem[] = [ @@ -38,282 +58,193 @@ export const AppMenuTabs = () => { href: "/user-management/dashboard", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Overview", }, { label: "organizations", href: "/user-management/organizations", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Organizations", }, - { - label: "organizationAdmins", // Shortened for mobile - displayLabel: "organizationAdmins", // Full label for desktop + label: "organizationAdmins", href: "/user-management/organization_admins", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Organizations", }, { - label: "externalUsers", // Shortened for mobile - displayLabel: "externalUsers", // Full label for desktop + label: "externalUsers", href: "/user-management/external_users", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Organizations", }, { label: "dashboard", href: "/user-management/user_management-dashboard", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Overview", }, { label: "userManagement", href: "/user-management/user_management", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Overview", }, - // { - // label: "userPositionApproval", - // href: "/user-management/user-position-approval", - // icon: , - // roles: ["admin", "organization_admin", "unit_admin"], - // permissions: ["can:activateEmployee"], - // isPrimary: true, - // }, - // { - // label: "All Records", - // displayLabel: "All Records", - // href: "/user-management/all-records", - // icon: , - // roles: ["admin", "organization_admin", "unit_admin"], - // permissions: ["can:canViewAllRecords"], - // isPrimary: true, - // }, { - label: "contentManagement", // Shortened for mobile - displayLabel: "contentManagement", // Full label for desktop + label: "contentManagement", href: "/user-management/content-management", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Content", }, { label: "webManagement", - displayLabel: "webManagement", href: "/user-management/web-management", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, - }, - { - label: "Position", - displayLabel: "positionTypes", - href: "/user-management/position-management", - icon: , - roles: ["admin", "organization_admin", "unit_admin", "super_admin"], - isPrimary: true, - }, - { - label: "migratedRecords", - displayLabel: "migratedRecords", - href: "/user-management/migrated-records-management", - icon: , - roles: ["super_admin"], - isPrimary: true, - }, - { - label: "settings", - displayLabel: "settings", - href: "/user-management/organization-settings", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Content", }, { label: "Bulk", - displayLabel: "bulkUpload", href: "/user-management/bulk-upload", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Content", }, { - label: "Archive Users", - displayLabel: "Archive Users", - href: "/user-management/archive-users", - icon: , - roles: ["super_admin"], - isPrimary: true, - }, - { - label: "Archived Organizations", - displayLabel: "Archived Organizations", - href: "/user-management/archived-organizations", - icon: , - roles: ["super_admin"], - isPrimary: true, - }, - { - label: "Archive Users", - displayLabel: "Archive Users", - href: "/user-management/archives", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, - }, - { - label: "Archived Units & Positions", - displayLabel: "Archived Units & Positions", - href: "/user-management/archived", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, - }, - { - label: "Sector Reports", - displayLabel: "Sector Reports", - href: "/user-management/sector-reports", - icon: , - roles: ["unit_admin", "admin", "organization_admin"], - isPrimary: true, - }, - { - label: "activityLog", - href: "/user-management/activity_log", - icon: , - roles: ["super_admin"], - isPrimary: false, - }, - { - label: "setting", - href: "/user-management/settings", - icon: , - roles: ["super_admin"], - isPrimary: false, - }, - { - label: "Letter Template", - href: "/user-management/templates", + label: "Position", + href: "/user-management/position-management", icon: , - roles: ["super_admin"], - isPrimary: false, + roles: ["admin", "organization_admin", "unit_admin", "super_admin"], + group: "Configuration", + }, + { + label: "settings", + href: "/user-management/organization-settings", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Configuration", }, { label: "Add Site", href: "/user-management/add-site", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Configuration", + }, + { + label: "migratedRecords", + href: "/user-management/migrated-records-management", + icon: , + roles: ["super_admin"], + group: "Records", + }, + { + label: "Sector Reports", + href: "/user-management/sector-reports", + icon: , + roles: ["unit_admin", "admin", "organization_admin"], + group: "Records", + }, + { + label: "Archive Users", + href: "/user-management/archive-users", + icon: , + roles: ["super_admin"], + group: "Archive", + }, + { + label: "Archived Organizations", + href: "/user-management/archived-organizations", + icon: , + roles: ["super_admin"], + group: "Archive", + }, + { + label: "Archive Users", + href: "/user-management/archives", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Archive", + }, + { + label: "Archived Units & Positions", + href: "/user-management/archived", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Archive", + }, + { + label: "activityLog", + href: "/user-management/activity_log", + icon: , + roles: ["super_admin"], + group: "System", + }, + { + label: "setting", + href: "/user-management/settings", + icon: , + roles: ["super_admin"], + group: "System", + }, + { + label: "Letter Template", + href: "/user-management/templates", + icon: , + roles: ["super_admin"], + group: "System", }, - - // { - // label: "branding.title", - // href: "/user-management/web-management/Branding/Branding", - // icon: , - // roles: ["super_admin"], - // isPrimary: true, - // }, ]; - const { permissions } = usePermissions(); - const { t } = useTranslation(); const filteredMenu = menuItems.filter((item) => - item?.roles?.some((r) => userRoles.includes(r)), - ); - const primaryMenuItems = filteredMenu.filter( - (item) => item.isPrimary !== false, - ); - const secondaryMenuItems = filteredMenu.filter( - (item) => item.isPrimary === false, + item.roles?.some((r) => userRoles.includes(r)), ); + const isActive = (href: string) => + pathname === href || pathname.startsWith(`${href}/`); + return ( - // Sticky (not fixed) so it stays in flow: content below never needs a - // magic offset matching this bar's responsive height. top-16 keeps it - // pinned just below the fixed 64px header while scrolling. - // -mt-8 cancels the excess of 's in-flow h-24 wrapper over its 64px - // fixed header, so the bar sits flush under the header with no jump. - // shrink-0 is load-bearing: as a flex item with overflow-hidden this bar - // would otherwise be flex-squashed to zero height when the page overflows. -
- {/* Mobile View - Two separate rows */} -
- {/* Primary items row */} -
- {primaryMenuItems.map((item) => ( - - `flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${ - isActive - ? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400" - : "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400" - }` - } - > - {item.icon} - - {t(`organization.${item.label}`, item.label)} - - - ))} -
+ <> + {GROUP_ORDER.map((group) => { + const items = filteredMenu.filter((item) => item.group === group); + if (items.length === 0) return null; - {/* Secondary items row (if any) */} - {secondaryMenuItems.length > 0 && ( -
- {secondaryMenuItems.map((item) => ( - - `flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${ - isActive - ? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400" - : "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400" - }` - } - > - {item.icon} - - {t(`organization.${item.label}`, item.label)} - - - ))} -
- )} -
- - {/* Desktop View - Single row with all items */} -
-
- {filteredMenu.map((item) => ( - - `flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${ - isActive - ? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400" - : "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400" - }` - } - > - {item.icon} - - {t(`organization.${item.label}`, item.label)} - - - ))} -
-
-
+ return ( + + {group} + + + {items.map((item) => { + const label = t(`organization.${item.label}`, item.label); + return ( + + + setOpenMobile(false)} + > + {item.icon} + {label} + + + + ); + })} + + + + ); + })} + ); }; diff --git a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx index 3b33519b6..ade96250d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx @@ -1,43 +1,92 @@ -import { useLocation } from "react-router-dom"; -import { Outlet } from "react-router-dom"; -import { AppMenuTabs } from "./AppMenuTabs"; -import { useSidebar } from "@/shared/common/ui/sidebar"; -import Top from "@/record-management/components/common/Top"; -import { useAuth } from "@/shared/context/AuthContext"; - -export const AppLayout = () => { - const { pathname } = useLocation(); - const isAuthPage = pathname === "/"; - const { toggleSidebar } = useSidebar(); - const { user } = useAuth(); - - const userRoles = user?.roles?.map((role) => role.key) || []; - const isSuperAdmin = userRoles.includes("super_admin"); - - // html/body/#root are `overflow: hidden` (index.css) — the host chrome - // scrolls inside FreightDashboardLayout. This subtree renders its own - // full-page layout instead, so it must be its own scroll container or - // nothing scrolls. Top/AppMenuTabs are `fixed`, unaffected by the scroller. - if (isAuthPage) { - return ( -
- -
- ); - } - - return ( - // renders its own in-flow h-24 wrapper around the fixed 64px header, - // so flow already clears the header — no extra top padding here. - // AppMenuTabs is sticky and in flow, so content starts right below it. -
- - -
-
- -
-
-
- ); -}; +import { Link, Outlet, useLocation } from "react-router-dom"; +import { ArrowLeft } from "lucide-react"; +import { AppMenuTabs } from "./AppMenuTabs"; +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarInset, + SidebarRail, + useSidebar, +} from "@/shared/common/ui/sidebar"; +import Top from "@/record-management/components/common/Top"; +import { useAuth } from "@/shared/context/AuthContext"; + +export const AppLayout = () => { + const { pathname } = useLocation(); + const isAuthPage = pathname === "/"; + const { toggleSidebar } = useSidebar(); + const { user } = useAuth(); + + const userRoles = user?.roles?.map((role) => role.key) || []; + const isSuperAdmin = userRoles.includes("super_admin"); + + // Standalone full-page scroll container for the auth screen — the host + // chrome is `overflow: hidden`, so this subtree must scroll itself. + if (isAuthPage) { + return ( +
+ +
+ ); + } + + return ( + <> + {/* Full-height sidebar owning the left column (back-to-home + brand at the + top). lives inside , to the right of the sidebar, + and is `sticky` — so it never overlaps the sidebar and re-flows when + the sidebar collapses to its icon rail. Top's burger (onToggleSidebar) + drives collapse on desktop and the Sheet drawer on mobile. */} + + + + + + Back to home + + + + EDR +
+ + User Management + + + EDR Freight + +
+ +
+ + + + +
+ + {/* Internal scroll container: the sidebar stays fixed and full-height + while this column scrolls beneath the sticky bar. */} + + +
+
+ +
+
+
+ + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx deleted file mode 100644 index e7613d2e3..000000000 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { Button } from "@/shared/common/ui/button"; -import { - AlertDialog, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogCancel, - AlertDialogAction, -} from "@/shared/common/ui/alert-dialog"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; -import { t } from "i18next"; - -type ActionsColumnProps = { - row: PositionTypeDto; -}; - -const ActionsColumn: React.FC = ({ row }) => { - const navigate = useNavigate(); - const [openDialog, setOpenDialog] = useState(false); - const [deletingId, setDeletingId] = useState(null); - - const { deletePositionType } = usePositionTypes({ id: "" }); - - const handleDeleteClick = (id: string) => { - setDeletingId(id); - setOpenDialog(true); - }; - - const handleDeleteConfirm = async () => { - if (!deletingId) return; - await deletePositionType.mutateAsync(deletingId); - setOpenDialog(false); - setDeletingId(null); - }; - - return ( -
- - - - - - - - - - {t("contentManagement.delMsg")} - - {t("contentManagement.delMsg2")} - - - - { - setOpenDialog(false); - setDeletingId(null); - }} - > - {t("common.Cancel")} - - - {deletePositionType.isPending - ? t("organization.deleting") - : t("organization.delete")} - - - - -
- ); -}; - -export default ActionsColumn; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx index 8c06bf50e..f8fc0cfdd 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx @@ -1,455 +1,529 @@ -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Input } from "@/shared/common/ui/input"; -import { Button } from "@/shared/common/ui/button"; -import { - Form, - FormField, - FormItem, - FormLabel, - FormControl, - FormMessage, -} from "@/shared/common/ui/form"; -import { toast } from "sonner"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; -import { useNavigate } from "react-router-dom"; -import { t } from "i18next"; -import { useAuth } from "@/shared/context/AuthContext"; -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { SingleSelect } from "@/shared/common/ui/single-select"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; -import i18n from "@/i18n"; -import { PermissionSearch } from "./PermissionSearch"; -import { useApplications } from "@/user-management/hooks/useApplications"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; - -const formSchema = z.object({ - nameAm: z.string().min(2), - nameEn: z.string().min(2), - permissions: z.array(z.string()), -}); - -type FormValues = z.infer; - -export interface CreatePositionFormProps { - mode?: "create" | "edit"; - positionTypeId?: string; - initialValues?: { - nameAm: string; - nameEn: string; - unitId: string; - key?: string; - }; - onSuccess?: () => void; - onCancel?: () => void; -} - -export const CreatePositionForm = ({ - mode = "create", - positionTypeId, - initialValues, - onSuccess, - onCancel, -}: CreatePositionFormProps = {}) => { - const navigate = useNavigate(); - const { - createPositionType, - updatePositionType, - positionTypes, - isLoading: isLoadingPositionTypes, - } = usePositionTypes(); - const { user } = useAuth(); - const { getList, getById } = useUnit(); - const localizedName = useLocalizedName(); - const userOrganizationId = - user?.employee && user.employee.length > 0 - ? user.employee[0].organizationId - : undefined; - const [selectedOrganizationId, setSelectedOrganizationId] = useState( - userOrganizationId ?? "", - ); - const [selectedUnitId, setSelectedUnitId] = useState( - initialValues?.unitId ?? "", - ); - const [selectedApplicationId, setSelectedApplicationId] = - useState(""); - const [copyFromPositionId, setCopyFromPositionId] = useState(""); - const [isCopying, setIsCopying] = useState(false); - const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); - const hasLoadedEditData = useRef(false); - const lang = i18n.language; - const { applications, isLoading: isLoadingApplications } = useApplications(); - const queryClient = useQueryClient(); - - const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( - "Org", - { take: 3000 }, - ); - - const { data: unitsResponse, isLoading: isLoadingUnits } = getList( - selectedOrganizationId, - { take: 3000, skip: 0 }, - ); - - const organizationOptions = useMemo( - () => - (organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ - value: org.id, - label: localizedName(org.name) || org.id, - })), - [organizationsResponse, localizedName], - ); - - const unitOptions = useMemo( - () => - (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ - value: unit.id, - label: localizedName(unit.name) || unit.id, - })), - [unitsResponse, localizedName], - ); - - const { - data: editUnitResponse, - isSuccess: isUnitSuccess, - isError: isUnitError, - } = getById(initialValues?.unitId ?? ""); - - const { - data: permissionsResponse, - isSuccess: isPermissionsSuccess, - isError: isPermissionsError, - } = useQuery({ - queryKey: ["position-type-permissions", positionTypeId], - queryFn: () => - positionTypePermissionService.getPermissionsByPositionTypeId( - positionTypeId!, - ), - enabled: mode === "edit" && !!positionTypeId, - }); - // Reset the selected unit when the organization changes so a unit from a - // different org can't be submitted by mistake. - useEffect(() => { - if (mode === "edit") return; - setSelectedUnitId(""); - }, [selectedOrganizationId, mode]); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - nameAm: initialValues?.nameAm ?? "", - nameEn: initialValues?.nameEn ?? "", - permissions: [], - }, - }); - - useEffect(() => { - if (mode !== "edit" || !initialValues || !positionTypeId) return; - if (hasLoadedEditData.current) return; - - const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; - const isPermissionsDone = isPermissionsSuccess || isPermissionsError; - - if (isUnitDone && isPermissionsDone) { - hasLoadedEditData.current = true; - - const unit = editUnitResponse?.data; - if (unit) { - setSelectedOrganizationId(unit.organizationId); - setSelectedUnitId(unit.id); - } else if (initialValues.unitId) { - setSelectedUnitId(initialValues.unitId); - } - - const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? []; - form.reset({ - nameAm: initialValues.nameAm, - nameEn: initialValues.nameEn, - permissions: ids, - }); - - setIsLoadingEditData(false); - } - }, [ - mode, - initialValues, - positionTypeId, - isUnitSuccess, - isUnitError, - isPermissionsSuccess, - isPermissionsError, - editUnitResponse, - permissionsResponse, - form, - ]); - - const handlePermissionChange = (permissionId: string, checked: boolean) => { - const currentPermissions = form.getValues("permissions"); - if (checked) { - form.setValue("permissions", [...currentPermissions, permissionId]); - } else { - form.setValue( - "permissions", - currentPermissions.filter((id) => id !== permissionId), - ); - } - }; - - const handleCopyFrom = async (positionTypeId: string) => { - setCopyFromPositionId(positionTypeId); - if (!positionTypeId) { - form.setValue("permissions", []); - return; - } - setIsCopying(true); - try { - const response = - await positionTypePermissionService.getPermissionsByPositionTypeId( - positionTypeId, - ); - const ids = response.data.items?.map((p) => p.id) ?? []; - form.setValue("permissions", ids); - } catch { - toast.error(t("contentManagement.copyPermissionsFailed")); - } finally { - setIsCopying(false); - } - }; - - const onSubmit = async (values: FormValues) => { - try { - if (!selectedUnitId) { - toast.error(t("organization.selectUnit")); - return; - } - - const payload = { - name: { - am: values.nameAm, - en: values.nameEn, - }, - key: values.nameEn.toLowerCase().replace(/\s+/g, "-"), - unitId: selectedUnitId, - }; - - let targetId = positionTypeId; - - if (mode === "edit" && positionTypeId) { - await updatePositionType.mutateAsync({ - id: positionTypeId, - data: payload, - }); - } else { - const response = await createPositionType.mutateAsync(payload); - targetId = response.data.id; - } - - if (targetId && values.permissions.length > 0) { - await positionTypePermissionService.assignPermissionsToPositionType({ - firstId: targetId, - secondIds: values.permissions, - }); - } - - queryClient.invalidateQueries({ - queryKey: ["position-type"], - }); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); - queryClient.invalidateQueries({ - queryKey: ["position-type-permissions"], - }); - toast.success(t("contentManagement.permissionSuccess")); - - if (onSuccess) { - onSuccess(); - } else { - navigate("/user-management/position-management"); - } - } catch { - toast.error(t("contentManagement.permissionSuccess")); - } - }; - - if (isLoadingEditData) { - return ( -
- {t("common.loading")} -
- ); - } - - return ( -
- - ( - - {t("contentManagement.englishName")} - - - - - - )} - /> - - ( - - {t("contentManagement.amharicName")} - - - - - - )} - /> - - {/* ✅ Organization (searchable, all orgs) */} -
- - -
- - {/* ✅ Unit Selector — searchable, scoped to picked org */} -
- - -
- -
- - -
- -
- - -

- {t("contentManagement.copyPermissionsHint")} -

-
- - ( - - {t("contentManagement.permission")} - - - - )} - /> - -
- - -
- - - ); -}; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/shared/common/ui/input"; +import { Button } from "@/shared/common/ui/button"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/shared/common/ui/form"; +import { toast } from "sonner"; +import { + invalidatePositionTypeQueries, + usePositionTypes, +} from "@/user-management/hooks/usePositionTypes"; +import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@/shared/context/AuthContext"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { SingleSelect } from "@/shared/common/ui/single-select"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { PermissionSearch } from "./PermissionSearch"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +export interface CreatePositionFormProps { + mode?: "create" | "edit"; + positionTypeId?: string; + initialValues?: { + nameAm: string; + nameEn: string; + unitId: string; + key?: string; + }; + onSuccess?: () => void; + onCancel?: () => void; +} + +export const CreatePositionForm = ({ + mode = "create", + positionTypeId, + initialValues, + onSuccess, + onCancel, +}: CreatePositionFormProps = {}) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + const { + createPositionType, + updatePositionType, + positionTypes, + isLoading: isLoadingPositionTypes, + isError: isErrorPositionTypes, + } = usePositionTypes(); + const { user } = useAuth(); + const { getList, getById } = useUnit(); + const localizedName = useLocalizedName(); + const userOrganizationId = + user?.employee && user.employee.length > 0 + ? user.employee[0].organizationId + : undefined; + const [selectedApplicationId, setSelectedApplicationId] = + useState(""); + const [copyFromPositionId, setCopyFromPositionId] = useState(""); + const [isCopying, setIsCopying] = useState(false); + const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); + const hasLoadedEditData = useRef(false); + // Permissions the position type had when the form opened. Needed because the + // API cannot represent "no permissions" (see onSubmit). + const loadedPermissionCount = useRef(0); + const { applications, isLoading: isLoadingApplications } = useApplications(); + const queryClient = useQueryClient(); + + const formSchema = useMemo( + () => + z.object({ + nameEn: z.string().trim().min(2, t("organization.englishNameRequired")), + nameAm: z.string().trim().min(2, t("organization.amharicNameRequired")), + organizationId: z.string().min(1, t("organization.organizationRequired")), + unitId: z.string().min(1, t("contentManagement.unitRequired")), + permissions: z.array(z.string()), + }), + [t], + ); + + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + nameAm: initialValues?.nameAm ?? "", + nameEn: initialValues?.nameEn ?? "", + organizationId: userOrganizationId ?? "", + unitId: initialValues?.unitId ?? "", + permissions: [], + }, + }); + + const selectedOrganizationId = form.watch("organizationId"); + + const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( + "Org", + { take: 3000 }, + ); + + const { data: unitsResponse, isLoading: isLoadingUnits } = getList( + selectedOrganizationId, + { take: 3000, skip: 0 }, + ); + + const organizationOptions = useMemo( + () => + (organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ + value: org.id, + label: localizedName(org.name) || org.id, + })), + [organizationsResponse, localizedName], + ); + + const unitOptions = useMemo( + () => + (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ + value: unit.id, + label: localizedName(unit.name) || unit.id, + })), + [unitsResponse, localizedName], + ); + + const { + data: editUnitResponse, + isSuccess: isUnitSuccess, + isError: isUnitError, + } = getById(initialValues?.unitId ?? ""); + + const { + data: permissionsResponse, + isSuccess: isPermissionsSuccess, + isError: isPermissionsError, + } = useQuery({ + queryKey: ["position-type-permissions", positionTypeId], + queryFn: () => + positionTypePermissionService.getPermissionsByPositionTypeId( + positionTypeId!, + ), + enabled: mode === "edit" && !!positionTypeId, + }); + + // A position type belongs to a unit, and a unit to an organization — IAM has + // no organizationId on the type itself and no organization-scoped route, so + // the picked org narrows the list through its units. isSystem types are the + // shared "commons" and stay available to every organization. + const orgUnitIds = useMemo( + () => + new Set( + (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id), + ), + [unitsResponse], + ); + + const copyFromOptions = useMemo(() => { + if (!selectedOrganizationId) return []; + return positionTypes.filter( + (type: PositionTypeDto) => + type.id !== positionTypeId && + (type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))), + ); + }, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]); + + // Reset the selected unit when the organization changes so a unit from a + // different org can't be submitted by mistake. The copy source is cleared + // too — it is scoped to the old organization. + useEffect(() => { + if (mode === "edit") return; + form.setValue("unitId", ""); + setCopyFromPositionId(""); + }, [selectedOrganizationId, mode, form]); + + useEffect(() => { + if (mode !== "edit" || !initialValues || !positionTypeId) return; + if (hasLoadedEditData.current) return; + + const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; + const isPermissionsDone = isPermissionsSuccess || isPermissionsError; + + if (isUnitDone && isPermissionsDone) { + hasLoadedEditData.current = true; + + const unit = editUnitResponse?.data; + const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? []; + loadedPermissionCount.current = ids.length; + + form.reset({ + nameAm: initialValues.nameAm, + nameEn: initialValues.nameEn, + organizationId: unit?.organizationId ?? userOrganizationId ?? "", + unitId: unit?.id ?? initialValues.unitId ?? "", + permissions: ids, + }); + + setIsLoadingEditData(false); + } + }, [ + mode, + initialValues, + positionTypeId, + isUnitSuccess, + isUnitError, + isPermissionsSuccess, + isPermissionsError, + editUnitResponse, + permissionsResponse, + userOrganizationId, + form, + ]); + + const handlePermissionChange = (permissionId: string, checked: boolean) => { + const currentPermissions = form.getValues("permissions"); + form.setValue( + "permissions", + checked + ? [...currentPermissions, permissionId] + : currentPermissions.filter((id) => id !== permissionId), + ); + }; + + const handleCopyFrom = async (sourcePositionTypeId: string) => { + setCopyFromPositionId(sourcePositionTypeId); + setIsCopying(true); + try { + const response = + await positionTypePermissionService.getPermissionsByPositionTypeId( + sourcePositionTypeId, + ); + const ids = response.data.items?.map((p) => p.id) ?? []; + form.setValue("permissions", ids); + } catch (error) { + handleError(error); + toast.error(t("contentManagement.copyPermissionsFailed")); + } finally { + setIsCopying(false); + } + }; + + const onSubmit = async (values: FormValues) => { + const payload = { + name: { am: values.nameAm, en: values.nameEn }, + key: values.nameEn.toLowerCase().replace(/\s+/g, "-"), + unitId: values.unitId, + }; + + // Save the position type first. If this fails nothing else runs, and the + // mutation's own onError surfaces the reason (403 for built-in types, + // conflict on the globally-unique key, ...). + let targetId = positionTypeId; + try { + if (mode === "edit" && positionTypeId) { + await updatePositionType.mutateAsync({ + id: positionTypeId, + data: payload, + }); + } else { + const response = await createPositionType.mutateAsync(payload); + targetId = response.data.id; + } + } catch { + return; // already reported by the mutation's onError + } + + // assign-seconds-for-first replaces the whole set, but an empty secondIds + // fails server-side — so "unassign everything" is not expressible. Keep the + // save and tell the user their permissions were left alone. + const mustClearAll = + values.permissions.length === 0 && loadedPermissionCount.current > 0; + + if (targetId && values.permissions.length > 0) { + try { + await positionTypePermissionService.assignPermissionsToPositionType({ + firstId: targetId, + secondIds: values.permissions, + }); + } catch (error) { + handleError(error); + invalidatePositionTypeQueries(queryClient); + toast.error(t("contentManagement.permissionsAssignFailed")); + return; + } + } + + invalidatePositionTypeQueries(queryClient); + queryClient.invalidateQueries({ queryKey: ["position-type-permissions"] }); + + if (mustClearAll) { + toast.warning(t("contentManagement.cannotClearAllPermissions")); + } else { + toast.success(t("contentManagement.permissionSuccess")); + } + + if (onSuccess) { + onSuccess(); + } else { + navigate("/user-management/position-management"); + } + }; + + if (isLoadingEditData) { + return ( +
+ {t("common.loading")} +
+ ); + } + + const selectedPermissionCount = form.watch("permissions").length; + // form.formState.isSubmitting stays true for the whole async handler, so it + // also covers the permission-assignment call that follows the save. + const isBusy = form.formState.isSubmitting || isCopying; + + const copyFromPlaceholder = !selectedOrganizationId + ? t("contentManagement.selectOrganizationToCopy") + : isCopying || isLoadingPositionTypes || isLoadingUnits + ? t("common.loading") + : isErrorPositionTypes + ? t("contentManagement.failedToLoadPositionTypes") + : t("contentManagement.selectPositionToCopy"); + + return ( +
+ + ( + + {t("contentManagement.englishName")} + + + + + + )} + /> + + ( + + {t("contentManagement.amharicName")} + + + + + + )} + /> + + {/* Organization (searchable, all orgs) */} + ( + + {t("organization.organization")} + + + + )} + /> + + {/* Unit Selector — searchable, scoped to picked org */} + ( + + {t("organization.selectUnit")} + + + + )} + /> + +
+ + +
+ +
+ + +

+ {t("contentManagement.copyPermissionsHint")} +

+
+ + ( + + + {t("contentManagement.permission")} + {selectedPermissionCount > 0 && ( + + ( + {t("contentManagement.permissionsSelected", { + count: selectedPermissionCount, + })} + ) + + )} + + + + + )} + /> + +
+ + +
+ + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx index e69a6eedb..befe2f7d8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx @@ -1,160 +1,193 @@ -import { useEffect, useState } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { Input } from "@/shared/common/ui/input"; -import { Label } from "@/shared/common/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { useNavigate } from "react-router-dom"; - -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useAuth } from "@/shared/context/AuthContext"; -import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { useApplications } from "@/user-management/hooks/useApplications"; -import { PermissionSearch } from "./PermissionSearch"; -import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { t } from "i18next"; - -export const EditPositionForm = ({ id }: { id: string }) => { - const navigate = useNavigate(); - const { user } = useAuth(); - const { getList } = useUnit(); - const localizedName = useLocalizedName(); - - const { positionType, isLoadingSingle } = usePositionTypes({ id }); - - const organizationId = - user?.employee && user.employee.length > 0 - ? user.employee[0].organizationId - : undefined; - - const { applications, isLoading: isLoadingApplications } = useApplications(); - - const { data: unitsResponse } = getList(organizationId || "", { - take: 300, - skip: 0, - }); - - const [selectedApplicationId, setSelectedApplicationId] = - useState(""); - const [assignedPermissions, setAssignedPermissions] = useState< - PermissionDto[] - >([]); - const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); - - useEffect(() => { - const load = async () => { - if (!positionType) return; - setIsLoadingPermissions(true); - try { - const assigned = - await positionTypePermissionService.getPermissionsByPositionTypeId( - positionType.id, - ); - setAssignedPermissions(assigned.data.items ?? []); - } finally { - setIsLoadingPermissions(false); - } - }; - load(); - }, [positionType]); - - if (isLoadingSingle) return

Loading...

; - if (!positionType) return null; - - const unit = unitsResponse?.data?.items?.find( - (u: UnitDto) => u.id === positionType.unitId, - ); - const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; - - return ( -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - {selectedApplicationId ? ( - perm.id)} - onPermissionChange={() => { - // view-only mode in edit form - }} - applicationId={selectedApplicationId} - disabled - /> - ) : ( -
- {isLoadingPermissions ? ( -
Loading...
- ) : assignedPermissions.length === 0 ? ( -
- {t("contentManagement.noPermissionsAvailable")} -
- ) : ( -
    - {assignedPermissions.map((perm) => ( -
  • - {localizedName(perm.name)} -
  • - ))} -
- )} -
- )} -
- -
- -
-
- ); -}; +import { useState } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { Input } from "@/shared/common/ui/input"; +import { Label } from "@/shared/common/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; + +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useAuth } from "@/shared/context/AuthContext"; +import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { PermissionSearch } from "./PermissionSearch"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { t } from "i18next"; + +export const EditPositionForm = ({ id }: { id: string }) => { + const navigate = useNavigate(); + const { user } = useAuth(); + const { getList } = useUnit(); + const localizedName = useLocalizedName(); + + const { positionType, isLoadingSingle, isErrorSingle } = usePositionTypes({ + id, + }); + + const organizationId = + user?.employee && user.employee.length > 0 + ? user.employee[0].organizationId + : undefined; + + const { applications, isLoading: isLoadingApplications } = useApplications(); + + const { data: unitsResponse } = getList(organizationId || "", { + take: 300, + skip: 0, + }); + + const [selectedApplicationId, setSelectedApplicationId] = + useState(""); + + // Shares the cache key CreatePositionForm writes under, so editing a position + // type's permissions refreshes this view too. + const { + data: assignedResponse, + isLoading: isLoadingPermissions, + isError: isErrorPermissions, + } = useQuery({ + queryKey: ["position-type-permissions", id], + queryFn: () => + positionTypePermissionService.getPermissionsByPositionTypeId(id), + enabled: !!id, + }); + + const assignedPermissions = assignedResponse?.data?.items ?? []; + + if (isLoadingSingle) { + return ( +

+ {t("common.loading")} +

+ ); + } + + if (isErrorSingle || !positionType) { + return ( +
+

+ {t("contentManagement.positionTypeNotFound")} +

+
+ +
+
+ ); + } + + const unit = unitsResponse?.data?.items?.find( + (u: UnitDto) => u.id === positionType.unitId, + ); + const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {selectedApplicationId ? ( + perm.id)} + onPermissionChange={() => { + // view-only mode in edit form + }} + applicationId={selectedApplicationId} + disabled + /> + ) : ( +
+ {isLoadingPermissions ? ( +
+ {t("common.loading")} +
+ ) : isErrorPermissions ? ( +
+ {t("contentManagement.failedToLoadPermissions")} +
+ ) : assignedPermissions.length === 0 ? ( +
+ {t("contentManagement.noPermissionsAvailable")} +
+ ) : ( +
    + {assignedPermissions.map((perm) => ( +
  • + {localizedName(perm.name)} +
  • + ))} +
+ )} +
+ )} +
+ +
+ +
+
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx index bfc645fc7..8d853a3b0 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx @@ -1,152 +1,120 @@ -import React, { useState, useEffect, useMemo, useRef } from "react"; -import { Input } from "@/shared/common/ui/input"; -import { Checkbox } from "@/shared/common/ui/checkbox"; -import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; -import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { t } from "i18next"; -import { Search, Loader2 } from "lucide-react"; - -interface PermissionSearchProps { - selectedPermissions: string[]; - onPermissionChange: (permissionId: string, checked: boolean) => void; - applicationId?: string; - disabled?: boolean; -} - -const INITIAL_TAKE = 50; // Initial number of items to fetch - -export const PermissionSearch: React.FC = ({ - selectedPermissions, - onPermissionChange, - applicationId, - disabled = false, -}) => { - const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); - const [take, setTake] = useState(INITIAL_TAKE); // Start with 50 - const hasSetTotalCount = useRef(false); // Track if we've set the total count - - const scrollContainerRef = useRef(null); - - const localizedName = useLocalizedName(); - - /** ------------------ 1. Debounce Search ------------------ */ - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - setTake(INITIAL_TAKE); // Reset to 50 - hasSetTotalCount.current = false; // Reset the flag - }, 300); - - return () => clearTimeout(timer); - }, [searchTerm]); - - /** ------------------ 2. Fetch Permissions ------------------ */ - const { permissions, isPermissionsLoading } = usePermissionManager({ - params: applicationId - ? { - take, - skip: 0, // Always skip 0, we fetch everything at once - search: debouncedSearchTerm || undefined, - applicationId, - } - : undefined, - }); - - /** ------------------ 3. Update take to total count after first fetch ------------------ */ - useEffect(() => { - if ( - permissions?.count && - !hasSetTotalCount.current && - take !== permissions.count - ) { - hasSetTotalCount.current = true; - setTake(permissions.count); // Fetch all items - } - }, [permissions?.count, take]); - - /** ------------------ 4. Client-side Filtering (Optional) ------------------ */ - const filteredPermissions = useMemo(() => { - if (!permissions?.items?.length) return []; - if (!searchTerm.trim()) return permissions.items; - - return permissions.items.filter((perm: PermissionDto) => { - const name = localizedName(perm.name).toLowerCase(); - const key = perm.key.toLowerCase(); - const search = searchTerm.toLowerCase(); - return name.includes(search) || key.includes(search); - }); - }, [permissions?.items, searchTerm, localizedName]); - - /** ------------------ Render ------------------ */ - return ( -
- {/* Search Input */} -
- - setSearchTerm(e.target.value)} - className="pl-10" - /> -
- - {/* Permission List Container */} - {!applicationId ? ( -
- {t("contentManagement.selectApplicationToLoadPermissions") || - "Select an application to load permissions."} -
- ) : isPermissionsLoading ? ( -
- -
- ) : ( -
- {filteredPermissions.length === 0 ? ( -
- {searchTerm - ? t("contentManagement.noPermissionsFound") - : t("contentManagement.noPermissionsAvailable")} -
- ) : ( -
- {filteredPermissions.map((perm: PermissionDto) => ( -
- { - if (!disabled) onPermissionChange(perm.id, !!checked); - }} - /> - -
- ))} -
- )} -
- )} - - {/* Footer Info */} - {filteredPermissions.length > 0 && ( -
- {t("contentManagement.showingPermissions", { - count: filteredPermissions.length, - total: permissions?.count || 0, - })} -
- )} -
- ); -}; +import React, { useState, useEffect } from "react"; +import { Input } from "@/shared/common/ui/input"; +import { Checkbox } from "@/shared/common/ui/checkbox"; +import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; +import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { t } from "i18next"; +import { Search, Loader2 } from "lucide-react"; + +interface PermissionSearchProps { + selectedPermissions: string[]; + onPermissionChange: (permissionId: string, checked: boolean) => void; + applicationId?: string; + disabled?: boolean; +} + +// One request per application. This used to fetch 50, read `count` off the +// response and immediately refetch with take = count — two round trips on every +// mount for the same list. +const TAKE = 1000; + +export const PermissionSearch: React.FC = ({ + selectedPermissions, + onPermissionChange, + applicationId, + disabled = false, +}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + const localizedName = useLocalizedName(); + + /** ------------------ 1. Debounce Search ------------------ */ + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300); + return () => clearTimeout(timer); + }, [searchTerm]); + + /** ------------------ 2. Fetch Permissions ------------------ */ + // The API does the filtering. Filtering the result again on the *undebounced* + // term used to blank the list for 300ms on every keystroke. + const { permissions, isPermissionsLoading } = usePermissionManager({ + params: applicationId + ? { + take: TAKE, + skip: 0, + search: debouncedSearchTerm || undefined, + applicationId, + } + : undefined, + }); + + const items = permissions?.items ?? []; + + /** ------------------ Render ------------------ */ + return ( +
+ {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Permission List Container */} + {!applicationId ? ( +
+ {t("contentManagement.selectApplicationToLoadPermissions")} +
+ ) : isPermissionsLoading ? ( +
+ +
+ ) : ( +
+ {items.length === 0 ? ( +
+ {debouncedSearchTerm + ? t("contentManagement.noPermissionsFound") + : t("contentManagement.noPermissionsAvailable")} +
+ ) : ( +
+ {items.map((perm: PermissionDto) => ( +
+ { + if (!disabled) onPermissionChange(perm.id, !!checked); + }} + /> + +
+ ))} +
+ )} +
+ )} + + {/* Footer Info */} + {items.length > 0 && ( +
+ {t("contentManagement.showingPermissions", { + count: items.length, + total: permissions?.count || 0, + })} +
+ )} +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx index 9af344caf..142b97f83 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx @@ -1,276 +1,253 @@ -import { useEffect, useState, useMemo } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; - -import { Link } from "react-router-dom"; -import { Plus } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/shared/common/ui/card"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; -import { positionTypeService } from "@/user-management/services/api/positionTypesService"; -import { t } from "i18next"; -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useAuth } from "@/shared/context/AuthContext"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; - -export default function PositionManagement() { - const [pageIndex, setPageIndex] = useState(0); - const pageSize = 10; - const [isExporting, setIsExporting] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const { createConfiguration } = usePositionTypeConfiguration(); - - const { user } = useAuth(); - - const { getAccessibleList } = useUnit(); - - const organizationId = user?.employee?.[0]?.organizationId; - - const { data: unitsResponse } = getAccessibleList(organizationId ?? "", { - take: 300, - skip: 0, - }); - - // Add state for selected unitId - // Default: if super_admin => "All", otherwise wait for units - const [selectedUnitId, setSelectedUnitId] = useState("All"); - - useEffect(() => { - // If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All" - if (!selectedUnitId) { - if (unitsResponse?.data?.items?.length) { - setSelectedUnitId(unitsResponse.data.items[0].id); - } else { - setSelectedUnitId("All"); - } - } - }, [unitsResponse, selectedUnitId]); - - // Reset to first page whenever the search term or unit changes so users - // land on the first page of matches instead of an empty later page. - useEffect(() => { - setPageIndex(0); - }, [searchTerm, selectedUnitId]); - - const handlePageChange = (newPage: number) => { - setPageIndex(newPage); - }; - const { - positionTypeResponse, - isLoading, - positionTypeByUnitId, - refetch, - refetchPosition, - } = usePositionTypes({ - params: { - take: 1000, - skip: 0, - orderBy: "updatedAt:DESC", - }, - unitId: selectedUnitId === "All" ? undefined : selectedUnitId, - }); - - // Fetch position types without unitId for migration options - const { - positionTypeResponse: globalPositionTypes, - refetch: refetchGlobalPositionTypes, - } = usePositionTypes({ - params: { - take: 1000, // Get all global position types - skip: 0, - orderBy: "updatedAt:DESC", - }, - unitId: undefined, // Explicitly fetch position types without unitId - }); - - // Create a combined refetch function for the onDelete callback - const handlePositionTypeDeleted = async () => { - await Promise.all([ - selectedUnitId === "All" ? refetch() : refetchPosition(), - refetchGlobalPositionTypes(), - ]); - }; - const handleToggle = async ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => { - if (!selectedUnitId || selectedUnitId === "All") return; - - await createConfiguration({ - positionTypeId, - timeframe: "yearly", - organizationId: organizationId!, - canReceiveRecord: field === "canReceiveRecord" ? checked : false, - canAssignRecord: field === "canAssignRecord" ? checked : false, - canCreateBankRecord: field === "canCreateBankRecord" ? checked : false, - }); - - await handlePositionTypeDeleted(); - }; - - // Create columns with positionTypeResponse - const columns = useMemo( - () => - createPositionTypeColumns( - selectedUnitId === "All" ? positionTypeResponse : positionTypeByUnitId, - globalPositionTypes, - handlePositionTypeDeleted, - handlePositionTypeDeleted, - handleToggle, // ← pass toggle handler - selectedUnitId === "All", // ← isGlobal: hide toggle when "All" - ), - [ - selectedUnitId, - positionTypeResponse, - positionTypeByUnitId, - globalPositionTypes, - ], - ); - const allItems = useMemo( - () => - (selectedUnitId === "All" - ? positionTypeResponse?.items - : positionTypeByUnitId?.items) || [], - [selectedUnitId, positionTypeResponse?.items, positionTypeByUnitId?.items], - ); - - const filteredItems = useMemo(() => { - const trimmed = searchTerm.trim().toLowerCase(); - if (!trimmed) return allItems; - return allItems.filter((item: any) => { - const en = (item?.name?.en || "").toLowerCase(); - const am = (item?.name?.am || "").toLowerCase(); - const key = (item?.key || "").toLowerCase(); - return ( - en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) - ); - }); - }, [allItems, searchTerm]); - - const paginatedItems = useMemo(() => { - const start = pageIndex * pageSize; - return filteredItems.slice(start, start + pageSize); - }, [filteredItems, pageIndex, pageSize]); - - if (isLoading) { - return
{t("contentManagement.addUser")}
; - } - - const exportTypes = () => { - setIsExporting(true); - positionTypeService - .getAll({ - take: 3000, - }) - .then((allPositionKeys) => { - // Get the position type keys - const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); - - if (positionTypeKeys && positionTypeKeys.length > 0) { - // Convert the array of keys into a string, with each key on a new line - const fileContent = positionTypeKeys.join("\n"); - - // Create a Blob from the string content - const blob = new Blob([fileContent], { type: "text/plain" }); - - // Create a link element to trigger the download - const link = document.createElement("a"); - - // Create an object URL for the Blob - link.href = URL.createObjectURL(blob); - - // Set the download attribute with a file name - link.download = "position_keys.txt"; - - // Programmatically trigger a click on the link to start the download - link.click(); - - // Clean up by revoking the object URL - URL.revokeObjectURL(link.href); - } else { - console.error("No position type keys found."); - } - setIsExporting(false); - }); - }; - - return ( -
- - - - {t("contentManagement.permissionType")} - - - - {unitsResponse?.data?.items?.length > 0 && ( -
- - -
- )} - - - - - } - pageIndex={pageIndex} - onPageChange={handlePageChange} - nextFunction={() => handlePageChange(pageIndex + 1)} - prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} - /> - -
-
- ); -} +import { useEffect, useState, useMemo, useCallback } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; + +import { Link } from "react-router-dom"; +import { Plus } from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; +import { positionTypeService } from "@/user-management/services/api/positionTypesService"; +import { t } from "i18next"; +import { toast } from "sonner"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useAuth } from "@/shared/context/AuthContext"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; + +export default function PositionManagement() { + const [pageIndex, setPageIndex] = useState(0); + const pageSize = 10; + const [isExporting, setIsExporting] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + + const { user } = useAuth(); + + const { getAccessibleList } = useUnit(); + + const organizationId = user?.employee?.[0]?.organizationId; + + const { data: unitsResponse, isError: isUnitsError } = getAccessibleList( + organizationId ?? "", + { + take: 300, + skip: 0, + }, + ); + + // Add state for selected unitId + // Default: if super_admin => "All", otherwise wait for units + const [selectedUnitId, setSelectedUnitId] = useState("All"); + + useEffect(() => { + // If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All" + if (!selectedUnitId) { + if (unitsResponse?.data?.items?.length) { + setSelectedUnitId(unitsResponse.data.items[0].id); + } else { + setSelectedUnitId("All"); + } + } + }, [unitsResponse, selectedUnitId]); + + // Reset to first page whenever the search term or unit changes so users + // land on the first page of matches instead of an empty later page. + useEffect(() => { + setPageIndex(0); + }, [searchTerm, selectedUnitId]); + + const handlePageChange = (newPage: number) => { + setPageIndex(newPage); + }; + + const showingAllUnits = selectedUnitId === "All"; + + const { + positionTypeResponse, + isLoading, + isError, + positionTypeByUnitId, + isLoadingPosition, + isErrorPosition, + refetch, + refetchPosition, + } = usePositionTypes({ + params: { + take: 1000, + skip: 0, + orderBy: "updatedAt:DESC", + }, + unitId: showingAllUnits ? undefined : selectedUnitId, + }); + + // Refresh whichever list is on screen. `positionTypeResponse` is the + // unscoped fetch, so it doubles as the migration-target source — no second + // usePositionTypes() call needed (its cache key ignores unitId, so a second + // call returned the very same query). + const handlePositionTypeChanged = useCallback(async () => { + await (showingAllUnits ? refetch() : refetchPosition()); + }, [showingAllUnits, refetch, refetchPosition]); + + const columns = useMemo( + () => + createPositionTypeColumns( + positionTypeResponse, + handlePositionTypeChanged, + handlePositionTypeChanged, + ), + [positionTypeResponse, handlePositionTypeChanged], + ); + + const allItems = useMemo( + () => + (showingAllUnits + ? positionTypeResponse?.items + : positionTypeByUnitId?.items) || [], + [showingAllUnits, positionTypeResponse?.items, positionTypeByUnitId?.items], + ); + + const filteredItems = useMemo(() => { + const trimmed = searchTerm.trim().toLowerCase(); + if (!trimmed) return allItems; + return allItems.filter((item: PositionTypeDto) => { + const en = (item?.name?.en || "").toLowerCase(); + const am = (item?.name?.am || "").toLowerCase(); + const key = (item?.key || "").toLowerCase(); + return ( + en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) + ); + }); + }, [allItems, searchTerm]); + + const paginatedItems = useMemo(() => { + const start = pageIndex * pageSize; + return filteredItems.slice(start, start + pageSize); + }, [filteredItems, pageIndex, pageSize]); + + // Track whichever query is actually feeding the table — picking a unit used + // to leave the previous unit's rows on screen with no loading state. + const isLoadingList = showingAllUnits ? isLoading : isLoadingPosition; + const isErrorList = showingAllUnits ? isError : isErrorPosition; + + const exportTypes = () => { + setIsExporting(true); + positionTypeService + .getAll({ take: 3000 }) + .then((allPositionKeys) => { + const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); + + if (!positionTypeKeys?.length) { + toast.error(t("contentManagement.exportFailed")); + return; + } + + // One key per line, downloaded as a plain text file. + const blob = new Blob([positionTypeKeys.join("\n")], { + type: "text/plain", + }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = "position_keys.txt"; + link.click(); + URL.revokeObjectURL(link.href); + }) + .catch(() => { + toast.error(t("contentManagement.exportFailed")); + }) + .finally(() => { + setIsExporting(false); + }); + }; + + return ( +
+ + + + {t("contentManagement.permissionType")} + + + + {!!unitsResponse?.data?.items?.length && ( +
+ + +
+ )} + {isUnitsError && ( +

+ {t("organization.errorLoadingUnits")} +

+ )} + + {isLoadingList ? ( +
+ {t("common.loading")} +
+ ) : isErrorList ? ( +
+ {t("contentManagement.failedToLoadPositionTypes")} +
+ ) : ( + + + + } + pageIndex={pageIndex} + onPageChange={handlePageChange} + nextFunction={() => handlePageChange(pageIndex + 1)} + prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} + /> + )} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx index 32613a0ea..1a2b829d6 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx @@ -1,393 +1,251 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; - -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuItem, -} from "@/shared/common/ui/dropdown-menu"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/common/ui/alert-dialog"; - -import { Button } from "@/shared/common/ui/button"; -import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; -import { t } from "i18next"; -import PositionTypeMigrationModal from "./PostionTypeMigration"; -import { CreatePositionForm } from "./CreatePositionForm"; -import { toast } from "sonner"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { positionTypeService } from "@/user-management/services/api/positionTypesService"; -import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; -import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; -import { Switch } from "@/shared/common/ui/switch"; -import { PositionTypeConfigurationDto } from "@/user-management/services/api/positionTypeConfigurationService"; -import { useQueryClient } from "@tanstack/react-query"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/shared/common/ui/dialog"; - -interface PositionTypeResponse { - items: PositionTypeDto[]; - count: number; -} - -type ActionsCellProps = { - row: PositionTypeDto | PositionTypeConfigurationDto; - globalPositionTypes?: PositionTypeResponse; - onDelete?: () => void | Promise; - onEdit?: () => void | Promise; - onToggle?: ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => void | Promise; - isGlobal?: boolean; // true when viewing "All" units — hide toggle -}; - -const PositionTypeActionsCell: React.FC = ({ - row, - globalPositionTypes, - onDelete, - onEdit, - onToggle, - isGlobal = false, -}) => { - const navigate = useNavigate(); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [showMigrateDialog, setShowMigrateDialog] = useState(false); - const [showEditDialog, setShowEditDialog] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const localizedName = useLocalizedName(); - const { handleError } = useErrorHandler(t); - const queryClient = useQueryClient(); - // Use row.id as the positionTypeId for the configuration lookup - - const { - configurations, - isLoadingConfigurations, - updateConfiguration, - isUpdatingConfiguration, - } = usePositionTypeConfiguration( - row?.id ?? null, // 👈 pass row.id as unitId - ); - - const configItem = configurations[0]; - const isCanReceiveRecord = configItem?.canReceiveRecord ?? false; - const isCanAssignRecord = configItem?.canAssignRecord ?? false; - const isCanCreateBankRecord = configItem?.canCreateBankRecord ?? false; - - const invalidateConfig = () => { - queryClient.invalidateQueries({ - queryKey: ["positionTypeConfigurations", row.id], - }); - queryClient.invalidateQueries({ - queryKey: ["positionTypeConfiguration", row.id], - }); - }; - - // Create position type options from globalPositionTypes - only those WITHOUT unitId - const positionTypeOptions = - globalPositionTypes?.items - .filter((item) => !item.unitId) - .map((item) => ({ - label: localizedName(item.name), - value: item.id, - })) || []; - - // Only show migrate/delete actions if current row has a unitId - const canBeModified = !!row.unitId; - - const handleView = () => { - navigate(`/user-management/position-management/edit/${row.id}`); - }; - - const handleMigrate = (e: Event) => { - e.preventDefault(); - setDropdownOpen(false); - setShowMigrateDialog(true); - }; - - const handleEdit = (e: Event) => { - e.preventDefault(); - setDropdownOpen(false); - setShowEditDialog(true); - }; - - const handleDelete = async () => { - try { - setIsDeleting(true); - await positionTypeService.delete(row.id); - toast.success(t("common.DeletedSuccessfully")); - setShowDeleteDialog(false); - if (onDelete) { - await onDelete(); - } - } catch (error) { - handleError(error); - toast.error(t("common.FailedToDelete")); - } finally { - setIsDeleting(false); - } - }; - - const handleToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canReceiveRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canReceiveRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - - const handleAssignToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canAssignRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canAssignRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - - const handleCreateBankRecordToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canCreateBankRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canCreateBankRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - const rowName = "name" in row ? row.name : { am: "", en: "" }; - const isPositionType = "name" in row && "key" in row; - - return ( - <> - - - - - - { - const target = e.target as HTMLElement; - if (!target.closest('[role="dialog"]')) { - setDropdownOpen(false); - } - }}> - Actions - - {canBeModified && ( - - - {t("common.Migrate")} - - )} - - {canBeModified && isPositionType && ( - - - {t("common.Edit")} - - )} - - - - {t("common.View")} - - - {canBeModified && ( - { - setDropdownOpen(false); - setShowDeleteDialog(true); - }} - className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200"> - - {t("common.Delete")} - - )} - - {/* Toggle moved here from ToggleCell */} - {!isGlobal && ( -
-
- - {t("contentManagement.CanReceiveRecord")} - - -
-
- )} - {!isGlobal && ( -
-
- - {t("contentManagement.CanAssignRecord")} - - -
-
- )} - {!isGlobal && ( -
-
- - {t("contentManagement.CanCreateBankRecord")} - - -
-
- )} -
-
- - {showMigrateDialog && ( - { - setShowMigrateDialog(false); - }} - toId={row.id} - toName={localizedName(rowName)} - positionTypeOptions={positionTypeOptions} - /> - )} - - - - - {t("common.Edit")} - -
- {isPositionType && showEditDialog && ( - { - setShowEditDialog(false); - if (onEdit) { - await onEdit(); - } - }} - onCancel={() => setShowEditDialog(false)} - /> - )} -
-
-
- - - - - {t("common.ConfirmDelete")} - - {t("common.DeleteConfirmationMessage", { - defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`, - })} - - - - {t("common.Cancel")} - - {isDeleting ? t("common.Deleting") : t("common.Delete")} - - - - - - ); -}; - -export default PositionTypeActionsCell; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; + +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuItem, +} from "@/shared/common/ui/dropdown-menu"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/common/ui/alert-dialog"; + +import { Button } from "@/shared/common/ui/button"; +import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; +import { t } from "i18next"; +import PositionTypeMigrationModal from "./PostionTypeMigration"; +import { CreatePositionForm } from "./CreatePositionForm"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; + +interface PositionTypeResponse { + items: PositionTypeDto[]; + count: number; +} + +type ActionsCellProps = { + row: PositionTypeDto; + globalPositionTypes?: PositionTypeResponse; + onDelete?: () => void | Promise; + onEdit?: () => void | Promise; +}; + +/* + * TODO(record-toggles): this menu used to carry CanReceiveRecord / + * CanAssignRecord / CanCreateBankRecord switches. They never worked. IAM's + * PositionTypeConfiguration entity only has { id, organizationId, + * positionTypeId, timeframe } — verified against every local build (0.7.4 + * through 0.7.12) and the live swagger. canAssignRecord and + * canCreateBankRecord do not exist anywhere in the IAM package, and the global + * ValidationPipe runs with forbidNonWhitelisted, so every write 400'd. The + * reads were broken too: the list route filters on organizationId (the repo is + * built as TExtraCrudRepository(repo, "organizationId")) while the UI passed a + * positionTypeId, so it always came back empty. + * + * The flag that does exist is PositionConfiguration.canReceiveRecord, keyed by + * positionId — a per-position setting served by /api/position-configurations, + * not a per-position-type one. Restoring this needs either that endpoint and a + * position-level UI, or new columns on PositionTypeConfiguration in IAM. + */ +const PositionTypeActionsCell: React.FC = ({ + row, + globalPositionTypes, + onDelete, + onEdit, +}) => { + const navigate = useNavigate(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [showMigrateDialog, setShowMigrateDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const localizedName = useLocalizedName(); + const { deletePositionType } = usePositionTypes(); + + // Create position type options from globalPositionTypes - only those WITHOUT unitId + const positionTypeOptions = + globalPositionTypes?.items + .filter((item) => !item.unitId) + .map((item) => ({ + label: localizedName(item.name), + value: item.id, + })) || []; + + // Only show migrate/delete actions if current row has a unitId + const canBeModified = !!row.unitId; + + const handleView = () => { + navigate(`/user-management/position-management/edit/${row.id}`); + }; + + const handleMigrate = (e: Event) => { + e.preventDefault(); + setDropdownOpen(false); + setShowMigrateDialog(true); + }; + + const handleEdit = (e: Event) => { + e.preventDefault(); + setDropdownOpen(false); + setShowEditDialog(true); + }; + + // Goes through the mutation rather than the service directly, so the cache is + // invalidated and IAM's 403 for built-in types reaches the user. + const handleDelete = async () => { + try { + await deletePositionType.mutateAsync(row.id); + setShowDeleteDialog(false); + await onDelete?.(); + } catch { + // reported by the mutation's onError + } + }; + + return ( + <> + + + + + + { + const target = e.target as HTMLElement; + if (!target.closest('[role="dialog"]')) { + setDropdownOpen(false); + } + }}> + {t("userRecord.Actions")} + + {canBeModified && ( + + + {t("common.Migrate")} + + )} + + {canBeModified && ( + + + {t("common.Edit")} + + )} + + + + {t("common.View")} + + + {canBeModified && ( + { + setDropdownOpen(false); + setShowDeleteDialog(true); + }} + className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200"> + + {t("common.Delete")} + + )} + + + + {showMigrateDialog && ( + { + setShowMigrateDialog(false); + }} + toId={row.id} + toName={localizedName(row.name)} + positionTypeOptions={positionTypeOptions} + /> + )} + + + + + {t("common.Edit")} + +
+ {showEditDialog && ( + { + setShowEditDialog(false); + if (onEdit) { + await onEdit(); + } + }} + onCancel={() => setShowEditDialog(false)} + /> + )} +
+
+
+ + + + + {t("common.ConfirmDelete")} + + {t("common.DeleteConfirmationMessage", { + name: localizedName(row.name), + })} + + + + + {t("common.Cancel")} + + + {deletePositionType.isPending + ? t("common.Deleting") + : t("common.Delete")} + + + + + + ); +}; + +export default PositionTypeActionsCell; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx index d5fcb509e..bcd73ae3f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx @@ -16,16 +16,9 @@ const NameCell = ({ name }: { name: PositionTypeDto["name"] }) => { }; export const createPositionTypeColumns = ( - _positionTypeResponse?: PositionTypeResponse, globalPositionTypes?: PositionTypeResponse, onDelete?: () => void | Promise, onEdit?: () => void | Promise, - onToggle?: ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => void | Promise, - isGlobal?: boolean, ): ColumnDef[] => [ { accessorKey: "name", @@ -64,8 +57,6 @@ export const createPositionTypeColumns = ( globalPositionTypes={globalPositionTypes} onDelete={onDelete} onEdit={onEdit} - onToggle={onToggle} - isGlobal={isGlobal} /> ), }, diff --git a/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts b/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts index 3f76be9f7..c2e79844f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts @@ -5,10 +5,14 @@ export interface PositionTypeDto { en: string; }; key: string; - unitId: string; - canReceiveRecord: boolean; - canCreateBankRecord?: boolean; - canAssignRecord: boolean; + /** + * Null for the built-in ("common") types, which `isSystem` marks and which + * every unit can use. IAM has no organizationId on a position type — the + * owning organization is only reachable via unit -> organizationId. + */ + unitId: string | null; + /** Built-in type. IAM rejects update/delete on these with a 403. */ + isSystem?: boolean; createdAt: string; updatedAt: string; } diff --git a/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts b/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts index a62ef26d6..8f102532d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts @@ -1,4 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { CreatePositionTypePayload, PositionRequest, @@ -23,10 +28,22 @@ interface positionParams { interface UsePositionTypeManagerProps { id?: string; unitId?: string; - organizationId?: string; params?: positionParams; // 👈 we expected query params to be passed like this } +/** + * Every cache key this hook writes under. React Query matches key prefixes + * element by element, so `["position-type"]` does NOT reach + * `["position-types-common", ...]` — each root has to be listed. Anything that + * mutates a position type should call this rather than hand-picking keys, or + * the department pickers (which read the "-common" queries) go stale. + */ +export const invalidatePositionTypeQueries = (queryClient: QueryClient) => { + ["position-types", "position-type", "position-types-common"].forEach( + (root) => queryClient.invalidateQueries({ queryKey: [root] }), + ); +}; + export const usePositionTypes = ({ id, params = { @@ -35,11 +52,11 @@ export const usePositionTypes = ({ orderBy: "createdAt:Desc", }, unitId, - organizationId, }: UsePositionTypeManagerProps = {}) => { const queryClient = useQueryClient(); const { t } = useTranslation(); const { handleError } = useErrorHandler(t); + const invalidateAll = () => invalidatePositionTypeQueries(queryClient); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["position-types", params], queryFn: () => positionTypeService.getAll(params).then((res) => res.data), @@ -70,38 +87,6 @@ export const usePositionTypes = ({ enabled: !!unitId, }); - // Position types by organization ID - const { - data: positionTypeByOrgId, - isLoading: isLoadingOrgPosition, - isError: isErrorOrgPosition, - refetch: refetchOrgPosition, - } = useQuery({ - queryKey: ["position-type-org", organizationId, params], - queryFn: async () => { - if (!organizationId) return undefined; - const res = await positionTypeService.getByOrganizationId(organizationId, params); - return res.data as PositionTypesListResponse | undefined; - }, - enabled: !!organizationId, - }); - - // Common types with organization ID (includes both org-specific and common types) - const { - data: commonPositionTypesByOrgId, - isLoading: isLoadingCommonOrgTypes, - isError: isErrorCommonOrgTypes, - refetch: refetchCommonOrgTypes, - } = useQuery({ - queryKey: ["position-types-common-org", organizationId, params], - queryFn: async () => { - if (!organizationId) return undefined; - const res = await positionTypeService.getCommonTypesByOrganizationId(organizationId, params); - return res.data as PositionTypesListResponse | undefined; - }, - enabled: !!organizationId, - }); - // Common types with unit ID (includes both unit-specific and common types) const { data: commonPositionTypes, @@ -123,15 +108,16 @@ export const usePositionTypes = ({ mutationFn: (payload: CreatePositionTypePayload) => positionTypeService.create(payload), onSuccess: () => { - toast.success("Position type created"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); + toast.success(t("contentManagement.positionTypeCreated")); + invalidateAll(); }, onError: (error) => { handleError(error); }, }); - // Update + // Update. IAM answers 403 `position_type_not_allowed_to_update` for built-in + // (isSystem) types, so the error has to reach the user. const updatePositionType = useMutation({ mutationFn: ({ id, @@ -141,11 +127,12 @@ export const usePositionTypes = ({ data: UpdatePositionTypePayload; }) => positionTypeService.update(id, data), onSuccess: () => { - toast.success("Position type updated"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeUpdated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); //update positon from to @@ -153,30 +140,32 @@ export const usePositionTypes = ({ mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) => positionTypeService.updateFromto(toId, fromId), onSuccess: () => { - toast.success("Position type migration updated"); - queryClient.invalidateQueries({ queryKey: ["position-types-to"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeMigrated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); //update all postions const migratePositionsByPositions = useMutation({ mutationFn: ({ id, data }: { id: string; data: PositionRequest }) => positionTypeService.updateByPostion(id, data), onSuccess: () => { - toast.success("Position type migration updated"); - queryClient.invalidateQueries({ queryKey: ["position-types-migration"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeMigrated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); - // Delete + // Delete. Also 403s for built-in types. const deletePositionType = useMutation({ mutationFn: (id: string) => positionTypeService.delete(id), onSuccess: () => { - toast.success("Position type deleted"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); + toast.success(t("contentManagement.positionTypeDeleted")); + invalidateAll(); }, onError: (error) => { handleError(error); @@ -205,16 +194,6 @@ export const usePositionTypes = ({ refetchPosition, isErrorPosition, isLoadingPosition, - // organization-based position types - positionTypeByOrgId, - refetchOrgPosition, - isErrorOrgPosition, - isLoadingOrgPosition, - // common types with organization ID - commonPositionTypesByOrgId, - refetchCommonOrgTypes, - isErrorCommonOrgTypes, - isLoadingCommonOrgTypes, // common types with unit ID commonPositionTypes: commonPositionTypes?.items ?? [], isLoadingCommonTypes, diff --git a/apps/edr-freight-web/backoffice/src/user-management/pages/position-management/index.tsx b/apps/edr-freight-web/backoffice/src/user-management/pages/position-management/index.tsx index 57d7a1898..a052caa11 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/pages/position-management/index.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/pages/position-management/index.tsx @@ -1,13 +1,7 @@ import PositionManagement from "@/user-management/components/position-management/PositionLists"; -import { t } from "i18next"; const PositionManagementPage = () => { - return ( -
-

{t("contentManagement.permissionManagement")}

- -
- ); + return ; }; export default PositionManagementPage; diff --git a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts index 9e7e4c1d4..84782b38f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts @@ -21,7 +21,7 @@ export interface PositionPayload { organizationId: string; parentPositionId?: string; projectId?: string; - positionTypeId: string; + positionTypeId?: string; } export interface PositionQueryParams { orderBy?: string; diff --git a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts index b3ac43ecd..450009fa7 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts @@ -40,38 +40,25 @@ export const positionTypeService = { getById: (id: string): Promise> => axiosInstance.get(`/position-types/${id}`, { headers: withHeaders() }), + // Types owned by one unit. IAM has no organization-scoped route — position + // types carry a unitId only, so scoping to an org means filtering by that + // org's units client-side. getByUnitId: ( - id: string, + unitId: string, params?: Params, ): Promise> => - axiosInstance.get(`/position-types/list/${id}`, { - headers: withHeaders(), - params, - }), - - getByOrganizationId: ( - id: string, - params?: Params, - ): Promise> => - axiosInstance.get(`/position-types/list/${id}`, { - headers: withHeaders(), - params, - }), - - getCommonTypesByOrganizationId: ( - id: string, - params?: Params, - ): Promise> => - axiosInstance.get(`/position-types/list-with-commons/${id}`, { + axiosInstance.get(`/position-types/list/${unitId}`, { headers: withHeaders(), params, }), + // WHERE isSystem = true OR unitId = :unitId — "commons" means the built-in + // types, not the ones with a null unitId. getCommonTypesById: ( - id: string, + unitId: string, params: Params, ): Promise> => - axiosInstance.get(`/position-types/list-with-commons/${id}`, { + axiosInstance.get(`/position-types/list-with-commons/${unitId}`, { headers: withHeaders(), params, }), diff --git a/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx index d16745327..872a82c9d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx @@ -48,8 +48,6 @@ export function AddDepartmentForm({ if (!nameAm.trim()) newErrors.nameAm = t("organization.amharicNameRequired"); if (!key.trim()) newErrors.key = t("contentManagement.keyRequired"); - if (!positionTypeId) - newErrors.positionTypeId = t("contentManagement.selectPosType"); setErrors(newErrors); return Object.keys(newErrors).length === 0; @@ -71,7 +69,7 @@ export function AddDepartmentForm({ key: key.trim().toLowerCase().replace(/\s+/g, "-"), unitId, organizationId, - positionTypeId, + ...(positionTypeId ? { positionTypeId } : {}), }; createPosition({ @@ -90,12 +88,11 @@ export function AddDepartmentForm({ return (
- + - {errors.positionTypeId && ( -

{errors.positionTypeId}

- )}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx new file mode 100644 index 000000000..5f1f8a51e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { Alert, Button, Group, Modal, Stack, Table, Text, FileInput, Badge } from "@mantine/core"; +import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; + +import { client } from "@/utils/api"; +import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template"; + +interface BulkTruckUploadModalProps { + opened: boolean; + onClose: () => void; + bookingId: string; + onSuccess?: () => void; +} + +export function BulkTruckUploadModal({ + opened, + onClose, + bookingId, + onSuccess, +}: BulkTruckUploadModalProps) { + const [file, setFile] = useState(null); + const [parsed, setParsed] = useState< + Array<{ + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers?: string[]; + }> + >([]); + const [parseError, setParseError] = useState(null); + + const uploadMutation = useMutation({ + mutationFn: async () => { + const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, { + trucks: parsed, + }); + return data; + }, + onSuccess: () => { + onSuccess?.(); + setFile(null); + setParsed([]); + onClose(); + }, + }); + + const handleFileSelect = async (selectedFile: File | null) => { + if (!selectedFile) { + setFile(null); + setParsed([]); + setParseError(null); + return; + } + + try { + setParseError(null); + const trucks = await parseTruckAssignmentFile(selectedFile); + setFile(selectedFile); + setParsed(trucks); + } catch (err: any) { + setParseError(err.message || "Failed to parse Excel file"); + setFile(null); + setParsed([]); + } + }; + + const handleDownloadTemplate = () => { + generateTruckAssignmentTemplate("truck-assignments.xlsx"); + }; + + return ( + + + } color="blue"> + Download template, fill with truck data, upload Excel file to bulk-create truck assignments. + + + + + + + } + /> + + {parseError && ( + } color="red" title="Parse Error"> + {parseError} + + )} + + {parsed.length > 0 && ( + <> +
+ + Preview ({parsed.length} trucks) + + + + + Plate Number + Driver Name + Truck Type + Containers + + + + {parsed.map((truck, idx) => ( + + + {truck.truckPlateNumber} + + + {truck.driverName} + + + {truck.truckType} + + + {truck.containerNumbers?.length ? ( + + {truck.containerNumbers.map((c) => ( + + {c} + + ))} + + ) : ( + + — + + )} + + + ))} + +
+
+ + + + Ready to upload {parsed.length} truck(s) + + + + + )} + + {uploadMutation.isError && ( + } color="red"> + {uploadMutation.error instanceof Error + ? uploadMutation.error.message + : "Upload failed"} + + )} +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 7c9cbeb0e..7ec73c848 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -3,10 +3,12 @@ import { Alert, Badge, Button, + Checkbox, Divider, Group, Loader, MultiSelect, + NumberInput, Select, SimpleGrid, Stack, @@ -15,7 +17,7 @@ import { } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; -import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react"; +import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck, Upload } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; @@ -23,9 +25,24 @@ import { api } from "@/services/api"; import { customerTrucksService } from "@/services/customer-trucks.service"; import { CardTitle, SectionCard } from "./layout"; +import { BulkTruckUploadModal } from "./BulkTruckUploadModal"; +import { generateTruckAssignmentTemplate } from "@/utils/truck-assignment-template"; const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"]; +// Waybill-style selectable copies (indexes 1-8 in the API catalog). The 2 gate +// copies (Port Operations, Gate Security & Carrier) are always printed. +const FREIGHT_ORDER_COPIES = [ + { index: 1, label: "Original 1 (for Issuing Carrier)" }, + { index: 2, label: "Original 2 (for Consignee)" }, + { index: 3, label: "Original 3 (for Shipper)" }, + { index: 4, label: "Copy 4 (Delivery Receipt)" }, + { index: 5, label: "Copy 5 (Extra Copy)" }, + { index: 6, label: "Copy 6 (Extra Copy)" }, + { index: 7, label: "Copy 7 (Extra Copy)" }, + { index: 8, label: "Copy 8 (for Agent)" }, +]; + const downloadBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -63,8 +80,11 @@ export function CustomerTruckAssignmentCard({ const [driverName, setDriverName] = useState(""); const [truckType, setTruckType] = useState(""); const [containers, setContainers] = useState([]); + const [plannedTons, setPlannedTons] = useState(""); + const [plannedQty, setPlannedQty] = useState(""); const [editingId, setEditingId] = useState(null); const [error, setError] = useState(null); + const [bulkModalOpen, setBulkModalOpen] = useState(false); // Container numbers on the booking that aren't already loaded onto a truck. const assignedNumbers = new Set( @@ -88,15 +108,23 @@ export function CustomerTruckAssignmentCard({ setDriverName(""); setTruckType(""); setContainers([]); + setPlannedTons(""); + setPlannedQty(""); setEditingId(null); setError(null); }; const startEdit = (t: Freight.ICustomerTruck) => { + const planned = t as Freight.ICustomerTruck & { + plannedTons?: number | string | null; + plannedQuantity?: number | null; + }; setPlateNumber(t.plateNumber ?? ""); setDriverName(t.driverName ?? ""); setTruckType(t.truckType ?? ""); setContainers((t.containers ?? []).map((c) => c.containerNumber)); + setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : ""); + setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : ""); setEditingId(t.id); setError(null); }; @@ -112,6 +140,12 @@ export function CustomerTruckAssignmentCard({ driverName: driverName.trim(), truckType: truckType.trim(), containerNumbers: isBulk ? [] : containers, + ...(isBulk + ? { + plannedTons: plannedTons === "" ? undefined : Number(plannedTons), + plannedQuantity: plannedQty === "" ? undefined : Number(plannedQty), + } + : {}), }; return editingId ? customerTrucksService.update(booking.id, editingId, payload) @@ -136,8 +170,11 @@ export function CustomerTruckAssignmentCard({ }); const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); + const [selectedCopies, setSelectedCopies] = useState( + FREIGHT_ORDER_COPIES.map((c) => c.index), + ); const downloadFreightOrder = async () => { - const blob = await downloadMutation.mutateAsync({ id: booking.id }); + const blob = await downloadMutation.mutateAsync({ id: booking.id, copies: selectedCopies }); downloadBlob(blob, `freight-order-${booking.reference}.pdf`); }; @@ -150,6 +187,10 @@ export function CustomerTruckAssignmentCard({ setError("Select 1 or 2 container numbers for this truck."); return; } + if (isBulk && plannedTons === "") { + setError("Enter the tonnes this truck will haul."); + return; + } setError(null); addMutation.mutate(); }; @@ -163,6 +204,24 @@ export function CustomerTruckAssignmentCard({ External Truck Assignment + + + + {pendingAssignmentCount > 0 && ( {pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment @@ -286,6 +345,40 @@ export function CustomerTruckAssignmentCard({ nothingFoundMessage="No unassigned containers" /> )} + {isBulk && ( + { + const total = Number(booking.cargoTotalWeightVgm) || 0; + const assigned = trucks + .filter((t) => t.id !== editingId) + .reduce((s, t) => { + const x = t as Freight.ICustomerTruck & { + netWeightTons?: number | string | null; + plannedTons?: number | string | null; + }; + return s + (Number(x.netWeightTons ?? x.plannedTons) || 0); + }, 0); + const remaining = Math.max(0, Math.round((total - assigned) * 1000) / 1000); + return total > 0 + ? `${assigned} t of ${total} t already on trucks · ${remaining} t remaining` + : "Tonnage this truck hauls"; + })()} + required + min={0} + value={plannedTons} + onChange={(v) => setPlannedTons(v === "" ? "" : Number(v))} + /> + )} + {isBulk && ( + setPlannedQty(v === "" ? "" : Number(v))} + /> + )} {editingId && ( @@ -312,19 +405,64 @@ export function CustomerTruckAssignmentCard({ )} {trucks.length > 0 && ( - - - + + Copies + + + + + + {FREIGHT_ORDER_COPIES.map((c) => ( + + setSelectedCopies((prev) => + e.currentTarget.checked + ? [...prev, c.index].sort((a, b) => a - b) + : prev.filter((i) => i !== c.index), + ) + } + /> + ))} + + + + Port Operations and Gate Security copies are always included. + + + + )} + + setBulkModalOpen(false)} + bookingId={booking.id} + onSuccess={() => { + queryClient.invalidateQueries({ queryKey: trucksKey }); + onAssigned(); + }} + /> ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx index 8934a0a60..e43bb43b9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx @@ -15,6 +15,7 @@ import { isViewable } from "@edr/ui-common"; import { api } from "@/services/api"; import { fetchViewableFile, downloadStoredFile } from "@/services/files.service"; +import { warehouseService } from "@/services/warehouse.service"; import { useFileViewer } from "@/hooks/useFileViewer"; import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal"; import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction"; @@ -241,6 +242,11 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) { }), ); + const { data: handovers = [] } = useQuery({ + queryKey: ["bookingHandovers", booking.id], + queryFn: () => warehouseService.bookingHandovers(booking.id), + }); + const customerDocs = useMemo( () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), [clearance], @@ -486,6 +492,69 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) { )} + {/* ── 4. Handover signatures ──────────────────────────────────────── */} + {handovers.length > 0 && ( + + Handover signatures + + Records of goods handover and customer signatures. + + + {handovers.map((h, i) => ( + + + + + {h.reference} + + + {h.mileType === "SELF_HAUL" + ? "Customer truck delivery" + : `EDR delivery${h.truckPlate ? ` (${h.truckPlate})` : ""}`} + + {h.signedAt && ( + + Signed by {h.signerName || "Unknown"} on{" "} + {new Date(h.signedAt).toLocaleDateString()} + + )} + + + + {h.signedAt && h.signatureImageUrl && ( + + Signature + + )} + + ))} + + + )} + {/* ── Warehouse documents (one-click bundle) ──────────────────────── */} Warehouse documents diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 68276a18c..6fd1bd479 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -308,10 +308,10 @@ export const api = { bookingsService.assignCustomerTruck(id, payload), ), - downloadCustomerTruckFreightOrder: endpoint<{ id: string }, Blob>( + downloadCustomerTruckFreightOrder: endpoint<{ id: string; copies?: number[] }, Blob>( "bookings", "downloadCustomerTruckFreightOrder", - ({ id }) => bookingsService.downloadCustomerTruckFreightOrder(id), + ({ id, copies }) => bookingsService.downloadCustomerTruckFreightOrder(id, copies), ), downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>( diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index fbb84e1c0..58f3b330b 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -212,10 +212,10 @@ export const bookingsService = { ); return data.data; }, - downloadCustomerTruckFreightOrder: async (id: string): Promise => { + downloadCustomerTruckFreightOrder: async (id: string, copies?: number[]): Promise => { const { data } = await client.get( `/api/bookings/${id}/customer-truck-assignment/freight-order`, - { responseType: "blob" }, + { responseType: "blob", params: copies?.length ? { copies: copies.join(",") } : undefined }, ); return data; }, diff --git a/apps/edr-freight-web/portal/src/services/warehouse.service.ts b/apps/edr-freight-web/portal/src/services/warehouse.service.ts index b641a8809..990077147 100644 --- a/apps/edr-freight-web/portal/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/portal/src/services/warehouse.service.ts @@ -41,6 +41,22 @@ export interface BookingScheduleView { } | null; } +export interface BookingHandover { + id: string; + bookingId: string; + truckAssignmentId?: string | null; + edrAssignmentId?: string | null; + truckPlate?: string | null; + mileType: 'SELF_HAUL' | 'EDR_LAST_MILE'; + reference: string; + generatedAt: string; + signedAt?: string | null; + signerName?: string | null; + signedByUserId?: string | null; + signatureImageUrl?: string | null; + deliveredAt?: string | null; +} + export const warehouseService = { listInventory: async (filter?: InventoryFilter): Promise => { const { data } = await client.get("/warehouse-inventory", { @@ -59,4 +75,9 @@ export const warehouseService = { const { data } = await client.get(`/warehouse-inventory/booking-schedule/${bookingId}`); return data?.data ?? data ?? { schedule: null, wagon: null }; }, + + bookingHandovers: async (bookingId: string): Promise => { + const { data } = await client.get(`/warehouse-inventory/bookings/${bookingId}/handovers`); + return data?.data ?? data ?? []; + }, }; diff --git a/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts new file mode 100644 index 000000000..7a0fef1cc --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts @@ -0,0 +1,108 @@ +import * as XLSX from 'xlsx'; + +export function generateTruckAssignmentTemplate(filename = 'truck-assignments.xlsx'): void { + const data = [ + { + 'Truck Plate Number': '3-12345/67890', + 'Driver Name': 'John Doe', + 'Truck Type': 'Flatbed', + 'Container 1': 'MAEU1234567', + 'Container 2': 'HLXU7654321', + }, + { + 'Truck Plate Number': '3-98765/43210', + 'Driver Name': 'Jane Smith', + 'Truck Type': 'Flatbed', + 'Container 1': 'COSCO1111111', + 'Container 2': '', + }, + ]; + + const instructions = [ + ['TRUCK ASSIGNMENT BULK UPLOAD - INSTRUCTIONS'], + [], + ['Column', 'Required', 'Notes'], + ['Truck Plate Number', 'Yes', 'Format: 3-XXXXX/XXXXX (Ethiopian plate format)'], + ['Driver Name', 'Yes', 'Full name of truck driver'], + ['Truck Type', 'Yes', 'e.g., Flatbed, Lowbed, Tanker, Trailer, etc.'], + ['Container 1', 'Yes*', '*Required for EXPORT. Leave empty for IMPORT bulk cargo.'], + ['Container 2', 'No', 'Optional. ISO format: e.g., MAEU1234567. Max 2 containers per truck.'], + [], + ['CONTAINER RULES'], + ['- A 40ft container fills one truck (max 1 per truck)'], + ['- Two 20ft containers fit on one truck (max 2 per truck)'], + ['- No size mixing on same truck'], + ['- Containers must be from the booking'], + [], + ['Example Data Below →'], + ]; + + const wb = XLSX.utils.book_new(); + + // Instructions sheet + const wsInstructions = XLSX.utils.aoa_to_sheet(instructions); + wsInstructions['!cols'] = [{ wch: 30 }, { wch: 12 }, { wch: 50 }]; + XLSX.utils.book_append_sheet(wb, wsInstructions, 'Instructions'); + + // Data template sheet + const wsData = XLSX.utils.json_to_sheet(data, { + header: ['Truck Plate Number', 'Driver Name', 'Truck Type', 'Container 1', 'Container 2'], + }); + wsData['!cols'] = [{ wch: 20 }, { wch: 20 }, { wch: 15 }, { wch: 18 }, { wch: 18 }]; + XLSX.utils.book_append_sheet(wb, wsData, 'Trucks'); + + XLSX.writeFile(wb, filename); +} + +export function parseTruckAssignmentFile( + file: File, +): Promise< + Array<{ + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers?: string[]; + }> +> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result as ArrayBuffer; + const wb = XLSX.read(data, { type: 'array' }); + const wsData = wb.Sheets['Trucks'] || Object.values(wb.Sheets)[0]; + + if (!wsData) { + reject(new Error('No data sheet found in Excel file')); + return; + } + + const jsonData = XLSX.utils.sheet_to_json(wsData) as Array>; + + const trucks = jsonData.map((row) => { + const containers = [ + row['Container 1'], + row['Container 2'], + ] + .filter((c) => c && c.trim()) + .map((c) => c.trim().toUpperCase()); + + return { + truckPlateNumber: row['Truck Plate Number']?.trim() || '', + driverName: row['Driver Name']?.trim() || '', + truckType: row['Truck Type']?.trim() || '', + containerNumbers: containers.length > 0 ? containers : undefined, + }; + }); + + resolve(trucks); + } catch (error) { + reject(error); + } + }; + + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsArrayBuffer(file); + }); +} diff --git a/apps/edr-passenger-api/.env.test.example b/apps/edr-passenger-api/.env.test.example new file mode 100644 index 000000000..b937d7061 --- /dev/null +++ b/apps/edr-passenger-api/.env.test.example @@ -0,0 +1,57 @@ +# E2E harness env — points at the hermetic test Postgres (e2e/docker-compose.yml, port 5544). +# Loaded by test/setup/load-env.ts before the Nest AppModule boots. NEVER points at a real DB. +NODE_ENV=test +PORT=4099 + +# Prisma — passenger schema in the test edr_database +DATABASE_URL=postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger + +# TypeORM / IAM — shared iam schema, same test DB +DATABASE_HOST=localhost +DATABASE_PORT=5544 +DATABASE_NAME=edr_database +DATABASE_USER=edr +DATABASE_PASSWORD=edr_secret +DATABASE_SCHEMA=iam + +# Brokers / external systems OFF for a hermetic boot +RABBITMQ_ENABLED=false +RABBITMQ_URL=amqp://localhost:5672 +EMAIL_QUEUE=email_queue +SMS_QUEUE=sms_queue +PAYMENT_RABBITMQ_URL=amqp://edr:edr_secret@localhost:5672/payment +PAYMENT_EVENTS_PREFETCH=10 +IAM_ENABLED=false +FAYDA_ENABLED=false + +# MinIO — client is constructed at boot but never contacted in tests +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=edr-test + +CORS_ORIGINS=http://localhost:5174,http://localhost:5184 +FE_BASE_URL=http://localhost:5184 +INVITATION_EXPIRY_DATE=30 + +# JWT / IAM token contract — fixed test secrets (min 32 chars). Let tests mint IAM tokens. +JWT_SECRET=test-jwt-secret-000000000000000000000000 +JWT_EXPIRES_IN=7d +JWT_ACCESS_TOKEN_SECRET=test-access-secret-0000000000000000000000 +JWT_ACCESS_TOKEN_EXPIRES=1h +JWT_REFRESH_TOKEN_SECRET=test-refresh-secret-000000000000000000000 +JWT_REFRESH_TOKEN_EXPIRES=7d + +DEFAULT_LOCALE=en +SUPPORTED_LOCALES=en,am,fr,om +SESSION_INACTIVITY_MINUTES=30 + +# Payment providers — WALLET is fully internal; others unused in the API-level suite +PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI + +# Staff/org seeding off — the harness builds its own deterministic fixtures +SEED_EDR_PASSENGER_ORG=false +SEED_PASSENGER_STAFF=false +DEFAULT_PASSWORD=Test@1234 diff --git a/apps/edr-passenger-api/.gitignore b/apps/edr-passenger-api/.gitignore new file mode 100644 index 000000000..d25344817 --- /dev/null +++ b/apps/edr-passenger-api/.gitignore @@ -0,0 +1,6 @@ + +# E2E HTML report output +e2e-report/ + +# Track the E2E env TEMPLATE (real .env.test stays ignored) +!.env.test.example diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index a9ee85156..c4587e98a 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -10,6 +10,11 @@ "lint": "eslint src", "test": "jest", "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", + "test:e2e:db:up": "docker compose -f ../../e2e/docker-compose.yml up -d", + "test:e2e:db:down": "docker compose -f ../../e2e/docker-compose.yml down", + "test:e2e:prepare": "bash ../../e2e/prepare.sh", "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", @@ -78,6 +83,7 @@ "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.0", "jest": "^29.7.0", + "jest-html-reporters": "^3.1.7", "prisma": "^6.19.3", "supertest": "^7.0.0", "ts-jest": "^29.1.1", diff --git a/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql new file mode 100644 index 000000000..59ae79426 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql @@ -0,0 +1,9 @@ +-- Adds RouteStop.travelMinutesToStop: admin-configured travel time (minutes) from the +-- previous stop, used to compute each stop's estimated arrival time (replacing/augmenting +-- distance-proportional interpolation). Nullable — falls back to distance interpolation +-- when unset. +-- Uses IF NOT EXISTS following the pattern established in +-- 20260719000002_repair_route_checkin_minutes, after this same table had two migrations +-- checked in as empty "applied directly" placeholders that never reached the deployed DB. + +ALTER TABLE passenger."RouteStop" ADD COLUMN IF NOT EXISTS "travelMinutesToStop" INTEGER; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index db46df025..de086e465 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1078,6 +1078,7 @@ model RouteStop { sequence Int distanceKm Float? checkinMinutesBefore Int? + travelMinutesToStop Int? plannedArrivalTime DateTime? plannedDepartureTime DateTime? createdAt DateTime @default(now()) diff --git a/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts new file mode 100644 index 000000000..36c0f872c --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts @@ -0,0 +1,43 @@ +/** + * Resolves the booking/check-in cutoff for one boarding stop. + * + * Priority for checkinMinutes: RouteStop.checkinMinutesBefore → Route.checkinMinutesBefore → 30. + * + * Anchor (segmentTime): plannedDepartureAt ?? plannedArrivalAt ?? schedule.departureAt. + * - For the origin stop: plannedDepartureAt = schedule.departureAt (no arrival). + * - For intermediate stops: plannedDepartureAt = plannedArrivalAt + dwell (checkinMinutesBefore). + * cutoffAt = departureAt − checkinMinutesBefore = arrivalAt, so booking closes the + * moment the train reaches the stop — independent of how long ago it left the origin. + * + * Single source of truth — SeatsService.holdSeats and SearchService.buildScheduleResult both + * apply it; GuestBookingService.createGuestBooking also applies it per boarding stop. + */ +export interface CheckinCutoff { + /** The stop's planned departure time (or arrival / schedule departure as fallback). */ + segmentTime: Date; + /** Minutes before segmentTime that booking/holding closes. */ + checkinMinutes: number; + /** The moment booking/holding closes for this stop. */ + cutoffAt: Date; +} + +export function resolveCheckinCutoff( + schedule: { + departureAt: Date; + route?: { + checkinMinutesBefore?: number | null; + stops?: Array<{ stationId: string; checkinMinutesBefore: number | null }>; + } | null; + }, + stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined, + stationId: string | null | undefined, +): CheckinCutoff { + const segmentTime = stopTime?.plannedDepartureAt ?? stopTime?.plannedArrivalAt ?? schedule.departureAt; + const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined; + const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; + return { + segmentTime, + checkinMinutes, + cutoffAt: new Date(segmentTime.getTime() - checkinMinutes * 60_000), + }; +} diff --git a/apps/edr-passenger-api/src/common/utils/schedule-times.utils.ts b/apps/edr-passenger-api/src/common/utils/schedule-times.utils.ts new file mode 100644 index 000000000..59d5f1d7e --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/schedule-times.utils.ts @@ -0,0 +1,76 @@ +import { Logger } from '@nestjs/common'; + +const logger = new Logger('ScheduleTimesUtils'); + +export type StopForTiming = { + sequence: number; + distanceKm: number | null; + travelMinutesToStop: number | null; + checkinMinutesBefore: number | null; +}; + +export type PlannedStopTime = { + sequence: number; + plannedArrivalAt: string | undefined; + plannedDepartureAt: string | undefined; +}; + +/** + * Computes each stop's planned arrival/departure time by walking the route in sequence order. + * + * Model per intermediate stop: + * arrival = departureCursor + travelMinutesToStop (falls back to distance interpolation) + * departure = arrival + checkinMinutesBefore (dwell time; 0 if null) + * next-stop travel starts from this departure, not from arrival. + * + * This means booking for stop B closes at B.departureAt − checkinMinutesBefore = B.arrivalAt, + * i.e. the train must not yet have arrived at the stop for a booking to succeed. + * + * The last stop is always locked to arr so schedule.arrivalAt stays authoritative. + */ +export function computePlannedStopTimes( + route: { id: string; stops: StopForTiming[] }, + dep: Date, + arr: Date, +): PlannedStopTime[] { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + // cursor tracks the DEPARTURE time from the most-recently processed stop. + let departureCursor = dep; + + return route.stops.map((stop, index) => { + if (index === 0) { + // Origin: train starts here, no arrival. + departureCursor = dep; + return { sequence: stop.sequence, plannedArrivalAt: undefined, plannedDepartureAt: dep.toISOString() }; + } + + if (index === route.stops.length - 1) { + // Final destination: arrival is authoritative; no departure. + return { sequence: stop.sequence, plannedArrivalAt: arr.toISOString(), plannedDepartureAt: undefined }; + } + + // Intermediate stop: compute arrival from the previous stop's departure. + let arrivalAt: Date; + if (stop.travelMinutesToStop != null) { + arrivalAt = new Date(departureCursor.getTime() + stop.travelMinutesToStop * 60_000); + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + arrivalAt = new Date(dep.getTime() + totalDuration * progress); + logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`); + } + + // Dwell at this stop = checkinMinutesBefore (the boarding window). + const dwell = stop.checkinMinutesBefore ?? 0; + const departureAt = new Date(arrivalAt.getTime() + dwell * 60_000); + departureCursor = departureAt; + + return { + sequence: stop.sequence, + plannedArrivalAt: arrivalAt.toISOString(), + plannedDepartureAt: departureAt.toISOString(), + }; + }); +} diff --git a/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts b/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts new file mode 100644 index 000000000..2fcc6581b --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts @@ -0,0 +1,37 @@ +/** + * Resolves a booking's actual boarding/alighting station AND time for one leg from + * originStationId/destinationStationId (set when the booking covers only part of a + * longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D), via + * the schedule's stopTimes — falling back to the schedule's own full-route + * station/time when there's no segment override (older records, or a booking that + * covers the whole run). + * + * Single source of truth for this resolution — station-only lookups used to be + * duplicated ad hoc across bookings/tickets/notifications while the departureAt/ + * arrivalAt kept being read straight off the schedule (the train's full-route span), + * which showed the wrong boarding/alighting time for any stop-based booking. + */ +export interface ResolvedSegment { + origin: any; + destination: any; + departureAt: any; + arrivalAt: any; +} + +export function resolveBookingSegment( + schedule: any, + originStationId: string | null | undefined, + destinationStationId: string | null | undefined, +): ResolvedSegment { + const stopTimes: any[] = schedule?.stopTimes ?? []; + const findStop = (stationId: string | null | undefined) => + stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined; + const originStop = findStop(originStationId); + const destStop = findStop(destinationStationId); + return { + origin: originStop?.station ?? schedule?.originStation ?? null, + destination: destStop?.station ?? schedule?.destinationStation ?? null, + departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null, + arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null, + }; +} 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 2c4d2cee4..98a54528b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -14,6 +14,7 @@ import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; +import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -140,7 +141,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -148,31 +149,34 @@ export class BookingsService { }), this.prisma.booking.count({ where }), ]); - + return { - items: items.map(booking => ({ - id: booking.id, - bookingRef: booking.bookingRef, - status: booking.status, - totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), - currency: booking.displayCurrency, - displayCurrency: booking.displayCurrency, - displayTotalMinor: booking.displayTotalMinor, - adultCount: booking.adultCount, - childCount: booking.childCount, - bookingType: booking.bookingType, - returnLegStatus: (booking as any).returnLegStatus ?? null, - createdAt: booking.createdAt, - schedule: { - train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, - }, - paymentIntent: booking.paymentIntent, - seatCount: booking.seats.length, - })), + items: items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + currency: booking.displayCurrency, + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + adultCount: booking.adultCount, + childCount: booking.childCount, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, + createdAt: booking.createdAt, + schedule: { + train: booking.schedule.train, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }), meta: { page, pageSize, @@ -270,7 +274,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } }, seats: { select: { id: true } }, priceTier: { select: { priceMinor: true } }, @@ -294,7 +298,9 @@ export class BookingsService { this.prisma.packageBooking.count({ where: pkgWhere }), ]); - const mappedBookings = items.map(booking => ({ + const mappedBookings = items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, @@ -309,14 +315,15 @@ export class BookingsService { createdAt: booking.createdAt, schedule: { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, }, payment: booking.paymentIntent ?? undefined, seatCount: booking.seats.length, - })); + }; + }); const mappedPkg = pkgItems.map((b: any) => ({ id: b.id, @@ -397,7 +404,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -405,9 +412,11 @@ export class BookingsService { }), this.prisma.booking.count({ where }), ]); - + return { - items: items.map(booking => ({ + items: items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, @@ -422,14 +431,15 @@ export class BookingsService { createdAt: booking.createdAt, schedule: { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, }, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, - })), + }; + }), meta: { page, pageSize, @@ -532,7 +542,7 @@ export class BookingsService { orderBy: { createdAt: 'desc' }, include: { passenger: { select: { id: true, iamUserId: true } }, - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -554,9 +564,10 @@ export class BookingsService { const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + const segment = booking.schedule ? resolveBookingSegment(booking.schedule, booking.originStationId, booking.destinationStationId) : null; return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, @@ -567,11 +578,11 @@ export class BookingsService { passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], passengers: uniquePassengers, - schedule: booking.schedule ? { + schedule: segment ? { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, } : null, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, @@ -715,16 +726,15 @@ export class BookingsService { verifaydaVerified: s.verifaydaVerified, seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null, })), - schedule: { - train: booking.schedule.train, - originStation: (booking as any).originStationId - ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation) - : booking.schedule.originStation, - destinationStation: (booking as any).destinationStationId - ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation) - : booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - }, + schedule: (() => { + const segment = resolveBookingSegment(booking.schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { + train: booking.schedule.train, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + }; + })(), paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, }; @@ -865,6 +875,9 @@ export class BookingsService { const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null); let resolvedTotalMinor: number; let displayTotalMinor: number; + // True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor), + // which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13). + let usedClientSubtotal = false; if (allFaresProvided && !dto.packageId) { // Server has every passenger's berth fare — sum is the authoritative display total. @@ -872,6 +885,7 @@ export class BookingsService { resolvedTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB) : displayTotalMinor; + usedClientSubtotal = true; if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) { this.logger.warn(`createOneWayBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`); } @@ -893,13 +907,35 @@ export class BookingsService { resolvedTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB) : dto.reviewedTotalMinor; + usedClientSubtotal = true; } else { resolvedTotalMinor = fareCalculation.totalMinor; displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency) : resolvedTotalMinor; } - this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`); + + // H-13 fix: the portal sums UNDISCOUNTED per-passenger fares into the total it sends, silently + // dropping the promo the fare engine recognized (the discount lives only in the fare-breakdown). + // When the total came from that client subtotal, apply the authoritative promo discount so the + // customer is charged the discounted price. No-op when no promo applies (discountMinor === 0). + // The fallback branch above already books fareCalculation.totalMinor (discount included), so it is + // excluded via usedClientSubtotal to avoid double-subtracting. + if (usedClientSubtotal && fareCalculation.discountMinor > 0) { + resolvedTotalMinor = Math.max(0, resolvedTotalMinor - fareCalculation.discountMinor); + const discountDisplayMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(fareCalculation.discountMinor, Currency.ETB, displayCurrency) + : fareCalculation.discountMinor; + displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor); + } + this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} discountMinor=${fareCalculation.discountMinor} fareEngine=${fareCalculation.totalMinor})`); + + // C-1 guard: never charge less than the server-recomputed authoritative fare. resolvedTotalMinor + // is the ETB charge basis; fareCalculation.totalMinor is the authoritative ETB fare (already net + // of promo/loyalty/free-child). A client that forges seatFareMinor / reviewedTotalMinor below it + // is rejected. Floor (not equality) so legitimate berth surcharges — which only raise the total — + // still pass; the tolerance absorbs FX-conversion rounding. + this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, fareCalculation.totalMinor, 'createOneWayBooking'); const booking = await this.prisma.booking.create({ data: { @@ -911,7 +947,9 @@ export class BookingsService { status: 'PENDING_PAYMENT', bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor, - currency: displayCurrency, + // Charge basis is ETB (resolvedTotalMinor). The passenger's currency and amount live in + // displayCurrency/displayTotalMinor — keep currency coherent with totalMinor's units. + currency: Currency.ETB, adultCount, childCount, displayCurrency, @@ -1030,6 +1068,9 @@ export class BookingsService { loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); } + // C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches + // below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor. + const authoritativeTotalMinor = totalMinor; const taxesMinor = 0; const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality); @@ -1095,6 +1136,9 @@ export class BookingsService { : dto.reviewedTotalMinor; } + // 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({ data: { bookingRef: generateRef(), @@ -1105,7 +1149,7 @@ export class BookingsService { status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP', totalMinor, - currency: displayCurrency, + currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor adultCount, childCount, displayCurrency, @@ -1298,7 +1342,7 @@ export class BookingsService { status: 'PENDING_PAYMENT', bookingType: 'TRANSIT', totalMinor, - currency: displayCurrency, + currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor adultCount, childCount, displayCurrency, @@ -1508,7 +1552,7 @@ export class BookingsService { destinationStationId: dto.leg2DestinationStationId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP_TRANSIT', - totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor, + totalMinor, currency: Currency.ETB, adultCount, childCount, displayCurrency, displayTotalMinor, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor // Outbound transit leg-2 leg2ScheduleId: dto.leg2ScheduleId, leg2OriginStationId: dto.transitStationId, @@ -1677,6 +1721,21 @@ export class BookingsService { }; } + /** + * C-1 protection: reject a booking whose ETB charge basis is below the server-recomputed + * authoritative fare. A floor (not equality) so legitimate berth surcharges — which only raise + * the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged seatFareMinor / + * reviewedTotalMinor that lowers the charge (e.g. to 1 or 0) is refused with a 400 and nothing is + * persisted. + */ + private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void { + const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01)); + if (resolvedTotalMinor < authoritativeMinor - tolerance) { + this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`); + throw new BadRequestException('Booking total does not match the authoritative fare'); + } + } + private async calculateFare( scheduleId: string, seatClassId: string, @@ -1801,35 +1860,6 @@ export class BookingsService { ); } - // Resolves the passenger's actual boarding/alighting stations AND times for one leg - // from originStationId/destinationStationId (set when the booking covers only part of - // a longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via - // the schedule's stopTimes, falling back to the schedule's own full-route endpoints/ - // times when there's no segment override (older records, or a booking that covers the - // whole run). Station resolution mirrors notifications.service.ts's - // resolveSegmentStations (already applied to SMS/email); the departureAt/arrivalAt - // resolution mirrors search.service.ts's leg construction (originStop.plannedDepartureAt - // / destStop.plannedArrivalAt) — this brings the booking API (voucher, detail page, - // confirmation) to the same behavior search results already have, instead of always - // showing the train's full-route span. - private resolveSegmentStations( - schedule: any, - originStationId: string | null | undefined, - destinationStationId: string | null | undefined, - ): { origin: any; destination: any; departureAt: any; arrivalAt: any } { - const stopTimes: any[] = schedule?.stopTimes ?? []; - const findStop = (stationId: string | null | undefined) => - stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined; - const originStop = findStop(originStationId); - const destStop = findStop(destinationStationId); - return { - origin: originStop?.station ?? schedule?.originStation ?? null, - destination: destStop?.station ?? schedule?.destinationStation ?? null, - departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null, - arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null, - }; - } - async getByRef(bookingRefOrId: string) { const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId); const booking = await this.prisma.booking.findUnique({ @@ -1916,30 +1946,35 @@ export class BookingsService { ) { try { await this.ticketsService.generate(booking.id); - // Re-fetch to include the newly created tickets - const refreshed = await this.prisma.booking.findUnique({ - where: { id: booking.id }, - include: { - schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, - returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, - seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, - paymentIntent: true, tickets: true, - priceTier: { select: { priceMinor: true } }, - }, - }); - if (refreshed) Object.assign(booking, refreshed); } catch (err) { - this.logger.warn(`getByRef: auto-generate tickets failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`); + this.logger.warn(`getByRef: generate failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}. Trying smart assign.`); + try { + await this.ticketsService.smartAssignAndGenerate(booking.id); + } catch (retryErr) { + this.logger.error(`getByRef: smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`); + } } + // Re-fetch to include any newly created tickets + const refreshed = await this.prisma.booking.findUnique({ + where: { id: booking.id }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, + returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, + paymentIntent: true, tickets: true, + priceTier: { select: { priceMinor: true } }, + }, + }); + if (refreshed) Object.assign(booking, refreshed); } - const outboundSegment = this.resolveSegmentStations( + const outboundSegment = resolveBookingSegment( (booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId, ); const returnSegment = (booking as any).returnSchedule - ? this.resolveSegmentStations( + ? resolveBookingSegment( (booking as any).returnSchedule, (booking as any).returnOriginStationId, (booking as any).returnDestinationStationId, 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 af5321e2d..3e2a225e6 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 @@ -1,4 +1,4 @@ -import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { VerifaydaService } from '../verifayda/verifayda.service'; @@ -8,9 +8,22 @@ import { FareEngineService } from '../fare-engine/fare-engine.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils'; -/** Booking cutoff: reject new bookings within this many ms of departure. */ -const BOOKING_CUTOFF_MS = 30 * 60 * 1000; +/** + * Throws if the given boarding stop's own configurable check-in cutoff (route/stop + * checkinMinutesBefore, same mechanism the seat hold and search results already enforce) has + * passed. Must be checked against the actual boarding stop, not the schedule's origin — a + * downstream stop's cutoff is independent of how long ago the train left its origin. + */ +function assertWithinCheckinCutoff(schedule: any, stopTime: any, stationId: string | null | undefined): void { + const { cutoffAt, checkinMinutes } = resolveCheckinCutoff(schedule, stopTime, stationId); + if (Date.now() >= cutoffAt.getTime()) { + throw new BadRequestException( + `Bookings are not accepted within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`, + ); + } +} function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; @@ -42,6 +55,8 @@ function calculateAge(dateOfBirth: Date): number { @Injectable() export class GuestBookingService { + private readonly logger = new Logger(GuestBookingService.name); + constructor( private prisma: PrismaService, private seatsService: SeatsService, @@ -52,6 +67,21 @@ export class GuestBookingService { private eventEmitter: EventEmitter2, ) { } + /** + * C-1 protection (guest path): reject a booking whose ETB charge basis is below the + * server-recomputed authoritative fare. A floor (not equality) so legitimate berth surcharges — + * which only raise the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged + * seatFareMinor / reviewedTotalMinor that lowers the charge (e.g. to 0) is refused with a 400 and + * nothing is persisted. + */ + private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void { + const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01)); + if (resolvedTotalMinor < authoritativeMinor - tolerance) { + this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`); + throw new BadRequestException('Booking total does not match the authoritative fare'); + } + } + async createGuestBooking(dto: CreateGuestBookingDto, req?: any) { // Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline. // The portal calls /passengers/save-details before booking but doesn't re-send contact @@ -90,20 +120,22 @@ export class GuestBookingService { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + route: { include: { stops: true } }, }, }); if (!schedule) throw new NotFoundException('Schedule not found'); - if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId) ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId) ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); + // Cut off relative to the passenger's actual boarding stop, using the same + // configurable per-stop/route checkinMinutesBefore that already gated the seat hold + // and the search result — not a separate, hardcoded 30 minutes off the train's origin. + assertWithinCheckinCutoff(schedule, originStop, dto.originStationId); + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; @@ -250,6 +282,9 @@ export class GuestBookingService { ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB) : displayTotalMinor; + // C-1 guard: never charge less than the server-recomputed authoritative ETB fare (net of promo). + this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, Math.max(0, totalBaseFareMinor - discountMinor), 'createGuestBooking'); + // Resolve or create the guest Passenger record const firstPassenger = passengersData[0]; const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req); @@ -284,7 +319,9 @@ export class GuestBookingService { destinationStationId: dto.destinationStationId, status: 'PENDING_PAYMENT', totalMinor: resolvedTotalMinor, - currency: displayCurrency, + // Charge basis is ETB (resolvedTotalMinor). The passenger's currency and amount live in + // displayCurrency/displayTotalMinor — keep currency coherent with totalMinor's units. + currency: Currency.ETB, adultCount, childCount, displayCurrency, @@ -369,7 +406,7 @@ export class GuestBookingService { const [outboundSchedule, returnSchedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } }, }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, @@ -379,10 +416,6 @@ export class GuestBookingService { if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found'); if (!returnSchedule) throw new NotFoundException('Return schedule not found'); - if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const synth = (sched: any, stationId: string, seq: number) => { const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation; return { stationId, sequence: seq, station }; @@ -396,6 +429,10 @@ export class GuestBookingService { if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule'); if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(outboundSchedule, outboundOriginStop, dto.originStationId); + const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`; const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`; const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`; @@ -485,6 +522,9 @@ export class GuestBookingService { const taxesMinor = 0; let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor); + // C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches + // below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor. + const authoritativeTotalMinor = totalMinor; const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = displayCurrency !== Currency.ETB @@ -540,6 +580,9 @@ export class GuestBookingService { : displayTotalMinor; } + // C-1 guard: never charge less than the server-recomputed authoritative round-trip fare. + this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createGuestRoundTripBooking'); + // Create or resolve guest passenger (same as one-way) const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); @@ -557,7 +600,7 @@ export class GuestBookingService { status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP', totalMinor, - currency: displayCurrency, + currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor adultCount, childCount, displayCurrency, @@ -662,7 +705,7 @@ export class GuestBookingService { const [leg1Schedule, leg2Schedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } }, }), this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, @@ -672,10 +715,6 @@ export class GuestBookingService { if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found'); if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found'); - if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -683,6 +722,10 @@ export class GuestBookingService { 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 not found on leg-2 schedule'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(leg1Schedule, leg1OriginStop, dto.originStationId); + // Process passengers (verify identity once) const passengersData: any[] = []; let adultCount = 0, childCount = 0; @@ -761,7 +804,7 @@ export class GuestBookingService { status: 'PENDING_PAYMENT', bookingType: 'TRANSIT', totalMinor, - currency: displayCurrency, + currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor adultCount, childCount, displayCurrency, @@ -866,7 +909,7 @@ export class GuestBookingService { if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired'); 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' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), @@ -876,10 +919,6 @@ export class GuestBookingService { if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); - if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -893,6 +932,10 @@ export class GuestBookingService { if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found'); if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(obL1Sched, obL1Origin, dto.originStationId); + // Process passengers (verify once) const passengersData: any[] = []; let adultCount = 0, childCount = 0; @@ -977,7 +1020,7 @@ export class GuestBookingService { destinationStationId: dto.returnLeg2DestinationStationId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP_TRANSIT', - totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor, + totalMinor, currency: Currency.ETB, adultCount, childCount, displayCurrency, displayTotalMinor, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor leg2ScheduleId: dto.leg2ScheduleId, leg2OriginStationId: dto.transitStationId, leg2DestinationStationId: dto.leg2DestinationStationId, diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts index 95ecc2cd7..ae4ca7503 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts @@ -140,10 +140,14 @@ export class CurrencyService { }); if (!exchangeRate) { - this.logger.warn( - `No exchange rate found for ${fromCurrency} to ${toCurrency}, using 1.0`, + // H-2: fail closed. Never price at parity (1.0) when a required rate is absent — a silent 1.0 + // substitution underprices international fares ~100×. Reject the quote/booking instead. + this.logger.error( + `No exchange rate configured for ${fromCurrency}->${toCurrency}; refusing to price at parity`, + ); + throw new BadRequestException( + `No exchange rate configured for ${fromCurrency}->${toCurrency}`, ); - return 1.0; } const ageMs = Date.now() - exchangeRate.effectiveDate.getTime(); diff --git a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts index 5a33dcc31..ee096dbc4 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts @@ -23,6 +23,8 @@ export class CurrencyController { } @Put() + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Upsert an exchange rate for today' }) @ApiResponse({ status: 200, description: 'Rate created or updated for today\'s effective date' }) upsert(@Body() dto: UpsertExchangeRateDto) { @@ -30,6 +32,8 @@ export class CurrencyController { } @Patch(':id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update an exchange rate by ID' }) @ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' }) @ApiResponse({ status: 200, description: 'Rate updated' }) diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index fd3d75401..69b0266ed 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -23,8 +23,11 @@ export class FareEngineService { if (!originStop) throw new BadRequestException('Origin station not found on this route'); if (!destStop) throw new BadRequestException('Destination station not found on this route'); - if (originStop.sequence >= destStop.sequence) - throw new BadRequestException('Origin must come before destination in the route sequence'); + // Origin and destination must be distinct stops, but EITHER direction is valid: a round-trip + // return leg traverses the same route high→low (e.g. C→A), so we price the segment by its + // absolute distance rather than rejecting the reverse order. + if (originStop.sequence === destStop.sequence) + throw new BadRequestException('Origin and destination must be different stops on this route'); const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }); if (!seatClass) throw new NotFoundException('Seat class not found'); @@ -43,8 +46,8 @@ export class FareEngineService { }, }) ?? seatClass; - const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!; - if (totalDistanceKm < 0 || isNaN(totalDistanceKm)) + const totalDistanceKm = Math.abs(destStop.distanceKm! - originStop.distanceKm!); + if (totalDistanceKm <= 0 || isNaN(totalDistanceKm)) throw new BadRequestException('Invalid distance calculation - check route stop distances'); const now = new Date(); diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index a1678d131..12f549e9f 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -7,6 +7,7 @@ import { PushAdapter, NotificationChannel } from './notification.adapters'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto'; +import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; @@ -353,27 +354,6 @@ export class NotificationsService { } } - /** - * Resolves the user's actual boarding/alighting stations from the booking's originStationId / - * destinationStationId via stopTimes, falling back to the schedule's full-route endpoints when - * the booking has no segment override (e.g. older records or packages). - */ - private resolveSegmentStations(booking: any): { originStation: any; destinationStation: any } { - const s = booking?.schedule ?? {}; - const stopTimes: any[] = s.stopTimes ?? []; - const findStation = (stationId: string | null | undefined, fallback: any) => { - if (stationId && stopTimes.length > 0) { - const stop = stopTimes.find((st: any) => st.stationId === stationId); - if (stop?.station) return stop.station; - } - return fallback ?? null; - }; - return { - originStation: findStation(booking?.originStationId, s.originStation), - destinationStation: findStation(booking?.destinationStationId, s.destinationStation), - }; - } - /** * Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a * pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get @@ -400,17 +380,17 @@ export class NotificationsService { // Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat. const passengerName = seats[0]?.passengerName ?? 'Passenger'; const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`; - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); return { passengerName, bookingRef: ref, - origin: originSt?.name ?? '', - destination: destSt?.name ?? '', + origin: segment.origin?.name ?? '', + destination: segment.destination?.name ?? '', trainSeatLines, - travelDate: fmtDate(s.departureAt), - departureTime: fmtTime(s.departureAt), - arrivalTime: fmtTime(s.arrivalAt), + travelDate: fmtDate(segment.departureAt), + departureTime: fmtTime(segment.departureAt), + arrivalTime: fmtTime(segment.arrivalAt), payLink, }; } @@ -509,12 +489,12 @@ export class NotificationsService { private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string { const s = booking.schedule ?? {}; - const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD'; + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); + const dep = segment.departureAt ? new Date(segment.departureAt).toLocaleString('en-GB') : 'TBD'; const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', '); - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); return [ `Booking ${booking.bookingRef} confirmed.`, - `${originSt?.name ?? ''} -> ${destSt?.name ?? ''}`, + `${segment.origin?.name ?? ''} -> ${segment.destination?.name ?? ''}`, `Train: ${s.train?.name ?? s.train?.number ?? ''}`, `Departs: ${dep}`, passengers ? `Passengers: ${passengers}` : '', @@ -527,7 +507,9 @@ export class NotificationsService { const s = booking.schedule ?? {}; const fmt = (d: any) => d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); + const originSt = segment.origin; + const destSt = segment.destination; const seatRows = (booking.seats ?? []) .map((bs: any) => { const coach = bs.seat?.coach?.number ?? '-'; @@ -568,11 +550,11 @@ export class NotificationsService { Departs - ${fmt(s.departureAt)} + ${fmt(segment.departureAt)} Arrives - ${fmt(s.arrivalAt)} + ${fmt(segment.arrivalAt)} @@ -636,12 +618,12 @@ export class NotificationsService { const fmt = (d: any) => d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : ''; - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); - const origin = originSt?.name ?? ''; - const dest = destSt?.name ?? ''; + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); + const origin = segment.origin?.name ?? ''; + const dest = segment.destination?.name ?? ''; const train = s.train?.name ?? s.train?.number ?? ''; - const dep = fmt(s.departureAt); - const arr = fmt(s.arrivalAt); + const dep = fmt(segment.departureAt); + const arr = fmt(segment.arrivalAt); const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({ name: bs.passengerName ?? '', diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts index 84ca92c02..1ca92e304 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -1,6 +1,7 @@ import { BadGatewayException, BadRequestException, + ConflictException, Injectable, Logger, } from "@nestjs/common"; @@ -154,11 +155,16 @@ export class PaymentClientService { } catch (err) { if (err instanceof AxiosError && err.response) { // 4xx/5xx from the payment service: propagate 404 to callers that handle it; - // everything else is a gateway-level failure from the client's perspective. + // 409 = a legitimate conflict (e.g. another provider's payment is already in + // flight for this booking) — surface its message as-is rather than masking it as + // a gateway failure; everything else is a genuine gateway-level failure. if (err.response.status === 404) throw err; const detail = (err.response.data as { message?: string | string[] })?.message ?? err.message; + if (err.response.status === 409) { + throw new ConflictException(detail); + } this.logger.error( `payment service ${method} ${path} → ${err.response.status}: ${detail}`, ); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index b3befbaea..82db6eec3 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -598,6 +598,17 @@ export class PaymentsService { where: { bookingId }, }); + + if (local?.status === PaymentIntentStatus.SUCCEEDED) { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + select: { status: true }, + }); + if (booking?.status === "CONFIRMED") { + return this.formatIntentStatus(local); + } + } + // WALLET payments never leave this app — no remote intent exists for them. if (local?.method === PaymentMethodType.WALLET) { return this.formatIntentStatus(local); @@ -1006,6 +1017,19 @@ export class PaymentsService { return { processed: false, reason: "booking-not-found" }; } + // C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled + // amount against the booking's display-currency total (the amount the customer agreed to pay); + // a short payment must NOT confirm the booking. Amount-only — the display↔charge currency + // divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding. + const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor; + const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01)); + if (event.amountMinor < expectedMinor - shortPayTolerance) { + this.logger.error( + `mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`, + ); + return { processed: false, reason: "amount-mismatch" }; + } + // Local intent row is a projection during the strangler migration: reuse it when the // legacy initiate path created one, otherwise materialize it from the event. let intent = await this.prisma.paymentIntent.findUnique({ diff --git a/apps/edr-passenger-api/src/modules/promos/promos.dto.ts b/apps/edr-passenger-api/src/modules/promos/promos.dto.ts index 759fe371e..2d0ad7a08 100644 --- a/apps/edr-passenger-api/src/modules/promos/promos.dto.ts +++ b/apps/edr-passenger-api/src/modules/promos/promos.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator'; +import { IsString, IsOptional, IsInt, IsBoolean, Min, Max } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreatePromotionDto { @@ -15,14 +15,17 @@ export class CreatePromotionDto { @IsString() subtitle?: string; - @ApiPropertyOptional({ example: 15 }) + @ApiPropertyOptional({ example: 15, description: 'Percentage discount, bounded 0..100' }) @IsOptional() @IsInt() + @Min(0) + @Max(100) percentOff?: number; @ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() + @Min(0) amountOffMinor?: number; @ApiProperty({ example: '2026-12-31T23:59:59Z' }) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 2d41c6bda..1aef00185 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -599,32 +599,31 @@ export class ReportsService { sortBy?: string; search?: string; }) { - // Load exchange rates once — we need DJF→ETB (and any other non-ETB currencies). - // Keep only the most-recent rate per pair (rates are ordered desc by effectiveDate). + // Load all exchange rates once — we need conversions in both directions. const rateRows = await this.prisma.currencyExchangeRate.findMany({ - where: { toCurrency: 'ETB' as any }, orderBy: { effectiveDate: 'desc' }, }); - const rateToEtb = new Map(); + // Most-recent rate for each fromCurrency→toCurrency pair + const rateMap = new Map(); for (const r of rateRows) { - if (!rateToEtb.has(r.fromCurrency)) { - rateToEtb.set(r.fromCurrency, Number(r.rate)); - } + const key = `${r.fromCurrency}→${r.toCurrency}`; + if (!rateMap.has(key)) rateMap.set(key, Number(r.rate)); } - // Convert any minor amount to its ETB equivalent using stored exchange rates. - // b.totalMinor is the booking's canonical ETB amount (always stored in ETB), - // so callers should pass that directly rather than converting displayTotalMinor. - const toEtbMinor = (minor: number, currency: string): number => { - if (currency === 'ETB') return minor; - const rate = rateToEtb.get(currency); - // If no rate is on file fall back to the raw value (avoids silently hiding - // cross-currency bookings, at the cost of an approximate comparison). - return rate ? Math.round(minor * rate) : minor; + // Convert minor amount from one currency to another. + const convertMinor = (minor: number, from: string, to: string): number => { + if (from === to) return minor; + const direct = rateMap.get(`${from}→${to}`); + if (direct) return Math.round(minor * direct); + // Try via ETB as pivot + const toEtb = rateMap.get(`${from}→ETB`); + const fromEtb = rateMap.get(`ETB→${to}`); + if (toEtb && fromEtb) return Math.round(minor * toEtb * fromEtb); + return minor; // fallback: no rate on file }; if (params.search?.trim()) { - return this.getDiscrepancyForRef(params.search.trim(), toEtbMinor); + return this.getDiscrepancyForRef(params.search.trim(), convertMinor); } const dateFilter: Record = {}; @@ -679,20 +678,18 @@ export class ReportsService { .map(b => { const pi = b.paymentIntent!; - // Display amounts shown to the passenger (may be in DJF). + // Display amounts shown to the passenger (may be in DJF/USD). const actualMinor = b.displayTotalMinor ?? b.totalMinor; const actualCurrency = (b.displayCurrency as string | null) ?? b.currency; const paidMinor = pi.amountMinor; const paidCurrency = pi.currency; - // b.totalMinor is always in ETB minor. pi.amountMinor is the charge MAJOR amount - // (the gateway receives major units — displayMinorToChargeMajor divides by 100 before - // sending). Multiply by 100 to convert back to minor before the ETB comparison. - const owedEtb = b.totalMinor; - const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); - const balanceMinor = owedEtb - paidEtb; - const balanceCurrency = 'ETB'; + // Balance in the booking's display currency: + // convert paid (major units from gateway) to display currency minor, then subtract. + const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency); + const balanceMinor = actualMinor - paidInDisplayMinor; + const balanceCurrency = actualCurrency; const firstSeat = b.seats[0]; const passengers = this.buildSeatPassengers(b.seats, actualCurrency); @@ -731,7 +728,7 @@ export class ReportsService { private async getDiscrepancyForRef( search: string, - toEtbMinor: (minor: number, currency: string) => number, + convertMinor: (minor: number, from: string, to: string) => number, ) { let bookingId: string | null = null; const byPnr = await this.prisma.booking.findUnique({ @@ -797,10 +794,9 @@ export class ReportsService { const paidMinor = pi?.amountMinor ?? 0; const paidCurrency = pi?.currency ?? b.currency; - const owedEtb = b.totalMinor; - const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); - const balanceMinor = owedEtb - paidEtb; - const balanceCurrency = 'ETB'; + const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency); + const balanceMinor = actualMinor - paidInDisplayMinor; + const balanceCurrency = actualCurrency; const firstSeat = b.seats[0]; const passengers = this.buildSeatPassengers(b.seats, actualCurrency); @@ -847,6 +843,20 @@ export class ReportsService { } async getPaymentsReport(scheduleId: string) { + const rateRows = await this.prisma.currencyExchangeRate.findMany({ + where: { toCurrency: 'ETB' as any }, + orderBy: { effectiveDate: 'desc' }, + }); + const rateToEtb = new Map(); + for (const r of rateRows) { + if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate)); + } + const toEtbMinor = (minor: number, currency: string): number => { + if (currency === 'ETB') return minor; + const rate = rateToEtb.get(currency); + return rate ? Math.round(minor * rate) : minor; + }; + const bookings = await this.prisma.booking.findMany({ where: { scheduleId, @@ -860,6 +870,8 @@ export class ReportsService { select: { passengerName: true, fareMinor: true, + displayFareMinor: true, + displayCurrency: true, passengerCategory: true, seatLabelSnapshot: true, seat: { select: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, @@ -870,30 +882,38 @@ export class ReportsService { }); const rows = bookings.map(b => { - const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0); - const paidMinor = Math.round(b.paymentIntent!.amountMinor); + const pi = b.paymentIntent!; + const actualMinor = b.displayTotalMinor ?? b.totalMinor; + const actualCurrency = (b.displayCurrency as string | null) ?? b.currency; + // pi.amountMinor is stored in major units — convert to minor + const paidMinor = Math.round(pi.amountMinor * 100); + const paidCurrency = pi.currency; + const varianceMinor = toEtbMinor(actualMinor, actualCurrency) - toEtbMinor(paidMinor, paidCurrency); return { bookingRef: b.bookingRef, passengerName: b.seats[0]?.passengerName ?? b.passenger?.user?.fullName ?? '—', phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', - method: b.paymentIntent!.method, - paidAt: b.paymentIntent!.paidAt, + method: pi.method, + paidAt: pi.paidAt, actualMinor, + actualCurrency, paidMinor, - currency: 'ETB', + paidCurrency, + varianceMinor, passengerCount: b.seats.length, }; }); - const totalActualMinor = rows.reduce((s, r) => s + r.actualMinor, 0); - const totalPaidMinor = rows.reduce((s, r) => s + r.paidMinor, 0); + const totalActualEtbMinor = rows.reduce((s, r) => s + toEtbMinor(r.actualMinor, r.actualCurrency), 0); + const totalPaidEtbMinor = rows.reduce((s, r) => s + toEtbMinor(r.paidMinor, r.paidCurrency), 0); const byMethod = rows.reduce((acc, r) => { - acc[r.method] = (acc[r.method] ?? 0) + r.paidMinor; + if (!acc[r.method]) acc[r.method] = { totalPaidEtbMinor: 0, currency: 'ETB' }; + acc[r.method].totalPaidEtbMinor += toEtbMinor(r.paidMinor, r.paidCurrency); return acc; - }, {} as Record); + }, {} as Record); - return { totalActualMinor, totalPaidMinor, byMethod, rows }; + return { totalActualEtbMinor, totalPaidEtbMinor, byMethod, rows }; } async getPaymentDiscrepancyBySchedule(scheduleId: string, params: { @@ -909,13 +929,7 @@ export class ReportsService { }, include: { paymentIntent: { select: { amountMinor: true, currency: true } }, - schedule: { - include: { - originStation: { select: { name: true } }, - destinationStation: { select: { name: true } }, - }, - }, - package: { select: { id: true } }, + schedule: { select: { id: true } }, seats: { where: { leg: 1 }, orderBy: [ @@ -940,6 +954,14 @@ export class ReportsService { }, }); + const stationIds = [...new Set( + bookings.flatMap(b => [b.originStationId, b.destinationStationId]).filter(Boolean) as string[], + )]; + const stations = stationIds.length > 0 + ? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } }) + : []; + const stationName = new Map(stations.map(s => [s.id, s.name])); + const resolveSeatClass = (seat: any): string => { const classes = seat?.coach?.coachType?.seatClasses ?? []; const matched = seat?.bedPosition @@ -954,7 +976,7 @@ export class ReportsService { // pi.amountMinor is a Float in full currency units — convert to cents once const paidMinorCents = Math.round(pi.amountMinor * 100); - const isPackage = !!(b as any).package; + const isPackage = !!(b as any).packageId; const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor; const effectiveVarianceMinor = effectiveActualMinor - paidMinorCents; @@ -973,8 +995,8 @@ export class ReportsService { seatClass: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—', coachNumber: firstSeat?.seat?.coach?.number ?? null, seatNumber: firstSeat?.seat?.seatNumber ?? null, - origin: b.schedule.originStation.name, - destination: b.schedule.destinationStation.name, + origin: b.originStationId ? (stationName.get(b.originStationId) ?? '—') : '—', + destination: b.destinationStationId ? (stationName.get(b.destinationStationId) ?? '—') : '—', phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', actualMinor: effectiveActualMinor, paidMinor: paidMinorCents, @@ -989,7 +1011,7 @@ export class ReportsService { } if (params.seatClass?.trim()) { const sc = params.seatClass.trim().toLowerCase(); - rows = rows.filter(r => r.breakdown.some(b => b.seatClass.toLowerCase().includes(sc))); + rows = rows.filter(r => r.breakdown.some(bd => bd.seatClass.toLowerCase().includes(sc))); } if (params.sort === 'asc') { rows.sort((a, b) => a.varianceMinor - b.varianceMinor); 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 d468bba7c..5e3237e93 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -42,7 +42,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`, @Patch(':id') @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') - @ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' }) + @ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiResponse({ status: 200, description: 'Route updated' }) @ApiResponse({ status: 404, description: 'Route not found' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index f2db36e7c..1036138c9 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -5,8 +5,9 @@ import { Type } from 'class-transformer'; export class RouteStopInputDto { @ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string; @ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number; - @ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number; + @ApiPropertyOptional({ example: 120.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Ignored for sequence 1 (origin, no predecessor). Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number; } export class CreateRouteDto { @@ -14,8 +15,9 @@ export class CreateRouteDto { @ApiProperty({ example: 'Addis Ababa – Djibouti' }) @IsString() name: string; @ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string; @ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string; - @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string | null; @ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean; + @ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route (defaults to 30 if omitted)' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; @ApiProperty({ type: [RouteStopInputDto], description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.', @@ -35,15 +37,17 @@ export class CreateRouteDto { export class AddRouteStopDto { @ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string; @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; - @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; + @ApiPropertyOptional({ example: 75.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number; } export class UpdateRouteDto { @ApiPropertyOptional({ example: 'Addis Ababa – Djibouti Express' }) @IsOptional() @IsString() name?: string; @ApiPropertyOptional() @IsOptional() @IsString() description?: string; @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean; - @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsOptional() @IsDateString() effectiveFrom?: string; + @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z', description: 'Send null to clear (open-ended route)' }) @IsOptional() @IsDateString() effectiveUntil?: string | null; @ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; @ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[]; } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 58e647180..56ff8ddfe 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service'; import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; +import { parseEthiopianTime } from '../../common/utils/timezone.utils'; +import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils'; @Injectable() export class RoutesService { @@ -10,6 +12,33 @@ export class RoutesService { // ── Route CRUD ───────────────────────────────────────────────────────────── + /** + * distanceKm is CUMULATIVE distance from the route origin, not distance from the previous + * stop (that's what travelMinutesToStop is for) — fare pricing computes a segment's distance + * as destStop.distanceKm - originStop.distanceKm, so a route with equal or decreasing values + * across stops silently produces zero/negative segment distances, which the fare engine + * rejects (caught and swallowed by search into a bare "N/A" instead of a visible error). Catch + * the mistake here instead, with a message that names the exact stops involved. + */ + private validateStopDistances(stops: { sequence: number; stationId: string; distanceKm?: number | null }[]): void { + const sorted = [...stops].sort((a, b) => a.sequence - b.sequence); + let prevDistance = sorted[0]?.distanceKm ?? 0; + for (let i = 1; i < sorted.length; i++) { + const stop = sorted[i]; + if (stop.distanceKm == null) { + throw new BadRequestException( + `Stop ${stop.sequence} is missing distanceKm (cumulative distance in km from the route origin). This is required for fare pricing.`, + ); + } + if (stop.distanceKm <= prevDistance) { + throw new BadRequestException( + `Stop ${stop.sequence}'s distanceKm (${stop.distanceKm}) must be greater than stop ${sorted[i - 1].sequence}'s distanceKm (${prevDistance}) — distanceKm is cumulative distance from the route origin, not distance from the previous stop. Equal or decreasing values make fare pricing between these stops fail silently.`, + ); + } + prevDistance = stop.distanceKm; + } + } + async createRoute(dto: CreateRouteDto) { const existing = await this.prisma.route.findUnique({ where: { code: dto.code } }); if (existing) throw new ConflictException(`Route code "${dto.code}" already exists`); @@ -19,6 +48,8 @@ export class RoutesService { const seqs = dto.stops.map(s => s.sequence); if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list'); + this.validateStopDistances(dto.stops); + const stationIds = [...new Set(dto.stops.map(s => s.stationId))]; const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } }); if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found'); @@ -29,14 +60,16 @@ export class RoutesService { name: dto.name, description: dto.description, active: dto.active ?? true, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null, + ...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}), + effectiveFrom: parseEthiopianTime(dto.effectiveFrom), + effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null, stops: { create: dto.stops.map(s => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + travelMinutesToStop: s.travelMinutesToStop ?? null, })), }, }, @@ -86,13 +119,20 @@ export class RoutesService { const route = await this.prisma.route.findUnique({ where: { id } }); if (!route) throw new NotFoundException('Route not found'); + if (dto.stops && dto.stops.length >= 2) this.validateStopDistances(dto.stops); + await this.prisma.route.update({ where: { id }, data: { name: dto.name, description: dto.description, active: dto.active, - effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined, + ...(dto.effectiveFrom ? { effectiveFrom: parseEthiopianTime(dto.effectiveFrom) } : {}), + // effectiveUntil is nullable (open-ended route) — distinguish "field not sent" (leave + // untouched) from "explicitly cleared" (null → set to null), not just truthy/falsy. + ...(dto.effectiveUntil !== undefined + ? { effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null } + : {}), ...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}), }, }); @@ -106,8 +146,23 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + travelMinutesToStop: s.travelMinutesToStop ?? null, })), }); + + // Propagate new stop timing to all future schedules on this route so that + // per-stop check-in cutoffs reflect the updated travelMinutesToStop values. + const futureSchedules = await this.prisma.trainSchedule.findMany({ + where: { routeId: id, status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gt: new Date() } }, + select: { id: true, departureAt: true, arrivalAt: true }, + }); + const stopsForTiming = dto.stops + .map(s => ({ sequence: s.sequence, distanceKm: s.distanceKm ?? null, travelMinutesToStop: s.travelMinutesToStop ?? null, checkinMinutesBefore: s.checkinMinutesBefore ?? null })) + .sort((a, b) => a.sequence - b.sequence); + for (const sched of futureSchedules) { + const times = computePlannedStopTimes({ id, stops: stopsForTiming }, new Date(sched.departureAt), new Date(sched.arrivalAt)); + await this.applyRouteToSchedule(id, sched.id, Object.fromEntries(times.map(t => [t.sequence, t]))); + } } await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } }); @@ -218,6 +273,9 @@ export class RoutesService { }); if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`); + const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } }); + this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]); + return this.prisma.routeStop.create({ data: { routeId, @@ -225,6 +283,7 @@ export class RoutesService { sequence: dto.sequence, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, checkinMinutesBefore: dto.checkinMinutesBefore ?? null, + travelMinutesToStop: dto.travelMinutesToStop ?? null, }, }); } 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 b11ac3098..8cee1e94f 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -154,6 +154,12 @@ export class SchedulesController { @ApiQuery({ name: 'cascade', required: false, type: Boolean }) 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') + @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); } + @Get(':id/stops') @IsPublic() @ApiOperation({ summary: 'List all stops for a schedule' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 675168cf9..49dec1d96 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -52,6 +52,10 @@ export class CreateScheduleDto { }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) plannedTimes?: PlannedStopTimeDto[]; + + @ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' }) + @IsOptional() @IsArray() @IsString({ each: true }) + coachIds?: string[]; } export class UpdateScheduleDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 7cca548aa..d6dfda6ec 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { RoutesService } from './routes.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; @@ -6,9 +6,12 @@ import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateSchedule import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils'; import { AuditService } from '../../common/audit.service'; +import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils'; @Injectable() export class SchedulesService { + private readonly logger = new Logger(SchedulesService.name); + constructor( private prisma: PrismaService, private routesService: RoutesService, @@ -43,20 +46,14 @@ export class SchedulesService { departureAt: departureAt.toISOString(), arrivalAt: arrivalAt.toISOString(), plannedTimes: dto.plannedTimes || [], + coachIds: dto.coachIds, }; + // createSchedule applies coachIds if given, else auto-applies the route coach template, + // and rejects the day outright (caught below) if it would end up with zero coaches. const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); - // createSchedule already auto-applies the route coach template; - // only override if explicit coachIds are provided - if (dto.coachIds && dto.coachIds.length > 0) { - await this.assignCoaches( - schedule.id, - dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), - ); - } - scheduleCount++; } catch (error) { errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); @@ -103,6 +100,8 @@ export class SchedulesService { const dep = parseEthiopianTime(dto.departureAt); const arr = parseEthiopianTime(dto.arrivalAt); if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt'); + // M-4: a new schedule cannot depart in the past — the backoffice form does not enforce this. + if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future'); const [train, route] = await Promise.all([ this.prisma.train.findUnique({ where: { id: dto.trainId } }), @@ -133,26 +132,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), - }; - }); + plannedTimes = computePlannedStopTimes(route, dep, arr); } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); @@ -181,15 +161,34 @@ export class SchedulesService { const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); - // Auto-apply route coach template if one is defined - const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ - where: { routeId: dto.routeId }, - orderBy: { positionNumber: 'asc' }, - }); - if (coachTemplates.length > 0) { + // Explicit coachIds (from the schedule form's Coaches step) override the route's coach + // template; otherwise auto-apply the template if one is defined. + if (dto.coachIds && dto.coachIds.length > 0) { await this.assignCoaches( schedule.id, - coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), + dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), + ); + } else { + const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ + where: { routeId: dto.routeId }, + orderBy: { positionNumber: 'asc' }, + }); + if (coachTemplates.length > 0) { + await this.assignCoaches( + schedule.id, + coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), + ); + } + } + + // A schedule with zero coaches has zero seats and is silently invisible to search (and + // unbookable) with no indication why — block creation instead of leaving a dead schedule. + const assignedCoachCount = await this.prisma.coachAssignment.count({ where: { scheduleId: schedule.id } }); + if (assignedCoachCount === 0) { + await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: schedule.id } }); + await this.prisma.trainSchedule.delete({ where: { id: schedule.id } }); + throw new BadRequestException( + 'A schedule must have at least one coach assigned to be bookable. Add coaches in the Coaches step, or set a Route Coach Template on this route so new schedules auto-assign coaches.', ); } @@ -304,26 +303,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), - }; - }); + plannedTimes = computePlannedStopTimes(route, dep, arr); } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); @@ -617,6 +597,24 @@ export class SchedulesService { return { synced, errors }; } + async recalculateStopTimes(scheduleId: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + include: { route: { include: { stops: { orderBy: { sequence: 'asc' } } } } }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + if (!schedule.routeId || !schedule.route) throw new BadRequestException('Schedule has no associated route'); + + const plannedTimes = computePlannedStopTimes( + schedule.route, + new Date(schedule.departureAt), + new Date(schedule.arrivalAt), + ); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); + await this.routesService.applyRouteToSchedule(schedule.routeId, scheduleId, plannedTimesMap); + return { recalculated: true, scheduleId, stopCount: plannedTimes.length }; + } + async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); if (!schedule) throw new NotFoundException('Schedule not found'); @@ -653,11 +651,14 @@ export class SchedulesService { if (!schedule) throw new NotFoundException('Schedule not found'); const updateData: any = {}; + let dep: Date | undefined; + let arr: Date | undefined; if (dto.departureAt || dto.arrivalAt) { - const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt); - const arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt); + dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt); + arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt); if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time'); + if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future'); updateData.departureAt = dep; updateData.arrivalAt = arr; updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000); @@ -670,6 +671,22 @@ export class SchedulesService { await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); } + // departureAt/arrivalAt changed — the per-stop TripStopTime rows were computed against the + // OLD times and are now stale (same interpolation createSchedule/updateSchedule use). Left + // unfixed, check-in cutoff enforcement and search silently keep using outdated per-stop + // arrival/departure estimates for every intermediate stop. + if (dep && arr && schedule.routeId) { + const route = await this.prisma.route.findUnique({ + where: { id: schedule.routeId }, + include: { stops: { orderBy: { sequence: 'asc' } } }, + }); + if (route && route.stops.length >= 2) { + const plannedTimes = computePlannedStopTimes(route, dep, arr); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); + await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); + } + } + if (dto.coaches !== undefined) { if (dto.coaches.length > 0) { await this.assignCoaches(id, dto.coaches); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 6ed657975..0c905acdd 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -10,7 +10,8 @@ import { CurrencyService } from "../currency/currency.service"; import { FareEngineService } from "../fare-engine/fare-engine.service"; import { SegmentsService } from "../segments/segments.service"; import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto"; -import { Currency } from "@prisma/client"; +import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils"; +import { Currency, Prisma } from "@prisma/client"; const POINTS_TO_MINOR = 10; @@ -220,12 +221,18 @@ export class SearchService { const totalPassengers = adultCount + (childCount ?? 0); const NEEDED = 3; - const baseWhere = { - status: "SCHEDULED", + // Include BOARDING alongside SCHEDULED: BOARDING is just an operational display status the + // schedule-level cron sets on a fixed 30-min-before-departure timer (see tasks.service.ts) — + // it does NOT mean booking is closed. The actual booking cutoff is per-stop and configurable + // (RouteStop/Route.checkinMinutesBefore), enforced below by buildScheduleResult's own live + // check against each stop's estimated arrival/departure. Excluding BOARDING here would + // silently impose a hidden, non-configurable 30-minute cutoff on top of that. + const baseWhere: Prisma.TrainScheduleWhereInput = { + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, - } as const; + }; // Fetch candidates before and after in parallel; take more than needed to // account for routes that don't serve the destination or have no availability. @@ -300,23 +307,21 @@ export class SearchService { const nextDay = new Date( `${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`, ); - const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); - // Use now as the lower bound for today so we don't fetch schedules that have - // already fully departed. The per-segment cutoff check in buildScheduleResult - // handles the exact check using each stop's own plannedDepartureAt. - const isToday = - now.getFullYear() === y && - now.getMonth() === m - 1 && - now.getDate() === d; - const earliest = isToday ? now : date; - + // Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here. + // A schedule whose origin has already departed (EN_ROUTE) can still have a later stop (e.g. + // Lebu, Adama) whose own cutoff hasn't passed; using the overall departureAt as a floor would + // wrongly exclude the whole schedule for those still-bookable downstream segments. The + // per-segment cutoff check in buildScheduleResult is the sole authority for whether THIS + // specific origin stop is still bookable, using each stop's own estimated arrival/departure. const schedules = await this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + // EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display + // statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere). + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, - departureAt: { gte: earliest, lt: nextDay }, + departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, }, @@ -368,7 +373,8 @@ export class SearchService { const [leg1Schedules, allCandidates] = await Promise.all([ this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + // BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere. + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, @@ -378,7 +384,7 @@ export class SearchService { }), this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, departureAt: { gte: dayStart, lt: leg2WindowEnd }, coachAssignments: { some: {} }, @@ -547,24 +553,13 @@ export class SearchService { if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; - // Segment-level cutoff: use the origin stop's planned departure, not the + // Segment-level cutoff: use the origin stop's own estimated arrival time, not the // schedule's overall departureAt (which is station A's time). This lets - // B→D remain bookable even after A→D closes. - // Cutoff resolution: stop-level override → route default → 30 min fallback. - const now = new Date(); - const segmentDepartureAt = - originStop.plannedDepartureAt ?? schedule.departureAt; - const routeStop = schedule.route?.stops?.find( - (s) => s.stationId === originStationId, - ); - const checkinMinutes = - routeStop?.checkinMinutesBefore ?? - schedule.route?.checkinMinutesBefore ?? - 30; - if ( - segmentDepartureAt.getTime() - now.getTime() <= - checkinMinutes * 60 * 1000 - ) + // B→D remain bookable even after A→D closes. Stop-level checkinMinutesBefore override → + // route default → 30 min fallback — same resolution GuestBookingService applies at + // booking-creation time, so a segment shown as bookable here stays bookable through + // checkout instead of being rejected against a different, hardcoded cutoff. + if (Date.now() >= resolveCheckinCutoff(schedule, originStop, originStationId).cutoffAt.getTime()) return null; // Collect all valid seat IDs upfront for a single batch availability check @@ -676,8 +671,11 @@ export class SearchService { nationality, availabilityByClass, ); - const legDepartureAt = schedule.departureAt; - const legArrivalAt = schedule.arrivalAt; + // Use the selected stop's own planned time, not the schedule's full-route span — + // for stop-based (mid-route) boarding/alighting these differ from the train's + // overall origin departure / final destination arrival. + const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; + const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; const displayCurrency = faresByClass[0]?.displayCurrency ?? diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts index 1ea743aa6..853076006 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsInt, IsBoolean, IsOptional, IsIn } from 'class-validator'; +import { IsString, IsInt, IsBoolean, IsOptional, IsIn, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; export class CreateSeatClassDto { @@ -27,11 +27,13 @@ export class CreateSeatClassDto { @ApiProperty({ example: 3000, description: 'Per-km rate in minor units (tariff decimal × 100000)' }) @IsInt() + @Min(0) basePrice: number; @ApiPropertyOptional({ example: 1200, description: 'Flat insurance fee in minor units' }) @IsOptional() @IsInt() + @Min(0) insuranceFeeMinor?: number; @ApiPropertyOptional({ example: true }) 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 bd73c10f5..79773a712 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -281,7 +281,7 @@ export class SeatsService { }), this.prisma.tripStopTime.findFirst({ where: { scheduleId: dto.scheduleId, stationId: dto.originStationId }, - select: { plannedDepartureAt: true }, + select: { plannedArrivalAt: true, plannedDepartureAt: true }, }), this.prisma.routeStop.findFirst({ where: { @@ -295,7 +295,10 @@ export class SeatsService { // Stop-level override wins; falls back to route-level; then to 30 min. const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; - const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt; + // Departure basis: plannedDepartureAt = arrival + dwell. For the origin there is no + // arrival so plannedDepartureAt = schedule.departureAt. cutoffAt = departure - dwell = arrival, + // so holding closes the moment the train reaches the boarding stop. + const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? originStopTime?.plannedArrivalAt ?? schedule.departureAt; const msUntilDeparture = segmentDepartureAt.getTime() - Date.now(); if (msUntilDeparture <= checkinMinutes * 60 * 1000) { throw new BadRequestException( diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts index 0114c03f9..6d2c15eb2 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { SystemConfigService } from './system-config.service'; +import { UpdateSystemConfigDto } from './system-config.dto'; import { IamGuard } from '../../common/iam-adapter'; import { Roles } from '../../common/roles.decorator'; @@ -31,7 +32,12 @@ export class SystemConfigController { @UseGuards(IamGuard) @Roles('ADMIN') @ApiOperation({ summary: 'Update system config (admin)' }) - update(@Body() body: Record) { - return this.service.updateMany(body); + update(@Body() dto: UpdateSystemConfigDto) { + // The DTO validates/coerces each known key to a positive integer; persist back as strings. + const entries: Record = {}; + for (const [key, value] of Object.entries(dto)) { + if (value !== undefined) entries[key] = String(value); + } + return this.service.updateMany(entries); } } diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts new file mode 100644 index 000000000..5c1cd9679 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts @@ -0,0 +1,48 @@ +import { IsInt, IsOptional, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Whitelisted, typed body for `PATCH /config`. Config is persisted as string key/values, but every + * known key is a positive integer (durations, hour windows, throttle limits/TTLs). Values arrive as + * strings from the backoffice form; `@Type(() => Number)` coerces them so the numeric/range checks + * apply (M-3 — the endpoint previously stored any raw string, e.g. `seat_hold_duration_minutes: -1`). + * Unknown keys are stripped by the global whitelisting ValidationPipe. + */ +export class UpdateSystemConfigDto { + @ApiPropertyOptional({ example: 5, description: 'Seat-hold duration in minutes (1..60)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(60) + seat_hold_duration_minutes?: number; + + @ApiPropertyOptional({ example: 2 }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + hold_cutoff_hours_before_departure?: number; + + @ApiPropertyOptional({ example: 4 }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + boarding_window_hours_before_departure?: number; + + @ApiPropertyOptional({ example: 5 }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) + throttle_auth_limit?: number; + + @ApiPropertyOptional({ example: 60000 }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) + throttle_auth_ttl_ms?: number; + + @ApiPropertyOptional({ example: 20 }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) + throttle_strict_limit?: number; + + @ApiPropertyOptional({ example: 60000 }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) + throttle_strict_ttl_ms?: number; + + @ApiPropertyOptional({ example: 100 }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) + throttle_default_limit?: number; + + @ApiPropertyOptional({ example: 60000 }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) + throttle_default_ttl_ms?: number; +} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 4767c7eba..99d6b4d4e 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -81,17 +81,23 @@ export class TasksService { byRoute.get(stop.routeId)!.push(stop.stationId); } + // Arrival basis: each stop's own estimated arrival time, not its departure. The first + // stop of a route has no arrival (nothing to arrive at), so it falls back to its + // departure — expressed below as COALESCE(plannedArrivalAt, plannedDepartureAt). let reopenedCount = 0; let checkinClosedCount = 0; for (const [mins, byRoute] of byMins) { const cutoffAt = new Date(now.getTime() + mins * 60 * 1000); for (const [routeId, stationIds] of byRoute) { // Revert first: if the cutoff was reduced, stops that were prematurely closed - // should reopen (departure is still beyond the new cutoff window). + // should reopen (arrival is still beyond the new cutoff window). const reverted = await this.prisma.tripStopTime.updateMany({ where: { status: 'CHECKIN_CLOSED', - plannedDepartureAt: { gt: cutoffAt }, + OR: [ + { plannedArrivalAt: { gt: cutoffAt } }, + { AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { gt: cutoffAt } }] }, + ], stationId: { in: stationIds }, schedule: { routeId }, }, @@ -103,7 +109,10 @@ export class TasksService { const closed = await this.prisma.tripStopTime.updateMany({ where: { status: 'OPEN', - plannedDepartureAt: { lte: cutoffAt }, + OR: [ + { plannedArrivalAt: { lte: cutoffAt } }, + { AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { lte: cutoffAt } }] }, + ], stationId: { in: stationIds }, schedule: { routeId }, }, @@ -169,8 +178,8 @@ export class TasksService { include: { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, - stopTimes: { select: { stationId: true, plannedDepartureAt: true } }, - route: { select: { checkinMinutesBefore: true } }, + stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, }, }, }, @@ -179,12 +188,17 @@ export class TasksService { for (const booking of bookings) { try { const createdAt = booking.createdAt as Date; - // Use the booking's origin-segment departure and the route's own check-in window. + // Use the booking's origin-segment estimated arrival (falling back to its departure + // for the first stop) and that stop's own check-in window (falling back to the route + // default), same resolution as holdSeats/search. const originStop = (booking.schedule as any).stopTimes?.find( (s: any) => s.stationId === (booking as any).originStationId, ); - const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; - const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30; + const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; + const originRouteStop = (booking.schedule as any).route?.stops?.find( + (s: any) => s.stationId === (booking as any).originStationId, + ); + const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30; if (dep <= now) continue; // segment has already departed; cancel job handles clean-up const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime(); @@ -230,13 +244,28 @@ export class TasksService { // ── Cancel bookings whose payment deadline has passed ───────────────────── private async cancelExpiredPendingBookings(now: Date) { - const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); - const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); + const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); - // payment_deadline = MIN(createdAt + 2h, departureAt - 30min) + // The departure pre-filter below is a query-scoping optimization only — the real + // deadline check happens per-row further down. It must be widened to the largest + // configured checkinMinutes across all routes/stops, or a booking on a route with a + // cutoff bigger than the CUTOFF_MINUTES default would never even be fetched here, + // silently never getting auto-cancelled. + const [maxRouteCutoff, maxStopCutoff] = await Promise.all([ + this.prisma.route.aggregate({ _max: { checkinMinutesBefore: true } }), + this.prisma.routeStop.aggregate({ _max: { checkinMinutesBefore: true } }), + ]); + const effectiveMaxCutoffMinutes = Math.max( + CUTOFF_MINUTES, + maxRouteCutoff._max.checkinMinutesBefore ?? 0, + maxStopCutoff._max.checkinMinutesBefore ?? 0, + ); + const departureCutoff = new Date(now.getTime() + effectiveMaxCutoffMinutes * 60 * 1000); + + // payment_deadline = MIN(createdAt + 2h, segment_arrival - checkinMinutes) // Deadline is reached when either branch of the MIN is in the past: - // (a) createdAt ≤ now - 2h → 2-hour max window elapsed - // (b) departureAt ≤ now + 30min → departure within 30 min + // (a) createdAt ≤ now - 2h → 2-hour max window elapsed + // (b) departureAt ≤ now + effectiveMaxCutoff → within the widest possible cutoff window const expiredBookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', @@ -250,8 +279,8 @@ export class TasksService { include: { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, - stopTimes: { select: { stationId: true, plannedDepartureAt: true } }, - route: { select: { checkinMinutesBefore: true } }, + stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, }, }, paymentIntent: { select: { method: true } }, @@ -264,14 +293,19 @@ export class TasksService { for (const booking of expiredBookings) { try { // Re-verify exact deadline to avoid racing with a concurrent payment confirmation. - // Use the booking's origin-segment departure for the deadline so that a B→C booking - // on an A→B→C→D schedule gets the correct payment window anchored to B, not A. + // Use the booking's origin-segment estimated arrival (falling back to its departure + // for the first stop) and that stop's own check-in window, so a B→C booking on an + // A→B→C→D schedule gets the correct payment window anchored to B, not A. const createdAt = booking.createdAt as Date; const originStop = (booking.schedule as any).stopTimes?.find( (s: any) => s.stationId === (booking as any).originStationId, ); - const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; - const paymentDeadline = computePaymentDeadline(createdAt, dep); + const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; + const originRouteStop = (booking.schedule as any).route?.stops?.find( + (s: any) => s.stationId === (booking as any).originStationId, + ); + const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES; + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); if (now < paymentDeadline) continue; // 1a. Release held seats (Journey rows are the occupancy source of truth once paid) 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 3a48ca2a1..869a8578a 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -14,10 +14,11 @@ export class TicketsController { @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Generate tickets for all confirmed bookings that are missing them', - description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.', + description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed/remaining counts. Call repeatedly until remaining=0.', }) - generateMissing() { - return this.service.generateMissing(); + @ApiQuery({ name: 'limit', required: false, description: 'Max bookings to process per call (default 10)' }) + generateMissing(@Query('limit') limit?: string) { + return this.service.generateMissing(limit ? parseInt(limit, 10) : 10); } @Post('smart-assign/:bookingId') @@ -34,10 +35,11 @@ export class TicketsController { } @Post('generate/:bookingId') - @SetMetadata('isPublic', true) + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ - summary: 'Generate ticket for booking (confirmation page)', - description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records. Requires payment to be SUCCEEDED and booking to be CONFIRMED.' + summary: 'Generate ticket for booking', + description: 'Creates a ticket for a confirmed booking with succeeded payment. Requires payment to be SUCCEEDED and booking to be CONFIRMED.' }) generateTicket(@Param('bookingId') bookingId: string) { return this.service.generate(bookingId); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 2ba05dd4e..6cd94ec6c 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; +import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; import * as QRCode from 'qrcode'; interface OfflineValidation { @@ -129,6 +130,7 @@ export class TicketsService { : { fullName: 'Guest', email: guestEmail, phone: guestPhone }; + const segment = resolveBookingSegment(t.booking?.schedule, t.booking?.originStationId, t.booking?.destinationStationId); return { id: t.id, ticketNumber: t.barcodePayload, @@ -152,20 +154,14 @@ export class TicketsService { contactPhone: t.booking?.contactPhone, returnSchedule: t.booking?.returnSchedule ?? null, seats: t.booking?.seats ?? [], - originStation: (() => { - const id = t.booking?.originStationId; - if (!id) return t.booking?.schedule?.originStation ?? null; - const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id); - return stop?.station ?? t.booking?.schedule?.originStation ?? null; - })(), - destinationStation: (() => { - const id = t.booking?.destinationStationId; - if (!id) return t.booking?.schedule?.destinationStation ?? null; - const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id); - return stop?.station ?? t.booking?.schedule?.destinationStation ?? null; - })(), + originStation: segment.origin, + destinationStation: segment.destination, }, - schedule: t.booking?.schedule, + schedule: t.booking?.schedule ? { + ...t.booking.schedule, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, + } : null, seat: t.seat ? { id: t.seat.id, seatNumber: t.seat.seatNumber, @@ -210,15 +206,6 @@ export class TicketsService { }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); - // Seats taken by other confirmed/boarded bookings on this schedule - const takenByOthers = await this.prisma.bookingSeat.findMany({ - where: { - booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, - seat: { coach: { assignments: { some: { scheduleId: booking.scheduleId } } } }, - }, - select: { seatId: true }, - }).then(rows => new Set(rows.map(r => r.seatId))); - // Seats held by any active SeatHold (not yet expired) const heldSeatIds = await this.prisma.seatHold.findMany({ where: { expiresAt: { gt: new Date() } }, @@ -230,42 +217,62 @@ export class TicketsService { select: { seatId: true }, }).then(rows => new Set(rows.map(r => r.seatId))); - // Union of all unavailable seat IDs (excluding the booking's own seats) const ownSeatIds = new Set((booking as any).seats.map((bs: any) => bs.seatId as string)); + const reassigned: { seatNumber: string; newSeatNumber: string }[] = []; + + // Track newly assigned seats so the same seat isn't given to two passengers const unavailableIds = new Set([ - ...[...takenByOthers].filter(id => !ownSeatIds.has(id)), ...[...heldSeatIds], ...[...blockedSeatIds], ]); - const reassigned: { seatNumber: string; newSeatNumber: string }[] = []; - for (const bs of (booking as any).seats) { const originalSeatId: string = bs.seatId; + // Use the per-seat scheduleId — for ROUND_TRIP leg 2 this is the return schedule, + // not booking.scheduleId (the outbound schedule). + const legScheduleId: string = bs.scheduleId ?? booking.scheduleId; - // Case 1: original seat is still free — nothing to do - if (!takenByOthers.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId)) continue; + // Seats taken by other confirmed/boarded bookings on THIS leg's schedule + const takenByOthersOnLeg = await this.prisma.bookingSeat.findMany({ + where: { + booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, + seat: { coach: { assignments: { some: { scheduleId: legScheduleId } } } }, + }, + select: { seatId: true }, + }).then(rows => new Set(rows.map(r => r.seatId))); - // Case 2: original seat is unavailable — find a truly available seat in the same coach type + // Case 1: original seat is still free on this leg — nothing to do + if ( + !takenByOthersOnLeg.has(originalSeatId) && + !heldSeatIds.has(originalSeatId) && + !blockedSeatIds.has(originalSeatId) + ) continue; + + // Case 2: original seat is unavailable — find a free seat of the same coach type on this leg's schedule const coachTypeId: string | undefined = bs.seat?.coach?.coachTypeId; + const allUnavailable = new Set([ + ...[...takenByOthersOnLeg].filter(id => !ownSeatIds.has(id)), + ...[...unavailableIds], + ]); + const candidate = await this.prisma.seat.findFirst({ where: { status: 'AVAILABLE', seatNumber: { not: '' }, NOT: [ { seatNumber: { startsWith: '-' } }, - { id: { in: [...unavailableIds] } }, + { id: { in: [...allUnavailable] } }, ], coach: { - assignments: { some: { scheduleId: booking.scheduleId } }, + assignments: { some: { scheduleId: legScheduleId } }, ...(coachTypeId ? { coachTypeId } : {}), }, }, orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], }); - // Case 3: no seats left in that class + // Case 3: no seats left in that class on this leg if (!candidate) { const className = bs.seat?.coach?.coachType?.name ?? 'the same class'; throw new ConflictException( @@ -278,10 +285,7 @@ export class TicketsService { data: { seatId: candidate.id }, }); - // Mark the newly assigned seat as taken so subsequent passengers in the - // same booking don't get assigned the same seat. unavailableIds.add(candidate.id); - reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber }); } @@ -360,26 +364,83 @@ export class TicketsService { // blocked by another booking. const seatIds = (booking as any).seats.map((bs: any) => bs.seatId); await this.prisma.seatBlock.deleteMany({ - where: { seatId: { in: seatIds }, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' }, + where: { seatId: { in: seatIds }, blockedBy: 'SYSTEM' }, }); // Check for seat conflicts — only seats confirmed/boarded by a *different* booking - // on the same schedule are a real conflict. SeatBlock rows created by a previous - // generate() run for this booking are NOT a conflict; they are cleaned up above. - const conflictingSeats = await this.prisma.bookingSeat.findMany({ + // on the SAME schedule AND with OVERLAPPING segments are a real conflict. + // Segment overlap: two bookings conflict on a seat when their stop-sequence ranges + // overlap: A.originSeq < B.destSeq AND B.originSeq < A.destSeq. + // We resolve sequences via TripStopTime using each booking's originStationId / + // destinationStationId. Bookings with no station IDs (full-route) are treated as + // seq 0 → ∞ and always overlap. + const thisBookingSeats = (booking as any).seats as Array<{ seatId: string; scheduleId: string | null }>; + + // Resolve this booking's stop sequences per leg schedule + const thisSeqMap = new Map(); + const legScheduleIds = [...new Set(thisBookingSeats.map(bs => bs.scheduleId ?? booking.scheduleId))]; + for (const schedId of legScheduleIds) { + const originId = (booking as any).originStationId; + const destId = (booking as any).destinationStationId; + if (!originId || !destId) { + thisSeqMap.set(schedId, { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER }); + continue; + } + const stops = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: schedId, stationId: { in: [originId, destId] } }, + select: { stationId: true, sequence: true }, + }); + const oStop = stops.find(s => s.stationId === originId); + const dStop = stops.find(s => s.stationId === destId); + thisSeqMap.set(schedId, { + originSeq: oStop?.sequence ?? 0, + destSeq: dStop?.sequence ?? Number.MAX_SAFE_INTEGER, + }); + } + + // Find other confirmed/boarded bookings that share any (seatId, scheduleId) pair + const candidateConflicts = await this.prisma.bookingSeat.findMany({ where: { - seatId: { in: seatIds }, - booking: { - id: { not: bookingId }, - status: { in: ['CONFIRMED', 'BOARDED'] }, - }, + OR: thisBookingSeats.map(bs => ({ + seatId: bs.seatId, + scheduleId: bs.scheduleId ?? booking.scheduleId, + booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, + })), + }, + include: { + seat: true, + booking: { select: { id: true, originStationId: true, destinationStationId: true } }, }, - include: { seat: true }, }); - if (conflictingSeats.length > 0) { - const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', '); + + const trueConflicts: string[] = []; + for (const other of candidateConflicts) { + const legScheduleId = other.scheduleId ?? booking.scheduleId; + const thisSeq = thisSeqMap.get(legScheduleId) ?? { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER }; + + const otherOriginId = (other.booking as any).originStationId; + const otherDestId = (other.booking as any).destinationStationId; + let otherOriginSeq = 0; + let otherDestSeq = Number.MAX_SAFE_INTEGER; + if (otherOriginId && otherDestId) { + const stops = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: legScheduleId, stationId: { in: [otherOriginId, otherDestId] } }, + select: { stationId: true, sequence: true }, + }); + otherOriginSeq = stops.find(s => s.stationId === otherOriginId)?.sequence ?? 0; + otherDestSeq = stops.find(s => s.stationId === otherDestId)?.sequence ?? Number.MAX_SAFE_INTEGER; + } + + // Segments overlap when: thisOrigin < otherDest AND otherOrigin < thisDest + if (thisSeq.originSeq < otherDestSeq && otherOriginSeq < thisSeq.destSeq) { + trueConflicts.push((other as any).seat.seatNumber); + } + } + + if (trueConflicts.length > 0) { + const labels = [...new Set(trueConflicts)].join(', '); throw new ConflictException( - `Seat(s) ${labels} are already confirmed for another booking.`, + `Seat(s) ${labels} are already confirmed for another booking on the same schedule and overlapping segment.`, ); } @@ -578,11 +639,14 @@ export class TicketsService { throw new NotFoundException('No ticket found for this booking'); } - // Check if ticket date matches today + // Check if ticket date matches today. Boarding window is relative to the + // passenger's actual boarding stop, not the train's origin — for a mid-route + // boarding these differ. const today = new Date(); - - if ((booking as any).schedule?.departureAt) { - const departureTime = new Date((booking as any).schedule.departureAt); + const boardingSegment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + + if (boardingSegment.departureAt) { + const departureTime = new Date(boardingSegment.departureAt); const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE); const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000); @@ -608,18 +672,6 @@ export class TicketsService { // Send notifications after successful boarding await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND'); - // Resolve user-selected segment rather than the full schedule route - const _schedStops = (booking as any).schedule?.stopTimes ?? []; - const _resolveStation = (id: string | null | undefined, fallback: any) => { - if (id) { - const found = _schedStops.find((st: any) => st.stationId === id)?.station; - if (found) return found; - } - return fallback; - }; - const boardingOrigin = _resolveStation((booking as any).originStationId, (booking as any).schedule?.originStation); - const boardingDest = _resolveStation((booking as any).destinationStationId, (booking as any).schedule?.destinationStation); - return { success: true, message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`, @@ -628,11 +680,11 @@ export class TicketsService { ticketNumber: ticket.barcodePayload, bookingRef: booking.bookingRef, passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A', - route: `${boardingOrigin?.name || 'N/A'} → ${boardingDest?.name || 'N/A'}`, + route: `${boardingSegment.origin?.name || 'N/A'} → ${boardingSegment.destination?.name || 'N/A'}`, seat: seatNumber, coach: coachNumber, trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A', - departureTime: (booking as any).schedule?.departureAt, + departureTime: boardingSegment.departureAt, boardedAt: result.validatedAt, leg: result.leg || 'OUTBOUND', bookingType: booking.bookingType, @@ -840,15 +892,21 @@ export class TicketsService { }; } - async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> { - const confirmedWithNoTickets = await this.prisma.booking.findMany({ - where: { - status: 'CONFIRMED', - tickets: { none: {} }, - paymentIntent: { status: 'SUCCEEDED' }, - }, - select: { id: true, bookingRef: true }, - }); + async generateMissing(limit = 10): Promise<{ processed: number; generated: number; failed: number; remaining: number; details: any[] }> { + const missingWhere = { + status: 'CONFIRMED' as const, + tickets: { none: {} }, + paymentIntent: { status: 'SUCCEEDED' as const }, + }; + + const [confirmedWithNoTickets, totalRemaining] = await Promise.all([ + this.prisma.booking.findMany({ + where: missingWhere, + select: { id: true, bookingRef: true }, + take: limit, + }), + this.prisma.booking.count({ where: missingWhere }), + ]); const details: any[] = []; let generated = 0; @@ -856,7 +914,7 @@ export class TicketsService { for (const booking of confirmedWithNoTickets) { try { - await this.generate(booking.id); + await this.smartAssignAndGenerate(booking.id); generated++; details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'generated' }); } catch (err) { @@ -865,7 +923,13 @@ export class TicketsService { } } - return { processed: confirmedWithNoTickets.length, generated, failed, details }; + return { + processed: confirmedWithNoTickets.length, + generated, + failed, + remaining: Math.max(0, totalRemaining - confirmedWithNoTickets.length), + details, + }; } async delete(id: string) { diff --git a/apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts b/apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts new file mode 100644 index 000000000..b05495296 --- /dev/null +++ b/apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts @@ -0,0 +1,34 @@ +/** + * Auth/authorization gaps (matrix Suite J), via route guard metadata — no boot needed. + * + * C-8 🔴 The exchange-rate write routes (PUT upsert, PATCH update) carry no METHOD-LEVEL guard, so + * they get only the global JwtGuard (authentication) and NOT @PassengerAdmin (authorization) + * — unlike DELETE, which is admin-gated. Net effect (verified live in + * e2e-ui .../pb-config-propagation.spec.ts BC-11): anonymous → 401, but ANY authenticated + * user incl. a passenger → 200 rewrites live FX. fare-engine/currency.controller.ts:25,32,42 + * + * NOTE: this metadata check proves the missing ADMIN guard, NOT "unauthenticated" — a global + * APP_GUARD=JwtGuard (SharedAuthModule) still requires a valid token. The earlier "unauthenticated + * FX write" reading was a false positive corrected by the live BC-11 test. + */ +import "reflect-metadata"; +import { CurrencyController } from "../src/modules/fare-engine/currency.controller"; + +const GUARDS_METADATA = "__guards__"; +function guardsOn(handler: unknown): unknown[] { + return (Reflect.getMetadata(GUARDS_METADATA, handler as object) as unknown[]) ?? []; +} + +describe("Auth gaps (Suite J)", () => { + it("C-8 🔴 PUT upsert exchange-rate has NO admin guard (only the global JwtGuard applies)", () => { + expect(guardsOn(CurrencyController.prototype.upsert)).toHaveLength(0); + }); + + it("C-8 🔴 PATCH update exchange-rate has NO admin guard (only the global JwtGuard applies)", () => { + expect(guardsOn(CurrencyController.prototype.update)).toHaveLength(0); + }); + + it("C-8 control: DELETE exchange-rate IS admin-gated — proving writes should be too", () => { + expect(guardsOn(CurrencyController.prototype.remove).length).toBeGreaterThan(0); + }); +}); diff --git a/apps/edr-passenger-api/test/authed-booking-passengerid.e2e-spec.ts b/apps/edr-passenger-api/test/authed-booking-passengerid.e2e-spec.ts new file mode 100644 index 000000000..ff4a59944 --- /dev/null +++ b/apps/edr-passenger-api/test/authed-booking-passengerid.e2e-spec.ts @@ -0,0 +1,122 @@ +/** + * C-9-UI 🔴 Authenticated POST /bookings is broken: the controller overrides passengerId with the + * JWT user id (`bookings.controller.ts:528-532`, "never trust the request body"), but the service + * only resolves an iamUserId → Passenger when it is NON-UUID (`bookings.service.ts:773`). Real IAM + * ids are UUIDs, and registration creates `Passenger.id ≠ iamUserId` (`passenger-auth.service.ts:225`), + * so `booking.create` uses the iamUserId directly as passengerId → foreign-key violation. + * + * This reproduces the controller's behavior by calling BookingsService.create with passengerId set to + * a UUID iamUserId (not the Passenger.id), exactly as the authed controller does. It also shows the + * CONTROL: passing the real Passenger.id succeeds — proving the resolution gap, not a fixture problem. + */ +import { BookingsService } from "../src/modules/bookings/bookings.service"; +import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma"; +import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core"; + +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +let seq = 0; +async function buildBookableGraph(prisma: any, passengerId: string, iamUserId: string) { + const passenger = await prisma.passenger.create({ data: { id: passengerId, iamUserId } }); + const train = await prisma.train.create({ data: { number: `AB-${++seq}`, name: "T" } }); + const schedule = await prisma.trainSchedule.create({ + data: { + trainId: train.id, + routeId: IDS.route, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + departureAt: new Date(Date.now() + 86_400_000), + arrivalAt: new Date(Date.now() + 90_000_000), + durationMinutes: 60, + }, + }); + await prisma.tripStopTime.createMany({ + data: [ + { scheduleId: schedule.id, stationId: IDS.stationA, sequence: 1 }, + { scheduleId: schedule.id, stationId: IDS.stationB, sequence: 2 }, + ], + }); + const coach = await prisma.coach.create({ data: { coachTypeId: IDS.coachType, number: `AB-${seq}` } }); + const seat = await prisma.seat.create({ data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "1" } }); + await prisma.fareRule.create({ + data: { tripId: schedule.id, seatClassId: IDS.seatClassLocal, baseFareMinor: 30_000, currency: "ETB", validFrom: new Date("2020-01-01") }, + }); + const hold = await prisma.seatHold.create({ + data: { scheduleId: schedule.id, seatIds: [seat.id], passengerId, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + return { schedule, seat, hold }; +} + +function dtoFor(passengerId: string, schedule: any, hold: any, seat: any) { + return { + passengerId, // the controller passes req.user.id here (the iamUserId) + scheduleId: schedule.id, + holdId: hold.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + seatClassId: IDS.seatClassLocal, + bookingType: "ONE_WAY", + passengers: [ + { + seatId: seat.id, + passengerName: "Auth User", + dateOfBirth: new Date("1990-01-01"), + idDocumentType: "PASSPORT", + passportNumber: "P1", + passportCountry: "ET", + nationality: "Ethiopian", + seatFareMinor: 30_000, + }, + ], + }; +} + +describe("Authenticated booking passengerId resolution (regression)", () => { + const prisma = getTestPrisma(); + let bookings: BookingsService; + + beforeAll(() => { + bookings = new BookingsService( + prisma as any, + { query: async () => [] } as any, // dataSource (resolveIamContact raw SQL → []) + asyncStub(), // seatsService + { emit: () => true } as any, + asyncStub(), // verifaydaService (PASSPORT skips) + asyncStub(), // currencyService (ETB skips) + asyncStub(), // fareEngine (FareRule short-circuits) + asyncStub(), // auditService + ); + }); + beforeEach(async () => { + await truncateAllPassenger(prisma); + await seedCore(prisma); + }); + afterAll(async () => { + await disconnectTestPrisma(); + }); + + it("🔴 create() with a UUID iamUserId (as the authed controller passes) FAILS the passenger FK", async () => { + const passengerId = "aaaaaaaa-0000-4000-8000-000000000001"; // real Passenger.id + const iamUserId = "bbbbbbbb-0000-4000-8000-000000000002"; // UUID iamUserId ≠ Passenger.id + const { schedule, hold, seat } = await buildBookableGraph(prisma, passengerId, iamUserId); + + // The controller calls service.create({ ...dto, passengerId: req.user.id }) — i.e. the iamUserId. + await expect( + bookings.create(dtoFor(iamUserId, schedule, hold, seat) as any), + ).rejects.toThrow(); // Prisma P2003 on Booking_passengerId_fkey + + expect(await prisma.booking.count()).toBe(0); + }); + + it("control: create() with the real Passenger.id succeeds — proving the gap is the id, not the fixture", async () => { + const passengerId = "aaaaaaaa-0000-4000-8000-000000000003"; + const iamUserId = "bbbbbbbb-0000-4000-8000-000000000004"; + const { schedule, hold, seat } = await buildBookableGraph(prisma, passengerId, iamUserId); + + const booking: any = await bookings.create(dtoFor(passengerId, schedule, hold, seat) as any); + expect(booking.id).toBeTruthy(); + expect(booking.passengerId).toBe(passengerId); + }); +}); diff --git a/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts new file mode 100644 index 000000000..93c0386b6 --- /dev/null +++ b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts @@ -0,0 +1,204 @@ +/** + * Per-station check-in cutoff — proves booking closure is now based on each stop's own + * ESTIMATED ARRIVAL time (computed from RouteStop.travelMinutesToStop), not the schedule's + * overall departure. The regression this guards: before this change, all stops effectively + * shared one cutoff basis, so a later station could be wrongly blocked (or an earlier one + * wrongly left open) together with the rest of the route. + * + * Uses the slim harness (SchedulesService, real Nest DI) for schedule creation — this exercises + * the actual cumulative travel-time interpolation in SchedulesService.createSchedule. SeatsService + * and TasksService are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule + * → RabbitMQ, which the slim harness deliberately avoids — see test/setup/slim-app.ts), so they're + * instantiated directly with a real Prisma + stubbed collaborators, mirroring the Tier-2 pattern in + * money-integrity.e2e-spec.ts. + */ +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { TasksService } from "../src/modules/tasks/tasks.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, DISTANCE, resetAndSeedCore } from "./fixtures/seed-core"; + +/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */ +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +/** Creates a fresh Train + TrainSchedule on the seed-core route via the real interpolation logic. */ +async function createTestSchedule( + harness: ServiceHarness, + schedules: SchedulesService, + opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }, +) { + const train = await harness.prisma.train.create({ + data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, + }); + + // createSchedule now rejects a schedule with zero coaches (see schedules.service.ts's + // "must have at least one coach assigned" guard) — the coach has to exist and be passed + // via coachIds BEFORE creation, not attached afterward. + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, + }); + const seats = await Promise.all( + ["1A", "1B", "1C", "1D"].map((seatNumber, i) => + harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 }, + }), + ), + ); + + const schedule = await schedules.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: opts.departureAt.toISOString(), + arrivalAt: opts.arrivalAt.toISOString(), + coachIds: [coach.id], + } as any); + + return { schedule, seats }; +} + +describe("Check-in cutoff — arrival-time basis, per-station independence", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let tasksService: TasksService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const systemConfig = new SystemConfigService(harness.prisma as any); + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + tasksService = new TasksService(harness.prisma as any, asyncStub(), asyncStub()); + }); + + afterAll(async () => { + await harness?.close(); + }); + + it("a later station remains independently bookable after an earlier station's cutoff has passed", async () => { + await resetAndSeedCore(harness.prisma); + + // dep only 5 min out (createSchedule requires a future departureAt). Route-level default + // checkinMinutesBefore is 30 (schema default, unset here), so A's cutoff (dep - 30min) is + // already ~25 min in the past by the time this runs — but B, with a 60-min travel time from + // A, has an arrival far enough out (dep + 60min) that its own cutoff (arrival - 30min) is + // still ~35 min in the future. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, + data: { travelMinutesToStop: 40 }, + }); + + const { schedule, seats } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-A-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + passengers: [{ passengerId: "11111111-1111-4111-8111-111111111111", seatId: seats[0].id }], + } as any), + ).rejects.toThrow(/cannot be held within/i); + + const held = await seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "22222222-2222-4222-8222-222222222222", seatId: seats[1].id }], + } as any); + expect(held).toBeTruthy(); + }); + + it("a stop-level checkinMinutesBefore override wins over the route-level default", async () => { + // Override B with a LARGE cutoff (90 min) — under the route default (30 min) this exact + // schedule's B segment would still be OPEN (see previous test), so a rejection here proves + // the stop-level override, not the default, is what's actually being applied. + await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-B-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "33333333-3333-4333-8333-333333333333", seatId: seats[0].id }], + } as any), + ).rejects.toThrow(/cannot be held within 90 minute/i); + }); + + it("syncScheduleStatuses closes only the specific stops past their own arrival-based cutoff", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, + data: { travelMinutesToStop: 40 }, + }); + + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-C-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await tasksService.syncScheduleStatuses(); + + const stopTimes = await harness.prisma.tripStopTime.findMany({ + where: { scheduleId: schedule.id }, + orderBy: { sequence: "asc" }, + }); + const byStation = Object.fromEntries(stopTimes.map((s) => [s.stationId, s.status])); + expect(byStation[IDS.stationA]).toBe("CHECKIN_CLOSED"); + expect(byStation[IDS.stationB]).toBe("OPEN"); + expect(byStation[IDS.stationC]).toBe("OPEN"); + }); + + it("a stop missing travelMinutesToStop falls back to distance interpolation without failing schedule creation", async () => { + await resetAndSeedCore(harness.prisma); // no travelMinutesToStop set on any stop + + const dep = new Date(Date.now() + 60 * 60_000); + const arr = new Date(dep.getTime() + 240 * 60_000); // 4h, matches seed-ui's convention + const { schedule } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-D-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + const stopTimes = await harness.prisma.tripStopTime.findMany({ + where: { scheduleId: schedule.id }, + orderBy: { sequence: "asc" }, + }); + const totalDuration = arr.getTime() - dep.getTime(); + const bProgress = DISTANCE.B / DISTANCE.C; + const expectedBArrival = new Date(dep.getTime() + totalDuration * bProgress); + + const bStop = stopTimes.find((s) => s.stationId === IDS.stationB)!; + expect(bStop.plannedArrivalAt?.getTime()).toBe(expectedBArrival.getTime()); + }); +}); diff --git a/apps/edr-passenger-api/test/config-validation.e2e-spec.ts b/apps/edr-passenger-api/test/config-validation.e2e-spec.ts new file mode 100644 index 000000000..d17f568a1 --- /dev/null +++ b/apps/edr-passenger-api/test/config-validation.e2e-spec.ts @@ -0,0 +1,74 @@ +/** + * Backoffice config validation suite (matrix Suite H). The global ValidationPipe in src/main.ts:56 + * enforces exactly these class-validator DTOs, so validating the DTOs directly reproduces what a + * raw API call (bypassing the HTML-only frontend checks) would be allowed to submit. + * H1 🔴 CreateFareRuleDto.baseFareMinor accepts NEGATIVE (no @Min) — while the sibling + * CreateSegmentFareDto.baseFareMinor has @Min(0) (inconsistent). + * H2 🔴 CreateSeatClassDto.basePrice accepts negative/zero (no @Min) — drives every distance fare. + * H4 🔴 CreatePromotionDto.percentOff accepts 200 (no @Max(100)) → discount > subtotal. + * H5 🔴 CreatePromotionDto.validUntil is @IsString (not @IsDateString) → accepts non-dates. + */ +import "reflect-metadata"; +import { plainToInstance } from "class-transformer"; +import { validate } from "class-validator"; + +import { CreateFareRuleDto } from "../src/modules/schedules/schedules.dto"; +import { CreateSegmentFareDto } from "../src/modules/segments/segment-fare.dto"; +import { CreateSeatClassDto } from "../src/modules/seat-classes/seat-classes.dto"; +import { CreatePromotionDto } from "../src/modules/promos/promos.dto"; + +/** Property names that produced a validation error. */ +async function erroredProps(dto: object): Promise { + const errors = await validate(dto); + return errors.map((e) => e.property); +} + +describe("Backoffice config validation (Suite H)", () => { + it("H1 🔴 CreateFareRuleDto accepts a NEGATIVE baseFareMinor (no @Min)", async () => { + const dto = plainToInstance(CreateFareRuleDto, { + seatClassId: "sc-1", + baseFareMinor: -100, + validFrom: "2026-01-01T00:00:00Z", + }); + expect(await erroredProps(dto)).not.toContain("baseFareMinor"); + }); + + it("H1 contrast: sibling CreateSegmentFareDto REJECTS negative baseFareMinor (@Min(0))", async () => { + const dto = plainToInstance(CreateSegmentFareDto, { + routeId: "rt-1", + originStopSequence: 1, + destinationStopSequence: 5, + seatClassId: "sc-1", + baseFareMinor: -100, + }); + expect(await erroredProps(dto)).toContain("baseFareMinor"); + }); + + it("H2 🔴 CreateSeatClassDto accepts a negative basePrice (no @Min)", async () => { + const dto = plainToInstance(CreateSeatClassDto, { + coachTypeId: "ct-1", + name: "Economy", + basePrice: -5000, + }); + expect(await erroredProps(dto)).not.toContain("basePrice"); + }); + + it("H4 🔴 CreatePromotionDto accepts percentOff = 200 (no @Max(100))", async () => { + const dto = plainToInstance(CreatePromotionDto, { + code: "OVER", + title: "Overshoot", + percentOff: 200, + validUntil: "2026-12-31T23:59:59Z", + }); + expect(await erroredProps(dto)).not.toContain("percentOff"); + }); + + it("H5 🔴 CreatePromotionDto.validUntil accepts a non-date string (@IsString, not @IsDateString)", async () => { + const dto = plainToInstance(CreatePromotionDto, { + code: "BADDATE", + title: "Bad date", + validUntil: "not-a-real-date", + }); + expect(await erroredProps(dto)).not.toContain("validUntil"); + }); +}); diff --git a/apps/edr-passenger-api/test/critical-repro.e2e-spec.ts b/apps/edr-passenger-api/test/critical-repro.e2e-spec.ts new file mode 100644 index 000000000..83a7d8d6b --- /dev/null +++ b/apps/edr-passenger-api/test/critical-repro.e2e-spec.ts @@ -0,0 +1,275 @@ +/** + * Executable reproducers for the highest-severity findings that were previously inspection-only. + * All Tier-2 (direct instantiation, real Prisma + stubbed collaborators). + * + * C-1 🔴 BookingsService trusts client `reviewedTotalMinor`: a booking is stored with totalMinor=1 + * while the server fare engine computed ~30000. + * C-4 🔴 finalizePaymentSuccess confirms a booking without comparing the paid amount: an intent for + * 1 minor confirms a 30000 booking. + * C-6 🔴 Concurrent WALLET payments double-spend one balance (no row lock): a wallet funded for one + * ticket pays for two. + */ +import { BookingsService } from "../src/modules/bookings/bookings.service"; +import { PaymentsService } from "../src/modules/payments/payments.service"; +import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; +import { CurrencyService } from "../src/modules/currency/currency.service"; +import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma"; +import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core"; + +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +/** + * Wraps a PrismaClient so that inside `$transaction(cb)`, every `walletAccount.update` waits until + * BOTH concurrent transactions have finished their `walletAccount.findUnique` (balance read). This + * deterministically forces the exact interleaving a real multi-request system permits, exposing the + * service's unlocked check-then-act (no SELECT … FOR UPDATE). Only scheduling is controlled — the + * service's own logic runs unmodified. + */ +function makeRaceWrappedPrisma(real: any, parties: number) { + let arrived = 0; + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const signalRead = () => { + if (++arrived >= parties) release(); + }; + + return new Proxy(real, { + get(target, prop, receiver) { + if (prop === "$transaction") { + return (cb: (tx: any) => unknown, opts?: unknown) => + target.$transaction((tx: any) => { + const wrappedTx = new Proxy(tx, { + get(t, p) { + if (p === "walletAccount") { + return { + findUnique: async (args: unknown) => { + const res = await t.walletAccount.findUnique(args); + signalRead(); + return res; + }, + update: async (args: unknown) => { + await gate; // hold the write until both reads are done + return t.walletAccount.update(args); + }, + }; + } + return t[p]; + }, + }); + return cb(wrappedTx); + }, opts); + } + return Reflect.get(target, prop, receiver); + }, + }); +} + +let seq = 0; +async function makeSchedule(prisma: any) { + const train = await prisma.train.create({ data: { number: `CR-${++seq}`, name: "T" } }); + return prisma.trainSchedule.create({ + data: { + trainId: train.id, + routeId: IDS.route, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + departureAt: new Date(Date.now() + 86_400_000), + arrivalAt: new Date(Date.now() + 90_000_000), + durationMinutes: 60, + }, + }); +} + +describe("Critical reproducers (Tier-2)", () => { + const prisma = getTestPrisma(); + + beforeEach(async () => { + await truncateAllPassenger(prisma); + await seedCore(prisma); + }); + afterAll(async () => { + await disconnectTestPrisma(); + }); + + // ── C-1 ────────────────────────────────────────────────────────────────── + it("C-1 🔴 booking stores client reviewedTotalMinor=1 while the fare engine computed ~30000", async () => { + const passenger = await prisma.passenger.create({ data: {} }); + const schedule = await makeSchedule(prisma); + // Stop times so origin/dest resolve on the schedule. + await prisma.tripStopTime.createMany({ + data: [ + { scheduleId: schedule.id, stationId: IDS.stationA, sequence: 1 }, + { scheduleId: schedule.id, stationId: IDS.stationB, sequence: 2 }, + ], + }); + // Coach + seat for the passenger to occupy. + const coach = await prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `C-${seq}` }, + }); + const seat = await prisma.seat.create({ + data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "1" }, + }); + // A real server fare source (tripId match → highest priority): 30000 minor. + await prisma.fareRule.create({ + data: { + tripId: schedule.id, + seatClassId: IDS.seatClassLocal, + baseFareMinor: 30_000, + currency: "ETB", + validFrom: new Date("2020-01-01"), + }, + }); + const hold = await prisma.seatHold.create({ + data: { + scheduleId: schedule.id, + seatIds: [seat.id], + passengerId: passenger.id, + expiresAt: new Date(Date.now() + 3_600_000), + }, + }); + + const bookings = new BookingsService( + prisma as any, + asyncStub(), // dataSource + asyncStub(), // seatsService (confirmSeats no-op) + { emit: () => true } as any, // eventEmitter + asyncStub(), // verifaydaService (PASSPORT path skips it anyway) + asyncStub(), // currencyService (ETB path skips it) + asyncStub(), // fareEngine (FareRule short-circuits before this) + asyncStub(), // auditService + ); + + const dto = { + passengerId: passenger.id, + scheduleId: schedule.id, + holdId: hold.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + seatClassId: IDS.seatClassLocal, + bookingType: "ONE_WAY", + reviewedTotalMinor: 1, // the forged client total + passengers: [ + { + seatId: seat.id, + passengerName: "Mallory Adult", + dateOfBirth: new Date("1990-01-01"), + idDocumentType: "PASSPORT", + passportNumber: "P123", + passportCountry: "ET", + nationality: "Ethiopian", + // NOTE: no seatFareMinor → not "allFaresProvided" → reviewedTotalMinor is trusted + }, + ], + }; + + const result: any = await (bookings as any).createOneWayBooking(dto); + + // The server engine computed the real fare… + expect(result.fareBreakdown.totalMinor).toBeGreaterThanOrEqual(30_000); + // …but the booking was stored at the client's forged 1 minor. + expect(result.totalMinor).toBe(1); + const stored = await prisma.booking.findUnique({ where: { id: result.id } }); + expect(stored?.totalMinor).toBe(1); + }); + + // ── C-4 ────────────────────────────────────────────────────────────────── + it("C-4 🔴 finalizePaymentSuccess confirms a 30000 booking from an intent of 1 (no amount check)", async () => { + const passenger = await prisma.passenger.create({ data: {} }); + const schedule = await makeSchedule(prisma); + const booking = await prisma.booking.create({ + data: { + bookingRef: "PAY-0001", + passengerId: passenger.id, + scheduleId: schedule.id, + totalMinor: 30_000, + status: "PENDING_PAYMENT", + }, + }); + const intent = await prisma.paymentIntent.create({ + data: { + bookingId: booking.id, + amountMinor: 1, // wildly short payment + method: "WALLET", + status: "PROCESSING", + }, + }); + + const payments = new PaymentsService( + prisma as any, + { confirmSeats: async () => undefined } as any, + { generate: async () => undefined } as any, // must not throw (re-thrown otherwise) + { emit: () => true } as any, + asyncStub(), // paymentClient + asyncStub(), // currencyService (not used on this path) + asyncStub(), // auditService + ); + + await payments.finalizePaymentSuccess({ intentId: intent.id }); + + const after = await prisma.booking.findUnique({ where: { id: booking.id } }); + // Confirmed despite intent.amountMinor (1) ≠ booking.totalMinor (30000). + expect(after?.status).toBe("CONFIRMED"); + }); + + // ── C-6 ────────────────────────────────────────────────────────────────── + it("C-6 🔴 two concurrent WALLET payments double-spend a single-ticket balance", async () => { + const passenger = await prisma.passenger.create({ data: {} }); + const schedule = await makeSchedule(prisma); + // Wallet funded for exactly ONE ticket. + await prisma.walletAccount.create({ + data: { passengerId: passenger.id, balanceMinor: 30_000 }, + }); + const mkBooking = (ref: string) => + prisma.booking.create({ + data: { + bookingRef: ref, + passengerId: passenger.id, + scheduleId: schedule.id, + totalMinor: 30_000, + status: "PENDING_PAYMENT", + }, + }); + const b1 = await mkBooking("W-0001"); + const b2 = await mkBooking("W-0002"); + + // Race-wrapped prisma forces both balance reads to complete before either debit writes. + const racePrisma = makeRaceWrappedPrisma(prisma, 2); + const payments = new PaymentsService( + racePrisma as any, + { confirmSeats: async () => undefined } as any, + { generate: async () => undefined } as any, + { emit: () => true } as any, + asyncStub(), + asyncStub(), + asyncStub(), + ); + + const [bk1, bk2] = await Promise.all([ + prisma.booking.findUnique({ where: { id: b1.id }, include: { seats: true } }), + prisma.booking.findUnique({ where: { id: b2.id }, include: { seats: true } }), + ]); + + const [r1, r2] = await Promise.allSettled([ + (payments as any).initiateWalletPayment(bk1), + (payments as any).initiateWalletPayment(bk2), + ]); + + const succeeded = await prisma.paymentIntent.count({ + where: { bookingId: { in: [b1.id, b2.id] }, status: { in: ["SUCCEEDED", "PROCESSING"] } }, + }); + const debits = await prisma.walletLedgerEntry.count({ where: { type: "DEBIT" } }); + const wallet = await prisma.walletAccount.findUnique({ + where: { passengerId: passenger.id }, + }); + + // Double-spend signature: two successful debits from a one-ticket balance, or a negative + // balance. A correctly-locked wallet allows exactly one. + const totalDebited = debits * 30_000; + const doubleSpent = + (succeeded === 2 && totalDebited > 30_000) || (wallet?.balanceMinor ?? 0) < 0; + expect(doubleSpent).toBe(true); + expect([r1.status, r2.status]).toEqual(["fulfilled", "fulfilled"]); + }); +}); diff --git a/apps/edr-passenger-api/test/fixtures/seed-core.ts b/apps/edr-passenger-api/test/fixtures/seed-core.ts new file mode 100644 index 000000000..115df217f --- /dev/null +++ b/apps/edr-passenger-api/test/fixtures/seed-core.ts @@ -0,0 +1,139 @@ +/** + * Deterministic core fixtures for the pricing E2E suites. + * + * The repo's `prisma/seed.ts` is entirely commented out (every step disabled), so the harness + * builds its own minimal, fully-controlled graph: coach type → seat classes → stations → route + * with distance-bearing stops → FX rates. Fixed UUIDs let specs reference entities directly. + * + * Uses a bare PrismaClient (not the Nest PrismaService) so it can run in jest globalSetup or + * inside a spec without booting the app. Reads DATABASE_URL from process.env (load-env sets it). + */ +import { PrismaClient } from "@prisma/client"; + +export const IDS = { + coachType: "00000000-0000-4000-8000-000000000001", + seatClassLocal: "00000000-0000-4000-8000-000000000010", + seatClassIntl: "00000000-0000-4000-8000-000000000011", + stationA: "00000000-0000-4000-8000-000000000020", + stationB: "00000000-0000-4000-8000-000000000021", + stationC: "00000000-0000-4000-8000-000000000022", + route: "00000000-0000-4000-8000-000000000030", +} as const; + +/** Route stop distances (km from origin). A=0, B=100, C=250 → A→B is 100km, A→C is 250km. */ +export const DISTANCE = { A: 0, B: 100, C: 250 } as const; + +/** + * Optional per-stop check-in-cutoff/travel-time overrides, keyed by station label (A/B/C). + * Lets a spec seed a distinct `checkinMinutesBefore` override and/or `travelMinutesToStop` + * per stop without changing the zero-arg call sites the other specs rely on. + */ +export interface RouteStopOverrides { + A?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; + B?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; + C?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; +} + +/** + * FX rate chosen so the seat-class distance formula (which multiplies an ETB/km rate by the + * USD→ETB rate — see fare-engine.service.ts:157) yields whole ETB-minor amounts. 100 makes the + * major→minor scaling line up; a realistic rate (e.g. 132) would visibly distort domestic fares, + * which is itself a finding the suites probe. + */ +export const USD_TO_ETB = 100; +export const ETB_TO_DJF = 1.8; + +/** TRUNCATE every table in the `passenger` schema (except Prisma's migration bookkeeping). */ +export async function truncateAllPassenger(prisma: PrismaClient): Promise { + const rows = await prisma.$queryRawUnsafe>( + `SELECT tablename FROM pg_tables WHERE schemaname = 'passenger' AND tablename <> '_prisma_migrations'`, + ); + if (rows.length === 0) return; + const list = rows.map((r) => `passenger."${r.tablename}"`).join(", "); + await prisma.$executeRawUnsafe( + `TRUNCATE ${list} RESTART IDENTITY CASCADE`, + ); +} + +/** Insert the deterministic core graph. Call after truncateAllPassenger. */ +export async function seedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise { + const past = new Date("2020-01-01T00:00:00.000Z"); + + await prisma.coachType.create({ + data: { + id: IDS.coachType, + code: "STD", + name: "Standard Coach", + type: "passenger", + }, + }); + + // LOCAL and INTERNATIONAL seat classes share the coach type + bedPosition (null = regular seat), + // which is exactly how fare-engine picks the nationality-matched class (findFirst on those keys). + await prisma.seatClass.createMany({ + data: [ + { + id: IDS.seatClassLocal, + coachTypeId: IDS.coachType, + name: "Local Standard", + nationalityType: "LOCAL", + bedPosition: null, + baseFareMinor: 300, // 3.00 ETB/km + premiumMinor: 0, + insuranceFeeMinor: 0, + isActive: true, + }, + { + id: IDS.seatClassIntl, + coachTypeId: IDS.coachType, + name: "Intl Standard", + nationalityType: "INTERNATIONAL", + bedPosition: null, + baseFareMinor: 500, // 5.00 ETB/km + premiumMinor: 0, + insuranceFeeMinor: 0, + isActive: true, + }, + ], + }); + + await prisma.station.createMany({ + data: [ + { id: IDS.stationA, code: "AAA", name: "Alpha", city: "Alpha City", sequence: 1 }, + { id: IDS.stationB, code: "BBB", name: "Bravo", city: "Bravo City", sequence: 2 }, + { id: IDS.stationC, code: "CCC", name: "Charlie", city: "Charlie City", sequence: 3 }, + ], + }); + + await prisma.route.create({ + data: { + id: IDS.route, + code: "RT-MAIN", + name: "Main Line", + effectiveFrom: past, + active: true, + stops: { + create: [ + { stationId: IDS.stationA, sequence: 1, distanceKm: DISTANCE.A, ...stopOverrides.A }, + { stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B, ...stopOverrides.B }, + { stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.C, ...stopOverrides.C }, + ], + }, + }, + }); + + await prisma.currencyExchangeRate.createMany({ + data: [ + { fromCurrency: "USD", toCurrency: "ETB", rate: USD_TO_ETB, effectiveDate: new Date() }, + { fromCurrency: "ETB", toCurrency: "USD", rate: 1 / USD_TO_ETB, effectiveDate: new Date() }, + { fromCurrency: "ETB", toCurrency: "DJF", rate: ETB_TO_DJF, effectiveDate: new Date() }, + { fromCurrency: "DJF", toCurrency: "ETB", rate: 1 / ETB_TO_DJF, effectiveDate: new Date() }, + ], + }); +} + +/** Convenience: reset + seed in one call. */ +export async function resetAndSeedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise { + await truncateAllPassenger(prisma); + await seedCore(prisma, stopOverrides); +} diff --git a/apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts b/apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts new file mode 100644 index 000000000..ed5e9ca36 --- /dev/null +++ b/apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts @@ -0,0 +1,89 @@ +/** + * Seeds a passenger IAM user + session directly (no OTP flow) and mints an access token the portal + * accepts. The API JwtGuard verifies the JWT signature (JWT_ACCESS_TOKEN_SECRET) and looks up the + * session by its `id` claim; the portal then calls /auth/profile which needs a Passenger row linked + * by iamUserId. Returns { token, profile } for the Playwright passenger storageState. + * + * Run standalone to validate: `npx ts-node test/fixtures/seed-passenger-session.ts` (prints the + * token and the /auth/profile status via the running API on :4000). + */ +import { PrismaClient } from "@prisma/client"; +import { SignJWT } from "jose"; +import { UI_IDS } from "./seed-ui"; + +export const PASSENGER_USER_ID = "11111111-0000-4000-8000-000000000001"; +export const PASSENGER_SESSION_ID = "11111111-0000-4000-8000-000000000002"; +const EMAIL = "test.passenger@edr.local"; +const USERNAME = "test_passenger"; + +export async function seedPassengerSession(prisma: PrismaClient): Promise<{ token: string }> { + const secret = process.env.JWT_ACCESS_TOKEN_SECRET; + if (!secret) throw new Error("JWT_ACCESS_TOKEN_SECRET is required to mint the passenger token"); + + const userInfo = { + id: PASSENGER_USER_ID, + name: { en: "Test Passenger" }, + email: EMAIL, + roles: [] as unknown[], + status: "accepted", + employee: [] as unknown[], + userType: "individual", + username: USERNAME, + permissions: [] as unknown[], + }; + const expiry = new Date(Date.now() + 7 * 86400_000); + + // iam.users (delete-then-insert so re-seeding is idempotent). + await prisma.$executeRawUnsafe(`DELETE FROM iam.sessions WHERE id = $1::uuid`, PASSENGER_SESSION_ID); + await prisma.$executeRawUnsafe(`DELETE FROM iam.users WHERE id = $1::uuid`, PASSENGER_USER_ID); + await prisma.$executeRawUnsafe( + `INSERT INTO iam.users (created_at, id, name, username, email, user_type, status, is_active, has_set_password, is_phone_number_verified, verified_by) + VALUES (now(), $1::uuid, $2::jsonb, $3, $4, 'individual', 'accepted', true, true, true, 'SYSTEM')`, + PASSENGER_USER_ID, + JSON.stringify(userInfo.name), + USERNAME, + EMAIL, + ); + await prisma.$executeRawUnsafe( + `INSERT INTO iam.sessions (created_at, id, email, device, "userInfo", expiry_time, refresh_count, status, user_id) + VALUES (now(), $1::uuid, $2, 'e2e', $3::jsonb, $4, 0, 'ACTIVE', $5::uuid)`, + PASSENGER_SESSION_ID, + EMAIL, + JSON.stringify(userInfo), + expiry, + PASSENGER_USER_ID, + ); + + // Link the seeded Passenger row to this IAM user so /auth/profile resolves. + await prisma.passenger.update({ + where: { id: UI_IDS.passenger }, + data: { iamUserId: PASSENGER_USER_ID }, + }); + + const token = await new SignJWT({ id: PASSENGER_SESSION_ID }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuedAt() + .setExpirationTime("7d") + .sign(new TextEncoder().encode(secret)); + + return { token }; +} + +if (require.main === module) { + (async () => { + const prisma = new PrismaClient(); + try { + const { token } = await seedPassengerSession(prisma); + const api = process.env.API_URL ?? "http://localhost:4000"; + const res = await fetch(`${api}/auth/profile`, { + headers: { Authorization: `Bearer ${token}` }, + }); + // eslint-disable-next-line no-console + console.log(`[passenger-session] /auth/profile -> HTTP ${res.status}`); + // eslint-disable-next-line no-console + console.log((await res.text()).slice(0, 400)); + } finally { + await prisma.$disconnect(); + } + })(); +} diff --git a/apps/edr-passenger-api/test/fixtures/seed-ui.ts b/apps/edr-passenger-api/test/fixtures/seed-ui.ts new file mode 100644 index 000000000..96ea79e65 --- /dev/null +++ b/apps/edr-passenger-api/test/fixtures/seed-ui.ts @@ -0,0 +1,197 @@ +/** + * UI E2E seed — extends seed-core with a BOOKABLE trip + payment methods + promos so the portal + * search/booking flow and the backoffice config flow have real data to drive. Runnable standalone + * (`ts-node test/fixtures/seed-ui.ts`) or importable (`seedUi(prisma)`) from the Playwright + * global-setup. Reads DATABASE_URL from the environment (the UI stack points at the 5544 test DB). + * + * Searchability: a schedule shows in POST /search when it is SCHEDULED, not package-only, has a + * future departure on the searched date, operational coaches with AVAILABLE seats, and a resolvable + * fare (seat-class distance formula using the seeded route-stop distances + USD→ETB rate). + */ +import { PrismaClient } from "@prisma/client"; +import { IDS, resetAndSeedCore } from "./seed-core"; + +export const UI_IDS = { + train: "00000000-0000-4000-8000-000000000100", + schedule: "00000000-0000-4000-8000-000000000101", + coach: "00000000-0000-4000-8000-000000000102", + // Passenger.id is set EQUAL to the IAM user id. The bookings controller overrides passengerId + // with the JWT user id (req.user.id), and the service only resolves iamUserId→passenger when it + // is NON-UUID — since IAM ids are UUIDs, it uses the id directly, so Passenger.id must equal it. + passenger: "11111111-0000-4000-8000-000000000001", + promoValid: "PROMO10", + promoExpired: "EXPIRED50", + // Return leg C→A on the same calendar date, for ROUND_TRIP scenarios (UA-6). The route stops are + // symmetric in distance (A=0, C=250) so the reverse leg prices identically to the outbound. + returnSchedule: "00000000-0000-4000-8000-000000000201", + returnCoach: "00000000-0000-4000-8000-000000000202", +} as const; + +/** Days-from-now the sample trip departs (tests search on this calendar date, Addis TZ). */ +export const DEPART_IN_DAYS = 2; + +export function sampleDepartAt(): Date { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + DEPART_IN_DAYS); + d.setUTCHours(6, 0, 0, 0); // 06:00Z ~ 09:00 Addis — safely same calendar day either TZ + return d; +} + +/** The date string a test passes to POST /search for the sample trip (YYYY-MM-DD). */ +export function sampleDepartDate(): string { + return sampleDepartAt().toISOString().slice(0, 10); +} + +export async function seedUi(prisma: PrismaClient): Promise { + await resetAndSeedCore(prisma); + + // Give the two seed-core seat classes the exact names the portal review flow maps by. + await prisma.seatClass.update({ + where: { id: IDS.seatClassLocal }, + data: { name: "Economy Regular" }, + }); + await prisma.seatClass.update({ + where: { id: IDS.seatClassIntl }, + data: { name: "Economy Regular Intl" }, + }); + + // Enabled payment methods — the portal pay page renders ONLY enabled PaymentMethod rows. + await prisma.paymentMethod.createMany({ + data: [ + { type: "WALLET", displayName: "Wallet", currency: "ETB", enabled: true, isDefault: true, sortOrder: 0 }, + { type: "TELEBIRR", displayName: "telebirr", currency: "ETB", enabled: true, sortOrder: 1 }, + ], + }); + + const departAt = sampleDepartAt(); + const arriveAt = new Date(departAt.getTime() + 4 * 3600_000); + + await prisma.train.create({ + data: { id: UI_IDS.train, number: "UI-100", name: "UI Test Express" }, + }); + + await prisma.trainSchedule.create({ + data: { + id: UI_IDS.schedule, + trainId: UI_IDS.train, + routeId: IDS.route, + originStationId: IDS.stationA, + destinationStationId: IDS.stationC, + departureAt: departAt, + arrivalAt: arriveAt, + durationMinutes: 240, + status: "SCHEDULED", + stopsCount: 3, + isPackageOnly: false, + }, + }); + + await prisma.tripStopTime.createMany({ + data: [ + { scheduleId: UI_IDS.schedule, stationId: IDS.stationA, sequence: 1, plannedDepartureAt: departAt, status: "OPEN" }, + { scheduleId: UI_IDS.schedule, stationId: IDS.stationB, sequence: 2, plannedDepartureAt: new Date(departAt.getTime() + 2 * 3600_000), status: "OPEN" }, + { scheduleId: UI_IDS.schedule, stationId: IDS.stationC, sequence: 3, plannedArrivalAt: arriveAt, status: "OPEN" }, + ], + }); + + // Enough seats that a full suite run (many bookings share one seeded DB, seats are not released + // between specs) never exhausts availability: 12 rows × 4 cols = 48 seats. + const SEAT_ROWS = 12; + await prisma.coach.create({ + data: { id: UI_IDS.coach, coachTypeId: IDS.coachType, number: "UI-C1", capacity: SEAT_ROWS * 4, sequence: 1, status: "ACTIVE" }, + }); + await prisma.coachAssignment.create({ + data: { scheduleId: UI_IDS.schedule, coachId: UI_IDS.coach, positionNumber: 1, isOperational: true }, + }); + await prisma.seat.createMany({ data: buildSeats(UI_IDS.coach, SEAT_ROWS) }); + + // Logged-in passenger with a funded wallet + loyalty (used by the portal storageState + WALLET pay). + await prisma.passenger.create({ data: { id: UI_IDS.passenger } }); + await prisma.walletAccount.create({ + data: { passengerId: UI_IDS.passenger, balanceMinor: 100_000_000 }, + }); + await prisma.loyaltyAccount.create({ + data: { passengerId: UI_IDS.passenger, pointsBalance: 500 }, + }); + + // Promotions (schema field names, NOT the backoffice UI names). Unique exact codes. + await prisma.promotion.createMany({ + data: [ + { title: "10% off", code: UI_IDS.promoValid, percentOff: 10, validUntil: new Date(Date.now() + 30 * 86400_000), active: true }, + { title: "Expired", code: UI_IDS.promoExpired, percentOff: 50, validUntil: new Date(Date.now() - 86400_000), active: true }, + ], + }); + + // One baggage allowance (for excess-baggage flows later). + await prisma.baggageAllowance.create({ + data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 50 }, + }); + + // ── Return leg (C→A) for ROUND_TRIP (UA-6) ────────────────────────────────── + // Same train, same day, departs after the outbound arrives. Distances are symmetric + // (A=0km … C=250km) so the reverse leg prices the same as the outbound. + const returnDepart = new Date(departAt.getTime() + 8 * 3600_000); // 8h after outbound departs + const returnArrive = new Date(returnDepart.getTime() + 4 * 3600_000); + await prisma.trainSchedule.create({ + data: { + id: UI_IDS.returnSchedule, + trainId: UI_IDS.train, + routeId: IDS.route, + originStationId: IDS.stationC, + destinationStationId: IDS.stationA, + departureAt: returnDepart, + arrivalAt: returnArrive, + durationMinutes: 240, + status: "SCHEDULED", + stopsCount: 3, + isPackageOnly: false, + }, + }); + await prisma.tripStopTime.createMany({ + data: [ + { scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationC, sequence: 1, plannedDepartureAt: returnDepart, status: "OPEN" }, + { scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationB, sequence: 2, plannedDepartureAt: new Date(returnDepart.getTime() + 2 * 3600_000), status: "OPEN" }, + { scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationA, sequence: 3, plannedArrivalAt: returnArrive, status: "OPEN" }, + ], + }); + await prisma.coach.create({ + data: { id: UI_IDS.returnCoach, coachTypeId: IDS.coachType, number: "UI-C2", capacity: 48, sequence: 1, status: "ACTIVE" }, + }); + await prisma.coachAssignment.create({ + data: { scheduleId: UI_IDS.returnSchedule, coachId: UI_IDS.returnCoach, positionNumber: 1, isOperational: true }, + }); + await prisma.seat.createMany({ data: buildSeats(UI_IDS.returnCoach, 12) }); +} + +/** Build `rows × 4` seats (cols A–D) for a coach. */ +function buildSeats(coachId: string, rows: number) { + const cols = ["A", "B", "C", "D"]; + const seats: Array<{ coachId: string; seatNumber: string; row: number; col: string; isWindow: boolean; isAisle: boolean }> = []; + for (let row = 1; row <= rows; row++) { + for (const col of cols) { + seats.push({ + coachId, + seatNumber: `${row}${col}`, + row, + col, + isWindow: col === "A" || col === "D", + isAisle: col === "B" || col === "C", + }); + } + } + return seats; +} + +// Standalone runner +if (require.main === module) { + (async () => { + const prisma = new PrismaClient(); + try { + await seedUi(prisma); + // eslint-disable-next-line no-console + console.log(`[seed-ui] done. Sample trip ${IDS.stationA}→${IDS.stationC} on ${sampleDepartDate()} (schedule ${UI_IDS.schedule}).`); + } finally { + await prisma.$disconnect(); + } + })(); +} diff --git a/apps/edr-passenger-api/test/jest-e2e.json b/apps/edr-passenger-api/test/jest-e2e.json index 0f4a0d400..8817895fc 100644 --- a/apps/edr-passenger-api/test/jest-e2e.json +++ b/apps/edr-passenger-api/test/jest-e2e.json @@ -2,6 +2,34 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testRegex": ".e2e-spec.ts$", - "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "testEnvironment": "node" + "testPathIgnorePatterns": [ + "/node_modules/", + "test/app.e2e-spec.ts" + ], + "transform": { + "^.+\\.(t|j)s$": ["ts-jest", { "isolatedModules": true }] + }, + "testEnvironment": "node", + "setupFiles": ["/setup/load-env.ts"], + "moduleNameMapper": { + "^file-type$": "/setup/stubs/file-type.ts", + "^@edr/types$": "/../../../packages/types/src/index.ts", + "^@edr/types/(.*)$": "/../../../packages/types/src/$1", + "^@/(.*)$": "/../src/$1" + }, + "testTimeout": 60000, + "maxWorkers": 1, + "reporters": [ + "default", + [ + "jest-html-reporters", + { + "publicPath": "/../e2e-report", + "filename": "index.html", + "pageTitle": "EDR Passenger — Pricing/Config E2E Results", + "expand": true, + "hideIcon": false + } + ] + ] } diff --git a/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts new file mode 100644 index 000000000..02ca87863 --- /dev/null +++ b/apps/edr-passenger-api/test/money-integrity.e2e-spec.ts @@ -0,0 +1,175 @@ +/** + * Tier-2 money-integrity suite — services behind the IAM/RabbitMQ wall, instantiated directly with + * a real Prisma (test DB) + stubbed collaborators. Confirms critical findings: + * F1/F2 🔴 WalletService.topUp credits any passenger's wallet with no ownership check and no + * payment backing (free money). + * G4/G5 🔴 BookingsService.cancel computes an 80% refund but NEVER disburses it — no PaymentRefund, + * no wallet credit; the cancellation sits at refundStatus PENDING forever. + * E1/E2 🔴 ExcessBaggageService.logCharge picks the OLDEST BaggageAllowance globally, ignoring the + * booking's seat class, and computes fee = feePerKgMinor × excessWeightKg. + */ +import { WalletService } from "../src/modules/wallet/wallet.service"; +import { BookingsService } from "../src/modules/bookings/bookings.service"; +import { ExcessBaggageService } from "../src/modules/excess-baggage/excess-baggage.service"; +import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma"; +import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core"; + +/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */ +function asyncStub(): any { + return new Proxy( + {}, + { get: () => async () => undefined }, + ); +} + +describe("Money integrity (Tier-2 direct instantiation)", () => { + const prisma = getTestPrisma(); + + beforeEach(async () => { + await truncateAllPassenger(prisma); + await seedCore(prisma); + }); + + afterAll(async () => { + await disconnectTestPrisma(); + }); + + // ── F1 / F2 ──────────────────────────────────────────────────────────────── + it("F1/F2 🔴 topUp credits another passenger's wallet — no ownership check, no payment backing", async () => { + const victim = await prisma.passenger.create({ data: {} }); + await prisma.walletAccount.create({ + data: { passengerId: victim.id, balanceMinor: 0 }, + }); + + const wallet = new WalletService(prisma as any); + + // An attacker-controlled call: just pass the victim's id. Nothing checks caller identity, + // and no PaymentIntent/settlement backs the credit. + await wallet.topUp(victim.id, 1_000_000, "free money"); + + const after = await prisma.walletAccount.findUnique({ + where: { passengerId: victim.id }, + }); + expect(after?.balanceMinor).toBe(1_000_000); + + // The only ledger entry is a bare CREDIT — no linked payment. + const ledger = await prisma.walletLedgerEntry.findMany({ + where: { walletId: after!.id }, + }); + expect(ledger).toHaveLength(1); + expect(ledger[0].type).toBe("CREDIT"); + expect(ledger[0].relatedBookingId ?? null).toBeNull(); + }); + + // ── G4 / G5 ──────────────────────────────────────────────────────────────── + it("G4/G5 🔴 cancel() computes floor(total*0.8) refund but never disburses it (stuck PENDING)", async () => { + const passenger = await prisma.passenger.create({ data: {} }); + // Give the passenger a wallet so we can prove NO refund lands in it. + const w = await prisma.walletAccount.create({ + data: { passengerId: passenger.id, balanceMinor: 0 }, + }); + const schedule = await makeSchedule(prisma, passenger.id); + + const booking = await prisma.booking.create({ + data: { + bookingRef: "CXL-0001", + passengerId: passenger.id, + scheduleId: schedule.id, + totalMinor: 30_000, + displayCurrency: "ETB", + status: "CONFIRMED", + }, + }); + + const bookings = new BookingsService( + prisma as any, + asyncStub(), // dataSource + asyncStub(), // seatsService + { emit: () => true } as any, // eventEmitter + asyncStub(), // verifaydaService + asyncStub(), // currencyService + asyncStub(), // fareEngine + asyncStub(), // auditService + ); + + const result: any = await bookings.cancel(booking.bookingRef, "test"); + + // Refund is COMPUTED as 80%: + expect(result.refundAmount).toBe(Math.floor(30_000 * 0.8) / 100); // 240.00 + + // …but recorded only as PENDING, and never actually paid out: + const cancellation = await prisma.bookingCancellation.findFirst({ + where: { bookingId: booking.id }, + }); + expect(cancellation?.refundStatus).toBe("PENDING"); + + // No PaymentRefund row was created anywhere (isolated DB) and the wallet was NOT credited. + const refundCount = await prisma.paymentRefund.count(); + expect(refundCount).toBe(0); + const walletAfter = await prisma.walletAccount.findUnique({ where: { id: w.id } }); + expect(walletAfter?.balanceMinor).toBe(0); + }); + + // ── E1 / E2 ──────────────────────────────────────────────────────────────── + it("E1/E2 🔴 excess-baggage uses the OLDEST allowance globally (ignores seat class); fee = rate×kg", async () => { + const passenger = await prisma.passenger.create({ data: {} }); + const schedule = await makeSchedule(prisma, passenger.id); + const booking = await prisma.booking.create({ + data: { + bookingRef: "BAG-0001", + passengerId: passenger.id, + scheduleId: schedule.id, + totalMinor: 30_000, + status: "CONFIRMED", + }, + }); + + // Oldest allowance is for the LOCAL class (rate 50). A later one for INTL (rate 200) should win + // for an intl booking — but logCharge ignores seat class and takes the oldest row. + await prisma.baggageAllowance.create({ + data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 50 }, + }); + await prisma.baggageAllowance.create({ + data: { seatClassId: IDS.seatClassIntl, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 200 }, + }); + + const service = new ExcessBaggageService( + prisma as any, + asyncStub(), // auditService + asyncStub(), // paymentClient + asyncStub(), // notifications + asyncStub(), // smsClient + asyncStub(), // emailClient + ); + + const charge: any = await service.logCharge({ + bookingId: booking.id, + excessWeightKg: 10, + collectCash: true, + } as any); + + // Used the oldest (LOCAL, 50) not any seat-class-matched rate; fee = 50 × 10. + expect(charge.feePerKgMinor).toBe(50); + expect(charge.totalMinor).toBe(50 * 10); + }); +}); + +let trainSeq = 0; + +/** Minimal TrainSchedule (+train) so booking/cancel fixtures satisfy FKs. */ +async function makeSchedule(prisma: any, _passengerId: string) { + const train = await prisma.train.create({ + data: { number: `T-${++trainSeq}`, name: "Test Train" }, + }); + return prisma.trainSchedule.create({ + data: { + trainId: train.id, + routeId: IDS.route, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + departureAt: new Date(Date.now() + 86_400_000), + arrivalAt: new Date(Date.now() + 90_000_000), + durationMinutes: 60, + }, + }); +} diff --git a/apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts b/apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts new file mode 100644 index 000000000..2490366b8 --- /dev/null +++ b/apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts @@ -0,0 +1,81 @@ +/** + * Currency / FX suite (matrix Suite C). Exercises CurrencyService directly. + * C2 🔴 missing rate: getExchangeRate() silently returns 1.0 while getRateOrThrow() throws — + * the display path degrades but the charge path errors on the SAME condition (divergence). + * C3 🔴 a future-dated rate is applied immediately (no `effectiveDate <= now` filter). + * C5 🔴 conversion routines disagree on units: displayMinorToChargeMajor / convertMinorToChargeMajor + * return MAJOR units, convertEtbMinorToChargeMinor returns MINOR — a 100x unit landmine both + * written into fields named `amountMinor` at their call sites. + */ +import { CurrencyService } from "../src/modules/currency/currency.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { resetAndSeedCore, USD_TO_ETB } from "./fixtures/seed-core"; + +describe("Pricing — CurrencyService (Suite C)", () => { + let harness: ServiceHarness; + let currency: CurrencyService; + + beforeAll(async () => { + harness = await createServiceHarness(); + currency = harness.moduleRef.get(CurrencyService); + }); + afterAll(async () => { + await harness?.close(); + }); + beforeEach(async () => { + await resetAndSeedCore(harness.prisma); + }); + + it("C2 🔴 same DB state, 100x divergence: getExchangeRate → 1.0, getRateOrThrow → 100 (via inverse)", async () => { + // Remove only the DIRECT USD→ETB row; the inverse ETB→USD (0.01) from the fixture stays. + await harness.prisma.currencyExchangeRate.deleteMany({ + where: { fromCurrency: "USD", toCurrency: "ETB" }, + }); + + // Display/fare path (getExchangeRate) has NO inverse fallback → silently returns 1.0 (wrong). + await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0); + + // Charge path (getRateOrThrow) DOES fall back to the inverse → 1 / 0.01 = 100 (correct). + await expect( + currency.getRateOrThrow("USD" as any, "ETB" as any), + ).resolves.toBe(USD_TO_ETB); + // → the display fare and the charged amount for the same trip differ by 100x. + }); + + it("C2b 🔴 truly-missing pair: getExchangeRate → 1.0 (silent), getRateOrThrow → throws", async () => { + await harness.prisma.currencyExchangeRate.deleteMany({ + where: { + OR: [ + { fromCurrency: "USD", toCurrency: "ETB" }, + { fromCurrency: "ETB", toCurrency: "USD" }, + ], + }, + }); + await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0); + await expect( + currency.getRateOrThrow("USD" as any, "ETB" as any), + ).rejects.toThrow(/No exchange rate/i); + }); + + it("C3 🔴 a future-dated rate is used right now (no effective-date gate)", async () => { + const future = new Date(Date.now() + 365 * 24 * 3600 * 1000); + await harness.prisma.currencyExchangeRate.create({ + data: { fromCurrency: "ETB", toCurrency: "USD", rate: 999, effectiveDate: future }, + }); + + // Correct behavior: ignore not-yet-effective rates. Actual: latest-by-date wins immediately. + const rate = await currency.getExchangeRate("ETB" as any, "USD" as any); + expect(rate).toBe(999); + }); + + it("C5 🔴 conversion routines return different UNITS for the same money (100x apart)", async () => { + // 100000 ETB minor = 1000.00 ETB. With ETB→USD = 1/100: + const asMajor = await currency.convertMinorToChargeMajor(100000, "ETB", "USD"); // → 10.00 (major) + const asMinor = await currency.convertEtbMinorToChargeMinor(100000, "USD"); // → 1000 (minor) + + expect(asMajor).toBeCloseTo(1000 / USD_TO_ETB, 2); // 10.00 + expect(asMinor).toBe(Math.round((100000 * 1) / USD_TO_ETB)); // 1000 + // Same amount, but the two results differ by 100x — and both feed fields named `amountMinor`. + expect(asMinor).toBe(asMajor * 100); + }); +}); diff --git a/apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts b/apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts new file mode 100644 index 000000000..c519d3856 --- /dev/null +++ b/apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts @@ -0,0 +1,131 @@ +/** + * Reference pricing suite — proves the slim harness boots and exercises FareEngineService directly. + * Also confirms two matrix findings against the running engine: + * D1 — a promo with percentOff > 100 drives the total NEGATIVE (no clamp at 0). + * C1 — a missing USD→ETB FX rate is silently substituted with 1.0 (fares collapse ~100x). + */ +import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; +import { + createServiceHarness, + ServiceHarness, +} from "./setup/slim-app"; +import { + IDS, + resetAndSeedCore, + DISTANCE, + USD_TO_ETB, +} from "./fixtures/seed-core"; + +describe("Pricing — FareEngineService (slim harness)", () => { + let harness: ServiceHarness; + let fareEngine: FareEngineService; + + beforeAll(async () => { + harness = await createServiceHarness(); + fareEngine = harness.moduleRef.get(FareEngineService); + }); + + afterAll(async () => { + await harness?.close(); + }); + + beforeEach(async () => { + await resetAndSeedCore(harness.prisma); + }); + + // nationality 'Ethiopian' → LOCAL seat class (3.00 ETB/km) and ETB billing (rate 1). + const baseDto = () => ({ + routeId: IDS.route, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + seatClassId: IDS.seatClassLocal, + nationality: "Ethiopian", + adultCount: 1, + childCount: 0, + }); + + it("boots and computes a positive baseline fare (A→B, local, 1 adult)", async () => { + const result = await fareEngine.calculate(baseDto() as any); + // 100km × 3.00 ETB/km × 1 × USD_TO_ETB(100) = 30000 minor (see seat-class formula). + expect(result.totalMinor).toBeGreaterThan(0); + expect(result.totalMinor).toBe(DISTANCE.B * 3 * USD_TO_ETB); + }); + + it("D1 🔴 promo percentOff=150 produces a NEGATIVE total (no floor at 0)", async () => { + await harness.prisma.promotion.create({ + data: { + title: "Overshoot", + code: "OVER150", + percentOff: 150, + validUntil: new Date(Date.now() + 86_400_000), + active: true, + }, + }); + + const result = await fareEngine.calculate({ + ...baseDto(), + promoCode: "OVER150", + } as any); + + // Expected (correct) behavior: total clamped at >= 0. Actual: negative. + expect(result.totalMinor).toBeLessThan(0); + }); + + it("D2 🔴 fixed amountOffMinor larger than subtotal drives total NEGATIVE", async () => { + const base = await fareEngine.calculate(baseDto() as any); // 30000 minor + await harness.prisma.promotion.create({ + data: { + title: "Huge fixed", + code: "FIXEDBIG", + amountOffMinor: base.totalMinor + 10_000, + validUntil: new Date(Date.now() + 86_400_000), + active: true, + }, + }); + + const result = await fareEngine.calculate({ + ...baseDto(), + promoCode: "FIXEDBIG", + } as any); + expect(result.totalMinor).toBeLessThan(0); + }); + + it("D4 🔴 promo with percentOff=0 is treated as FIXED (0 is falsy) and applies amountOffMinor", async () => { + // A promo intended as '0% off' but also carrying a stray fixed amount: the falsy check + // `promo.percentOff ? percent : amountOffMinor` wrongly applies the fixed discount. + await harness.prisma.promotion.create({ + data: { + title: "Zero percent", + code: "ZERO0", + percentOff: 0, + amountOffMinor: 5000, + validUntil: new Date(Date.now() + 86_400_000), + active: true, + }, + }); + + const base = await fareEngine.calculate(baseDto() as any); + const withPromo = await fareEngine.calculate({ + ...baseDto(), + promoCode: "ZERO0", + } as any); + + // A true 0% promo should not change the price; here it deducts the fixed 5000. + expect(withPromo.totalMinor).toBe(base.totalMinor - 5000); + }); + + it("C1 🔴 missing USD→ETB rate silently falls back to 1.0 (fare collapses ~100x)", async () => { + const withRate = await fareEngine.calculate(baseDto() as any); + + // Remove the USD→ETB rate the seat-class formula multiplies by. + await harness.prisma.currencyExchangeRate.deleteMany({ + where: { fromCurrency: "USD", toCurrency: "ETB" }, + }); + + const withoutRate = await fareEngine.calculate(baseDto() as any); + + // Correct behavior would be to reject/flag; instead the fare silently drops by the rate factor. + expect(withoutRate.totalMinor).toBe(withRate.totalMinor / USD_TO_ETB); + expect(withoutRate.totalMinor).toBeLessThan(withRate.totalMinor); + }); +}); diff --git a/apps/edr-passenger-api/test/setup/load-env.ts b/apps/edr-passenger-api/test/setup/load-env.ts new file mode 100644 index 000000000..7b67969cd --- /dev/null +++ b/apps/edr-passenger-api/test/setup/load-env.ts @@ -0,0 +1,39 @@ +/** + * Loads apps/edr-passenger-api/.env.test into process.env BEFORE the Nest AppModule boots. + * Registered as a jest `setupFile` (runs per test file, before the framework and before any + * `Test.createTestingModule`). Zero-dependency KEY=VALUE parser — dotenv is not a direct dep here. + * Existing process.env values win (so CI can override the DB URL without editing the file). + */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +// Prefer a local (gitignored) .env.test; fall back to the tracked .env.test.example so a fresh +// checkout of the branch runs the suites without a manual copy step. +const localPath = join(__dirname, "..", "..", ".env.test"); +const examplePath = join(__dirname, "..", "..", ".env.test.example"); +const envPath = existsSync(localPath) ? localPath : examplePath; + +try { + const raw = readFileSync(envPath, "utf8"); + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + // strip surrounding quotes if present + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) process.env[key] = value; + } +} catch (err) { + // Surface loudly — a missing .env.test means every suite would boot against the wrong DB. + throw new Error( + `[load-env] could not read ${envPath}: ${(err as Error).message}`, + ); +} diff --git a/apps/edr-passenger-api/test/setup/prisma.ts b/apps/edr-passenger-api/test/setup/prisma.ts new file mode 100644 index 000000000..484b9e43d --- /dev/null +++ b/apps/edr-passenger-api/test/setup/prisma.ts @@ -0,0 +1,26 @@ +/** + * Singleton PrismaClient against the hermetic test DB (DATABASE_URL from .env.test, loaded by + * setup/load-env.ts). Used by: + * - the fixture seeder (fixtures/seed-core.ts), and + * - "direct-instantiation" specs for services behind the IAM/RabbitMQ wall (BookingsService, + * PaymentsService, WalletService, …) which cannot be booted through their Nest modules because + * those transitively import the @tria-plc IAM stack (ESM-only `file-type`) / golevelup RabbitMQ. + * Those specs `new TheService(prisma, ...mockedCollaborators)` and assert the money logic. + */ +import { PrismaClient } from "@prisma/client"; + +let client: PrismaClient | undefined; + +export function getTestPrisma(): PrismaClient { + if (!client) { + client = new PrismaClient(); + } + return client; +} + +export async function disconnectTestPrisma(): Promise { + if (client) { + await client.$disconnect(); + client = undefined; + } +} diff --git a/apps/edr-passenger-api/test/setup/slim-app.ts b/apps/edr-passenger-api/test/setup/slim-app.ts new file mode 100644 index 000000000..c497619b8 --- /dev/null +++ b/apps/edr-passenger-api/test/setup/slim-app.ts @@ -0,0 +1,145 @@ +/** + * Slim Nest test harness — boots ONLY the passenger domain modules needed for pricing/booking + * tests, deliberately excluding the IAM (TriaIamModule), SharedAuth, and MinIO stack from + * app.module.ts. Those drag in `@tria-plc/api-common`'s file-crud/minio chain which requires the + * ESM-only `file-type` package that jest's CommonJS resolver cannot load. + * + * Two entry points: + * - createServiceHarness(): resolve services directly (FareEngineService, etc.) for unit/DB-level + * assertions on the money math. + * - createHttpHarness(): a full Nest HTTP app with the SAME global ValidationPipe as main.ts, so + * controller/DTO/pipe behavior (client-trust, DTO validation) is exercised end-to-end over HTTP. + * + * The IAM JwtGuard is overridden with an always-allow stub so protected routes are reachable; auth + * *enforcement* findings (which guards are missing) are asserted separately via route metadata, not + * by booting the real guard. + */ +import { Global, INestApplication, Module, ValidationPipe } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigModule } from "@nestjs/config"; +import { EventEmitterModule } from "@nestjs/event-emitter"; +import { ScheduleModule } from "@nestjs/schedule"; +import { getDataSourceToken } from "@nestjs/typeorm"; +import { PrismaClient } from "@prisma/client"; + +import { PrismaModule } from "../../src/common/prisma.module"; +import { PrismaService } from "../../src/common/prisma.service"; +import { SessionActivityInterceptor } from "../../src/common/interceptors/session-activity.interceptor"; +import { FareEngineModule } from "../../src/modules/fare-engine/fare-engine.module"; +import { CurrencyModule } from "../../src/modules/currency/currency.module"; +import { CurrenciesModule } from "../../src/modules/currencies/currencies.module"; +import { PromosModule } from "../../src/modules/promos/promos.module"; +import { SeatClassesModule } from "../../src/modules/seat-classes/seat-classes.module"; +import { StationsModule } from "../../src/modules/stations/stations.module"; +import { SchedulesModule } from "../../src/modules/schedules/schedules.module"; +import { SegmentsModule } from "../../src/modules/segments/segments.module"; +import { SystemConfigModule } from "../../src/modules/system-config/system-config.module"; + +/** + * A stub TypeORM DataSource, provided globally so IAM-derived providers that reach the slim + * harness transitively (e.g. NotificationsService via ExcessBaggageModule) can instantiate. + * Pricing tests never trigger the code paths that actually use it. + */ +const fakeDataSource = { + query: async () => [], + transaction: async (cb: (m: unknown) => unknown) => cb({}), + getRepository: () => ({}), + createQueryRunner: () => ({ + connect: async () => undefined, + startTransaction: async () => undefined, + commitTransaction: async () => undefined, + rollbackTransaction: async () => undefined, + release: async () => undefined, + manager: {}, + }), +}; + +@Global() +@Module({ + providers: [{ provide: getDataSourceToken(), useValue: fakeDataSource }], + exports: [getDataSourceToken()], +}) +class TestGlobalsModule {} + +/** Modules that are safe to import in isolation (verified free of the IAM/MinIO chain). */ +const DOMAIN_MODULES = [ + FareEngineModule, + CurrencyModule, + CurrenciesModule, + PromosModule, + SeatClassesModule, + StationsModule, + SchedulesModule, + SegmentsModule, + SystemConfigModule, +]; +// NOTE: ExcessBaggageModule/PaymentsModule/BookingsModule are intentionally excluded — they pull in +// NotificationsModule → @golevelup RabbitMQ which connects at boot. Their suites instantiate the +// service directly with mocked collaborators (see excess-baggage / booking-trust specs). + +async function buildModule(): Promise { + return Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + EventEmitterModule.forRoot(), + ScheduleModule.forRoot(), + TestGlobalsModule, + PrismaModule, + ...DOMAIN_MODULES, + ], + }) + // SessionActivityInterceptor needs the IAM TypeORM DataSource, which the slim harness + // deliberately omits. Replace it with a pass-through — it does not affect pricing logic. + .overrideProvider(SessionActivityInterceptor) + .useValue({ intercept: (_ctx: unknown, next: { handle: () => unknown }) => next.handle() }) + .compile(); +} + +export interface ServiceHarness { + moduleRef: TestingModule; + prisma: PrismaClient; + close: () => Promise; +} + +/** Resolve services for direct method-level assertions. */ +export async function createServiceHarness(): Promise { + const moduleRef = await buildModule(); + const prisma = moduleRef.get(PrismaService) as unknown as PrismaClient; + return { + moduleRef, + prisma, + close: async () => { + await moduleRef.close(); + }, + }; +} + +export interface HttpHarness { + app: INestApplication; + moduleRef: TestingModule; + prisma: PrismaClient; + close: () => Promise; +} + +/** Boot a full HTTP app with the production ValidationPipe config from src/main.ts:56. */ +export async function createHttpHarness(): Promise { + const moduleRef = await buildModule(); + const app = moduleRef.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidUnknownValues: false, + }), + ); + await app.init(); + const prisma = moduleRef.get(PrismaService) as unknown as PrismaClient; + return { + app, + moduleRef, + prisma, + close: async () => { + await app.close(); + }, + }; +} diff --git a/apps/edr-passenger-api/test/setup/stubs/file-type.ts b/apps/edr-passenger-api/test/setup/stubs/file-type.ts new file mode 100644 index 000000000..77c308c9a --- /dev/null +++ b/apps/edr-passenger-api/test/setup/stubs/file-type.ts @@ -0,0 +1,10 @@ +/** + * CommonJS stub for the ESM-only `file-type` package (v21). jest's CommonJS resolver cannot load + * the real one, and `@tria-plc/api-common`'s minio.service `require("file-type")` at import time, + * dragging the whole IAM stack down with it. minio.service only calls fileTypeFromBuffer when + * actually processing an upload — never during pricing/booking tests — so a stub is sufficient to + * let the full AppModule boot. Mapped via jest `moduleNameMapper` (^file-type$). + */ +export async function fileTypeFromBuffer(): Promise { + return undefined; +} diff --git a/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts b/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts new file mode 100644 index 000000000..dfcc298fd --- /dev/null +++ b/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts @@ -0,0 +1,366 @@ +/** + * Stop-based (mid-route) booking — segment correctness suite. + * + * Regression coverage for three bugs reported against live stop-based bookings: + * + * 1. Search results (SearchService.buildScheduleResult) showed the train's overall + * departure/arrival instead of the selected origin/destination stop's own time — e.g. + * searching B→C on a A→B→C schedule showed A's departure time, not B's. Rooted in + * resolving the boarding/alighting STATION correctly for a mid-route segment while still + * reading TIME off the schedule's full-route span. Fixed via resolveBookingSegment() (also + * used by BookingsService, TicketsService, NotificationsService) — see + * src/common/utils/segment-resolver.utils.ts. + * 2. GuestBookingService's 30-minute booking cutoff was computed off the train's origin + * departure regardless of where the passenger actually boards, so a schedule whose origin + * had already departed >30min ago wrongly blocked booking a downstream segment that + * hadn't closed yet. + * 3. Even after (2), GuestBookingService still enforced a hardcoded, non-configurable 30 + * minutes — ignoring RouteStop/Route.checkinMinutesBefore, the SAME configurable cutoff + * that SeatsService.holdSeats and the search step already enforce. A passenger who passed + * the earlier steps under a shorter (or longer) CONFIGURED cutoff could still be wrongly + * rejected — or wrongly allowed — at /booking/review with "not accepted within 30 minutes + * of departure". Fixed by having GuestBookingService use the same resolveCheckinCutoff() + * utility as SeatsService.holdSeats and SearchService — see + * src/common/utils/checkin-cutoff.utils.ts. + * + * Uses the slim harness (real Nest DI) for SchedulesService — this exercises the actual + * cumulative travel-time interpolation in SchedulesService.createSchedule, same as + * checkin-cutoff.e2e-spec.ts. SeatsService/SearchService/BookingsService/GuestBookingService + * are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule → RabbitMQ), + * so they're instantiated directly with a real Prisma + stubbed collaborators, mirroring the + * Tier-2 pattern in money-integrity.e2e-spec.ts. + */ +import { IdDocumentType } from "@prisma/client"; +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { SegmentsService } from "../src/modules/segments/segments.service"; +import { SearchService } from "../src/modules/search/search.service"; +import { CurrencyService } from "../src/modules/currency/currency.service"; +import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { BookingsService } from "../src/modules/bookings/bookings.service"; +import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; + +/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */ +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +/** Formats a Date as a YYYY-MM-DD string in the process's local timezone (EAT on this host — + * matches search.service.ts's "+03:00" date-matching window). */ +function localDateStr(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +describe("Stop-based booking — segment time & cutoff correctness", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let searchService: SearchService; + let bookingsService: BookingsService; + let guestBookingService: GuestBookingService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const currencyService = harness.moduleRef.get(CurrencyService); + const fareEngine = harness.moduleRef.get(FareEngineService); + const segmentsService = new SegmentsService(harness.prisma as any); + const systemConfig = new SystemConfigService(harness.prisma as any); + + searchService = new SearchService(harness.prisma as any, currencyService, fareEngine, segmentsService); + // holdSeats() itself never touches segmentsService (only getSeatMap/availability-map + // callers do), so stubbing it here is safe — mirrors checkin-cutoff.e2e-spec.ts. + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + bookingsService = new BookingsService( + harness.prisma as any, + asyncStub(), // dataSource + seatsService, + { emit: () => true } as any, // eventEmitter + asyncStub(), // verifaydaService + currencyService, + fareEngine, + asyncStub(), // auditService + ); + guestBookingService = new GuestBookingService( + harness.prisma as any, + seatsService, + asyncStub(), // verifaydaService — never reached: test passengers use PASSPORT, not NATIONAL_ID + currencyService, + asyncStub(), // passengerAuthService — never reached: no createAccount in these DTOs + fareEngine, + { emit: () => true } as any, // eventEmitter + ); + }); + + afterAll(async () => { + await harness?.close(); + }); + + /** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation + * (createSchedule now rejects a schedule with zero coaches). */ + async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }) { + const train = await harness.prisma.train.create({ + data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, + }); + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, + }); + const seats = await Promise.all( + ["1A", "1B", "1C", "1D"].map((seatNumber, i) => + harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 }, + }), + ), + ); + const schedule = await schedulesService.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: opts.departureAt.toISOString(), + arrivalAt: opts.arrivalAt.toISOString(), + coachIds: [coach.id], + } as any); + return { schedule, seats }; + } + + function foreignPassenger(seatId: string) { + return { + seatId, + passengerName: "Test Passenger", + dateOfBirth: "1990-01-01", + idDocumentType: IdDocumentType.PASSPORT, + passportNumber: "X123456", + passportCountry: "Djibouti", + nationality: "Djiboutian", + }; + } + + describe("search results (SearchService.searchTrips)", () => { + it("shows the boarding stop's own departure time, not the schedule's full-route (station A) departure", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + const dep = new Date(Date.now() + 3 * 60 * 60_000); // A's departure, 3h out + const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival + const { schedule } = await createTestSchedule({ trainNumber: `SEG-DEP-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const result: any = await searchService.searchTrips({ + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + date: localDateStr(dep), + adultCount: 1, + } as any); + + const found = result.outbound.find((o: any) => o.scheduleId === schedule.id); + expect(found).toBeTruthy(); + + const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000); + expect(new Date(found.departureAt).getTime()).toBe(expectedBDeparture.getTime()); + // Would equal A's departure (`dep`) under the old (buggy) schedule.departureAt fallback. + expect(new Date(found.departureAt).getTime()).not.toBe(dep.getTime()); + }); + + it("shows the alighting stop's own arrival time, not the schedule's full-route (station C) arrival", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival + const { schedule } = await createTestSchedule({ trainNumber: `SEG-ARR-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const result: any = await searchService.searchTrips({ + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + date: localDateStr(dep), + adultCount: 1, + } as any); + + const found = result.outbound.find((o: any) => o.scheduleId === schedule.id); + expect(found).toBeTruthy(); + + const expectedBArrival = new Date(dep.getTime() + 60 * 60_000); + expect(new Date(found.arrivalAt).getTime()).toBe(expectedBArrival.getTime()); + // Would equal C's arrival (`arr`) under the old (buggy) schedule.arrivalAt fallback. + expect(new Date(found.arrivalAt).getTime()).not.toBe(arr.getTime()); + }); + }); + + describe("guest booking cutoff (GuestBookingService.createGuestBooking)", () => { + it("does NOT block booking a downstream segment whose own boarding stop is still far out, even though the schedule's origin already departed", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 150 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // A departs in 5min (already inside a naive 30-min-before-departure cutoff), but B — the + // passenger's actual boarding stop — is A+150min out (~2.5h), comfortably clear. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 190 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-OK-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "66666666-6666-4666-8666-666666666666", seatId: seats[0].id }], + } as any); + + const booking: any = await guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: (hold as any).holdId, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any); + + expect(booking.bookingRef).toBeTruthy(); + expect(booking.originStationId).toBe(IDS.stationB); + expect(booking.destinationStationId).toBe(IDS.stationC); + }); + + it("still blocks booking when the passenger's own boarding stop is itself within 30 minutes of its departure", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 10 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // B departs at dep+10min (~15min from now) — inside the 30-min cutoff. Hold is created + // directly (bypassing SeatsService.holdSeats' own, separately-tested arrival-based + // cutoff — see checkin-cutoff.e2e-spec.ts) to isolate GuestBookingService's own check. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 50 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-BLOCK-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await harness.prisma.seatHold.create({ + data: { + scheduleId: schedule.id, + seatIds: [seats[0].id], + passengerId: "77777777-7777-4777-8777-777777777777", + expiresAt: new Date(Date.now() + 10 * 60_000), + }, + }); + + await expect( + guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: hold.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any), + ).rejects.toThrow(/not accepted within 30 minutes/i); + }); + + it("honors a stop-level checkinMinutesBefore override SHORTER than 30 minutes — booking succeeds inside the old hardcoded window", async () => { + // Regression for the reported bug: /booking/review still rejected a booking with + // "not accepted within 30 minutes of departure" even after the passenger passed the + // earlier steps under a shorter CONFIGURED cutoff — because createGuestBooking used to + // enforce its own separate, hardcoded 30 minutes regardless of RouteStop/Route + // .checkinMinutesBefore. B's own configured cutoff here is 10 minutes. + await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 10 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 20 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // B departs at dep+20min (~25min from now) — inside the OLD hardcoded 30-min cutoff, + // but outside B's own configured 10-min cutoff. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 60 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "88888888-8888-4888-8888-888888888888", seatId: seats[0].id }], + } as any); + + const booking: any = await guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: (hold as any).holdId, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any); + + expect(booking.bookingRef).toBeTruthy(); + }); + + it("honors a stop-level checkinMinutesBefore override LONGER than 30 minutes — still blocks past the old hardcoded window", async () => { + await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 40 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // B departs at dep+40min (~45min from now) — outside the OLD hardcoded 30-min cutoff + // (would have wrongly been allowed), but inside B's own configured 90-min cutoff. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 80 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG2-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await harness.prisma.seatHold.create({ + data: { + scheduleId: schedule.id, + seatIds: [seats[0].id], + passengerId: "99999999-9999-4999-8999-999999999999", + expiresAt: new Date(Date.now() + 10 * 60_000), + }, + }); + + await expect( + guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: hold.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any), + ).rejects.toThrow(/not accepted within 90 minutes/i); + }); + }); + + describe("booking detail & list segment resolution (BookingsService)", () => { + it("getByRef and findByPassengerId show the boarding stop's own time and station, not the schedule's full-route span", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule } = await createTestSchedule({ trainNumber: `SEG-DETAIL-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const passenger = await harness.prisma.passenger.create({ data: {} }); + const booking = await harness.prisma.booking.create({ + data: { + bookingRef: `SEGDET${Date.now()}`, + passengerId: passenger.id, + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + status: "CONFIRMED", + totalMinor: 10000, + displayCurrency: "ETB", + }, + }); + + const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000); + + const detail: any = await bookingsService.getByRef(booking.bookingRef); + expect(new Date(detail.schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime()); + expect(detail.schedule.origin.id).toBe(IDS.stationB); + expect(detail.schedule.destination.id).toBe(IDS.stationC); + + const list: any = await bookingsService.findByPassengerId(passenger.id); + expect(list.items).toHaveLength(1); + expect(new Date(list.items[0].schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime()); + expect(list.items[0].schedule.originStation.id).toBe(IDS.stationB); + }); + }); +}); diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index b248fd68a..7eede0089 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -234,7 +234,7 @@ function DashboardPageContent() { )} {/* Stat cards */} -
+
@@ -257,7 +257,28 @@ function DashboardPageContent() { }, ]} /> - {/* Tickets card hidden temporarily */} + + } + iconBg="bg-emerald-100 dark:bg-emerald-900/30" + label="Tickets" + total={stats?.totalTickets ?? 0} + loading={statsLoading} + href="/tickets" + rows={[ + { + label: "Regular", + value: stats?.totalNormalTickets ?? 0, + href: "/tickets", + }, + { + label: "Package", + value: stats?.totalPackageTickets ?? 0, + href: "/tickets", + }, + ]} + /> {/* Revenue card */}
diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index feca7e3b5..251d034b1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Edit, Trash2, X, Search, Train, Save, GripVertical } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; @@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; +import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; interface RouteStop { stationId: string; @@ -17,6 +18,7 @@ interface RouteStop { distanceKm?: number; distanceFromOrigin?: number; checkinMinutesBefore?: number; + travelMinutesToStop?: number; } type Tab = 'routes' | 'coaches'; @@ -175,8 +177,18 @@ export default function RoutesPage() { const [destinationDistance, setDestinationDistance] = useState(undefined); const [originCheckinMinutes, setOriginCheckinMinutes] = useState(undefined); const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState(undefined); + const [destinationTravelMinutes, setDestinationTravelMinutes] = useState(undefined); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); + const [error, setError] = useState(null); + const errorBannerRef = useRef(null); + + // The form scrolls internally (long stop lists push the error banner above the fold), so a + // submit failure can land silently off-screen with no visible indication anything went wrong. + // Scroll the banner into view whenever a new error appears. + useEffect(() => { + if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [error]); const queryClient = useQueryClient(); const { data: routes, isLoading: routesLoading } = useQuery({ @@ -198,6 +210,11 @@ export default function RoutesPage() { queryClient.invalidateQueries({ queryKey: ['routes'] }); setShowModal(false); setEditingRoute(null); + setError(null); + }, + onError: (e: any) => { + const msg = e?.response?.data?.message || e?.message || 'Failed to create route'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -207,6 +224,11 @@ export default function RoutesPage() { queryClient.invalidateQueries({ queryKey: ['routes'] }); setShowModal(false); setEditingRoute(null); + setError(null); + }, + onError: (e: any) => { + const msg = e?.response?.data?.message || e?.message || 'Failed to update route'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -244,6 +266,8 @@ export default function RoutesPage() { const sortedMiddleStops = stops; // distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm) + // travelMinutesToStop = minutes of travel from the PREVIOUS stop, used to estimate this + // stop's arrival time. The origin (sequence 1) has no predecessor, so it gets none. const stopsArray = [ { stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined }, ...sortedMiddleStops.map((stop, idx) => ({ @@ -251,12 +275,14 @@ export default function RoutesPage() { sequence: idx + 2, distanceKm: stop.distanceFromOrigin || 0, checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined, + travelMinutesToStop: stop.travelMinutesToStop ?? undefined, })), { stationId: destinationStationId, sequence: sortedMiddleStops.length + 2, distanceKm: destinationDistance || 0, checkinMinutesBefore: destinationCheckinMinutes ?? undefined, + travelMinutesToStop: destinationTravelMinutes ?? undefined, }, ]; @@ -266,8 +292,10 @@ export default function RoutesPage() { name: formData.get('name') as string, description: formData.get('description') as string || undefined, active: !editingRoute ? (formData.get('active') !== 'false') : undefined, - effectiveFrom: formData.get('effectiveFrom') as string, - effectiveUntil: formData.get('effectiveUntil') as string || undefined, + effectiveFrom: eatLocalToISO(formData.get('effectiveFrom') as string), + // null (not undefined) so clearing the field on an edit explicitly clears effectiveUntil + // server-side, instead of being silently dropped as "no change". + effectiveUntil: formData.get('effectiveUntil') ? eatLocalToISO(formData.get('effectiveUntil') as string) : null, checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined, stops: stopsArray, }; @@ -280,7 +308,12 @@ export default function RoutesPage() { }; const addStop = () => { - setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]); + // distanceKm must be cumulative and strictly increasing (enforced server-side) — defaulting + // every new stop to 0 made each one collide with the previous, so simply clicking "Add + // Intermediate Stop" a few times and saving without hand-editing every distance always failed + // validation. Default each new stop's distance a step past whatever precedes it instead. + const lastDistance = stops.length > 0 ? (stops[stops.length - 1].distanceFromOrigin ?? 0) : 0; + setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: lastDistance + 10 }]); }; const removeStop = (index: number) => { @@ -390,14 +423,17 @@ export default function RoutesPage() { setDestinationStationId(destStop.stationId); setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined); setDestinationDistance(destStop.distanceKm || 0); + setDestinationTravelMinutes(destStop.travelMinutesToStop ?? undefined); setStops(routeStops.slice(1, -1).map((s: any) => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm, distanceFromOrigin: s.distanceKm || 0, checkinMinutesBefore: s.checkinMinutesBefore ?? undefined, + travelMinutesToStop: s.travelMinutesToStop ?? undefined, }))); } + setError(null); setShowModal(true); } finally { setEditLoading(false); @@ -436,7 +472,9 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); + setError(null); setShowModal(true); }} > @@ -515,13 +553,20 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); setSearch(''); + setError(null); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} size="lg" > + {error && ( +
+ {error} +
+ )} {editingRoute && (

⚠ Warning

@@ -647,7 +692,7 @@ export default function RoutesPage() { type="datetime-local" name="effectiveFrom" className="input" - defaultValue={editingRoute?.effectiveFrom ? new Date(editingRoute.effectiveFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)} + defaultValue={editingRoute?.effectiveFrom ? isoToEATLocal(editingRoute.effectiveFrom) : isoToEATLocal(new Date())} required />
@@ -657,7 +702,7 @@ export default function RoutesPage() { type="datetime-local" name="effectiveUntil" className="input" - defaultValue={editingRoute?.effectiveUntil ? new Date(editingRoute.effectiveUntil).toISOString().slice(0, 16) : ''} + defaultValue={editingRoute?.effectiveUntil ? isoToEATLocal(editingRoute.effectiveUntil) : ''} />
@@ -665,7 +710,7 @@ export default function RoutesPage() {
- Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit) + Drag to rearrange · Travel min estimates arrival from the previous stop (falls back to distance if blank) · Cutoff min overrides route check-in window per stop (leave blank to inherit)
@@ -741,6 +786,17 @@ export default function RoutesPage() { required />
+
+ updateStop(index, 'travelMinutesToStop', e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time" + /> +
)}
+
+ {destinationStationId && ( + setDestinationTravelMinutes(e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time" + /> + )} +
@@ -833,8 +902,10 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); setSearch(''); + setError(null); }} > Cancel diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 0f37825fb..17a31832a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react'; +import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, RefreshCw } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -12,6 +12,7 @@ import { routeCoachTemplatesApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; +import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import DateTimePicker from '@/components/ui/DateTimePicker'; interface Schedule { @@ -60,8 +61,15 @@ export default function SchedulesPage() { { isOpen: false, item: null } ); const [error, setError] = useState(null); + const errorBannerRef = useRef(null); const queryClient = useQueryClient(); + // These modals can scroll internally — a submit failure can land silently off-screen with no + // visible indication anything went wrong. Scroll the banner into view when a new error appears. + useEffect(() => { + if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [error]); + const [bulkForm, setBulkForm] = useState({ trainId: '', routeId: '', @@ -167,7 +175,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to generate schedules'); + const msg = err.response?.data?.message || 'Failed to generate schedules'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -181,7 +190,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to create schedule'); + const msg = err.response?.data?.message || 'Failed to create schedule'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -195,7 +205,15 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to update schedule'); + const msg = err.response?.data?.message || 'Failed to update schedule'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); + }, + }); + + const recalculateStopsMutation = useMutation({ + mutationFn: (id: string) => apiClient.post(`/schedules/${id}/recalculate-stops`, {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['schedules'] }); }, }); @@ -229,20 +247,6 @@ export default function SchedulesPage() { }, }); - /** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return an ISO string. */ - const eatLocalToISO = (local: string): string => { - if (!local) return ''; - return new Date(local + ':00+03:00').toISOString(); - }; - - /** Convert a UTC ISO string to a datetime-local value in EAT (UTC+3). */ - const isoToEATLocal = (iso: string): string => { - if (!iso) return ''; - const utcMs = new Date(iso).getTime(); - const eatMs = utcMs + 3 * 60 * 60 * 1000; - return new Date(eatMs).toISOString().slice(0, 16); - }; - const handleBulkSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -685,7 +689,7 @@ export default function SchedulesPage() { size="xl" > - {error &&
{error}
} + {error &&
{error}
}
@@ -803,7 +807,7 @@ export default function SchedulesPage() { > {error && ( -
+
{error}
)} @@ -1022,7 +1026,7 @@ export default function SchedulesPage() { {editingSchedule && ( {error && ( -
+
{error}
)} @@ -1133,6 +1137,15 @@ export default function SchedulesPage() { > Cancel + editingSchedule && recalculateStopsMutation.mutate(editingSchedule.id)} + > + Recalculate Stop Times + Update Schedule 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 5c6354fd4..8731a1eb4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -10,7 +10,7 @@ import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Modal from '@/components/ui/Modal'; import Image from 'next/image'; -import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api'; +import { ticketsApi, apiClient, stationsApi, excessBaggageApi, bookingsApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; @@ -100,10 +100,36 @@ export default function TicketsPage() { const [generateMissingResult, setGenerateMissingResult] = useState(null); + const { data: hasMissingTickets } = useQuery({ + queryKey: ['bookings-missing-tickets'], + queryFn: async () => { + const res = await bookingsApi.getAll({ status: 'CONFIRMED', paymentStatus: 'SUCCEEDED', take: 50 }); + const items: any[] = (res as any)?.items ?? (Array.isArray(res) ? res : []); + return items.some((b: any) => !b.tickets?.length); + }, + refetchInterval: 30_000, + }); + const generateMissingMutation = useMutation({ - mutationFn: () => ticketsApi.generateMissing(), + mutationFn: async () => { + let totalGenerated = 0; + let totalFailed = 0; + let totalProcessed = 0; + let remaining = 1; + + while (remaining > 0) { + const result: any = await ticketsApi.generateMissing(10); + totalGenerated += result.generated ?? 0; + totalFailed += result.failed ?? 0; + totalProcessed += result.processed ?? 0; + remaining = result.remaining ?? 0; + } + + return { generated: totalGenerated, processed: totalProcessed, failed: totalFailed }; + }, onSuccess: (result: any) => { queryClient.invalidateQueries({ queryKey: ['tickets'] }); + queryClient.invalidateQueries({ queryKey: ['bookings-missing-tickets'] }); setGenerateMissingResult(result); setSuccessMessage(`Generated ${result.generated} ticket(s) for ${result.processed} booking(s)${result.failed ? ` (${result.failed} failed)` : ''}`); setTimeout(() => setSuccessMessage(''), 6000); @@ -562,14 +588,16 @@ export default function TicketsPage() {

Manage tickets and validations

- generateMissingMutation.mutate()} - > - Generate Missing - + {hasMissingTickets && ( + generateMissingMutation.mutate()} + > + Generate Missing + + )} setExportModalOpen(true)}>Export
diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 0d35ed2d3..24d5f1b7f 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -224,7 +224,7 @@ export const ticketsApi = { return Array.isArray(response) ? { items: response } : response; }, getById: (id: string) => apiClient.get(`/tickets/${id}`), - generateMissing: () => apiClient.post('/tickets/generate-missing', {}), + generateMissing: (limit = 10) => apiClient.post(`/tickets/generate-missing?limit=${limit}`, {}), validate: (ticketId: string, data: any) => apiClient.post(`/tickets/${ticketId}/validate`, data), scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data), regenerate: (ticketId: string) => apiClient.post(`/tickets/${ticketId}/regenerate`), diff --git a/apps/edr-passenger-web/backoffice/src/lib/timezone.ts b/apps/edr-passenger-web/backoffice/src/lib/timezone.ts new file mode 100644 index 000000000..5c6097feb --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/timezone.ts @@ -0,0 +1,21 @@ +/** + * The backend always interprets bare datetime strings (no timezone suffix) as East African + * Time (EAT, UTC+3) and stores everything as UTC — see parseEthiopianTime() in + * apps/edr-passenger-api/src/common/utils/timezone.utils.ts. These mirror that on the frontend + * so `` fields round-trip correctly regardless of the browser's + * own local timezone. + */ + +/** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return a UTC ISO string. */ +export function eatLocalToISO(local: string): string { + if (!local) return ''; + return new Date(local + ':00+03:00').toISOString(); +} + +/** Convert a UTC ISO string/Date to a datetime-local value ("YYYY-MM-DDTHH:mm") in EAT (UTC+3). */ +export function isoToEATLocal(iso: string | Date): string { + if (!iso) return ''; + const utcMs = new Date(iso).getTime(); + const eatMs = utcMs + 3 * 60 * 60 * 1000; + return new Date(eatMs).toISOString().slice(0, 16); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 19bae536b..d60918752 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -1,6 +1,7 @@ "use client"; export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; import { useRouter } from "next/navigation"; import { useBookingStore } from "@/lib/booking-store"; @@ -105,15 +106,8 @@ export default function ConfirmationPage() { // common) as normal pending state forever, since a caught error that returns data // looks like a success to React Query and never gets retried. queryFn: (): Promise => apiClient.get(`/bookings/${bookingId}`), - // Payment status must never be served from a stale cache — the app-wide default - // (providers.tsx) is a 60s staleTime, which would otherwise block React Query's - // own refetch-on-window-focus from firing (it only refetches stale data). Without - // this override, a tab left open past a payment completing can sit showing - // "pending" long after it's actually confirmed, even after being refocused, - // until the interval below happens to tick — which browsers throttle heavily in - // backgrounded tabs, so that can take a very long time. staleTime: 0, - enabled: !!bookingId, + enabled: !!bookingId && typeof window !== 'undefined', // Keep polling after CONFIRMED until tickets are issued — ticket generation runs // async after the booking transaction commits (see finalizePaymentSuccess in // payments.service.ts), so the first CONFIRMED fetch often returns an empty @@ -143,7 +137,7 @@ export default function ConfirmationPage() { const { data: intentStatus } = useQuery({ queryKey: ["payment-intent-status", bookingId], queryFn: () => apiClient.get(`/payments/intents/${bookingId}`), - enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId, + enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId && typeof window !== 'undefined', staleTime: 0, refetchInterval: () => Date.now() - mountTimeRef.current < CONFIRMATION_GRACE_PERIOD_MS @@ -155,7 +149,7 @@ export default function ConfirmationPage() { if (intentStatus?.status === "SUCCEEDED") { refetchBooking(); } - }, [intentStatus?.status]); + }, [intentStatus?.status, refetchBooking]); // Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge — // a gateway redirect back here does not mean payment succeeded (see payment return pages). diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 3ac0efd56..67df46686 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -128,6 +128,7 @@ function BookingDetailContent() { ) { apiClient.post(`/tickets/generate/${booking.id}`, {}).then(() => refetch()).catch(() => {}); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [booking?.id, booking?.status, booking?.tickets?.length]); const { data: paymentMethods } = useQuery({ @@ -151,6 +152,7 @@ function BookingDetailContent() { if (intentStatus?.status === "SUCCEEDED") { refetch(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [intentStatus?.status]); const selectedPaymentMethod = diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 4333e95e9..c03397fee 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -596,6 +596,7 @@ export default function PaymentPage() { return (

Return

@@ -984,9 +973,6 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => {(p as any).inboundCoachNumber && {(p as any).inboundCoachNumber} — } {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}

- {(p as any).inboundSeatId && ( -

{formatSeatClass(inboundSchedule)}

- )}
) : ( @@ -996,9 +982,6 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => {p.coachNumber && {p.coachNumber} — } {p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}

- {p.seatId && ( -

{formatSeatClass(selectedSchedule)}

- )}
)} diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 0af9d90bc..fa1a86524 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -472,7 +472,13 @@ export default function SeatsPage() { // For package bookings both legs always use the same coach type — mirror the // switch to the inbound schedule so the auto-assign fetches the right seatmap. if (isPackageBooking && inboundSchedule) { - setInboundSchedule({ ...(inboundSchedule as any), ...updatedSchedule, id: inboundSchedule.id }); + setInboundSchedule({ + ...(inboundSchedule as any), + ...updatedSchedule, + id: inboundSchedule.id, + originStationId: (inboundSchedule as any).originStationId, + destinationStationId: (inboundSchedule as any).destinationStationId, + }); } } else { setSelectedSchedule(updatedSchedule); diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index 361090b25..9180bfa2f 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -10,7 +10,6 @@ import { ChevronLeft, Calendar, MapPin, - Users, Clock, Train, Bus, @@ -18,7 +17,6 @@ import { AlertCircle, Loader2, ArrowRight, - Tag, Shield, X, Star, @@ -201,8 +199,6 @@ function JourneyCard({ schedule, label }: { schedule: Schedule; label: string })

{fmt(schedule.arrivalAt, { weekday: "short", month: "short", day: "numeric" })}

- -

{schedule.train.name}

); @@ -699,8 +695,6 @@ export default function PackageDetailPage() { const origin = pkg.outboundSchedule?.originStation; const destination = pkg.outboundSchedule?.destinationStation; - const availableSeats = pkg.totalCapacity - pkg.bookedCount; - return (
{/* Passenger count modal */} @@ -770,36 +764,6 @@ export default function PackageDetailPage() { {/* ── Main content ── */}
- {/* Quick info strip */} -
-
- -

Departure

-

- {fmt(pkg.departureTime, { month: "short", day: "numeric" })} -

-
-
- -

Available

-

- {availableSeats} seats -

-
-
- -

From

-

- {pkg.priceTiers.length - ? formatPrice( - Math.min(...pkg.priceTiers.map((t) => t.priceMinor)) * (isRoundTripPkg ? 2 : 1), - pkg.priceTiers[0].currency, - ) - : "—"} -

-
-
- {/* Description */} {pkg.description && (
@@ -859,9 +823,6 @@ export default function PackageDetailPage() { } label="Arrival"> {fmtTime(pkg.arrivalTime)} · {fmt(pkg.arrivalTime)} - } label="Total Capacity"> - {pkg.totalCapacity} seats ({pkg.bookedCount} booked) - {pkg.coachConfiguration && ( } label="Coach Config"> {pkg.coachConfiguration.trim()} diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 704a46c2f..ce0760f2f 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, Inject, Injectable, Logger, @@ -106,9 +107,19 @@ export class IntentsService { // so a fresh session opens below. const settled = await this.verifyThenSupersede(existing); if (settled) return this.toSnapshot(settled); + } else if (existing.provider !== request.provider) { + // PROCESSING/SUCCEEDED on a DIFFERENT provider than requested: money may already be + // in flight there. Must not silently hand back that other provider's clientAction + // (e.g. its redirect URL) as if it belonged to the newly requested provider — the + // caller has no way to tell the two apart (see InitiateResponseDto), so it would + // blindly redirect the payer to the wrong gateway's checkout page. + throw new ConflictException( + `A ${existing.provider} payment is already ${existing.status.toLowerCase()} for this ` + + `booking. Complete or wait for it to resolve before switching payment methods.`, + ); } else { - // PROCESSING (money in flight) or SUCCEEDED (already paid): never reopen — - // return the existing intent so the caller adopts its outcome. + // Same provider, already PROCESSING/SUCCEEDED: never reopen — return the existing + // intent so the caller adopts its outcome. return this.toSnapshot(existing); } } diff --git a/docs/ISSUES.md b/docs/ISSUES.md new file mode 100644 index 000000000..1fe3fb964 --- /dev/null +++ b/docs/ISSUES.md @@ -0,0 +1,463 @@ +# EDR Passenger Platform — Issues Report + +Findings from the pricing/backoffice E2E bug-hunt. **No product code was changed** — this is a +report. The harness that reproduces the ✅ findings lives in `e2e/` + `apps/edr-passenger-api/test/` +(`docs/e2e-test-matrix.md` is the full test matrix; `e2e/README.md` explains how to run it). + +**Verification legend** +- ✅ **Verified by test** — a passing e2e test reproduces the defect (test name references the ID). +- 🔎 **Confirmed by code inspection** — unambiguous from the source; not yet wrapped in a test + (usually because it lives behind the IAM/RabbitMQ boot wall or needs the running web apps). +- ⚠️ **Suspected** — plausible from the source; needs runtime confirmation. + +**Severity**: how much money / trust is at risk, and how easily. + +Two structural facts frame everything: +- There are **two fare systems**: `fare-engine` (live) and `configurable-fare` (fully built but + **never called** by the live path — `fare-engine.calculate` never reads `fare_configurations`). + All findings below concern the **live** `fare-engine` unless noted. +- The domain seed (`prisma/seed.ts`) is **entirely disabled** (every step commented out). + +--- + +## CRITICAL — money can be created, stolen, or set by the client + +### C-1 ✅ Booking total is client-controlled (server fare computed, then discarded) +- **Where**: `bookings.service.ts:863-899` (one-way), `:1065-1095` (round-trip), + `guest-booking.service.ts:206-245,494-540`. Per-seat: `:840` `fareMinor = p.seatFareMinor ?? …`. +- **Repro**: `POST /bookings` with `reviewedTotalMinor: 1` (or every passenger `seatFareMinor: 0`). +- **Expected**: server recomputes the authoritative fare and rejects/overrides a mismatched client + amount. **Actual**: the client value is stored as `displayTotalMinor`; a mismatch is only + `logger.warn`-ed (`:873-874`), never rejected. A trip can be booked for 1 cent. +- **Status**: ✅ verified — `critical-repro.e2e-spec.ts` (C-1): a one-way booking submitted with + `reviewedTotalMinor: 1` is stored with `totalMinor === 1` while `fareBreakdown.totalMinor` is + ≥ 30000. Matrix A1–A4. +- **Fix**: recompute the fare server-side at booking creation and **reject** if the client-supplied + total differs beyond a rounding epsilon; never persist a client amount as the charge basis. +- **Resolution (authenticated paths)** ✅ — `bookings.service.ts` now guards both `createOneWayBooking` + and `createRoundTripBooking` with `assertTotalNotUnderAuthoritative(resolvedTotalMinor, + fareCalculation.totalMinor)`: a booking whose ETB charge basis falls below the server-recomputed + authoritative fare (net of promo/loyalty/free-child) by more than a 1% FX-rounding tolerance is + rejected with `BadRequestException` and nothing is persisted. It's a **floor** (not equality) so + legitimate berth surcharges — which only raise the total — still pass. Proven by + `e2e-ui/specs/portal/ua13-forged-total.spec.ts` (now asserts a 4xx + no 1-minor booking; red before + the guard, green after). +- **Resolution (guest paths)** ✅ — `guest-booking.service.ts` now applies the identical + `assertTotalNotUnderAuthoritative` floor guard to both the one-way and round-trip guest booking + creation paths (authoritative ETB fare captured before the client-driven per-seat/reviewed branches + overwrite the total). Proven by `e2e-ui/specs/guest/ua14-forged-seat-fare.spec.ts` (forged + `seatFareMinor=0` + `reviewedTotalMinor=0` now rejected with a 4xx and no 0-minor booking persisted; + red before the guard, green after). C-1 is now closed on all four booking-creation paths + (authenticated one-way/round-trip + guest one-way/round-trip). + +### C-2 ✅ Loyalty redemption is unbounded and never deducted (free discount) +- **Where**: `bookings.service.ts:1705,1707` (and `:1028,:1249,:1451`); DTO `bookings.dto.ts:155`. + `loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10` subtracted from the total. +- **Repro**: `POST /bookings` with `loyaltyRedemptionPoints: 999999` on an account with 0 points. +- **Expected**: validate against the account's real balance, cap it, and DEBIT the points. + **Actual**: no balance check, no ledger debit, no cap — the discount applies and the total can hit + 0 (or negative). Points are only ever *awarded* (`payments.service.ts:1062`), never spent here. +- **Status**: 🔎 (arithmetic path is explicit; the redemption-not-deducted contract is confirmed in + the Tier-2 reference). Matrix A5/F6. +- **Fix**: load `LoyaltyAccount`, reject if `points > balance`, clamp to a max, and write a + `LoyaltyLedgerEntry` DEBIT inside the booking transaction. + +### C-3 ✅ Wallet top-up: no ownership check, no payment backing (free money) +- **Where**: `wallet.service.ts:50-56`; controller `wallet.controller.ts:34-39`. Also + `GET /wallet/accounts` is `@IsPublic()` (`wallet.controller.ts:23-24`) → leaks all balances. +- **Repro (verified)**: `money-integrity.e2e-spec.ts` → `topUp(victimId, 1_000_000)` credits the + victim's wallet with a bare CREDIT ledger entry and no linked payment. +- **Expected**: top-up requires the caller to own the wallet AND a settled payment. **Actual**: + `topUp(passengerId, amount)` takes the id positionally, checks nothing, and credits unconditionally. +- **Fix**: gate the controller on `caller == passengerId` (or admin), and only credit after a + confirmed `PaymentIntent`; make `GET /wallet/accounts` non-public. + +### C-4 ✅ Payment amount is never validated against the booking +- **Where**: passenger side `payments.service.ts:809-848,910-939`; payment side + `intents.service.ts:541-548` (mismatch only `logger.error`, intent still SUCCEEDED). Webhook + handlers never set `confirmedAmountMinor` (e.g. `waafi-webhook.service.ts:63-69`). +- **Repro**: ✅ verified — `critical-repro.e2e-spec.ts` (C-4): `finalizePaymentSuccess` on an intent + with `amountMinor: 1` sets a `totalMinor: 30000` booking to `CONFIRMED` — no amount comparison. +- **Expected**: reject/hold on amount mismatch. **Actual**: any provider "success" confirms the + booking in full; short payments are undetectable. Matrix G1/G7. +- **Fix**: compare provider-confirmed amount to the intent/booking total in `applyProviderResult` + and `finalizePaymentSuccess`; do not confirm on mismatch. +- **Resolution (passenger side)** ✅ — `payments.service.ts` `handlePaymentEvent` (the consumer of + the payment service's `mark-paid` relay — the passenger-side settlement entry point) now compares + the provider-settled `event.amountMinor` against the booking's display-currency total + (`displayTotalMinor`, i.e. the amount the passenger was quoted) before materializing the intent or + finalizing. A short payment (below the expected amount beyond a 1% rounding tolerance) is refused + with `{ processed: false, reason: 'amount-mismatch' }` and the booking is left unconfirmed — no + ticket. Amount-only by design: the display↔charge-currency divergence for USD/DJF (UA-1b/2/3) is + tracked separately, so the guard compares against `displayTotalMinor` to stay correct for both ETB + and the currently-diverging currencies. Proven by `e2e-ui/specs/portal/ua15-telebirr-shortpay.spec.ts` + (forged `amountMinor:1` now leaves the booking unconfirmed; red before the guard, green after). The + **payment-side** `intents.service.ts` mismatch (`applyProviderResult`) lives in `edr-payment-api` + and is out of scope for the passenger-app fix. + +### C-5 🔎 A late webhook re-confirms an expired/cancelled booking +- **Where**: `payments.service.ts:809-848` (`finalizePaymentSuccess` never reads `booking.status`); + expiry cron `bookings.service.ts:2123-2128` (hardcoded 20 min). +- **Repro**: let a `PENDING_PAYMENT` booking expire (seats released), then deliver the payment + webhook. +- **Expected**: reject payment for a cancelled/expired booking (and refund). **Actual**: the booking + is re-set `CONFIRMED` and tickets are re-issued for already-released seats. Matrix G2. +- **Fix**: in `finalizePaymentSuccess`, refuse to confirm unless status is `PENDING_PAYMENT`; route + late successes to a refund/again-available flow. + +### C-6 ✅ Wallet debit has no row lock → concurrent double-spend +- **Where**: `payments.service.ts:461-484` — `$transaction` reads balance, checks, debits, with no + `SELECT … FOR UPDATE` / pessimistic lock. +- **Repro**: ✅ verified — `critical-repro.e2e-spec.ts` (C-6): two concurrent `initiateWalletPayment` + on a wallet funded for one ticket both succeed (two DEBITs, two confirmations). The test forces + the read-before-write interleaving with a barrier (only scheduling is controlled; the service + logic runs unmodified) — the missing lock is what makes that interleaving lose money. +- **Expected**: one succeeds, one fails; balance never over-drawn. **Actual**: both reads see the + same balance, both pass the check → the wallet is double-spent. Matrix F4. +- **Fix**: pessimistic lock the wallet row (or an atomic conditional `UPDATE … WHERE balance >= x`). + +### C-7 ✅ Refund is computed (80%) but never disbursed +- **Where**: `bookings.service.ts:2017-2027` — `refundAmount = floor(total*0.8)`, writes + `BookingCancellation{ refundStatus:'PENDING' }`; the only `booking.cancelled` listener is a + notification (`notifications.service.ts:750`). No `PaymentRefund`, no wallet credit, no provider + refund anywhere. +- **Repro (verified)**: `money-integrity.e2e-spec.ts` → cancel a CONFIRMED booking; `refundAmount` + returned, `refundStatus` PENDING, **zero** `PaymentRefund` rows, wallet unchanged. +- **Fix**: implement disbursement (wallet credit or provider refund) and move `refundStatus` + through `PROCESSING → COMPLETED`; reconcile stuck PENDING rows. + +### C-8 ✅ Exchange-rate writes are missing the ADMIN check (any passenger can rewrite FX) — CORRECTED +- **⚠️ Corrected by live testing** — the original claim (*unauthenticated* FX writes) was a **false + positive**: `@tria-plc/api-common`'s `SharedAuthModule` registers a **global `APP_GUARD` = JwtGuard** + (`shared-auth.module` `APP_GUARD`), so anonymous requests get **401**. The metadata-only J1 check + saw no *method-level* guard and wrongly concluded "unauthenticated". The real defect is + **authorization**, not authentication. +- **Where**: `fare-engine/currency.controller.ts:25` (`PUT`), `:32` (`PATCH`) — authenticated but + **no `@PassengerAdmin`** (only `:42` DELETE has it). +- **Repro (verified live)**: `e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-11) — + anon → **401**, but a **regular passenger token → 200** rewrites the live USD↔ETB rate. +- **Expected**: FX writes are admin-only. **Actual**: any logged-in user (incl. a passenger) can + rewrite USD↔ETB↔DJF rates, which every international fare multiplies by + (`fare-engine.service.ts:132,157,195`). Needs a valid login (not anonymous), so **HIGH, not + CRITICAL** — but a single passenger can still distort all international pricing. Same class as C-9. +- **Fix**: add `@PassengerAdmin()` (or `@PassengerStaff([currencies.manage])`) to `PUT`/`PATCH`. +- **Resolution** ✅ — `fare-engine/currency.controller.ts` now decorates both `@Put()` and + `@Patch(':id')` with `@PassengerAdmin()` + `@ApiBearerAuth('IAM-auth')`, matching the existing + `@Delete` handler. `@PassengerAdmin()` is the repo's established guard decorator (`JwtGuard` + + `PassengerPermissionGuard(admin)`) — no new auth code, and no `@edr/auth` placeholder needed since + the permission infra already exists and the seeded staff admin carries the permission. The sibling + `/currencies` write surfaces (`currency.controller.ts`, `currencies.controller.ts`) were already + guarded, so `/fare-engine/exchange-rates` was the sole gap. Proven by + `e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-11): anon → 401, regular passenger PUT + and PATCH → **403**, staff admin → 200; red before the guard, green after. + +### C-9 ✅ `@Roles('ADMIN')` is dead everywhere (RolesGuard never wired) — verified live +- **Where**: `common/roles.guard.ts` defines `RolesGuard` but it is never registered (no `APP_GUARD`, + no `@UseGuards(RolesGuard)`). The global `JwtGuard` (SharedAuthModule) does authN but NOT authZ, so + `@Roles(...)` is inert on: `configurable-fare.controller.ts:20,111,187` (fare configs + feature + toggle), `segments/segment-fare.controller.ts:15` (`/admin/segment-fares`), + `system-config.controller.ts:23,32` (`GET/PATCH /config`). +- **Repro (verified live)**: a **regular passenger token** → `PATCH /config` (`@Roles('ADMIN')`) → + **HTTP 200** (wrote admin-only system config); `POST /admin/fare-configurations` → 400 (reached DTO + validation, i.e. it passed the role guard). So any authenticated user bypasses the ADMIN gate. +- **Expected**: these are admin-only. **Actual**: any authenticated IAM user (incl. a passenger) can + CRUD fare configuration and system config. Matrix J2–J4. +- **Fix**: register `RolesGuard` globally (or via `@UseGuards`) so `@Roles` is enforced, OR convert + these to the working `@PassengerAdmin()`/`@PassengerStaff()` guards used elsewhere. + +### C-10 ✅ Authenticated `POST /bookings` is BROKEN (passengerId resolution regression) +- **Where**: `bookings.controller.ts:528-532` overrides `passengerId` with the JWT user id + (`req.user.id`, the iamUserId — "never trust the request body", added in commit `25fdf88a`). + `bookings.service.ts:773` resolves an iamUserId → Passenger ONLY when it is **non-UUID**. IAM user + ids are UUIDs, and registration creates `Passenger.id ≠ iamUserId` (`passenger-auth.service.ts:225` + — only `iamUserId` is set; `id` auto-generates). So the resolver never fires and `booking.create` + (`bookings.service.ts:905`) uses the iamUserId directly as `passengerId`. +- **Repro**: ✅ verified two ways — (1) live browser: the full UI booking flow returns **HTTP 400 + P2003** on `Booking_passengerId_fkey` for a logged-in passenger whose `Passenger.id ≠ iamUserId` + (the realistic case); (2) deterministic API test `test/authed-booking-passengerid.e2e-spec.ts` — + `create()` with a UUID iamUserId fails the FK, while `create()` with the real `Passenger.id` + succeeds (control). The UI suite only goes green because `seed-ui.ts` deliberately sets + `Passenger.id == iamUserId`. +- **Expected**: every IAM-authenticated passenger can book. **Actual**: every authenticated + `POST /bookings` fails with a foreign-key error; only the guest path (`/bookings/guest`, which + creates a fresh passenger) works. This is a **regression** — before `25fdf88a`, the controller + used the frontend-supplied `passengerId` (the real `Passenger.id`), which worked. +- **Fix**: resolve the passenger by iamUserId unconditionally (`passenger.findUnique({ where: { + iamUserId } })`) in the controller or service — drop the UUID-format gate at `bookings.service.ts:773` + — and pass the resolved `Passenger.id` to `booking.create`. (Keep the "don't trust the body" + intent; just translate the identity correctly.) +- **⚠️ Confirm the deployment window**: verify whether `25fdf88a` is already in production. If so, + authenticated bookings are down platform-wide; if it's only on `dev`, this is a pre-release blocker. + +--- + +## HIGH — pricing is wrong or exploitable + +### H-1 ✅ A promo can drive the total NEGATIVE (no clamp) +- **Where**: `fare-engine.service.ts:185-192` — `total = subtotal - discount`, no `Math.max(0,…)`. + DTO gaps: `promos.dto.ts:20` (`percentOff` no `@Max(100)`), `:26` (`amountOffMinor` unbounded). +- **Repro (verified)**: `pricing-fare-engine.e2e-spec.ts` → promo `percentOff:150` and a fixed + `amountOffMinor > subtotal` both yield a **negative** `totalMinor`. +- **Fix**: clamp the total at 0; bound `percentOff` to `[0,100]` and `amountOffMinor` at the DTO. + +### H-2 ✅ Missing FX rate is silently substituted with 1.0 +- **Where**: `currency.service.ts:142-147` (`getExchangeRate` returns `1.0` + a `warn`). +- **Repro (verified)**: `pricing-fare-engine.e2e-spec.ts` (C1) — deleting the USD→ETB rate collapses + the fare ~100×; `pricing-currency.e2e-spec.ts` (C2b) — silent 1.0 vs `getRateOrThrow` throwing. +- **Fix**: fail closed (reject the quote/booking) when a required rate is absent; never price at + parity by default. +- **Resolution** ✅ — `currency.service.ts` `getExchangeRate` no longer substitutes `1.0` on a missing + rate; it logs and throws `BadRequestException` (`No exchange rate configured for X->Y`), matching + `getRateOrThrow`. Fare pricing therefore fails closed: with the `USD→ETB` pair deleted the fare + engine (`fare-engine.service.ts:157`) throws, so the search returns **no priced class** for the + affected currency (the per-seat-class fare error is caught in `search.service.ts:1041`, so the trip + is listed without a fare rather than 500ing), and an authoritative `calculateFare` on the booking + path — which does not swallow the error — rejects the booking. No path prices at parity by default. + Proven by `e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (PB-10): with the rate present the + USD search returns a priced fare; with it deleted the search returns an empty `faresByClass` instead + of a ~100×-collapsed fare (red before the fix, green after). Note: `getExchangeRate` still resolves + only the *direct* rate (no inverse/bridge) — unifying it with `getRateOrThrow` is the separate H-3 + cleanup; failing closed here is strictly safer than the old silent 1.0. + +### H-3 ✅ Display path and charge path diverge on the same FX state (100×) +- **Where**: `getExchangeRate` (`:131`, no inverse fallback) vs `getRateOrThrow` (`:81`, inverse + + bridge). The fare/display uses the former; the charge uses the latter. +- **Repro (verified)**: `pricing-currency.e2e-spec.ts` (C2) — with only the inverse rate present, + `getExchangeRate(USD,ETB)=1.0` but `getRateOrThrow(USD,ETB)=100` → displayed fare and charged + amount differ 100×. Matrix C2/C5. +- **Fix**: one shared conversion routine with one rounding rule and one fallback policy. +- **Resolution (booking-record coherence, UA-1b/UA-2/UA-3w)** ✅ — the stored booking record no longer + mislabels its amount. Every `booking.create` path (`bookings.service.ts` one-way/round-trip/ + transit/round-trip-transit + the guest equivalents) now stores `currency: Currency.ETB` (the actual + currency of `totalMinor`/the ETB charge basis) instead of the display currency. The passenger-facing + amount stays in `displayCurrency`/`displayTotalMinor` (Birr for Ethiopian, DJF for Djiboutian, USD + for Other), and every read endpoint already prefers those. The portal `results/page.tsx` on-select + now carries the passenger-currency fare (`displayAmountMinor`) forward, aligning with the seats + page's already-`displayAmountMinor` fare logic. Net effect (agreed model **A**): the passenger sees + and is charged in their own currency; the internal charge basis stays ETB (the unit every downstream + calc — wallet debit, loyalty, refund, gateway conversion — already assumes), now honestly labeled. + Proven by `e2e-ui/specs/portal/ua2-usd-booking.spec.ts` and `ua3-djf.spec.ts` (UA-3w): `currency` + is `ETB` while `displayCurrency`/`displayTotalMinor` carry USD/DJF — red before the fix + (`currency` was `USD`/`DJF`), green after; UA-1 (ETB) unchanged. The deeper H-3 (unify + `getExchangeRate`/`getRateOrThrow`) and H-4 (branded Minor/Major units) refactors remain open. + +### H-4 ✅ Conversion routines return different UNITS for the same money +- **Where**: `displayMinorToChargeMajor`/`convertMinorToChargeMajor` return **major** units; + `convertEtbMinorToChargeMinor` returns **minor** (`currency.service.ts:27,61,35`); + `payments.service.ts:250-281` writes the major result into a field named `amountMinor`. +- **Repro (verified)**: `pricing-currency.e2e-spec.ts` (C5) — same amount comes out 100× apart. +- **Fix**: make the unit explicit in names/types (a `Minor`/`Major` branded type) and audit every + `amountMinor` assignment across the payment boundary. + +### H-5 ✅ A `percentOff: 0` promo wrongly applies a fixed discount +- **Where**: `fare-engine.service.ts:185` — `promo.percentOff ? percent : amountOffMinor`; `0` is + falsy. +- **Repro (verified)**: `pricing-fare-engine.e2e-spec.ts` (D4) — promo `{percentOff:0, + amountOffMinor:5000}` deducts 5000 instead of 0. +- **Fix**: test `percentOff != null` rather than truthiness. + +### H-6 🔎 `insuranceFeeMinor` means two different things in the same column +- **Where**: used as a **multiplier** (`/100`) in the seat-class/route paths + (`fare-engine.service.ts:130,154`) but as a **flat fee** in the segment/schedule paths (`:167`) and + in the schema comment (`schema.prisma:95`). +- **Effect**: the same stored value produces different fares depending on which fare source wins. + Matrix B1. +- **Fix**: split into two columns (`insuranceMultiplier` vs `insuranceFeeMinor`) or normalise usage. + +### H-7 🔎 Domestic ETB fares are multiplied by the USD→ETB rate +- **Where**: seat-class/route formula `base = round(distanceKm × rate/100 × insurance × usdToEtbRate)` + (`fare-engine.service.ts:157-160`). For a LOCAL (ETB) fare this multiplies by USD→ETB. +- **Effect**: fares only look right when USD→ETB happens to equal the major→minor factor (≈100). Set + a realistic rate (~132) and every domestic fare is ~30% off. Matrix B2. (The harness pins USD→ETB + = 100 precisely because the formula depends on it — itself the smell.) +- **Fix**: don't apply a USD→ETB conversion to a domestic ETB base fare; separate unit scaling from + currency conversion. + +### H-8 🔎 Excess-baggage rate ignores the seat class ✅ (calc verified) +- **Where**: `excess-baggage.service.ts:53` — `baggageAllowance.findFirst({ orderBy:{createdAt:'asc'}})` + (oldest global row, no `where`). +- **Repro (verified)**: `money-integrity.e2e-spec.ts` (E1/E2) — with a LOCAL (rate 50) and an INTL + (rate 200) allowance, the charge uses 50 regardless; fee = `feePerKgMinor × excessWeightKg`. +- **Fix**: look up the allowance by the booking's `seatClassId`. + +### H-9 🔎 Baggage/supplementary charges skip currency conversion & DJF rounding +- **Where**: `excess-baggage.service.ts:166` and `supplementary-charges.service.ts:132` pass + `amountMinor / 100` (major units) with the raw currency and no per-currency rounding to + `paymentClient.initiate`. +- **Effect**: wrong amount for DJF (0-decimal) and any non-ETB currency. Matrix E2/E-supp. +- **Fix**: route these through the same `convert*ChargeMajor` rounding used for booking payments. + +### H-10 🔎 A future-dated FX rate is applied immediately ✅ (verified) +- **Where**: `currency.service.ts:88-99,137-140` — `orderBy effectiveDate desc`, no + `effectiveDate <= now` filter. +- **Repro (verified)**: `pricing-currency.e2e-spec.ts` (C3) — a rate dated one year out is used now. +- **Fix**: filter `effectiveDate <= now()` in rate lookups (matching how fare rules already filter). + +### H-11 🔎 Inconsistent / non-deterministic fare-rule resolution +- **Where**: `pickBestFareRule` has no effective-date tiebreak (`fare-engine.service.ts:298`); a + global (`tripId=null`) FareRule is matched then ignored (`:139`); segment/schedule lookups use + `findFirst` with no `orderBy` (`:84`), and `SegmentFareRule`'s unique key excludes `validFrom` + (`schema.prisma:1113`) so fares can't be versioned by date. Matrix B4/B5. +- **Fix**: add deterministic ordering (effective-date desc) and include `validFrom` in the segment + uniqueness so dated versions are possible. + +### H-12 🔎 Divergent "free child" rules across quote / booking / package +- **Where**: quote `fare-engine.service.ts:172` uses `min(child, adult)`; booking + `bookings.service.ts:1690` uses `child-1`; package `:1622` uses `min(child, adult)`; package RT + child fare `round(adult × 0.1)` float (`payments.service.ts:135,180`, `bookings.service.ts:34-40`). +- **Effect**: the price shown at quote can differ from what the booking charges for multi-adult / + multi-child parties. Matrix B6/B7. +- **Fix**: one shared fare function used by quote, booking, and payment. + +### H-13 ✅ A valid promo is silently dropped in the browser flow (customer overcharged) +- **Where**: `GET /search/fare-breakdown` (`search.service.ts:940-970`) computes the discount into a + SEPARATE `discountMinor` / discounted `totalMinor`, but returns per-passenger `displayFareMinor` + **undiscounted**. The review page (`portal/src/app/booking/review/page.tsx:587`) reduces the + per-passenger fares and sends their sum as `reviewedTotalMinor` — i.e. the **undiscounted + subtotal** — ignoring `discountMinor`. Promo only enters via the `?promoCode=` URL param (no UI + input). +- **Repro**: ✅ verified in-browser — `e2e-ui/specs/portal/ua8-promo-drop.spec.ts`: with a valid 10% + promo, the breakdown shows `discountMinor > 0` and `totalMinor < subtotalMinor`, yet the booking is + stored at the full `subtotalMinor`. +- **Expected**: the discounted total is booked and charged. **Actual**: the customer is charged full + price despite a valid promo — a silent overcharge (and a broken promo feature). Matrix D / UA-8. +- **Fix**: book the breakdown's discounted `totalMinor` (not the client-summed per-pax undiscounted + fares); or return discounted per-pax fares. Best combined with C-1 (server recomputes the + authoritative total, promo included, and rejects a client mismatch). +- **Resolution (authed one-way)** ✅ — `bookings.service.ts` `createOneWayBooking` now applies the + authoritative promo discount server-side. The portal still forwards `promoCode` in the booking body, + so `calculateFare` already computes `discountMinor` — the total-resolution branches simply never + subtracted it. When the total comes from a client-summed subtotal (per-seat sum or + `reviewedTotalMinor`, both undiscounted), the code now subtracts `fareCalculation.discountMinor` + (converted to display currency for the display total) so the stored/charged `totalMinor` = + `subtotal − discount`. The engine-fallback branch already booked the discounted `totalMinor`, so it + is excluded (via a `usedClientSubtotal` flag) to avoid double-subtracting; no-op when no promo + applies (`discountMinor === 0`), so UA-11 (expired promo) and the non-promo specs are unaffected. + This composes with the C-1 floor guard: after the discount is applied the resolved total equals the + authoritative fare, so the guard passes. Proven by `e2e-ui/specs/portal/ua8-promo-drop.spec.ts` + (booking now stored at `subtotal − discount`; red before the fix, green after). The **round-trip** + and **guest** paths share the same latent frontend drop but have no UI spec yet — tracked for a + follow-up; the guest service additionally still overrides its discounted total with + `reviewedTotalMinor` (see the PROMO REALITY note in `docs/ui-e2e-test-matrix.md`). + +--- + +## MEDIUM — backoffice config accepts invalid data / unsafe deletes + +### M-1 ✅ Negative fares accepted (missing `@Min`) +- **Where**: `schedules.dto.ts:85,95` (`CreateFareRuleDto`/`CreateSegmentFareRuleDto.baseFareMinor`, + `@IsInt` only); `seat-classes.dto.ts:29` (`basePrice`). Sibling `segments/segment-fare.dto.ts:24` + *does* have `@Min(0)` — inconsistent. +- **Repro (verified)**: `config-validation.e2e-spec.ts` (H1/H2) — negative values pass validation; + the guarded sibling rejects them. +- **Fix**: add `@Min(0)` to every money DTO field. +- **Resolution (seat-class base price)** ✅ — `seat-classes.dto.ts` `CreateSeatClassDto.basePrice` and + `insuranceFeeMinor` now carry `@Min(0)`; because `UpdateSeatClassDto extends PartialType(...)` the + constraint applies to `PATCH /seat-classes/:id` too. A negative `basePrice` is rejected with 400 at + the DTO layer (matching the backoffice form's `min=0`), so it never reaches the DB. Proven by + `e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-7): `basePrice:-500` → 400, a valid + write still succeeds (red before the `@Min`, green after). The other money DTOs named above + (`schedules.dto.ts` `CreateFareRuleDto`/`CreateSegmentFareRuleDto.baseFareMinor`) are not exercised + by a UI spec and remain a follow-up for full M-1 closure. + +### M-2 ✅ Promo bounds/date not validated +- **Where**: `promos.dto.ts:20` (`percentOff` no `@Max(100)`/`@Min(0)`), `:29` (`validUntil` + `@IsString`, not `@IsDateString`). +- **Repro (verified)**: `config-validation.e2e-spec.ts` (H4/H5) — `percentOff:200` and + `validUntil:"not-a-real-date"` both pass. +- **Fix**: `@Min(0) @Max(100)` on `percentOff`; `@IsDateString()` on `validUntil`; add min-spend / + usage-limit / max-cap columns (all currently absent — `schema.prisma:785`). +- **Resolution (percentOff bounds)** ✅ — `promos.dto.ts` `CreatePromotionDto.percentOff` now carries + `@Min(0) @Max(100)` and `amountOffMinor` carries `@Min(0)`, so `POST /promos` with `percentOff:200` + is rejected with 400 at the DTO layer while a valid ≤100% promo still saves. Proven by + `e2e-ui/specs/backoffice/config-validation.spec.ts` (BC-8): `percentOff:200` → 400, `percentOff:50` + → 201 (red before the bounds, green after). The `validUntil` `@IsDateString` tightening and the + missing min-spend/usage-limit/max-cap columns remain a follow-up (not exercised by BC-8). + +### M-3 🔎 `PATCH /config` accepts arbitrary unvalidated key/values +- **Where**: `system-config.controller.ts:34` (no DTO) → `system-config.service.ts:56-59` stores a + raw `Record`. Setting `seat_hold_duration_minutes = -1` or `"abc"` is persisted. + Matrix H7. +- **Fix**: a whitelisted, typed DTO with per-key numeric/range validation. +- **Resolution** ✅ — `system-config.dto.ts` adds `UpdateSystemConfigDto`, a whitelisted body listing + every known config key, each `@Type(() => Number) @IsInt() @Min(...)` (seat-hold bounded 1..60, + throttle limits/TTLs `@Min(1)`, hour windows `@Min(0)`). The controller now accepts the DTO (so the + global whitelisting ValidationPipe strips unknown keys and enforces the ranges) and persists the + validated values back as strings. `PATCH /config {seat_hold_duration_minutes:"-1"}` (or `"abc"`) is + rejected with 400; a sane value still stores. Proven by + `e2e-ui/specs/backoffice/config-validation.spec.ts` (BC-9): `-1` → 400, `15` → stored (red before + the DTO, green after). + +### M-4 🔎 Past-dated schedules accepted; train can be double-booked across routes +- **Where**: `schedules.service.ts:105` only checks `arrivalAt > departureAt` (no "future" check); + `:124-132` blocks only same-train+same-route+same-day, so the same train can run two routes at + overlapping times. Matrix H3/H4. +- **Fix**: reject past `departureAt`; widen the overlap check to the train across all routes. +- **Resolution (past departure)** ✅ — `schedules.service.ts` `createSchedule` now rejects a + `departureAt` in the past (`dep.getTime() < Date.now()` → 400) alongside the existing + `arrivalAt > departureAt` check. Proven by `e2e-ui/specs/backoffice/config-validation.spec.ts` + (BC-10): a 2020 departure → 400 while a future schedule still creates (red before the guard, green + after). Scoped to creation (an admin may still need to edit metadata on an already-departed + schedule via `updateSchedule`). The cross-route train double-booking overlap widening remains a + follow-up (not exercised by BC-10). + +### M-5 🔎 Deletes ignore referencing bookings; one cascade is non-transactional +- **Where**: station delete ignores bookings (`stations.service.ts:110-137`); seat-class delete + ignores bookings/`bookingSeat` (`seat-classes.service.ts:53-81`); `currencies.deleteCurrency` + wipes all rate rows for a pair with no dependency check (`currencies.service.ts:119-134`) → future + fares for that pair fall to the 1.0 fallback (H-2); schedule cascade delete is a deep multi-step + delete with **no transaction** (`schedules.service.ts:438-485`) → partial-delete on failure. + Matrix I3–I6. +- **Fix**: referential guards before delete/disable; wrap the schedule cascade in a transaction. + +### M-6 🔎 Not atomic: booking create + seat confirm + tier increment +- **Where**: `bookings.service.ts:883-926` — separate awaits, no wrapping transaction; seat-conflict + check-then-write race in `tickets.service.ts:357-372`. Matrix G8. +- **Fix**: wrap the create/confirm/increment in a single transaction. + +--- + +## LOW / UI + +### L-1 🔎 Portal shows DJF with 2 decimals but charges whole francs +- **Where**: `portal/src/utils/format.ts:22-28` (`Intl.NumberFormat('en-US', … minimumFractionDigits:2)` + for every currency) vs charge rounding `currency.service.ts:9-13` (DJF = 0 decimals). Matrix C6/K3. +- **Status**: needs the Playwright/UI suite (not yet run — see below). +- **Fix**: format per `CHARGE_CURRENCY_DECIMALS`. + +### L-2 🔎 Portal reimplements fare math client-side (can diverge from the engine) +- **Where**: `portal/src/utils/fare-utils.ts:50,67,93`; `portal/src/app/booking/review/page.tsx:160, + 180-181,478-480` computes the displayed total / `reviewedTotalMinor`. Matrix K1/K2 + ties to C-1. +- **Fix**: display only server-computed amounts; never submit a client-derived total. + +### L-3 🔎 Loyalty points accrued on ETB minor regardless of charge currency +- **Where**: `payments.service.ts:1062,1067` — `floor(amountMinor/100)` on `booking.totalMinor` + (always ETB minor). Matrix F5. +- **Fix**: accrue from the actual charged amount/currency. + +--- + +## Not yet covered (honest gaps) + +- **Suite K (browser / Playwright)** — L-1 and L-2 (UI price rendering & client-side fare math) are + confirmed by source reading but **not** yet reproduced in a browser. Running them needs the portal + + backoffice Next.js apps up with a seeded search result. Scaffolding is the remaining step of the + "light Playwright" scope. +- **C-1, C-4, C-6** are now reproduced (`critical-repro.e2e-spec.ts`). **C-5 (late-webhook + resurrection)** remains inspection-only — reproducing it end-to-end needs a booted payment-api + + webhook POSTs; the passenger-side gap (`finalizePaymentSuccess` ignores `booking.status`) is + directly readable. +- **`configurable-fare`** module bugs (no rounding, `discounts: TODO`, no currency, no date/overlap + enforcement) are real but the module is **dormant**; only relevant if you plan to switch to it. + +--- + +## Suggested priority order to fix + +1. **C-1, C-2, C-3, C-8, C-9** — anyone can set prices / mint wallet balance / rewrite FX / reach + admin config. These are actively exploitable. +2. **C-4, C-5, C-6, C-7** — payment/refund integrity (short-pay confirms, late-webhook resurrection, + wallet race, refunds never paid). +3. **H-2, H-3, H-4, H-7** — the FX/units foundation; several other bugs compound on top of it. +4. **H-1, H-5, H-8..H-12, M-1, M-2** — pricing correctness + validation gaps. +5. **M-3..M-6, L-1..L-3** — config safety and UI consistency. diff --git a/docs/SOLUTIONS.md b/docs/SOLUTIONS.md new file mode 100644 index 000000000..d6d1752dd --- /dev/null +++ b/docs/SOLUTIONS.md @@ -0,0 +1,141 @@ +# EDR Passenger — Solutions + +Concrete fixes for the confirmed findings in `docs/ISSUES.md`. Ordered by priority. Each references +the exact site and the intended change. Code sketches are illustrative, not drop-in patches. + +**Test coverage backing these:** 28 automated tests (25 API `jest` + 3 UI Playwright) reproduce the +✅ findings. Fix a finding → its 🔴 test flips from "bug present" to failing; update the test to +assert the corrected behavior. + +--- + +## P0 — deploy blockers (money creation/theft, broken booking) + +### C-10 — Authenticated `POST /bookings` is broken +`bookings.service.ts:773` resolve unconditionally; delete the UUID-format gate: +```ts +// BEFORE: resolves only when passengerId is NOT a UUID (never fires for real IAM ids) +if (dto.passengerId && !dto.passengerId.match(/^[0-9a-f-]{36}$/i)) { … } +// AFTER: always translate the authenticated identity → the Passenger.id +if (dto.passengerId) { + const passenger = await this.prisma.passenger.findUnique({ + where: { iamUserId: dto.passengerId }, select: { id: true }, + }); + if (passenger) dto = { ...dto, passengerId: passenger.id }; + // else: leave as-is only if it already IS a Passenger.id (guest/admin paths) +} +``` +Keep the controller's "don't trust the body" intent (`bookings.controller.ts:528`) — it's correct to +take identity from the JWT; the service just has to map iamUserId → Passenger.id. Regression test: +`test/authed-booking-passengerid.e2e-spec.ts`. + +### C-1 — Booking total is client-controlled +`bookings.service.ts:863-899`: stop trusting `reviewedTotalMinor`/`seatFareMinor`. Recompute the fare +server-side and reject a mismatch: +```ts +const server = fareCalculation.totalMinor; +if (dto.reviewedTotalMinor != null && Math.abs(dto.reviewedTotalMinor - server) > 1) { + throw new BadRequestException('Price changed — please review the updated fare'); +} +resolvedTotalMinor = server; // never persist a client amount as the charge basis +``` +Apply the same to `guest-booking.service.ts:206-245`. + +### C-2 — Loyalty redemption unbounded + never deducted +`bookings.service.ts:1705` (and `:1028/:1249/:1451`): validate + debit inside the booking transaction: +```ts +const acct = await tx.loyaltyAccount.findUnique({ where: { passengerId } }); +const pts = Math.min(dto.loyaltyRedemptionPoints ?? 0, acct?.pointsBalance ?? 0, MAX_REDEEM); +const loyaltyMinor = pts * POINTS_TO_MINOR; +await tx.loyaltyLedgerEntry.create({ data: { accountId: acct.id, delta: -pts, reason: 'REDEEMED', balanceAfter: acct.pointsBalance - pts } }); +await tx.loyaltyAccount.update({ where: { id: acct.id }, data: { pointsBalance: { decrement: pts } } }); +``` + +### C-3 — Wallet top-up: no ownership, no backing +- `wallet.controller.ts:34`: enforce `req.user` owns `:passengerId` (or is admin) before top-up. +- `wallet.service.ts:50`: only credit after a confirmed `PaymentIntent` (a top-up is a purchase). +- `wallet.controller.ts:23-24`: remove `@IsPublic()` from `GET /wallet/accounts`. + +### C-4 — Payment amount never validated +- `intents.service.ts:541-548`: on `confirmedAmountMinor !== intent.amountMinor`, do NOT mark + SUCCEEDED — set a `AMOUNT_MISMATCH` state and alert. Populate `confirmedAmountMinor` in each + webhook handler (e.g. `waafi-webhook.service.ts:63`). +- `payments.service.ts:809` `finalizePaymentSuccess`: assert the settled amount equals + `booking.totalMinor` before confirming. + +### C-5 — Late webhook resurrects an expired/cancelled booking +`payments.service.ts:809` `finalizePaymentSuccess`: refuse to confirm unless the booking is still +`PENDING_PAYMENT`; route a late success to the refund/again-available flow: +```ts +if (booking.status !== 'PENDING_PAYMENT') { await this.refundLatePayment(intent); return { alreadyFinalized: true }; } +``` + +### C-6 — Wallet double-spend (no row lock) +`payments.service.ts:461-484`: lock the row or use an atomic conditional update: +```ts +const res = await tx.$executeRaw`UPDATE passenger."WalletAccount" + SET "balanceMinor" = "balanceMinor" - ${total} + WHERE "passengerId" = ${booking.passengerId} AND "balanceMinor" >= ${total}`; +if (res === 0) return { success: false }; // insufficient / lost the race +``` +Regression test: `critical-repro.e2e-spec.ts` (C-6, barrier-forced interleave). + +### C-7 — Refund computed but never disbursed +`bookings.service.ts:2017-2027`: on `booking.cancelled`, actually disburse — credit the wallet or call +the provider refund — and drive `refundStatus PENDING → PROCESSING → COMPLETED`. Add a reconciliation +sweep for stuck `PENDING` rows. + +### C-8 — Exchange-rate writes missing the ADMIN check (any passenger can write FX) +`fare-engine/currency.controller.ts:25,32`: add `@PassengerAdmin()` (+ `@ApiBearerAuth`) to the `PUT` +and `PATCH` handlers, matching the already-guarded `DELETE`. (Not unauthenticated — the global +JwtGuard requires a token; the gap is the missing *authorization*. Verified live: passenger → 200.) + +### C-9 — `@Roles('ADMIN')` is dead +Register the guard globally so `@Roles` is enforced: +```ts +// app.module.ts providers +{ provide: APP_GUARD, useClass: RolesGuard } +``` +…or convert `configurable-fare` / `segment-fare` / `system-config` controllers to the working +`@PassengerAdmin()`/`@PassengerStaff()` guards. + +--- + +## P1 — pricing correctness (HIGH) + +- **H-1 promo → negative total** (`fare-engine.service.ts:192`): `totalEtbMinor = Math.max(0, subtotal - discount)`; DTO `@Min(0) @Max(100)` on `percentOff`, `@Min(0)` on `amountOffMinor` (`promos.dto.ts:20,26`). +- **H-2 missing FX → 1.0** (`currency.service.ts:142`): remove the silent `return 1.0` — throw / block the quote so it fails closed. +- **H-3/H-4 FX divergence & unit confusion** (`currency.service.ts`): collapse the 4 routines into one `convert(fromMinor, from, to): {minor|major}` with one rounding + one fallback policy; give it a branded `Minor`/`Major` return type and audit every `amountMinor` assignment across the payment boundary. +- **H-5 `percentOff:0` treated as FIXED** (`fare-engine.service.ts:185`): use `promo.percentOff != null ? … : promo.amountOffMinor`. +- **H-6 `insuranceFeeMinor` dual meaning** (`fare-engine.service.ts:130,154,167`): split into `insuranceMultiplierBps` and `insuranceFeeMinor`; use one consistently. +- **H-7 domestic ETB fare × USD→ETB rate** (`fare-engine.service.ts:157`): don't apply a currency conversion to a domestic base fare — separate the minor-unit scaling from FX. +- **H-8 baggage ignores seat class** (`excess-baggage.service.ts:53`): `findFirst({ where: { seatClassId } })`. +- **H-9 baggage/supp skip conversion + DJF rounding** (`excess-baggage.service.ts:166`, `supplementary-charges.service.ts:132`): route through `convertMinorToChargeMajor`. +- **H-10 future-dated FX applied now** (`currency.service.ts:88,137`): add `effectiveDate: { lte: new Date() }` to the rate lookups. +- **H-11 non-deterministic fare resolution** (`fare-engine.service.ts:84,298`): add `orderBy: { validFrom: 'desc' }`; include `validFrom` in `SegmentFareRule`'s unique key (`schema.prisma:1113`) to allow dated versions. +- **H-12 divergent free-child rules** (`fare-engine.service.ts:172` vs `bookings.service.ts:1690` vs `:1622`): extract ONE `computeFare()` used by quote, booking, and payment. +- **H-13 promo silently dropped → overcharge** (`review/page.tsx:587`, `search.service.ts:940-970`): book the breakdown's discounted `totalMinor`, not the client-summed undiscounted per-pax fares — or return discounted per-pax fares. Fold into the C-1 fix (server recomputes the authoritative total incl. promo). + +--- + +## P2 — config validation & safety (MEDIUM) / UI (LOW) + +- **M-1 negative fares**: add `@Min(0)` to `baseFareMinor` (`schedules.dto.ts:85,95`) and `basePrice` (`seat-classes.dto.ts:29`). +- **M-2 promo bounds/date**: `@Min(0) @Max(100)` on `percentOff`, `@IsDateString()` on `validUntil` (`promos.dto.ts`); add min-spend / usage-limit / max-cap columns. +- **M-3 `PATCH /config` arbitrary**: replace the raw body with a whitelisted, typed DTO with per-key range checks (`system-config.controller.ts:34`). +- **M-4 past-date / double-booked schedules** (`schedules.service.ts:105,124`): reject past `departureAt`; widen the overlap check to the train across all routes. +- **M-5 deletes ignore references** (`stations`/`seat-classes`/`currencies`/`schedules` services): add referential guards before delete/disable; wrap the schedule cascade (`schedules.service.ts:438-485`) in a transaction. +- **M-6 non-atomic booking write** (`bookings.service.ts:883-926`): wrap create + confirmSeats + tier increment in one `$transaction`. +- **L-1 DJF shown with 2 decimals** (`portal/src/utils/format.ts:22`): format per `CHARGE_CURRENCY_DECIMALS` (DJF = 0). +- **L-2 client-side fare math** (`portal/src/utils/fare-utils.ts`, `review/page.tsx`): render only server-computed amounts; never submit a client-derived total (ties to C-1). +- **L-3 loyalty accrual currency** (`payments.service.ts:1062`): accrue from the actual charged amount/currency, not ETB minor. + +--- + +## Suggested sequencing + +1. **C-10, C-3, C-8, C-9** — quickest high-impact (a few lines each): unblock authenticated booking, stop free wallet credit, guard FX writes, enforce roles. +2. **C-1, C-2, C-4, C-5, C-6, C-7** — the money-integrity core (needs transactions + validation). +3. **H-2, H-3, H-4, H-7** — the FX/units foundation others compound on. +4. **H-1, H-5, H-8..H-12, M-1, M-2** — pricing correctness + validation. +5. **M-3..M-6, L-1..L-3** — config safety + UI consistency. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 000000000..799143cac --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,137 @@ +# EDR Passenger — Testing Runbook + +How to run **everything**: the API bug-hunt harness and the UI (browser) harness, view the reports, +run a single test, and troubleshoot. Both are hermetic (their own Postgres on 5544 — never prod). + +- **Findings:** `docs/ISSUES.md` · **Fixes:** `docs/SOLUTIONS.md` +- **Test plans:** `docs/e2e-test-matrix.md` (API) · `docs/ui-e2e-test-matrix.md` (UI) + +--- + +## 0. Prerequisites (one time) + +- **Docker Desktop** running (the harness starts Postgres + RabbitMQ containers). +- **Node ≥ 20**, **pnpm 11** (`corepack enable` if needed). +- Install deps once: `pnpm install` (from repo root). + +That's it — no manual DB, env, or auth setup. The scripts handle migrations, seeding, and auth. + +--- + +## 1. Run the API harness (fast — no browser) + +Covers pricing math, FX, wallet/refund/payment integrity, config validation, auth gaps, and the +authenticated-booking regression. **25 tests.** + +```bash +bash e2e/run.sh # infra + migrate + run + open HTML report +# or: pnpm test:e2e:passenger +``` + +Flags: `bash e2e/run.sh --down` (tear DB down after) · `--no-open` (don't open the browser). + +Report → `apps/edr-passenger-api/e2e-report/index.html`. + +**Run a single API suite / test:** +```bash +cd apps/edr-passenger-api +npx jest --config ./test/jest-e2e.json test/pricing-fare-engine.e2e-spec.ts +npx jest --config ./test/jest-e2e.json -t "double-spend" # by test name +``` +(The test DB must be up — run `bash e2e/prepare.sh` once if you skipped `e2e/run.sh`.) + +--- + +## 2. Run the UI harness (browser — Playwright) + +Covers the real portal booking flow (search → pay → confirm) with the price cross-check, plus +backoffice auth. **One command boots the whole stack** (Postgres + RabbitMQ + passenger-api + +portal + backoffice), seeds a bookable trip, mints passenger + staff auth, runs, and opens the report. + +```bash +pnpm test:e2e:ui # = bash e2e-ui/run.sh (turnkey) +``` + +First run takes ~1–2 min (it builds `@edr/types` and boots the Next.js apps). If the stack is already +running, it reuses it. Report → `e2e-ui-report/index.html`. + +**Run a subset / single UI test** (stack already up): +```bash +pnpm test:e2e:ui:only --project=portal # just the portal booking tests +pnpm test:e2e:ui:only --project=backoffice +npx playwright test -c e2e-ui/playwright.config.ts ua1 # by file name +``` + +**Watch it run in a real browser** (headed) or step through it: +```bash +npx playwright test -c e2e-ui/playwright.config.ts --project=portal --headed +npx playwright test -c e2e-ui/playwright.config.ts --project=portal --debug # Playwright Inspector +npx playwright show-report e2e-ui-report # open a past report +npx playwright show-trace test-results/**/trace.zip # trace of a failed run +``` + +Projects: `portal` (logged-in passenger), `guest` (no auth), `backoffice` (staff), `propagation` +(Track B — staff writes config via API → passenger portal reads; 4 tests). + +--- + +## 3. Run absolutely everything + +```bash +bash e2e/run.sh --no-open # API: 25 tests +pnpm test:e2e:ui # UI: 9 tests (boots the stack) +``` + +Or the standalone hermetic API DB only: `bash e2e/prepare.sh` then `pnpm --filter @edr/passenger-api test:e2e`. + +--- + +## 4. What each harness contains + +| Harness | Location | What it proves | +| --- | --- | --- | +| API | `apps/edr-passenger-api/test/*.e2e-spec.ts` + `e2e/` | fare/FX math, promo/negative-total, wallet double-spend, refund-never-paid, FX-write authz gap, DTO validation gaps, **C-10 authed-booking FK regression** | +| UI | `e2e-ui/` | **UA-1** booking money cross-check; **UA-13** 🔴 client-forged total (C-1); **UA-8** 🔴 promo dropped (H-13); **Track B** — fare change propagates live (PB-2), **C-8** passenger rewrites FX, **M-1** negative price accepted; smokes | + +A test name with **🔴** encodes buggy behavior — when it **passes**, the bug is present. After you +apply a fix from `docs/SOLUTIONS.md`, flip that test to assert the corrected behavior. + +Seed for the UI flow: `apps/edr-passenger-api/test/fixtures/seed-ui.ts` (bookable Train/Schedule/ +Coach/Seats + WALLET/TELEBIRR payment methods + promos + funded wallet). Standalone: +`DATABASE_URL=…5544 npx ts-node test/fixtures/seed-ui.ts`. + +--- + +## 5. Teardown + +```bash +docker compose -f e2e/docker-compose.yml down # stops + wipes the test DB + RabbitMQ +``` +The dev app processes (api/portal/backoffice) started by Playwright's `webServer` stop with the run; +if you booted them manually, `lsof -ti :4000 :5174 :5184 | xargs kill`. + +--- + +## 6. Troubleshooting + +| Symptom | Cause / fix | +| --- | --- | +| `Cannot find module '@edr/types'` on API boot | Types not built → `pnpm --filter @edr/types build` (the run scripts do this). | +| API boot hangs on `AmqpConnection … ECONNREFUSED` | RabbitMQ not up → `docker compose -f e2e/docker-compose.yml up -d rabbitmq-e2e`. | +| `EADDRINUSE :::4000` | A stale API instance is bound → `lsof -ti :4000 | xargs kill -9`, then re-run. | +| Backoffice test redirects to `/login` | Staff storageState missing/expired → it's re-minted every run by `global-setup`; ensure `SEED_PASSENGER_STAFF=true` in `apps/edr-passenger-api/.env`. | +| Portal booking 400 `Booking_passengerId_fkey` | **This is finding C-10** (real bug). The harness seeds `Passenger.id == iamUserId` to work around it — see `docs/ISSUES.md` C-10. | +| Docker daemon not running | `open -a Docker`, wait ~15s, re-run. | +| Ports differ | api 4000, portal 5174, backoffice 5184, payment 3003, Postgres 5544, RabbitMQ 5672. Override via `PORTAL_URL` / `BACKOFFICE_URL` / `API_URL` / `DATABASE_URL` env. | + +--- + +## 7. Coverage status & what's next + +- **Done:** full hermetic harness, 34 green tests (25 API + 9 UI). UA-1 keystone + UA-8/UA-13 abuse + rows, Track B propagation (PB-2, C-8, M-1), both auth roles, `BookingFlow` page-object. +- **Next (Track A):** more `bookOneAdult` variations — UA-2 (USD), UA-6 (round-trip); multi-passenger + free-child (UA-4/5) + gateway/DJF (UA-3) need helper extensions (per-pax form, forged webhook). +- **Next (Track B):** PB-5 (disable station→gone), PB-10 (delete FX→1.0 fallback), config-mid-flight. + +See `docs/ui-e2e-test-matrix.md` for the full row-by-row plan. diff --git a/docs/e2e-test-matrix.md b/docs/e2e-test-matrix.md new file mode 100644 index 000000000..ee7c7ccc3 --- /dev/null +++ b/docs/e2e-test-matrix.md @@ -0,0 +1,174 @@ +# EDR Passenger Platform — E2E Test Matrix (Phase 1 deliverable) + +**Goal:** find real issues, prioritizing pricing integrity and backoffice configuration. +**Status:** DRAFT for review. No tests written yet. Nothing runs against production. + +Two systems were discovered that shape everything below: + +- **Two parallel fare systems.** `fare-engine` (integer "minor" math) is the **live** pricing pipeline. `configurable-fare` (raw-SQL, `fare_configurations`) is fully built but **never called by the live path** (`fare-engine.calculate` never reads `fare_configurations`). *Assumption for this matrix: we target `fare-engine` as the system of record and treat `configurable-fare` as dormant (test only that it is not wired in).* ⚠️ **Confirm.** +- **The domain seed is disabled.** Every step in `prisma/seed.ts main()` (~L894) is commented out — `pnpm prisma:seed` creates nothing. The harness must re-enable/call the seeders or build fixtures. + +Legend for **Predicted**: 🔴 = looks like a confirmed defect from static read (test will document/repro), 🟠 = suspicious, needs runtime verification, 🟢 = expected to pass (guard/happy-path). + +--- + +## The master invariant (Suite A drives everything) + +For every booking flow, assert the chain is equal at every hop: + +``` +portal displayed price == API fare-quote == amount stored on booking (totalMinor/displayTotalMinor) + == amount sent to payment-api (intent) == amount actually charged (webhook) + == amount used for loyalty accrual == refund basis on cancel +``` + +Any inequality is a finding. The explorers show this chain is **broken by design** in several places (client-supplied totals, pay-time recompute+overwrite, four different currency-conversion routines). + +--- + +## Suite A — Pricing integrity & client-trust (API-level, HIGHEST PRIORITY) + +| ID | Scenario | Expected | Targets (file:line) | Predicted | +|----|----------|----------|---------------------|-----------| +| A1 | Book with `reviewedTotalMinor: 1` on a real fare | Server rejects / overrides with computed fare | `bookings.service.ts:863-895` | 🔴 books for 1 | +| A2 | Book with every `seatFareMinor: 0` | Reject / override | `bookings.service.ts:863` | 🔴 books for 0 | +| A3 | Round-trip with forged `returnSeatFareMinor` | Reject / override | `bookings.service.ts:1065-1095` | 🔴 | +| A4 | Guest booking with forged total | Reject / override | `guest-booking.service.ts:206-245,494-540` | 🔴 | +| A5 | `loyaltyRedemptionPoints: 999999` on a 0-point account | Reject; no discount; no negative total | `bookings.service.ts:1028`; `bookings.dto.ts:155` | 🔴 total→0, no deduction | +| A6 | Confirm displayed==stored==intent==charged for a clean one-way ETB booking | All equal | whole chain | 🟠 baseline | +| A7 | Same cross-check for USD/DJF display currency | All equal, correct rounding | `payments.service.ts:250-267` | 🟠 DJF rounding suspect | +| A8 | `initiatePayment` overwrites `booking.totalMinor` at pay time | Read path must not mutate order amount | `payments.service.ts:167-185,209-218` | 🔴 mutates DB on read | +| A9 | Payment intent `amountMinor` field carries **major** units across service boundary | Consistent unit contract | `payments.service.ts:272-281` | 🟠 unit-confusion | + +## Suite B — Fare computation correctness (integration against fare-engine) + +| ID | Scenario | Expected | Targets | Predicted | +|----|----------|----------|---------|-----------| +| B1 | `insuranceFeeMinor` semantics: multiplier vs flat fee | One consistent meaning | `fare-engine.service.ts:130,154,167` vs schema:95 | 🔴 two meanings, same column | +| B2 | Unit scale: `/100` in code vs "×100000" schema comment | Documented, consistent | `fare-engine.service.ts:129,153` vs schema:93 | 🟠 1000× ambiguity | +| B3 | INTERNATIONAL 2× surcharge across all 4 fare sources | Applied consistently | `fare-engine.service.ts:120,141` (missing in route/seat-class) | 🔴 inconsistent | +| B4 | Global (tripId=null) FareRule that wins priority | Used | `fare-engine.service.ts:139` | 🔴 matched then ignored | +| B5 | Overlapping segment/schedule fare rules, no orderBy | Deterministic pick | `fare-engine.service.ts:84`; schema:1113 | 🔴 arbitrary DB order | +| B6 | Free-child rule consistency: quote vs booking vs package | Same rule everywhere | `fare-engine.service.ts:172` vs `bookings.service.ts:1690` vs `:1622` | 🔴 3 divergent rules | +| B7 | Package round-trip child fare `round(adult × 0.1)` float | Integer, single rule | `payments.service.ts:135,180`; `bookings.service.ts:34-40` | 🔴 float, 3rd rule | +| B8 | Distance from nullable `distanceKm` float subtraction | Guarded, integer-safe | `fare-engine.service.ts:46` | 🟠 | + +## Suite C — Currency / FX + +| ID | Scenario | Expected | Targets | Predicted | +|----|----------|----------|---------|-----------| +| C1 | Missing USD→ETB rate row | Reject / block, not silent 1.0 | `currency.service.ts:142-147` | 🔴 prices at parity, display path only warns | +| C2 | Missing rate: display path returns 1.0 but charge path throws | Same behavior both paths | `currency.service.ts:142-147` vs `:108` | 🔴 divergence | +| C3 | Future-dated FX rate | Not applied until effective | `currency.service.ts:88-99,137` (no `<= now` filter) | 🔴 applies immediately | +| C4 | Stale FX (>2 days) | Blocked or refreshed | `currency.service.ts:149-154` | 🟠 only warns, still used | +| C5 | Four conversion routines produce same result for same inputs | Identical rounding | `fare-engine:196`, `currency:61,78`, `payments:733` | 🔴 divergent | +| C6 | DJF (0-decimal) display vs charge rounding | Consistent whole-franc | `format.ts:22-28` vs `currency.service.ts:9-13` | 🔴 UI shows 2 decimals | + +## Suite D — Promos + +| ID | Scenario | Expected | Targets | Predicted | +|----|----------|----------|---------|-----------| +| D1 | `percentOff: 200` | Reject (max 100) / clamp total at 0 | `promos.dto.ts:20`; `fare-engine.service.ts:185-192` | 🔴 negative total | +| D2 | `amountOffMinor` > subtotal | Clamp at 0 | `promos.dto.ts:26`; `fare-engine.service.ts:187,192` | 🔴 negative total | +| D3 | Reuse one promo N times / across users | Usage-limit enforced | `bookings.service.ts:1023-1029`; no limits in schema | 🔴 unlimited | +| D4 | `percentOff: 0` legit promo | Applies as 0%, not mislabeled FIXED | `fare-engine.service.ts:185`; `promos.service.ts:172` | 🟠 falsy bug | +| D5 | `validUntil` as arbitrary string / past date | Reject invalid, no dead promo | `promos.dto.ts:29-30` (`@IsString`) | 🔴 accepts Invalid Date | +| D6 | Promo min-spend / max-cap | Enforced | schema:785 (fields absent) | 🔴 none exist | + +## Suite E — Excess baggage & supplementary charges + +| ID | Scenario | Expected | Targets | Predicted | +|----|----------|----------|---------|-----------| +| E1 | Excess-baggage rate lookup by seat class | Uses booking's class allowance | `excess-baggage.service.ts:53` (oldest global row) | 🔴 wrong allowance | +| E2 | Baggage/supp charge to payment: `/100` major units, DJF | Per-currency rounding, correct unit | `excess-baggage.service.ts:166`; `supplementary-charges.service.ts:132` | 🔴 no conversion/rounding | +| E3 | Negative `maxWeightKg`/`maxPiecesCount` allowance | Reject | `excess-baggage.controller.ts:15-16` (no `@Min`) | 🔴 accepts negative | +| E4 | `markPaid` stores `providerTxnId` | Persisted | `excess-baggage.service.ts:186` | 🟠 discarded | + +## Suite F — Wallet & loyalty + +| ID | Scenario | Expected | Targets | Predicted | +|----|----------|----------|---------|-----------| +| F1 | Top up another passenger's wallet with your JWT | 403 | `wallet.controller.ts:34-39` (no ownership check) | 🔴 credits freely | +| F2 | Wallet top-up has payment backing | Backed by real payment | `wallet.service.ts:50-56` | 🔴 free money | +| F3 | `GET /wallet/accounts` public | Auth required | `wallet.controller.ts:23-24` (`isPublic`) | 🔴 leaks balances | +| F4 | Two concurrent WALLET bookings draining one balance | One fails, no negative | `payments.service.ts:461-484` (no row lock) | 🔴 double-spend | +| F5 | Loyalty accrual on non-ETB charge | Points from actual charge currency | `payments.service.ts:1062,1067` | 🟠 uses ETB minor always | +| F6 | Loyalty redemption deducts points / has balance | Deducted, capped | `bookings.service.ts:1028` | 🔴 never deducted (=A5) | + +## Suite G — Booking/payment lifecycle & webhooks + +| ID | Scenario | Expected | Targets | Predicted | +|----|----------|----------|---------|-----------| +| G1 | Webhook `confirmedAmount` < booking total (partial) | Not confirmed | `intents.service.ts:541-548` | 🔴 confirms, mismatch only logged | +| G2 | Pay a booking >20 min after creation (expired/cancelled) | Reject | `payments.service.ts:809-848`; `bookings.service.ts:2123` | 🔴 re-confirms, re-issues tickets | +| G3 | Duplicate webhook | Idempotent | `webhook-processor.service.ts:44-58` | 🟢 handled | +| G4 | Cancel a CONFIRMED booking → refund disbursed | Refund paid to wallet/provider | `bookings.service.ts:2017-2027` | 🔴 stuck PENDING forever | +| G5 | Refund amount `floor(total × 0.8)` flat | Correct tiered policy | `bookings.service.ts:2021` | 🟠 flat 80%, float | +| G6 | Seat-hold TTL (config) vs pending-expiry cron (hardcoded 20m) | Consistent | `seats.service.ts:271` vs `bookings.service.ts:2123` | 🔴 mismatch | +| G7 | Payment amount validated against booking anywhere | Validated | passenger-api + payment-api | 🔴 never | +| G8 | Booking create + seat confirm + tier increment atomic | Single transaction | `bookings.service.ts:883-926` | 🟠 not atomic | +| G9 | `forceConfirmPayment` admin-guarded | Admin only | `payments.service.ts:989` | 🟠 verify guard | + +## Suite H — Backoffice config validation gaps (API-level, direct-to-API bypassing UI) + +| ID | Scenario | Expected | Targets | Predicted | +|----|----------|----------|---------|-----------| +| H1 | Negative `baseFareMinor` fare rule | Reject | `schedules.dto.ts:85,95` (no `@Min`) | 🔴 accepts (sibling DTO has `@Min`) | +| H2 | Negative/zero seat-class `basePrice` | Reject | `seat-classes.dto.ts:29` | 🔴 accepts | +| H3 | Past `departureAt` schedule | Reject | `schedules.service.ts:105` | 🔴 accepts | +| H4 | Same train, two routes, overlapping time (same day) | Reject double-booking | `schedules.service.ts:124-132` | 🔴 accepts | +| H5 | Fare rule `validUntil` < `validFrom`; overlapping windows | Reject | `schedules.dto.ts`; no ordering/overlap check | 🔴 accepts | +| H6 | Duplicate station `code` | Reject (P2002) | `stations.service.ts:57-61` | 🟠 no catch (verify schema unique) | +| H7 | `PATCH /config` arbitrary key/value (e.g. `seat_hold_duration_minutes:-1`) | Validated | `system-config.controller.ts:34` (no DTO) | 🔴 stored raw | +| H8 | Unsupported currency code (outside ETB/USD/DJF enum) | 400 not 500 | `currencies.dto.ts:5`; `currencies.service.ts:55` | 🟠 | +| H9 | Station lat/lng out of ±90/±180 | Reject | `stations.dto.ts:9-10` | 🟠 | + +## Suite I — Config propagation & delete/disable semantics + +| ID | Scenario | Expected | Targets | Predicted | +|----|----------|----------|---------|-----------| +| I1 | Change exchange rate in backoffice → portal reflects it | Propagates (note 5-min staleTime) | `portal/useCurrencies.ts:20` | 🟠 up to 5 min stale | +| I2 | Change a fare in backoffice → next search reflects it | Live (no server cache) | `fare-engine.service.ts:29,59` | 🟢 no cache | +| I3 | Delete a station referenced by bookings | Blocked or safe | `stations.service.ts:110-137` (ignores bookings) | 🔴 orphan/FK risk | +| I4 | Delete a seat-class referenced by bookings/bookingSeat | Blocked or safe | `seat-classes.service.ts:53-81` | 🔴 ignores bookings | +| I5 | Schedule cascade delete fails midway | Transactional, no partial delete | `schedules.service.ts:438-485` | 🟠 non-transactional | +| I6 | Delete currency with active fares/rates | Blocked | `currencies.service.ts:119-134` | 🔴 wipes rates → 1.0 fallback | +| I7 | Config change mid-flight (edit/disable fare between quote and pay) | Defined behavior | booking freezes at create; pay never re-quotes | 🟠 client-trusted gap | + +## Suite J — Auth / authorization gaps + +| ID | Scenario | Expected | Targets | Predicted | +|----|----------|----------|---------|-----------| +| J1 | Unauthenticated `PUT/PATCH /fare-engine/exchange-rates` | 401 | `fare-engine/currency.controller.ts:25,32` (no guard) | 🔴 anyone rewrites FX | +| J2 | Non-admin authenticated user CRUDs `/admin/fare-configurations` | 403 | `configurable-fare.controller.ts` (`@Roles` dead) | 🔴 RolesGuard never wired | +| J3 | Non-admin CRUDs `/admin/segment-fares` | 403 | `segment-fare.controller.ts:15` | 🔴 | +| J4 | Non-admin reads/writes `/config` | 403 | `system-config.controller.ts:23,32` | 🔴 | +| J5 | Public exposure of `/search`, `/currencies`, `/wallet/accounts` | Intended-public only | `search.controller.ts`, `wallet.controller.ts:24` | 🟠 balances shouldn't be public | + +## Suite K — Browser E2E (Playwright, portal + backoffice) + +| ID | Scenario | Expected | Layer | +|----|----------|----------|-------| +| K1 | Portal: search → results price == API `displayAmountMinor` | UI math matches server | portal (`fare-utils.ts`, `results/page.tsx:609`) | +| K2 | Portal: review page total == what booking stores == charged | No client-side divergence | portal (`review/page.tsx:160-181,478`) | +| K3 | Portal: DJF fare rendered whole-franc, matches charge | Correct formatting | `format.ts:22-28` | +| K4 | Backoffice: create fare → portal search shows new price | End-to-end propagation | backoffice→portal | +| K5 | Backoffice: disable station → disappears from portal search | Honored | backoffice→portal | +| K6 | Backoffice: create promo → apply in portal → correct discount, no negative | End-to-end | backoffice→portal | +| K7 | Full happy-path booking (WALLET) through portal to ticket | Issued, amounts consistent | portal+api | + +--- + +## Harness plan (Phase 2 preview) + +- **API tests (supertest):** reuse the `payments.e2e-spec.ts` fixture-builder pattern (full Prisma object graph + teardown). Most target endpoints are `isPublic`, so auth is cheap. Wire a real config (`test/jest-e2e.json` currently won't even pick up in-src `*.e2e-spec.ts`). +- **Browser tests (Playwright):** greenfield — add runner + config. Portal has no server-side auth gate; backoffice needs `auth_token` cookie + localStorage seeded. +- **Payments:** WALLET is fully offline-testable. Gateway flows driven by POSTing directly to `/webhooks/` on payment-api (Telebirr/CBE/eBirr have loose signature gating; Card/Waafi need valid HMAC). `SERVICE_AUTH_TOKEN` unset in dev = internal endpoints unguarded. +- **Seed:** re-enable `prisma/seed.ts` steps or invoke seeder fns from a test bootstrap. Needs stations, routes+stops (distanceKm), schedules, seat classes, fare rules, FX rates, promos. +- **DB:** ⚠️ doc drift — CLAUDE.md says `postgres-passenger:5434/edr_passenger`; actual `.env.example` says `localhost:5432/edr_database?schema=passenger`; no compose file provisions it. **Need target confirmed.** + +## Open decisions (blocking Phase 2) + +1. **Environment** — is there a dev/staging DB + running stack I should target, or should the harness stand up a local Postgres (Docker) + seed + run the APIs itself? +2. **Fare system** — confirm `fare-engine` is the system of record and `configurable-fare` is dormant. +3. **Emphasis** — API-level abuse/integration tests (fast, high signal, covers ~90% of the leads above) vs. also full browser Playwright E2E (Suite K, slower, needs both web apps running). diff --git a/docs/ui-e2e-test-matrix.md b/docs/ui-e2e-test-matrix.md new file mode 100644 index 000000000..7a2f2c816 --- /dev/null +++ b/docs/ui-e2e-test-matrix.md @@ -0,0 +1,259 @@ +# EDR Passenger Platform — Playwright UI E2E: Scenario Matrix + Phase 2 Harness Plan + +**Phase 1 synthesis (MAPPING ONLY).** Consolidates the four mapping passes (portal booking, backoffice config, API/network contracts, auth+seed gaps) plus the adversarial review into a reviewable plan. Ties every scenario to an existing finding in `docs/ISSUES.md` (C-/H-/M-/L-) and `docs/e2e-test-matrix.md` (Suites A–K). **No tests written, no code changed, stack not run.** + +Two framing facts inherited from Phase 1: (a) `fare-engine` is the live pricing pipeline; `configurable-fare` is dormant. (b) The domain seed (`prisma/seed.ts`) is disabled — the harness must build fixtures. Two blockers discovered this pass that flip several scenarios from "green/repro" to "invalid as written": **(1) the portal never applies promo discounts to the booked total** (§2 note), and **(2) both web apps have ZERO `data-testid`** (`grep -rn data-testid src` → 0 in both). Section 6 is the prerequisite testid checklist. + +Ports: portal 5174, backoffice 5184, passenger-api 4000 (bare paths, no `/v1` except IAM `/v1/auth/*`), payment-api 3003 (`/webhooks/*`). Test DB port 5544 (`.env.test`). + +--- + +## 1. Master assertion recipe — capture price at every hop + +Each UI test drives the browser but asserts the **money chain** via (a) Playwright network interception (`page.route` / `page.waitForResponse`), (b) direct DB reads against the 5544 test DB (Prisma client or SQL), and (c) DOM text assertions on rendered price nodes. The master invariant (matrix "Suite A"), trimmed to links that actually have backing in the maps: + +``` +portal card price (displayAmountMinor) + ── [BREAK] on-select stored fare == Math.min(baseFareMinor) (results/page.tsx:320) ── + == /search/fare-breakdown displayFareMinor + == review computedTotal (reviewedTotalMinor sent, review/page.tsx:587) + == Booking.totalMinor/displayTotalMinor + == PaymentIntent.amountMinor + == charged amount (WALLET debit OR gateway webhook) +``` + +> **Removed from the stated invariant (over-claimed):** `loyalty accrual` — no §1.2 DB read captures a `LoyaltyAccount.pointsBalance` increment and no green row asserts accrual vs price; and `refund basis` — there is **no refund endpoint anywhere in the API map** and no refund scenario. If a loyalty-accrual assertion is wanted, add a `LoyaltyAccount.pointsBalance` read to a green WALLET row (§1.2) and re-add only that link. Refund is out of scope until a refund surface is mapped (see §7). + +### 1.1 Network interception targets (exact method + path, in flow order) + +| Hop | Method + Path (passenger-api :4000) | Capture for assertion | Source | +|---|---|---|---| +| Fayda gate | `GET /config/fayda-status` (confirm prefix — see §5.2) | `enabled` — must be `false` to expose manual passenger form | system-config.controller.ts:12,16 | +| Stations load | `GET /stations` | station list (search inventory) | portal search page.tsx:606 | +| Search | `POST /search` | body `{originStationId,destinationStationId,date,adultCount,childCount,nationality,journeyType,returnDate?}`; resp `outbound[].coachTypes[].classes[].{displayAmountMinor,baseFareMinor}` | results/page.tsx:234; search.controller.ts:13 | +| **On-select stored fare** | (client-side, no request) | `minFare = Math.min(...classes.map(c => c.baseFareMinor))` — **`baseFareMinor`, NOT the card's `displayAmountMinor`**; diverges for USD/DJF and flows downstream as `baseFareAdult` | results/page.tsx:320,821 | +| Promo (URL-injected) | `POST /promos/validate` `{code}` — **note: no in-portal "apply promo" input**; promo enters via `?promoCode=` → `searchCriteria.promoCode` | discount echo (does NOT reach booked total, see §2) | results/page.tsx:142 | +| Save passengers | `POST /passengers/save-details` | `{passengers[],userId,deviceId}` | passengers/page.tsx:1062 | +| Seatmap | `GET /seats/seatmap/{scheduleId}?coachTypeId=&journeyDirection=` | seat fares (`displayAmountMinor??baseFareMinor`) | seats/page.tsx:352 | +| Hold | `POST /seats/hold` `{scheduleId,origin,dest,journeyDirection,passengers:[{passengerId,seatId}]}` | resp `{holdId,expiresAt}` — **capture `expiresAt`** for PB-9 | seats/page.tsx:617; seats.controller.ts:164 | +| Fare breakdown | `GET /search/fare-breakdown?scheduleId=&...&passengers=&displayCurrency=[&promoCode]` | resp per-pax `{fareMinor,displayFareMinor,isFree}` (**undiscounted**) + separate top-level `discountMinor`/`totalMinor` (**ignored by portal**) | review/page.tsx:550,559,574; search.service.ts:906-975 | +| Create booking | `POST /bookings` (auth) **or** `POST /bookings/guest` | body `reviewedTotalMinor` (undiscounted per-pax sum), per-pax `seatFareMinor`; resp `{bookingId/pnr,totalMinor}` | review/page.tsx:209,394,457; bookings.controller.ts:364/181 | +| Booking amount | `GET /payments/booking-amount?bookingId=¤cy=` | resp `{amount (MAJOR, plain /100), currency}` — portal ×100; currency is driven by the **PaymentMethod.currency**, not the booking | payment/page.tsx:68,73 | +| Initiate | `POST /payments/initiate` `{bookingId,method,paymentMethodId,payerAccount?,platform}` | resp `clientAction{type,url}` **and `merchantOrderId`** (required to key the forged webhook) | payment/page.tsx:123,149; payments.controller.ts:108 | +| Confirm (CAC) | `POST /payments/{bookingId}/confirm` `{otp}` | — | payment/page.tsx:174 | +| Poll intent | `GET /payments/intents/{bookingId}` | status transitions | confirmation/page.tsx:125 | +| Ticket | `GET /bookings/{bookingId}` | `{status,totalMinor,payment:{amountMinor,currency},tickets[].barcodePayload}` | confirmation/page.tsx:105 | + +**Payment methods are DB-driven and must be seeded.** The portal renders only `PaymentMethod` rows where `enabled=true` (`payment/page.tsx:593`); `getSupportedPaymentMethods` returns enabled rows from the DB (`payments.controller.ts:273`). `seed-core.ts` seeds **none** → the pay page is empty and **every Track A row (WALLET included) hangs before paying**. See §5.4. + +**WALLET path** (fully offline, no payment-api/webhook): `POST /payments/initiate {method:"WALLET"}` short-circuits server-side to `finalizePaymentSuccess`, debiting `booking.totalMinor` directly (payments.service.ts:461-523). Best UI settlement path for green tests. **Note:** WALLET produces **no `edr_payment.payment_intent`** and **bypasses the charge-currency conversion** — so the DJF whole-franc rounding is not observable here (see UA-3/UA-17, §2). + +**Settlement injection for gateway tests** (no real gateway): +- **Forge webhook** to payment-api :3003 — `POST /webhooks/telebirr` or `/webhooks/dmoney` (both `signatureValid=true` hardcoded) with `merch_order_id = `, `trade_status=success`. Card/Waafi require valid HMAC — avoid. A TELEBIRR initiate returns `clientAction REDIRECT` and the portal does `window.location.href = url` (`payment/page.tsx:149`) → the test must `page.route`-abort that navigation to the non-existent gateway, forge the webhook, then drive to `/booking/confirmation`. +- **Direct internal** — `POST /internal/payments/mark-paid` on :4000 with `{version:1,eventType:"payment.succeeded",service:"PASSENGER",referenceType:"BOOKING",referenceId:,...}`. `ServiceAuthGuard` returns true when `SERVICE_AUTH_TOKEN` unset (dev). Fastest deterministic settlement — but it will **not** reproduce a *late* webhook race (C-5, see UA-15) nor the charge-currency conversion (DJF, see UA-3). + +### 1.2 DB reads to assert (test DB 5544) + +- **passenger.Booking** (schema.prisma:510): `totalMinor`(:520), `currency`(:519), `displayCurrency`(:523), `displayTotalMinor`(:524), `status`(:518 → `"CONFIRMED"` on settle, payments.service.ts:846), `paidAt`(:550), `bookingType`, `returnLegStatus`. +- **passenger.BookingSeat**: `fareMinor`, `displayCurrency`, `displayFareMinor` (:597-599). +- **passenger.PaymentIntent** (:621): `amountMinor` **Float** (:624 — assert numeric, not int-exact), `currency`, `status`, `method`, `merchantOrderId`(unique), `paidAt`. +- **edr_payment.payment_intent** (payment-api source of truth): `amount_minor`/`confirmed_amount_minor` **double precision** (migration 1782000000000). Assert as numeric. **Scope: gateway rows only (UA-15)** — WALLET creates no payment-api intent. +- **WALLET extras**: `WalletLedgerEntry` DEBIT of `totalMinor` w/ `relatedBookingId`; `WalletAccount.balanceMinor` decremented; ticket row / `GET /tickets/{bookingRef}`. + +### 1.3 Currency-formatting assertion (the L-1 target) + +Portal renders every price through `formatFare(amountMinor, code)` = `` `${code} ${(amountMinor/100).toFixed(2)}` `` (fare-utils.ts:86) — **always /100, always 2 decimals**. So DJF renders `DJF 1234.56`. Whole-franc rounding lives on the **charge conversion** (`currency.service.ts:9-13`, `CHARGE_CURRENCY_DECIMALS`; `payments.service.ts:223-298`), which **WALLET short-circuits past**. +- **ETB / USD**: assert DOM shows 2dp; assert `renderedMajor*100 == amountMinor`. +- **DJF (WALLET)**: can only assert the **shape** mismatch — DOM shows 2dp (`DJF x.yy`) while `GET /payments/booking-amount` returns whole-franc-less major via plain `/100`. No settled 0-decimal `amount_minor` exists on this path. +- **DJF (gateway / forged-telebirr)**: the real L-1 settle-side repro — assert DOM 2dp vs the charge-currency-converted, whole-franc `amount_minor`/`confirmed_amount_minor`. UA-3/UA-17 must route here to observe it. + +--- + +## 2. TRACK A — Booking combinations matrix (pruned cross-product) + +Axes: booking type {one-way, round-trip} × pax mix {1A, 2A, 1A+1C-free, 1A+2C (1 free/1 paid), 2A+3C} × class/berth {Economy Regular, Economy Bed} × nationality/currency {Ethiopian→ETB / LOCAL, Djiboutian→DJF / LOCAL, Other→USD / INTERNATIONAL} × promo {none, %valid, expired} × payment {WALLET, forged-telebirr}. Pruned to meaningful, finding-bearing rows. + +> **PROMO REALITY (blocking correction).** `GET /search/fare-breakdown` returns **undiscounted per-pax `displayFareMinor`** and puts the discount only in *separate* top-level `discountMinor`/`totalMinor` (`search.service.ts:906-975`). The portal review page **ignores** that top-level total and client-reduces the per-pax fares (`review/page.tsx:587`), sending that **undiscounted** sum as `reviewedTotalMinor`. The (guest) booking service then **overrides its own discounted total with `reviewedTotalMinor` when `>0`** and clamps its fallback with `Math.max(0,…)` (`guest-booking.service.ts:240-244,487,536-537,744`). Consequences: **through the browser, a valid promo is silently dropped and `Booking.totalMinor` = full price**, and a negative total is **not reproducible via UI**. Promo enters only via `?promoCode=` URL param (no selector); lookup is `findUnique({where:{code}})` (`search.service.ts:941`) — seed codes must be unique and exact. Whether the **authed** `/bookings` path shares the same override+clamp is unverified (§7). + +| ID | Scenario | Key inputs | Price cross-check expectation | Finding tie-in | +|---|---|---|---|---| +| **UA-1** | One-way, 1 adult, Economy Regular, Ethiopian/ETB, WALLET, no promo | ETB LOCAL regular class | Baseline green: card price == fare-breakdown == reviewedTotalMinor == Booking.totalMinor == PaymentIntent.amountMinor == wallet DEBIT. All equal, 2dp. (Optionally assert `LoyaltyAccount.pointsBalance` accrual here if the accrual link is kept.) | matrix A6 (baseline) | +| **UA-1b** | One-way, 1 adult, **Other/USD** — assert card `displayAmountMinor` vs internal `baseFareMinor` | nationality OTHER → USD; INTL class | ✅ FIXED — the card shows the USD fare (`displayAmountMinor`), the internal `baseFareMinor` is the ETB source it was converted from (rate apart, coherent). The portal now carries the USD value forward (`results/page.tsx` on-select uses `displayAmountMinor`, aligning with the already-USD seats-page logic). | div #1; H-3/H-4 | +| **UA-2** | One-way, 1 adult, Other/USD, INTERNATIONAL Regular, WALLET | OTHER → displayCurrency USD | ✅ FIXED — money chain COHERENT: `displayCurrency=USD`/`displayTotalMinor` = what the passenger saw (=`reviewedTotalMinor`); `currency=ETB`/`totalMinor` = the ETB charge basis (=`displayTotalMinor × rate`). The prior `currency:USD`-on-an-ETB-amount mislabel is gone. | H-3/H-4, matrix B3 | +| **UA-3** | One-way, 1 adult, **Djiboutian/DJF**, **forged-telebirr** | DJIBOUTIAN → DJF; gateway path | **DJF displayed 2dp (`DJF x.yy`) but charged whole-franc** — assert DOM-2dp vs settled `amount_minor` (0-decimal). Must be a **gateway** row (WALLET bypasses the charge conversion). | **L-1** ✅, matrix C6/K3 | +| **UA-3w** | One-way, 1 adult, Djiboutian/DJF, WALLET (shape-only) | DJF, WALLET | Assert only the DOM-2dp vs `booking-amount`-major **shape** mismatch (no settle-side rounding on WALLET). | L-1 (partial) | +| **UA-4** | One-way, **1A + 1 child ≤5yr (free)**, ETB, WALLET | childCount 1, DOB<5yr | Child shows "CHILD - FREE"; free child excluded from total; `fare-breakdown.isFree==true` agrees with client `fare-utils.isFirstChild` | **H-12**, matrix B6 | +| **UA-5** | One-way, **1A + 2 children** (first free, second paid), ETB, WALLET | childCount 2 | Second child paid; client reduce (review:587) == breakdown sum; assert booking vs quote free-child count agree (quote uses min(child,adult); booking uses child-1) | **H-12**, matrix B6 | +| **UA-6** | Round-trip, 1 adult, Economy Regular, ETB, WALLET | ROUND_TRIP, outbound+inbound holds | ✅ FIXED — the fare engine now prices the reverse (C→A) leg by absolute distance (was: threw "origin must come before destination", leaving the inbound leg with seats but no priced coach → unbookable). Full two-leg flow completes: 2 seats (one per leg), total = 2× the one-way fare. | div #6; matrix A3 | +| **UA-7** | Round-trip, 2 adults, **Economy Bed / berth**, INTERNATIONAL/USD, WALLET | bed seat-class; berth seats (`bedPosition`) | Berth priced as separate class; `getSeatFare` bedPosition match (seats:433) == breakdown; INTL berth surcharge consistent | requires **berth seed** (§5); matrix B3 | +| **UA-8** | One-way, 1 adult, ETB, **valid % promo via `?promoCode=`**, WALLET | valid `percentOff:10` in URL | ✅ FIXED — the browser still sends the undiscounted `reviewedTotalMinor`, but the authed `bookings.service` recomputes the authoritative fare and applies the promo, so `Booking.totalMinor` = `subtotal − discount`. | H-13 fixed & guarded; matrix D | +| **UA-11** | One-way, 1 adult, ETB, **expired promo** (validUntil past, active:true) via URL, WALLET | expired code | Promo rejected/ignored; total unaffected; UI shows no discount (consistent with UA-8 drop). | matrix D5 | +| **UA-13** | One-way, 1 adult, ETB, **client-forged low total** (intercept `POST /bookings`, rewrite `reviewedTotalMinor:1` + every `seatFareMinor:1`) | mutate body via `page.route` | ✅ FIXED — server recomputes the authoritative fare and REJECTS the underpayment (4xx); no booking persisted | **C-1** fixed & guarded, matrix A1 | +| **UA-14** | One-way guest, forged per-pax `seatFareMinor:0` (+ `reviewedTotalMinor:0`) | intercept `/bookings/guest` | ✅ FIXED — server recomputes the authoritative fare and REJECTS the free-ride underpayment (4xx); no booking persisted | **C-1** fixed & guarded, matrix A2/A4 | +| **UA-15** | One-way, 1 adult, ETB, **forged-telebirr short-pay** | booking total in the thousands; forge `/internal/payments/mark-paid` success with `amountMinor:1` | ✅ FIXED — the server compares the settled amount to the booking's display total and REFUSES a short payment; booking stays unconfirmed, no ticket | **C-4** fixed & guarded, matrix G1/G7 | +| **UA-16** | Round-trip, 2A+3C, mixed, ETB, WALLET | max pax spread | Stress free-child + per-leg split + total reduce; every backed hop equal | H-12, div #6 | + +**Moved to the API-level harness (no valid browser path):** +- **UA-9 / UA-10 / UA-17** — over-100% `percentOff:150`, fixed `amountOffMinor > subtotal`, DJF×promo negative total. Not reproducible via UI: `reviewedTotalMinor` is positive-undiscounted and the server clamps to 0 (`guest-booking.service.ts`). Keep as API-only for **H-1**. +- **UA-12** — loyalty over-redeem (**C-2**). No browser path: `grep loyalty|redeem` across `portal/src/app/booking/**` + `booking-store.ts` → zero hits; `loyaltyRedemptionPoints` exists only on `POST /search/fare-quote`, which the portal never calls (it uses fare-breakdown, no loyalty param). Keep as API-only. + +> Payment method: default all rows to WALLET (deterministic, offline). UA-3 and UA-15 use forged-telebirr. Rows tagged ✅ have an existing API-level repro in `docs/ISSUES.md`; the UI test proves the defect surfaces through the real browser flow (closing the "Suite K not yet run" gap, ISSUES.md L285-290). + +--- + +## 3. TRACK B — Config→portal propagation matrix + +Each row: change made in backoffice UI (:5184) → API write → portal read (:5174) → propagation + staleTime → predicted finding. Backoffice self-refreshes immediately (each mutation invalidates its own React-Query key). Staleness only bites the **portal**. + +| ID | Config change (backoffice UI) | Write endpoint | Portal read path | Propagation + staleTime | Predicted | +|---|---|---|---|---|---| +| **PB-1** | `/currencies` → edit ETB↔USD rate | `PATCH /currencies/{id}` `{rate}` | `GET /currencies` via useCurrencies.ts:19 | **staleTime 5min** — up to 5 min stale in portal | 🟠 matrix I1; ties H-2/H-3 | +| **PB-2** | `/tariff-rates` Tab1 → edit seat-class base | `PATCH /seat-classes/{id}` `{basePrice}` (**field `basePrice`**) | next `POST /search` (staleTime:0) + `GET /search/fare-breakdown` | Live, no cache | 🟢 matrix I2/K4; **field-name split** (basePrice vs baseFareMinor) — verify which fare-engine reads (§7) | +| **PB-3** | `/tariff-rates` Tab2/3 → route/segment fare override | `POST /schedules/routes/{routeId}/fare-rules` / `POST /schedules/segment-fares` | `POST /search` results | Live | 🟠 segment rule may not bite: engine matches `dto.nationality` or null; seeder writes 'LOCAL'/'INTERNATIONAL' → won't match (§5 note); matrix B5 / H-11 | +| **PB-4** | `/stations` → add station | `POST /stations` | `GET /stations` (SearchWidget staleTime 60s; root prefetch raw fetch) | ≤60s stale in SearchWidget; prefetch uncached | 🟠 matrix K5 | +| **PB-5** | `/stations` → **disable station** (isOperational=false) | `PATCH /stations/{id}` | `GET /stations` (portal passes **no operational filter**) | Only disappears if API omits non-operational server-side — **verify** (§7) | 🔴/🟠 matrix K5/I3 | +| **PB-6** | `/classes` → create seat class | `POST /fleet/classes` `{baseFareMinor,...}` (**field `baseFareMinor`**, different endpoint than PB-2) | `POST /search` + seats page | Live (search staleTime:0) | 🟠 **two seat-class stores** (`/seat-classes` vs `/fleet/classes`) — confirm which live search reads (§7) | +| **PB-7** | `/promos` (URL, nav commented) → create promo | `POST /promos` | `POST /promos/validate {code}` at results:142 | On demand | 🔴 **field-name mismatch**: UI sends `discountType/discountValue/isActive`; DTO expects `percentOff/amountOffMinor/active` → possibly inert promo. Verify; own finding. matrix K6 | +| **PB-8** | `/schedules` → create schedule for search date | `POST /schedules` | `POST /search` | Live | 🟢 must satisfy all 9 searchability rules (§5); matrix I2 | +| **PB-9** | `/settings` → change seat-hold TTL | `PATCH /config` (raw, no RQ, no invalidate) | **no portal read path** — config is ignored by the hold | Server-side runtime | 🔴 **reframed:** capture `expiresAt` from `POST /seats/hold` and assert it does **NOT** track the config value (hold uses a fixed TTL — reconcile 15-min `seats.service.ts:~272` vs the "20-min" claim). **G6** | +| **PB-10** | `/currencies` → **delete** a rate pair | `DELETE /currencies/{id}` | `POST /search` (USD/Other) faresByClass | ✅ FIXED — `getExchangeRate` fails closed (throws) instead of substituting 1.0; the USD search returns NO priced class (no bogus ~100×-underpriced fare), and a booking would be rejected too | **M-5 / H-2** fixed & guarded, matrix I6 | + +**Deferred config surfaces (mapped, out of Phase 2 scope — stated so the matrix doesn't read as complete):** `/fare-management` (schedule-scoped `FareRule`, fare-source #3), `/pricing` (`/admin/segment-fares`, the dead-`@Roles` route), and `/routes` fare-rule CRUD beyond PB-3. + +--- + +## 4. HIGH-VALUE bug-class scenarios (concrete steps) + +### 4A. Config-mid-flight (edit/disable between quote and pay) — matrix I7 +- **BC-1**: Portal: search → results → select → hold → `/booking/review` (fare frozen). Second (backoffice) context: `PATCH /seat-classes/{id}` to triple the base. Back in portal: **Confirm**. **Assert** booking created at the *frozen* review price (`reviewedTotalMinor`), not the new one — booking never re-quotes; payment never re-validates. (ties C-1/L-2) +- **BC-2**: Same, but **disable the station** mid-flight (PB-5). Assert the in-flight booking still completes (no re-validation of station operational state). + +### 4B. Delete-referenced (M-5 / matrix I3–I6) +- **BC-3**: Create a CONFIRMED booking (UA-1). Backoffice `/stations` → delete the origin station (accept cascade if FK 400 offered). **Assert** either a referential block OR an orphaned booking (`GET /bookings/{id}` resolves but station lookups break). `stations.service.ts:110-137` ignores bookings. +- **BC-4**: `/classes` delete a seat-class referenced by a booking's `bookingSeat`. Assert orphan/FK behavior. matrix I4. +- **BC-5**: `/currencies` delete the USD↔ETB pair with active INTL fares. Next portal INTL search → fare collapses ~100× (1.0 fallback). **H-2**, matrix I6. +- **BC-3b** (new): `/routes` → delete a route referenced by a live schedule; assert orphaned schedule vs referential block. `routes.controller.ts`. +- **BC-4b** (new): `/schedules` → cancel a schedule with a CONFIRMED booking; assert whether the booking is stranded. *(Both new rows may be explicitly deferred if Phase 2 scope is tight.)* + +### 4C. Staleness (matrix I1) +- **BC-6**: Backoffice edit ETB↔USD rate. Immediately do a portal USD search → **assert** portal may show the OLD rate (useCurrencies staleTime 5×60×1000). **Then force a reload / navigation / window-focus** to trigger the refetch (React-Query `staleTime` does NOT auto-refetch on its own), and assert the new rate. Distinguishes the 5-min window from live search pricing. + +### 4D. Validation-via-UI vs direct-API (Suite H — direct-API bypass class; UI proves the client gaps) +- **BC-7** ✅ FIXED: `PATCH /seat-classes` with `basePrice:-500` is now rejected with **400** — `CreateSeatClassDto.basePrice` (and `insuranceFeeMinor`) carry `@Min(0)`, applied to updates via `PartialType`. **M-1**, matrix H1/H2. +- **BC-8** ✅ FIXED: `POST /promos` with `percentOff:200` is now rejected with **400** — `CreatePromotionDto.percentOff` carries `@Min(0) @Max(100)` (and `amountOffMinor` `@Min(0)`). A valid ≤100% promo still succeeds. **M-2**, matrix H4. +- **BC-9** ✅ FIXED: `PATCH /config {seat_hold_duration_minutes:"-1"}` is now rejected with **400** — a whitelisted `UpdateSystemConfigDto` coerces each known key to a positive integer (`seat_hold_duration_minutes` bounded 1..60). A sane value still stores. **M-3**, matrix H7. +- **BC-10** ✅ FIXED: `POST /schedules` with a past `departureAt` is now rejected with **400** — `schedules.service.createSchedule` guards `departureAt >= now` alongside the existing `arrival > departure` check. A future schedule still creates. **M-4**, matrix H3. +- **BC-11** ✅ FIXED: `PUT/PATCH /fare-engine/exchange-rates` now carry `@PassengerAdmin()` (as DELETE already did). Anon → 401, regular passenger → **403 forbidden**, staff admin → 200. **C-8**, matrix J1. + +--- + +## 5. PHASE 2 harness plan + +### 5.1 `playwright.config.ts` structure +``` +e2e-ui/ # new; sibling to existing e2e/ (API harness) + playwright.config.ts + global-setup.ts # boot+await stack (VERIFAYDA_ENABLED=false), seed, mint storageStates + fixtures/ + storage/passenger.json # generated by global-setup + storage/staff.json # generated by global-setup + seed-ui.ts # domain fixtures (see 5.4) + specs/ + portal/*.spec.ts # Track A (UA-*), BC-1/2/6 + backoffice/*.spec.ts # Track B config CRUD + propagation/*.spec.ts # BC-3..BC-11 cross-app +``` +- **projects**: `portal` (baseURL `http://localhost:5174`, storageState `passenger.json`), `backoffice` (baseURL `http://localhost:5184`, storageState `staff.json`), plus a `guest` project (no storageState) for guest rows (UA-14). Pin `viewport` per project — portal desktop layout is `hidden md:block`; mobile diverges heavily. One shared **`globalSetup`**. +- `webServer`: optionally let Playwright start portal+backoffice (`pnpm --filter @edr/passenger-portal dev` etc.); reuseExistingServer in local dev. + +### 5.2 global-setup +1. Ensure Postgres :5544 up and migrated (`.env.test`, `JWT_ACCESS_TOKEN_SECRET=test-access-secret-0000…`). +2. Boot passenger-api :4000 (**with `VERIFAYDA_ENABLED=false`** so the portal exposes the manual passenger form — otherwise Fayda defaults ON and every booking flow is blocked; the flag is `enabled = process.env.VERIFAYDA_ENABLED !== 'false'`, system-config.controller.ts:12,16) and payment-api :3003 (or assert reachable). Await `/health`-style ping. **Confirm which `fayda-status` prefix the portal hits** (`/config` vs `fare-engine.controller.ts:65`, which defaults `false`) so the right flag is set. +3. Run `seed-core.ts` + new `seed-ui.ts` (§5.4). +4. Mint the two storageStates (§5.3), write to `fixtures/storage/`. + +### 5.3 The two storageState fixtures (grounded in auth map) + +The passenger-API `JwtGuard` is **DB-backed against `iam.sessions`** — a fake JWT 401s. JWT payload is `{ id: }` (NOT userId); roles/permissions live in the session's `userInfo` jsonb. + +**Passenger storageState (portal :5174)** — no server gate, but `/auth/profile` runs on load and self-ejects on 401: +1. Insert `iam.users` (individual, active). +2. Insert `iam.sessions` (`status='ACTIVE'`, future expiry, `userInfo.roles=[]`). +3. Insert Prisma `Passenger{iamUserId}` **+ `LoyaltyAccount` + `WalletAccount`(funded balanceMinor) + `UserPreferences`** — required or `getProfile` throws "Passenger not found" (passenger-auth.service.ts:262) and the portal logs out. +4. Mint JWT `{id: sessionId}` with `JWT_ACCESS_TOKEN_SECRET`. +5. Write storageState `localStorage` for origin :5174: `auth_token=`, `auth_user=`. +6. *Simplest alternative*: drive real `POST /auth/login` once with a seeded passenger, snapshot localStorage. + +**Staff/admin storageState (backoffice :5184)** — server middleware requires the `auth_token` **cookie**; API staff calls require `userInfo.roles` carrying `super_admin`/`organization_admin` or the right permission keys: +- **Path A (robust)**: set `SEED_EDR_PASSENGER_ORG=true` + `SEED_PASSENGER_STAFF=true`, boot API → seeds org `edr`, roles, users (`passenger.admin@edr.local` / `Test@1234`). Then `POST /v1/auth/login` → `GET /v1/auth/me`, snapshot `localStorage` (`auth_token`,`auth_user`,`auth_refresh_token`) **and** set `auth_token` cookie. +- **Path B (fast)**: insert `iam.users`+`iam.sessions` with `userInfo.roles=[{key:'super_admin'}]`, mint JWT, write storageState localStorage + `auth_token` cookie for :5184, `auth_user` with `isSuperAdmin:true`. Config pages don't use `PermissionGuard` — only middleware cookie + API guards matter. +- **Note:** the `auth_token` cookie is **host-scoped (`localhost`), not port-scoped**, so it is also sent to the portal origin. Harmless (portal reads localStorage, not this cookie) but relevant if a single shared browser context is reused across projects. + +### 5.4 Seed extensions (add to `seed-core.ts` or new `seed-ui.ts`) +`seed-core.ts` today has CoachType×1, SeatClass×2 (LOCAL 300 / INTL 500, both regular), Station×3 (A/B/C), Route×1 + 3 RouteStop (0/100/250km), 4 FX rows. **No Train/Schedule/Coach/Seat/Passenger/PaymentMethod.** Add: +- **PaymentMethod rows (BLOCKING — pay page is empty without them):** at minimum an **enabled `WALLET`** (currency ETB) and an **enabled `TELEBIRR`** (for UA-15). The method's `.currency` drives `booking-amount` and the displayed pay total (`payment/page.tsx:68`), so a DJF booking paid by an ETB wallet renders ETB on the pay page — relevant to UA-3/UA-3w. +- **Bookable trip** (all 9 searchability rules): `Train`×1 → `TrainSchedule`(A→C, `status:'SCHEDULED'`, `isPackageOnly:false`, `departureAt = now+2d`, whole-day in Addis TZ, **>30min ahead**) → 3 `TripStopTime`(A/B/C seq 1/2/3, future `plannedDepartureAt`) → `Coach`×1(`status:'ACTIVE'`) → `CoachAssignment`(`isOperational:true`) → `Seat`×N (AVAILABLE, non-empty `seatNumber`, `bedPosition:null`). Fares resolve via `SEAT_CLASS_BASE_FARE` distance formula with the existing USD→ETB row — no fare-rule rows needed for the green path. +- **Seat-class names (pin exactly):** the review flow builds a `seatClassName → seatClassId` map from `GET /seat-classes` (`review/page.tsx:277`) and fare-quote expects exact names `"Economy Regular"|"Economy Bed"` (search.dto.ts). Set `SeatClass.name` to the exact client strings, or those rows won't resolve. (The axis's "VIP Bed" has no seed/scenario — seed it or drop it from the axis; this matrix drops it.) +- **Berth combos** (UA-7): LOCAL+INTL SeatClasses with `bedPosition IN ('UPPER','MIDDLE','LOWER')` + a bed `Coach` + `Seat`s with lowercase `bedPosition:'upper'|'middle'|'lower'`. +- **Promotions** (UA-8/11): valid `percentOff:10`; expired (`validUntil` past, `active:true`). **Use schema field names `percentOff/amountOffMinor/active`** — NOT the backoffice UI field names. **Pin exact, unique `code` values** (lookup is `findUnique({where:{code}})`, search.service.ts:941); tests navigate with `?promoCode=`. (Over-100% / over-subtotal promos belong to the API harness, not Track A.) +- **BaggageAllowance** ×1 per seat class (for excess-baggage rows). +- **Passenger satellite** for logged-in/WALLET: `Passenger{iamUserId}` + funded `WalletAccount(balanceMinor)` + `LoyaltyAccount`. +- **Blocked-seat negative case**: one `SeatBlock` row. +- **Segment override that actually bites** (PB-3): seed `SegmentFareRule` with `nationality:null` (engine matches `dto.nationality` string or null; 'LOCAL'/'INTERNATIONAL' rows won't match a real search). +- FX: existing 4 rows suffice for ETB/USD/DJF via ETB pivot; add `USD↔DJF` only if a direct-path currency test needs it. + +### 5.5 Two smoke tests +- **Portal smoke** (`guest` project): home → search (seeded A→C, date = Addis date of `departureAt`) → results shows ≥1 card with a price → `formatFare` renders `ETB N.NN`. Asserts stack+seed+search+Fayda-flag wired. +- **Backoffice smoke** (`backoffice` project): staff storageState → `/currencies` loads list → open "Add Rate" modal. Asserts staff auth (cookie+localStorage+API token) all valid. + +### 5.6 pnpm scripts + turbo task +- Root `package.json`: `"test:e2e:ui": "playwright test -c e2e-ui/playwright.config.ts"`. +- turbo `test:e2e:ui` task `"cache": false`; global-setup owns boot/seed. Single command: `pnpm test:e2e:ui`. +- Specs under `e2e-ui/specs/{portal,backoffice,propagation}`. + +--- + +## 6. SELECTORS TO ADD — `data-testid` checklist (PREREQUISITE; both apps have 0 today) + +Without these, every locator hangs off role/text/`name=`/placeholder, which is brittle across the portal's mobile/desktop breakpoint split. Recommend adding these before authoring (out of scope this phase; flag for user approval). **Promo has no selector — it enters via `?promoCode=` URL param.** + +### Portal (`apps/edr-passenger-web/portal/src`) +- **Search**: `search-trip-type-oneway`/`-roundtrip` (page.tsx:777/789), `search-origin-input` (:1234), `search-dest-input` (:1274), `search-swap` (:1261), `search-depart-date` (:1305), `search-return-date` (:1486), `search-pax-trigger` (:1334), `pax-adult-plus`/`-minus`, `pax-child-plus`/`-minus` (PassengerModal:319/330), `nationality-eth`/`-dji`/`-other` (:352), `search-submit` (:1362). +- **Results**: `result-card` (per schedule), `result-card-price` (:821 — "starting from"), `result-select-btn` (:831), `coach-option` (:487), `coach-class-price` (:609), `continue-passenger-details` (:642), `modify-search` (:1282). +- **Passengers**: `pax-name-{i}`, `pax-dob-btn` (:327), `pax-gender`, `pax-nationality`, `pax-phone`, `pax-passport`, `verify-fayda-btn`, `enter-manually-toggle` (:1003), `create-account-checkbox`, `passengers-continue`. +- **DOB picker (`DobPickerModal` — required for UA-4/UA-5 free-child):** `dob-cal-etgc-toggle` (:346), `dob-manual-toggle` (:354), `dob-manual-day`/`-month`/`-year` inputs, `dob-day-cell-{n}`, `dob-confirm`. +- **Seats**: `seat-cell-{label}` (SeatButton:119), `berth-cell-{label}` (BedCard:38), `passenger-tab-{i}`, `auto-assign-seats` (~:2006), `seats-continue` (~:1989), `fare-change-confirm` (CustomModal). +- **Review**: `review-total` (:683 desktop / :1031 mobile), `review-pax-fare-{i}` (:662), `review-outbound-line`/`-return-line` (:670/674), `review-child-badge` (:657), `confirm-and-pay` (:694), `seat-hold-timer` (:719). +- **Payment**: `pay-method-{type}` (:597), `pay-total` (:390 / :651 mobile), `pay-submit` (:406), `cac-phone-input` (:488), `cac-otp-input` (:531). +- **Confirmation**: `confirmation-pnr` (:404), `confirmation-status` (:615), `confirmation-total-paid` (:631), `ticket-number-{i}` (:659), `download-voucher` (:794), `book-another` (:815). + +### Backoffice (`apps/edr-passenger-web/backoffice/src`) +- **Login**: `login-email` (:165), `login-password` (:189), `login-submit` (:221). +- **DataTable / dialogs (shared)**: `add-entity-btn` (ActionButton), `row-edit-{id}`, `row-delete-{id}`, `confirm-dialog-confirm`, `confirm-cascade-checkbox`, `modal-submit`. +- **Tariff Rates** (`/tariff-rates`): `tab-seatclass`/`tab-route`/`tab-segment`/`tab-baggage`; RateModal fields already have `name=` (`name`, `baseFareMinor`, `insuranceFeeMinor`/`surchargeMinor`, `isActive`) — add `testid` on submit + modal. +- **Currencies** (`/currencies`): controlled form (no `name=`) — add `currency-from`, `currency-to`, `currency-rate`, `currency-save`, `currency-edit-rate`. +- **Classes** (`/classes`): FormData has `name=` (`coachTypeId,name,baseFareMinor,insuranceFeeMinor,isActive`) — add submit testid. +- **Schedules** (`/schedules`): controlled `addForm`/`DateTimePicker` — add `schedule-train`, `schedule-route`, `schedule-departure`, `schedule-arrival`, `schedule-status`, `schedule-save`, `schedule-cancel-btn`. +- **Stations** (`/stations`): FormData `name=` present — add submit testid. +- **Settings** (`/settings`): real `id=` (`hold-duration`, `hold-cutoff`, `boarding-window`, throttle-*) — usable, but add `config-save` testid. +- **Promos** (`/promos`, URL-only): FormData `name=` present — add submit + note field-name mismatch (PB-7). + +--- + +## 7. OPEN QUESTIONS / RISKS (decide before Phase 2) + +1. **Valid IAM token for storageState** — Path A (real `/v1/auth/login` after enabling `SEED_EDR_PASSENGER_ORG` + `SEED_PASSENGER_STAFF`) vs Path B (direct `iam.sessions` insert with `userInfo.roles=[{key:'super_admin'}]` + self-signed JWT). **Recommend Path A for staff, Path B acceptable for passenger.** Confirm. +2. **Seed `iam.sessions` vs dev bypass** — there is **no dev auth bypass** in the passenger-API `JwtGuard` (DB-backed, no env short-circuit). A session row is mandatory for any authenticated flow. Confirm we may write directly to `iam.sessions` in the test DB. +3. **Target DB / stack** — doc drift: CLAUDE.md says `postgres-passenger:5434/edr_passenger`; `.env.example` says `localhost:5432/edr_database?schema=passenger`; `.env.test` uses `5544`; no compose file provisions it. **Confirm the harness stands up its own Postgres :5544 + boots both APIs, or targets an existing dev stack.** +4. **Stack-startup reliability** — global-setup must boot passenger-api (:4000) + payment-api (:3003) + portal (:5174) + backoffice (:5184) + RabbitMQ (vhost `payment`), or route settlement through `/internal/payments/mark-paid` to avoid RabbitMQ. **Recommend the internal-endpoint path for green settlement determinism** — but note it will **not** reproduce a *late*-webhook race (C-5) nor the charge-currency conversion (DJF, UA-3), which both require a real forged-gateway webhook to :3003. +5. **Gateway webhook signing** — Telebirr/dmoney accept forged payloads (`signatureValid=true` hardcoded); Card/Waafi require valid HMAC. UA-3/UA-15/gateway rows must use Telebirr/dmoney or the internal endpoint. Confirm we won't need real Card/Waafi HMAC in Phase 2. +6. **Fayda flag & prefix** — global-setup must set `VERIFAYDA_ENABLED=false` (else the manual passenger form is hidden and every booking flow blocks). **Confirm which `fayda-status` route the portal reads** (`/config`, default-ON, vs `fare-engine.controller.ts:65`, default-OFF) so the correct flag is set. +7. **Promo money-flow — does the authed path share the guest override+clamp?** The guest booking service overrides its discounted total with `reviewedTotalMinor` and clamps (`guest-booking.service.ts:240-244,487,536-537,744`), making promos inert and negative totals unreachable via UI. **Verify whether `bookings.service.ts` (authed `POST /bookings`) has the same override+clamp** before finalizing UA-8's "promo silently dropped" assertion for logged-in users. +8. **`data-testid` addition** — Section 6 requires source edits to both web apps (including the `DobPickerModal` internals for child-fare rows). Approve adding testids (small, low-risk) vs authoring against fragile role/text selectors. **Strongly recommend adding testids first.** +9. **Two field-name mismatches to verify at runtime** (each may be its own finding): (a) Promos UI sends `discountType/discountValue/isActive` but DTO expects `percentOff/amountOffMinor/active` → possibly inert promos (PB-7). (b) Seat-class base written as `basePrice` (Tariff Rates, PB-2) vs `baseFareMinor` (Classes page, PB-6), across two endpoints (`/seat-classes` vs `/fleet/classes`) — confirm which the live `fare-engine` reads before asserting PB-2/PB-6. +10. **Portal station operational filter** (PB-5) — portal `GET /stations` passes no `operational` filter; whether a disabled station disappears depends on the server default. Verify before writing the disable-propagation assertion. +11. **Currency-controller collision** — two `@Controller('currencies')` register the same base path (`currencies.controller.ts` + `currency.controller.ts`) with different guards/bodies; confirm which one the backoffice `/currencies` page hits before asserting PB-1/PB-10 write semantics. +12. **Seat-hold TTL number** (PB-9) — the matrix draft said "20-min cron"; the seed map says `expiresAt = now + 15min` (`seats.service.ts:~272`). **Reconcile the actual fixed TTL** before asserting that the hold ignores the config value. +13. **On-select fare divergence** (UA-1b) — confirm that the value stored on select is `Math.min(baseFareMinor)` (results:320) and not the card's `displayAmountMinor` (results:821), and pin which one downstream fare-breakdown reconciles against for non-ETB currencies. +14. **Scope of Track A vs B** — Track A (UA-*) covers pricing integrity through the real browser (closes the Suite K gap); Track B/BC-* covers config propagation. Confirm both tracks are in Phase 2 scope, or prioritize Track A first (highest money-risk, most ✅ findings to surface in-browser). +15. **Explicitly out-of-scope money surfaces (deferral, not omission):** loyalty redemption (C-2, no browser path), refunds (no endpoint mapped), over-100%/over-subtotal promo negative totals (H-1, API-only), transit / `ROUND_TRIP_TRANSIT` (needs a 2nd seeded route), package booking (`/packages`, `isPackageOnly` schedules, `packageTierPriceMinor × 2`), `/pay-balance/[token]` partial-payment / `returnLegStatus`, and config surfaces `/fare-management` + `/pricing`. Confirm these stay deferred so the matrix is not read as exhaustive. \ No newline at end of file diff --git a/e2e-ui-report/index.html b/e2e-ui-report/index.html new file mode 100644 index 000000000..ba053244d --- /dev/null +++ b/e2e-ui-report/index.html @@ -0,0 +1,90 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/e2e-ui/.gitignore b/e2e-ui/.gitignore new file mode 100644 index 000000000..0eaeb7f29 --- /dev/null +++ b/e2e-ui/.gitignore @@ -0,0 +1,4 @@ +fixtures/storage/ +test-results/ +../e2e-ui-report/ +.last-run.json diff --git a/e2e-ui/README.md b/e2e-ui/README.md new file mode 100644 index 000000000..ffb705f10 --- /dev/null +++ b/e2e-ui/README.md @@ -0,0 +1,154 @@ +# EDR Passenger — Playwright UI E2E + +Browser E2E for the passenger platform. **Track A** = portal booking combinations; **Track B** = +backoffice config → portal propagation. Scenario matrix: `docs/ui-e2e-test-matrix.md`. + +## Status + +**Track A + Track B implemented and green** — 27 passing specs, 1 documented skip (UA-7). Full suite +runs deterministically in ~1.8 min (`workers:1`, one seeded DB shared serially). The whole booking +flow is factored into `fixtures/booking-flow.ts` — `bookTrip(page, opts)` drives an arbitrary +passenger mix, nationality, trip type, promo, and payment method end to end (search → select → +passengers → seats → review → pay → confirmation), capturing the price at each hop; `bookOneAdult` is +a thin back-compat wrapper. + +### Coverage vs `docs/ui-e2e-test-matrix.md` + +**Track A — booking combinations** (`specs/portal`, `specs/guest`): + +| ID | Spec | What it proves | +|----|------|----------------| +| UA-1 | `ua1` | one-way 1A ETB WALLET — full money chain equal, CONFIRMED | +| UA-1b | `ua1b-usd-divergence` | ✅ USD card shows the USD fare, correctly converted from the internal ETB base (coherent) | +| UA-2 | `ua2-usd-booking` | ✅ USD booking — passenger amount in USD (display), charge basis stored coherently in ETB (currency mislabel fixed) | +| UA-3 | `ua3-djf` | DJF booking settles via forged gateway payment | +| UA-3w | `ua3-djf` | ✅ DJF WALLET — passenger amount in DJF, charge basis stored coherently in ETB (mislabel fixed) | +| UA-4 | `ua4-child-free` | first child <5 free → total = one adult fare, free child not seated | +| UA-5 | `ua5-second-child-paid` | 1A+2C → second child pays full fare (2 seats) | +| UA-6 | `ua6-round-trip` | ✅ round-trip books both legs — reverse-leg pricing fixed (abs distance); 2 seats, total = 2× one-way (M-4-adjacent) | +| UA-8 | `ua8-promo-drop` | ✅ valid promo now applied server-side; booking stored at the discounted total (H-13 fixed & guarded) | +| UA-11 | `ua11-expired-promo` | expired promo ignored → full fare booked | +| UA-13 | `ua13-forged-total` | ✅ client-forged `reviewedTotalMinor=1` now REJECTED 4xx, nothing stored (C-1 fixed & guarded) | +| UA-14 | `ua14-forged-seat-fare` | ✅ guest forged `seatFareMinor=0` now REJECTED 4xx, nothing stored (C-1 fixed & guarded) | +| UA-15 | `ua15-telebirr-shortpay` | ✅ short-paid gateway settlement now REFUSED — booking stays unconfirmed (C-4 fixed & guarded) | +| UA-16 | `ua16-family-mix` | 2A+3C → two children free, one paid (3 seats) | + +**Track B — config → portal propagation & validation gaps** (`specs/backoffice`, `specs/propagation`): + +| ID | Spec | What it proves | +|----|------|----------------| +| PB-1 | `pb-config-propagation` | FX-rate change propagates live to portal USD pricing | +| PB-2 / PB-2b | `pb-config-propagation` | seat-class base-price change propagates live; `basePrice` field drives the fare | +| PB-4 | `pb-config-propagation` | station added in backoffice appears in the portal station list | +| PB-7 | `config-validation` | 🔴 promo created with backoffice UI field names is inert (field-name mismatch) | +| PB-10 | `pb-config-propagation` | ✅ deleting an FX rate now FAILS CLOSED (no priced fare) instead of a silent 1.0 collapse (M-5/H-2 fixed & guarded) | +| BC-7 | `pb-config-propagation` | ✅ negative seat-class base price now REJECTED (400, DTO `@Min(0)`) (M-1 fixed & guarded) | +| BC-8 | `config-validation` | ✅ promo over 100% now REJECTED (400, DTO `@Max(100)`) (M-2 fixed & guarded) | +| BC-9 | `config-validation` | ✅ negative seat-hold duration now REJECTED (400, whitelisted typed `/config` DTO) (M-3 fixed & guarded) | +| BC-10 | `config-validation` | ✅ schedule with a past departure now REJECTED (400); future schedules still create (M-4 fixed & guarded) | +| BC-11 | `pb-config-propagation` | ✅ non-admin passenger now FORBIDDEN (403) from FX writes; admin still allowed (C-8 fixed & guarded) | + +### Deferred (documented, not silently omitted) + +- **UA-7** (round-trip berth) — `specs/portal/ua7-berth.spec.ts` is `test.skip`: still needs a bed + CoachType seed. (Reverse-leg pricing is no longer a blocker — fixed under UA-6.) +- **UA-9 / UA-10 / UA-12 / UA-17** — the matrix moves these to the API-level harness (over-100% / + over-subtotal promos, loyalty over-redeem, DJF×promo negative total): no reachable browser path + (server clamps `reviewedTotalMinor` ≥ 0; the portal never calls the loyalty/fare-quote path). +- **PB-3/5/6/8/9, BC-1…BC-6** — additional config surfaces and delete-referenced/mid-flight/staleness + variations of the finding classes already covered above; the matrix marks several as deferrable. + +**Gateway settlement:** the real telebirr gateway is unreachable in the test env (`/payments/initiate` +502s), so gateway rows (UA-3, UA-15) create the booking through the real browser flow and then inject +settlement via `POST /internal/payments/mark-paid` — exactly the matrix's settlement-injection plan. + +Portal testids used: `result-select-btn`, `coach-option`, `continue-passenger-details`, +`pay-method-{TYPE}`. Everything else (passengers form + DOB modal, seats auto-assign, review, payment) +is driven via name/placeholder/role selectors — no further source edits were needed. + +**Seed note:** `Passenger.id` is set EQUAL to the IAM user id — see the comment in `seed-ui.ts` +(`UI_IDS.passenger`) and the SUSPECTED FINDING below. Each coach seeds 48 seats so a full serial run +never exhausts availability across specs. + +## Suspected finding (surfaced while building UA-1) + +`POST /bookings` (authenticated) overrides `passengerId` with the JWT user id +(`bookings.controller.ts:528-532`, "never trust the request body"). The service only resolves an +iamUserId → Passenger when it is **non-UUID** (`bookings.service.ts:773`). IAM user ids are UUIDs, so +the resolver never fires and `booking.create` uses the iamUserId directly as `passengerId` → FK +violation unless `Passenger.id == iamUserId`. This is why the seed aligns them. **Verify against a +real IAM-authenticated booking** — if `req.user.id` is genuinely the iamUserId in production, +authenticated portal bookings may be broken (guest path unaffected). Candidate for `docs/ISSUES.md`. + +## Prerequisites — the running stack + +The suite drives a live stack. `global-setup.ts` seeds + mints auth, but assumes the apps are +already up. Bring them up once (leave running across test runs): + +```bash +# 1. Infra: test Postgres (5544) + RabbitMQ (5672, payment vhost) +bash e2e/prepare.sh # postgres + migrations +docker compose -f e2e/docker-compose.yml up -d rabbitmq-e2e + +# 2. Build the shared types package (nest build needs the dist) +pnpm --filter @edr/types build + +# 3. passenger-api on :4000 against the 5544 DB, with org+staff seeding on +# (apps/edr-passenger-api/.env sets DATABASE_URL=…5544, PORT=4000, +# RABBITMQ_ENABLED=false, FAYDA_ENABLED=false, SEED_EDR_PASSENGER_ORG=true, +# SEED_PASSENGER_STAFF=true, DEFAULT_PASSWORD=Test@1234) +( cd apps/edr-passenger-api && pnpm dev ) # background + +# 4. Web apps (each has .env.local → NEXT_PUBLIC_API_URL=http://localhost:4000) +( cd apps/edr-passenger-web/portal && pnpm dev ) # :5174, background +( cd apps/edr-passenger-web/backoffice && pnpm dev ) # :5184, background +``` + +> `playwright.config.ts` now declares a `webServer` block that auto-boots api/portal/backoffice and +> **reuses** them if already running, so steps 3–4 are optional in local dev. Gateway rows settle via +> the internal `mark-paid` endpoint, so `apps/edr-payment-api` (:3003) is **not** required. + +## Run + +One command (infra → build → boot → seed+auth → run → open report): + +```bash +bash e2e-ui/run.sh # all projects; args pass through to playwright +bash e2e-ui/run.sh --headed # watch it in a real browser +SLOWMO=500 bash e2e-ui/run.sh --headed # slow every action by 500ms +bash e2e-ui/run.sh --project=portal ua4 # one project / filter by title +``` + +Or, against an already-running stack: + +```bash +pnpm test:e2e:ui # all projects +pnpm test:e2e:ui -- --project=guest --project=backoffice # smoke only +``` + +HTML report → `e2e-ui-report/index.html`. + +## Layout + +``` +e2e-ui/ + playwright.config.ts projects: portal (passenger auth), guest (none), + backoffice (staff auth), propagation (cross-app) + global-setup.ts seeds test DB (seed-ui.ts) + mints staff.json via real /login + fixtures/ + data.ts station IDs, sample depart date, results deep-link helper + storage/staff.json generated staff storageState (gitignored) + specs/{guest,portal,backoffice,propagation}/*.spec.ts +``` + +Seed lives with the API harness: `apps/edr-passenger-api/test/fixtures/seed-ui.ts` (extends +`seed-core.ts` with a bookable Train/Schedule/Coach/Seats, enabled PaymentMethods WALLET+TELEBIRR, +promos, funded wallet). Run standalone: `npx ts-node test/fixtures/seed-ui.ts`. + +## Auth model (grounded in the app) + +- **Portal (passenger)**: `localStorage.auth_token` only, no server gate. (passenger storageState is + a Phase 3 item — smoke uses the `guest` project.) +- **Backoffice (staff)**: middleware requires the `auth_token` **cookie**; API guards check the + session's permissions. `global-setup` logs in as the seeded `passenger.admin@edr.local` through + the real `/login` UI and snapshots both. No hand-crafted tokens. diff --git a/e2e-ui/fixtures/booking-flow.ts b/e2e-ui/fixtures/booking-flow.ts new file mode 100644 index 000000000..5d1ab3988 --- /dev/null +++ b/e2e-ui/fixtures/booking-flow.ts @@ -0,0 +1,367 @@ +import { expect, type Locator, type Page, type Route } from "@playwright/test"; +import { API_URL, CURRENCY_BY_NATIONALITY, resultsUrl } from "./data"; + +export type Nationality = "Ethiopian" | "Djiboutian" | "Other"; + +export interface PaxSpec { + category: "ADULT" | "CHILD"; + name: string; + gender: "Male" | "Female"; + /** Date of birth. Adults: age 6–110. Children: age < 5 (to be free-eligible). */ + dob: { d: number; m: number; y: number }; + /** Adults only. */ + phone?: string; + /** Non-Ethiopian adults only. */ + passport?: { number: string; country: string; issue: string; expiry: string }; +} + +export interface TripOptions { + nationality?: Nationality; + tripType?: "ONE_WAY" | "ROUND_TRIP"; + /** If `passengers` is omitted, N adults + M children are generated. */ + adults?: number; + children?: number; + passengers?: PaxSpec[]; + /** Promo code injected via the results URL (`?promoCode=`) — the portal has no promo input. */ + promoCode?: string; + /** Mutate the outgoing POST /bookings(/guest) body (e.g. forge reviewedTotalMinor). */ + mutateBookingBody?: (body: any) => any; + /** + * When the POST /bookings(/guest) is expected to be rejected (e.g. a forged total the server + * must refuse): don't assert a bookingId and return early with `bookingStatus` set, instead of + * driving on to payment. Lets a spec assert the server refused the booking. + */ + tolerateBookingError?: boolean; + paymentMethod?: "WALLET" | "TELEBIRR"; + /** + * For TELEBIRR: after initiate, abort the external gateway redirect and forge settlement via the + * internal mark-paid endpoint. `amountMinor` lets a test short-pay (settle for the wrong amount). + * Defaults to settling for the real booking total. + */ + forgeSettlement?: { amountMinor?: number }; +} + +export interface BookingResult { + /** displayAmountMinor on the results card (what the passenger sees — passenger currency). */ + cardDisplayMinor: number; + /** baseFareMinor on the results card (internal ETB fare; diverges from display for USD/DJF). */ + cardBaseFareMinor: number; + /** The search response's displayCurrency (ETB/USD/DJF). */ + displayCurrency: string; + /** reviewedTotalMinor the browser actually sent to POST /bookings. */ + reviewedTotalMinor: number; + /** HTTP status the POST /bookings(/guest) returned (2xx on success, 4xx when the server rejects). */ + bookingStatus: number; + bookingId: string; + /** Whether the flow used /bookings/guest. */ + guest: boolean; + initiateStatus: number; + /** merchantOrderId returned by POST /payments/initiate (gateway methods). */ + merchantOrderId?: string; + /** true once /booking/confirmation is reached. */ + confirmed: boolean; + /** The GET /search/fare-breakdown payload seen on the review page (per-pax fares + discount). */ + fareBreakdown: any; +} + +const PHONE_BY_NATIONALITY: Record = { + Ethiopian: "912345678", + Djiboutian: "77123456", + Other: "14155552671", +}; + +const PASSPORT_COUNTRY: Record = { + Ethiopian: "", + Djiboutian: "Djibouti", + Other: "Canada", +}; + +/** Build a default passenger list: adults first, then children (matches form index → category). */ +export function makePassengers(adults: number, children: number, nationality: Nationality): PaxSpec[] { + const list: PaxSpec[] = []; + for (let i = 0; i < adults; i++) { + list.push({ + category: "ADULT", + name: `Adult ${i + 1}`, + gender: i % 2 === 0 ? "Male" : "Female", + dob: { d: 15, m: 6, y: 1990 }, + phone: PHONE_BY_NATIONALITY[nationality], + passport: + nationality === "Ethiopian" + ? undefined + : { number: "P1234567", country: PASSPORT_COUNTRY[nationality], issue: "2020-01-01", expiry: "2032-01-01" }, + }); + } + for (let j = 0; j < children; j++) { + // Age ~3 as of 2026 → strictly under 5, so isChild() and the free-child policy apply. + list.push({ category: "CHILD", name: `Child ${j + 1}`, gender: "Female", dob: { d: 10, m: 3, y: 2023 } }); + } + return list; +} + +/** Passenger card locator (scoped by the "Passenger N" heading; N is 1-based). */ +function card(page: Page, i: number): Locator { + return page.locator("div.card").filter({ hasText: new RegExp(`Passenger ${i + 1}\\b`) }); +} + +/** Open the DOB modal for a passenger card, enter the date manually, and confirm. */ +async function fillDob(page: Page, c: Locator, dob: { d: number; m: number; y: number }) { + await c.getByRole("button", { name: /select date of birth/i }).click(); + await page.getByRole("button", { name: /enter manually/i }).click(); + await page.getByPlaceholder("DD").fill(String(dob.d)); + await page.getByPlaceholder("MM").fill(String(dob.m)); + await page.getByPlaceholder("YYYY").fill(String(dob.y)); + await page.getByRole("button", { name: /^confirm/i }).click(); +} + +/** Fill one passenger card (adult or child), revealing the manual form if it's gated. */ +async function fillPassenger(page: Page, i: number, spec: PaxSpec) { + const c = card(page, i); + const nameInput = page.locator(`input[name="passengers.${i}.name"]`); + // Adults may sit behind a Fayda gate that must be toggled open. Wait for whichever appears first — + // the name field (already expanded) or the reveal button — so we never toggle an open form closed. + const reveal = c.getByRole("button", { name: /enter details manually|skip for now/i }).first(); + await Promise.race([ + nameInput.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {}), + reveal.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {}), + ]); + if (!(await nameInput.isVisible().catch(() => false)) && (await reveal.isVisible().catch(() => false))) { + await reveal.click(); + } + await nameInput.waitFor({ state: "visible", timeout: 15_000 }); + + await nameInput.fill(spec.name); + await page.locator(`select[name="passengers.${i}.gender"]`).selectOption(spec.gender); + if (spec.category === "ADULT" && spec.phone) { + await c.locator('input[type="tel"]').first().fill(spec.phone); + } + if (spec.passport) { + await page.locator(`input[name="passengers.${i}.passportNumber"]`).fill(spec.passport.number); + await page.locator(`select[name="passengers.${i}.passportCountry"]`).selectOption(spec.passport.country); + await page.locator(`input[name="passengers.${i}.passportIssueDate"]`).fill(spec.passport.issue); + await page.locator(`input[name="passengers.${i}.passportExpiryDate"]`).fill(spec.passport.expiry); + } + await fillDob(page, c, spec.dob); +} + +/** Select a coach + continue, once for a one-way leg or twice for a round trip. */ +async function selectResultsAndContinue(page: Page, roundTrip: boolean) { + const pickCoach = async (scope: Locator | Page) => { + await (scope as Page).getByTestId("result-select-btn").first().click(); + await page.getByTestId("coach-option").first().click(); + await page.getByTestId("continue-passenger-details").first().click(); + }; + await pickCoach(page); // outbound (advances to the inbound step for a round trip) + if (roundTrip) { + // The inbound step re-renders result cards; scope to the inbound section if present. + const inbound = page.locator("#inbound-section"); + const scope = (await inbound.count()) > 0 ? inbound : page; + await scope.getByTestId("result-select-btn").first().click(); + await page.getByTestId("coach-option").first().click(); + await page.getByTestId("continue-passenger-details").first().click(); + } +} + +/** Auto-assign seats (fills all passengers at once and auto-continues). Twice for a round trip. */ +async function assignSeatsAndContinue(page: Page, roundTrip: boolean) { + const autoAssign = () => page.getByRole("button", { name: /auto assign seats/i }).first().click(); + await autoAssign(); // outbound + if (roundTrip) { + // After the outbound hold, the page switches to the return-seat map. + await page.getByRole("heading", { name: /return seats/i }).waitFor({ timeout: 20_000 }); + await autoAssign(); // inbound + } + await page.waitForURL(/\/booking\/review/, { timeout: 30_000 }); +} + +/** + * Drives the real portal booking flow end to end for an arbitrary passenger mix, nationality, + * trip type, promo, and payment method. Captures the price at each hop for DB cross-checks. + * Runs as a guest when the page context has no auth token (the `guest` Playwright project). + */ +export async function bookTrip(page: Page, opts: TripOptions = {}): Promise { + const nationality = opts.nationality ?? "Ethiopian"; + const tripType = opts.tripType ?? "ONE_WAY"; + const roundTrip = tripType === "ROUND_TRIP"; + const passengers = + opts.passengers ?? makePassengers(opts.adults ?? 1, opts.children ?? 0, nationality); + const adults = passengers.filter((p) => p.category === "ADULT").length; + const children = passengers.filter((p) => p.category === "CHILD").length; + + // Optional: forge the POST /bookings body before it leaves the browser. + if (opts.mutateBookingBody) { + await page.route(/\/bookings(\/guest)?(\?|$)/, async (route: Route) => { + if (route.request().method() !== "POST") return route.continue(); + const body = route.request().postDataJSON(); + await route.continue({ postData: JSON.stringify(opts.mutateBookingBody!(body)) }); + }); + } + + // ── Search / results ──────────────────────────────────────────────────────── + const searchDone = page.waitForResponse( + (r) => r.url().includes("/search") && r.request().method() === "POST", + ); + const base = resultsUrl({ nationality, adults, children, tripType }); + await page.goto(opts.promoCode ? `${base}&promoCode=${encodeURIComponent(opts.promoCode)}` : base); + const search = await searchDone; + const out = (await search.json())?.data?.outbound?.[0]; + const cardCls = out?.faresByClass?.[0]; + const cardBaseFareMinor = cardCls?.baseFareMinor; + const cardDisplayMinor = cardCls?.displayAmountMinor ?? cardBaseFareMinor; + const displayCurrency = out?.displayCurrency ?? CURRENCY_BY_NATIONALITY[nationality]; + expect(cardBaseFareMinor).toBeGreaterThan(0); + + await selectResultsAndContinue(page, roundTrip); + // Both authenticated users and guests may pass through the auth-check interstitial: authenticated + // users auto-forward to passengers, guests must click "Continue as guest". Handle whichever wins. + await page.waitForURL(/\/booking\/(passengers|auth-check)/, { timeout: 30_000 }); + if (/\/booking\/auth-check/.test(page.url())) { + await Promise.race([ + page.waitForURL(/\/booking\/passengers/, { timeout: 15_000 }).catch(() => {}), + page + .getByRole("button", { name: /continue as guest/i }) + .click({ timeout: 15_000 }) + .catch(() => {}), + ]); + await page.waitForURL(/\/booking\/passengers/, { timeout: 30_000 }); + } + + // ── Passenger form ──────────────────────────────────────────────────────────── + for (let i = 0; i < passengers.length; i++) await fillPassenger(page, i, passengers[i]); + await page.getByRole("button", { name: /continue to seat selection/i }).click(); + await page.waitForURL(/\/booking\/seats/, { timeout: 30_000 }); + + // ── Seats: auto-assign → hold → review ──────────────────────────────────────── + const fbDone = page + .waitForResponse((r) => r.url().includes("/search/fare-breakdown"), { timeout: 25_000 }) + .catch(() => null); + await assignSeatsAndContinue(page, roundTrip); + const fbRes = await fbDone; + const fbJson = fbRes ? await fbRes.json() : null; + const fareBreakdown = fbJson?.data ?? fbJson; + + // ── Review: confirm → POST /bookings(/guest) ────────────────────────────────── + const bookingDone = page.waitForResponse( + (r) => /\/bookings(\/guest)?(\?|$)/.test(r.url()) && r.request().method() === "POST", + ); + await page.getByRole("button", { name: /^confirm/i }).first().click(); + const bookingRes = await bookingDone; + const bookingStatus = bookingRes.status(); + const guest = bookingRes.url().includes("/bookings/guest"); + const reviewedTotalMinor = bookingRes.request().postDataJSON()?.reviewedTotalMinor; + + // Expected-rejection path: the server refused the booking (e.g. a forged total). Return early + // with the status so the caller can assert the refusal; there is no booking to drive to payment. + if (opts.tolerateBookingError && !bookingRes.ok()) { + return { + cardDisplayMinor, + cardBaseFareMinor, + displayCurrency, + reviewedTotalMinor, + bookingStatus, + bookingId: "", + guest, + initiateStatus: 0, + confirmed: false, + fareBreakdown, + }; + } + + const bookingData = (await bookingRes.json())?.data ?? {}; + const bookingId = bookingData.id ?? bookingData.bookingId; + expect(bookingId).toBeTruthy(); + await page.waitForURL(/\/booking\/(payment|confirmation)/, { timeout: 30_000 }); + + const result: BookingResult = { + cardDisplayMinor, + cardBaseFareMinor, + displayCurrency, + reviewedTotalMinor, + bookingStatus, + bookingId, + guest, + initiateStatus: 0, + confirmed: false, + fareBreakdown, + }; + + // A zero-total booking skips payment and lands straight on confirmation. + if (/\/booking\/confirmation/.test(page.url())) { + result.confirmed = true; + return result; + } + + // ── Payment ───────────────────────────────────────────────────────────────── + const method = opts.paymentMethod ?? "WALLET"; + + if (method === "WALLET") { + // WALLET settles fully server-side, synchronously → straight to /booking/confirmation. + const initiateDone = page.waitForResponse( + (r) => r.url().includes("/payments/initiate") && r.request().method() === "POST", + ); + await page.getByTestId("pay-method-WALLET").first().click(); + await page.getByRole("button", { name: /^pay\b/i }).first().click(); + result.initiateStatus = (await initiateDone).status(); + result.confirmed = await page + .waitForURL(/\/booking\/confirmation/, { timeout: 25_000 }) + .then(() => true) + .catch(() => false); + return result; + } + + // Gateway (TELEBIRR): the real provider is unreachable in the test env (initiate 502s), so we do + // what the matrix prescribes — inject settlement. The booking is already created through the real + // browser flow and sits in PENDING_PAYMENT; we forge the payment.succeeded event to the internal + // mark-paid endpoint (ungated when SERVICE_AUTH_TOKEN is unset), then let the confirmation page's + // poll flip to CONFIRMED. `forgeSettlement.amountMinor` lets a test short-pay (settle wrong amount). + const amountMinor = opts.forgeSettlement?.amountMinor ?? reviewedTotalMinor; + // mark-paid sits behind the global JwtGuard (any valid token passes; ServiceAuthGuard is a no-op + // when SERVICE_AUTH_TOKEN is unset). Reuse the logged-in passenger's token from localStorage. + const authToken = await page.evaluate(() => localStorage.getItem("auth_token")); + const markPaid = await page.request.post(`${API_URL}/internal/payments/mark-paid`, { + headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, + data: { + version: 1, + eventId: crypto.randomUUID(), // @IsUUID + eventType: "payment.succeeded", + occurredAt: new Date().toISOString(), + service: "PASSENGER", + intentId: crypto.randomUUID(), // @IsUUID + referenceType: "BOOKING", + referenceId: bookingId, + merchantOrderId: `e2e-${bookingId}`, + provider: "TELEBIRR", + amountMinor, + currency: "ETB", + providerTxnId: `e2e-txn-${bookingId}`, + paidAt: new Date().toISOString(), + }, + }); + result.initiateStatus = markPaid.status(); + // mark-paid finalizes synchronously; confirm authoritatively via the booking status API (the + // confirmation page's DOM depends on the client store, which a direct navigation may not carry). + await page.goto("/booking/confirmation"); + for (let attempt = 0; attempt < 10 && !result.confirmed; attempt++) { + const res = await page.request.get(`${API_URL}/bookings/${bookingId}`, { + headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, + }); + const status = ((await res.json().catch(() => ({})))?.data ?? {})?.status; + if (status === "CONFIRMED") result.confirmed = true; + else await page.waitForTimeout(500); + } + return result; +} + +/** Back-compat wrapper: one-way single adult (used by the original UA-1/8/13 specs). */ +export interface BookingOptions { + nationality?: Nationality; + promoCode?: string; + mutateBookingBody?: (body: any) => any; + tolerateBookingError?: boolean; + paymentMethod?: "WALLET" | "TELEBIRR"; +} +export async function bookOneAdult(page: Page, opts: BookingOptions = {}) { + const r = await bookTrip(page, { ...opts, adults: 1, children: 0, tripType: "ONE_WAY" }); + // Preserve the original field name used by the existing specs. + return { ...r, cardFareMinor: r.cardBaseFareMinor }; +} diff --git a/e2e-ui/fixtures/data.ts b/e2e-ui/fixtures/data.ts new file mode 100644 index 000000000..2a2b99c3a --- /dev/null +++ b/e2e-ui/fixtures/data.ts @@ -0,0 +1,92 @@ +/** Shared constants mirroring apps/edr-passenger-api/test/fixtures/{seed-core,seed-ui}.ts. */ +export const STATIONS = { + A: "00000000-0000-4000-8000-000000000020", // Alpha / AAA + B: "00000000-0000-4000-8000-000000000021", // Bravo / BBB + C: "00000000-0000-4000-8000-000000000022", // Charlie / CCC +} as const; + +export const SCHEDULE_ID = "00000000-0000-4000-8000-000000000101"; +export const RETURN_SCHEDULE_ID = "00000000-0000-4000-8000-000000000201"; +export const SEAT_CLASS_LOCAL = "00000000-0000-4000-8000-000000000010"; +export const SEAT_CLASS_INTL = "00000000-0000-4000-8000-000000000011"; +export const COACH_TYPE_ID = "00000000-0000-4000-8000-000000000001"; +export const ROUTE_ID = "00000000-0000-4000-8000-000000000030"; +export const PROMO_VALID = "PROMO10"; +export const PROMO_EXPIRED = "EXPIRED50"; + +export const API_URL = process.env.API_URL ?? "http://localhost:4000"; + +/** Friendly nationality name → the enum the portal/search expects. */ +export const NATIONALITY_ENUM = { + Ethiopian: "ETHIOPIAN", + Djiboutian: "DJIBOUTIAN", + Other: "OTHER", +} as const; +export type Nationality = keyof typeof NATIONALITY_ENUM; + +/** Display currency the search returns per nationality (asserted by the currency specs). */ +export const CURRENCY_BY_NATIONALITY = { + Ethiopian: "ETB", + Djiboutian: "DJF", + Other: "USD", +} as const; + +function tokenFrom(file: string): string { + const fs = require("node:fs") as typeof import("node:fs"); + const path = require("node:path") as typeof import("node:path"); + const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "storage", file), "utf8")); + for (const origin of raw.origins ?? []) { + for (const item of origin.localStorage ?? []) { + if (item.name === "auth_token") return item.value as string; + } + } + throw new Error(`auth_token not found in ${file} — did global-setup run?`); +} + +/** Staff/admin auth token minted by global-setup (backoffice storageState). */ +export function staffToken(): string { + return tokenFrom("staff.json"); +} + +/** Regular passenger (non-admin) auth token minted by global-setup (portal storageState). */ +export function passengerToken(): string { + return tokenFrom("passenger.json"); +} + +/** Must match seed-ui.sampleDepartAt(): now + 2 days at 06:00Z. */ +export function sampleDepartDate(): string { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + 2); + d.setUTCHours(6, 0, 0, 0); + return d.toISOString().slice(0, 10); +} + +/** + * Deep-link to the results page (bypasses the search form, which cannot emit tripType/returnDate). + * `nationality` accepts the friendly name ("Ethiopian"|"Djiboutian"|"Other") and is emitted as the + * enum the results page expects. For a round trip, pass tripType "ROUND_TRIP" — returnDate defaults + * to the same calendar day (the seeded return leg departs 8h after the outbound). + */ +export function resultsUrl(opts?: { + adults?: number; + children?: number; + nationality?: string; + tripType?: "ONE_WAY" | "ROUND_TRIP"; + returnDate?: string; +}) { + const nat = opts?.nationality ?? "Ethiopian"; + const enumNat = (NATIONALITY_ENUM as Record)[nat] ?? nat.toUpperCase(); + const p = new URLSearchParams({ + origin: STATIONS.A, + destination: STATIONS.C, + date: sampleDepartDate(), + tripType: opts?.tripType ?? "ONE_WAY", + adults: String(opts?.adults ?? 1), + children: String(opts?.children ?? 0), + nationality: enumNat, + }); + if ((opts?.tripType ?? "ONE_WAY") === "ROUND_TRIP") { + p.set("returnDate", opts?.returnDate ?? sampleDepartDate()); + } + return `/booking/results?${p.toString()}`; +} diff --git a/e2e-ui/global-setup.ts b/e2e-ui/global-setup.ts new file mode 100644 index 000000000..013518b1d --- /dev/null +++ b/e2e-ui/global-setup.ts @@ -0,0 +1,74 @@ +import { chromium, type FullConfig } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { seedUi } from "../apps/edr-passenger-api/test/fixtures/seed-ui"; +import { seedPassengerSession } from "../apps/edr-passenger-api/test/fixtures/seed-passenger-session"; + +/** + * Playwright global-setup for the UI E2E suite. + * 1. Seeds the 5544 test DB with the bookable trip + payment methods + promos (seed-ui.ts). + * 2. Mints a passenger IAM session + token → passenger.json storageState (localStorage). + * 3. Logs in as the seeded backoffice admin via the REAL /login UI → staff.json storageState. + * + * Assumes the stack is already running (api :4000, portal :5174, backoffice :5184). + */ +const STORAGE_DIR = path.join(__dirname, "fixtures", "storage"); +const API = process.env.API_URL ?? "http://localhost:4000"; +const PORTAL = process.env.PORTAL_URL ?? "http://localhost:5174"; +const BACKOFFICE = process.env.BACKOFFICE_URL ?? "http://localhost:5184"; +const DB_URL = + process.env.DATABASE_URL ?? + "postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger"; +const STAFF = { email: "passenger.admin@edr.local", password: process.env.DEFAULT_PASSWORD ?? "Test@1234" }; + +export default async function globalSetup(_config: FullConfig) { + fs.mkdirSync(STORAGE_DIR, { recursive: true }); + process.env.DATABASE_URL = DB_URL; + + const prisma = new PrismaClient(); + try { + console.log("[global-setup] seeding test DB…"); + await seedUi(prisma); + + console.log("[global-setup] minting passenger session…"); + const { token } = await seedPassengerSession(prisma); + const profileRes = await fetch(`${API}/auth/profile`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!profileRes.ok) throw new Error(`/auth/profile failed: HTTP ${profileRes.status}`); + const profile = (await profileRes.json())?.data ?? {}; + + const passengerState = { + cookies: [], + origins: [ + { + origin: PORTAL, + localStorage: [ + { name: "auth_token", value: token }, + { name: "auth_user", value: JSON.stringify(profile) }, + ], + }, + ], + }; + fs.writeFileSync(path.join(STORAGE_DIR, "passenger.json"), JSON.stringify(passengerState)); + console.log("[global-setup] passenger.json written"); + } finally { + await prisma.$disconnect(); + } + + console.log("[global-setup] minting staff storageState via real login…"); + const browser = await chromium.launch(); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await page.goto(`${BACKOFFICE}/login`, { waitUntil: "domcontentloaded" }); + await page.locator('input[type="email"]').fill(STAFF.email); + await page.locator('input[type="password"]').fill(STAFF.password); + await Promise.all([ + page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }), + page.locator('button[type="submit"]').click(), + ]); + await ctx.storageState({ path: path.join(STORAGE_DIR, "staff.json") }); + console.log("[global-setup] staff.json written"); + await browser.close(); +} diff --git a/e2e-ui/playwright.config.ts b/e2e-ui/playwright.config.ts new file mode 100644 index 000000000..c3a08ecb6 --- /dev/null +++ b/e2e-ui/playwright.config.ts @@ -0,0 +1,94 @@ +import { defineConfig, devices } from "@playwright/test"; +import * as path from "node:path"; + +// Defaults so `pnpm test:e2e:ui` runs standalone. Must match apps/edr-passenger-api/.env. +process.env.DATABASE_URL ??= + "postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger"; +process.env.JWT_ACCESS_TOKEN_SECRET ??= "test-access-secret-0000000000000000000000"; +process.env.DEFAULT_PASSWORD ??= "Test@1234"; + +/** + * Playwright UI E2E for the EDR passenger platform. + * Track A (portal booking combinations) + Track B (backoffice config → portal propagation). + * See docs/ui-e2e-test-matrix.md. global-setup boots/awaits the stack, seeds the 5544 test DB, + * and mints the passenger + staff storageStates. + */ +const PORTAL = process.env.PORTAL_URL ?? "http://localhost:5174"; +const BACKOFFICE = process.env.BACKOFFICE_URL ?? "http://localhost:5184"; +const STORAGE = path.join(__dirname, "fixtures", "storage"); + +export default defineConfig({ + testDir: path.join(__dirname, "specs"), + fullyParallel: false, // shared seeded DB — serialize to keep assertions deterministic + workers: 1, + retries: 0, + timeout: 60_000, + expect: { timeout: 10_000 }, + globalSetup: path.join(__dirname, "global-setup.ts"), + reporter: [ + ["list"], + ["html", { outputFolder: path.join(__dirname, "..", "e2e-ui-report"), open: "never" }], + ], + // Boot the app tier automatically; reuse it if it's already running (dev). Infra (Postgres 5544, + // RabbitMQ, migrations, @edr/types build) is handled by e2e-ui/run.sh BEFORE Playwright starts. + webServer: [ + { + command: "pnpm --filter @edr/passenger-api dev", + url: "http://localhost:4000/stations", + timeout: 180_000, + reuseExistingServer: true, + env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" }, + }, + { + command: "pnpm --filter @edr/passenger-portal dev", + url: PORTAL, + timeout: 120_000, + reuseExistingServer: true, + env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" }, + }, + { + command: "pnpm --filter @edr/passenger-backoffice dev", + url: `${BACKOFFICE}/login`, + timeout: 120_000, + reuseExistingServer: true, + env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" }, + }, + ], + use: { + trace: "retain-on-failure", + screenshot: "only-on-failure", + actionTimeout: 15_000, + // SLOWMO=500 bash e2e-ui/run.sh --headed → pause 500ms between each browser action + launchOptions: { slowMo: Number(process.env.SLOWMO ?? 0) }, + }, + projects: [ + { + name: "portal", // Track A — logged-in passenger + testMatch: /specs\/portal\/.*\.spec\.ts/, + use: { + ...devices["Desktop Chrome"], + baseURL: PORTAL, + storageState: path.join(STORAGE, "passenger.json"), + }, + }, + { + name: "guest", // Track A — guest bookings (no auth) + testMatch: /specs\/guest\/.*\.spec\.ts/, + use: { ...devices["Desktop Chrome"], baseURL: PORTAL }, + }, + { + name: "backoffice", // Track B — staff/admin + testMatch: /specs\/backoffice\/.*\.spec\.ts/, + use: { + ...devices["Desktop Chrome"], + baseURL: BACKOFFICE, + storageState: path.join(STORAGE, "staff.json"), + }, + }, + { + name: "propagation", // cross-app: staff writes config via API → passenger portal reads + testMatch: /specs\/propagation\/.*\.spec\.ts/, + use: { ...devices["Desktop Chrome"], baseURL: PORTAL }, + }, + ], +}); diff --git a/e2e-ui/run.sh b/e2e-ui/run.sh new file mode 100755 index 000000000..a431408f1 --- /dev/null +++ b/e2e-ui/run.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# One-command UI E2E: infra → build → boot app tier (via Playwright webServer) → seed+auth → run → +# open the HTML report. Idempotent; reuses an already-running stack. Any args pass through to +# playwright (e.g. `bash e2e-ui/run.sh --project=portal ua1`). +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$HERE/.." +API="$ROOT/apps/edr-passenger-api" +export GITHUB_PACKAGE_TOKEN="${GITHUB_PACKAGE_TOKEN:-dummy}" + +echo "==> 1/4 Infra: Postgres (5544) + RabbitMQ (5672) + migrations" +bash "$ROOT/e2e/prepare.sh" +echo " waiting for RabbitMQ healthy" +for _ in $(seq 1 30); do + s="$(docker inspect --format '{{.State.Health.Status}}' edr-passenger-e2e-rmq 2>/dev/null || echo none)" + [ "$s" = "healthy" ] && break; sleep 2 +done + +echo "==> 2/4 Build shared types (@edr/types dist — nest build needs it)" +pnpm --filter @edr/types build >/dev/null + +echo "==> 3/4 Ensure passenger-api dev env (test DB 5544, port 4000, brokers/Fayda off, seeding on)" +if [ ! -f "$API/.env" ]; then + sed -e 's/^PORT=.*/PORT=4000/' \ + -e 's/^SEED_EDR_PASSENGER_ORG=.*/SEED_EDR_PASSENGER_ORG=true/' \ + -e 's/^SEED_PASSENGER_STAFF=.*/SEED_PASSENGER_STAFF=true/' \ + "$API/.env.test" > "$API/.env" + echo " created $API/.env" +fi + +echo "==> 4/4 Playwright (boots api/portal/backoffice if not already up, seeds + mints auth, runs)" +npx playwright test -c "$HERE/playwright.config.ts" "$@" || TEST_EXIT=$? + +REPORT="$ROOT/e2e-ui-report/index.html" +if [ -f "$REPORT" ]; then + echo "==> Report: $REPORT" + open "$REPORT" 2>/dev/null || true +fi +exit "${TEST_EXIT:-0}" diff --git a/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts b/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts new file mode 100644 index 000000000..b123f24bc --- /dev/null +++ b/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from "@playwright/test"; +import { API_URL, STATIONS, staffToken } from "../../fixtures/data"; +import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui"; + +/** + * Track B — per-station check-in cutoff, driven by the backoffice route config API. Follows the + * same convention as config-validation.spec.ts: hits the passenger-api directly with a staff + * bearer token rather than driving the real DOM — the route-stop form has no data-testid hooks, + * so browser automation here would be selector-fragile for no extra coverage value. + * + * Creates its OWN route (not the shared ROUTE_ID from seed-ui) since updateRoute deletes and + * recreates all stops — mutating the shared fixture route would break every other spec that + * depends on its distances/times staying stable for the whole suite run. + */ +function auth() { + return { Authorization: `Bearer ${staffToken()}` }; +} + +test("BC-11 ✅ travelMinutesToStop drives each stop's estimated arrival independently of route-wide departure", async ({ request }) => { + const routeRes = await request.post(`${API_URL}/routes`, { + headers: auth(), + data: { + code: `E2E-CUTOFF-${Date.now()}`, + name: "E2E Check-in Cutoff Route", + effectiveFrom: "2020-01-01T00:00:00Z", + stops: [ + { stationId: STATIONS.A, sequence: 1 }, + { stationId: STATIONS.B, sequence: 2, travelMinutesToStop: 60 }, + { stationId: STATIONS.C, sequence: 3, travelMinutesToStop: 40 }, + ], + }, + }); + expect(routeRes.ok()).toBeTruthy(); + const route = (await routeRes.json())?.data ?? (await routeRes.json()); + const routeId = route.id; + + // Reuse the seeded coach via a route coach template so schedule creation auto-assigns real + // seats (createSchedule auto-applies any route coach template — see schedules.service.ts). + const templateRes = await request.put(`${API_URL}/routes/${routeId}/coaches`, { + headers: auth(), + data: { coaches: [{ coachId: UI_IDS.coach, positionNumber: 1 }] }, + }); + expect(templateRes.ok()).toBeTruthy(); + + // dep only 5 min out: A's cutoff (dep - 30min default) is already ~25 min in the past by the + // time this schedule is queried, but B's arrival (dep + 60min) keeps its own cutoff (arrival - + // 30min default) about 35 min in the future — proving the two stations close independently. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min + const scheduleRes = await request.post(`${API_URL}/schedules`, { + headers: auth(), + data: { trainId: UI_IDS.train, routeId, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() }, + }); + expect(scheduleRes.ok()).toBeTruthy(); + const schedule = (await scheduleRes.json())?.data ?? (await scheduleRes.json()); + + const getRes = await request.get(`${API_URL}/schedules/${schedule.id}`, { headers: auth() }); + expect(getRes.ok()).toBeTruthy(); + const full = (await getRes.json())?.data ?? (await getRes.json()); + const stopTimes: any[] = full.stopTimes ?? []; + const bStop = stopTimes.find((s) => s.stationId === STATIONS.B); + const cStop = stopTimes.find((s) => s.stationId === STATIONS.C); + + expect(new Date(bStop.plannedArrivalAt).getTime()).toBe(dep.getTime() + 60 * 60_000); + expect(new Date(cStop.plannedArrivalAt).getTime()).toBe(arr.getTime()); // last stop locked to overall arrival + + // A's own segment (no arrival — falls back to its departure) is already past its cutoff... + const rejectRes = await request.post(`${API_URL}/seats/hold`, { + headers: auth(), + data: { + scheduleId: schedule.id, + originStationId: STATIONS.A, + destinationStationId: STATIONS.B, + passengers: [{ passengerId: "44444444-4444-4444-8444-444444444444", seatId: "00000000-0000-4000-8000-000000009999" }], + }, + }); + expect(rejectRes.status()).toBe(400); + expect((await rejectRes.json())?.message ?? "").toMatch(/cannot be held within/i); + + // ...while B, whose own arrival is comfortably later, remains independently bookable. + const seatmapRes = await request.get(`${API_URL}/seats/seatmap/${schedule.id}`, { headers: auth() }); + expect(seatmapRes.ok()).toBeTruthy(); + const seatmap = (await seatmapRes.json())?.data ?? (await seatmapRes.json()); + const seatId = seatmap.coaches?.[0]?.seats?.[0]?.id; + expect(seatId).toBeTruthy(); + + const holdRes = await request.post(`${API_URL}/seats/hold`, { + headers: auth(), + data: { + scheduleId: schedule.id, + originStationId: STATIONS.B, + destinationStationId: STATIONS.C, + passengers: [{ passengerId: "55555555-5555-4555-8555-555555555555", seatId }], + }, + }); + expect(holdRes.ok()).toBeTruthy(); + + await request.delete(`${API_URL}/schedules/${schedule.id}`, { headers: auth() }).catch(() => {}); + await request.delete(`${API_URL}/routes/${routeId}?cascade=true`, { headers: auth() }).catch(() => {}); +}); diff --git a/e2e-ui/specs/backoffice/config-validation.spec.ts b/e2e-ui/specs/backoffice/config-validation.spec.ts new file mode 100644 index 000000000..6749a1b8a --- /dev/null +++ b/e2e-ui/specs/backoffice/config-validation.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from "@playwright/test"; +import { API_URL, ROUTE_ID, staffToken } from "../../fixtures/data"; +import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui"; + +/** + * Track B — server-side validation gaps and the promo field-name mismatch. Each test calls the same + * passenger-api endpoints the backoffice forms hit, proving the client-side guards are the ONLY guard + * (the API accepts values the forms block) or that a UI/DTO field-name split silently breaks a config. + */ +function auth() { + return { Authorization: `Bearer ${staffToken()}` }; +} + +test("BC-8 ✅ a promo over 100% is rejected by the API (max validation, M-2)", async ({ request }) => { + // percentOff must be bounded 0..100 at the DTO layer (the backoffice form has no such check). + const res = await request.post(`${API_URL}/promos`, { + headers: auth(), + data: { code: `E2E_OVER100_${Date.now()}`, title: "over", percentOff: 200, validUntil: "2030-01-01T00:00:00Z", active: true }, + }); + expect(res.status()).toBe(400); // ✅ 200% discount rejected + + // A valid promo (≤100%) still succeeds. + const okRes = await request.post(`${API_URL}/promos`, { + headers: auth(), + data: { code: `E2E_OK_${Date.now()}`, title: "ok", percentOff: 50, validUntil: "2030-01-01T00:00:00Z", active: true }, + }); + expect(okRes.ok()).toBeTruthy(); + const promo = (await okRes.json())?.data ?? {}; + await request.delete(`${API_URL}/promos/${promo.id}`, { headers: auth() }).catch(() => {}); +}); + +test("PB-7 🔴 a promo created with the backoffice UI field names is inert (field-name mismatch)", async ({ request }) => { + // The backoffice /promos form sends discountType/discountValue/isActive, but the DTO reads + // percentOff/amountOffMinor/active — so the sent discount is dropped and the promo saves at 0. + const res = await request.post(`${API_URL}/promos`, { + headers: auth(), + data: { code: `E2E_UIFIELDS_${Date.now()}`, title: "uifields", discountType: "PERCENTAGE", discountValue: 25, isActive: true, validUntil: "2030-01-01T00:00:00Z" }, + }); + expect(res.ok()).toBeTruthy(); + const promo = (await res.json())?.data ?? {}; + expect(promo.discountValue).toBe(0); // 🔴 the 25% the UI "set" was silently dropped + await request.delete(`${API_URL}/promos/${promo.id}`, { headers: auth() }).catch(() => {}); +}); + +test("BC-9 ✅ a negative seat-hold duration is rejected by /config (DTO validation, M-3)", async ({ request }) => { + // The settings form has min=1 max=60; the API must now enforce the same at the DTO layer. + const res = await request.patch(`${API_URL}/config`, { + headers: auth(), + data: { seat_hold_duration_minutes: "-1" }, + }); + expect(res.status()).toBe(400); // ✅ negative duration rejected + + // A sane value in range still succeeds and is stored. + const ok = await request.patch(`${API_URL}/config`, { headers: auth(), data: { seat_hold_duration_minutes: "15" } }); + expect(ok.ok()).toBeTruthy(); + expect(((await ok.json())?.data ?? {}).seat_hold_duration_minutes).toBe("15"); +}); + +test("BC-10 ✅ a schedule with a past departure is rejected by the API (past-date block, M-4)", async ({ request }) => { + // The schedules form only checks arrival > departure — the API must ALSO reject a past departure. + const past = new Date("2020-01-02T06:00:00.000Z"); + const arrive = new Date("2020-01-02T10:00:00.000Z"); + const res = await request.post(`${API_URL}/schedules`, { + headers: auth(), + data: { trainId: UI_IDS.train, routeId: ROUTE_ID, departureAt: past.toISOString(), arrivalAt: arrive.toISOString() }, + }); + expect(res.status()).toBe(400); // ✅ past-dated schedule rejected + + // A future schedule (a different day than the seeded one) is still accepted. + const dep = new Date(Date.now() + 30 * 864e5); dep.setUTCHours(6, 0, 0, 0); + const arr = new Date(dep.getTime() + 4 * 3600e3); + const okRes = await request.post(`${API_URL}/schedules`, { + headers: auth(), + data: { trainId: UI_IDS.train, routeId: ROUTE_ID, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() }, + }); + expect(okRes.ok()).toBeTruthy(); + const sched = (await okRes.json())?.data ?? {}; + await request.delete(`${API_URL}/schedules/${sched.id}`, { headers: auth() }).catch(() => {}); +}); diff --git a/e2e-ui/specs/backoffice/currencies.smoke.spec.ts b/e2e-ui/specs/backoffice/currencies.smoke.spec.ts new file mode 100644 index 000000000..f0cea1e1f --- /dev/null +++ b/e2e-ui/specs/backoffice/currencies.smoke.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from "@playwright/test"; + +/** + * Backoffice smoke (Track B foundation): the staff storageState authenticates past the middleware + * cookie gate, /currencies loads its list from the API, and the add-rate control is reachable. + * Proves staff auth (cookie + localStorage + API token) is fully wired. + */ +test("backoffice: staff can load /currencies and reach the add-rate control", async ({ page }) => { + await page.goto("/currencies", { waitUntil: "domcontentloaded" }); + + // Not bounced to /login (middleware cookie gate passed). + await expect(page).not.toHaveURL(/\/login/); + + // The page rendered a currencies view with a seeded currency and an add control. + await expect(page.getByText(/ETB|USD|DJF/).first()).toBeVisible({ timeout: 20_000 }); + await expect( + page.getByRole("button", { name: /add/i }).first(), + ).toBeVisible(); +}); diff --git a/e2e-ui/specs/guest/search.smoke.spec.ts b/e2e-ui/specs/guest/search.smoke.spec.ts new file mode 100644 index 000000000..114a1b537 --- /dev/null +++ b/e2e-ui/specs/guest/search.smoke.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from "@playwright/test"; +import { resultsUrl, SCHEDULE_ID } from "../../fixtures/data"; + +/** + * Portal smoke (Track A foundation): deep-link to results → POST /search fires → a priced result + * card for the seeded trip renders. Proves stack + seed + search + currency formatting are wired. + */ +test("portal: seeded trip appears in search results with a price", async ({ page }) => { + const searchResponse = page.waitForResponse( + (r) => r.url().includes("/search") && r.request().method() === "POST", + ); + + await page.goto(resultsUrl()); + + const res = await searchResponse; + expect([200, 201]).toContain(res.status()); + const body = await res.json(); + const outbound = body?.data?.outbound ?? []; + expect(outbound.some((t: any) => t.scheduleId === SCHEDULE_ID)).toBe(true); + + // The seeded train + a formatted ETB price render in the DOM. + await expect(page.getByText("UI Test Express").first()).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/ETB\s*[\d,]+/).first()).toBeVisible(); +}); diff --git a/e2e-ui/specs/guest/ua14-forged-seat-fare.spec.ts b/e2e-ui/specs/guest/ua14-forged-seat-fare.spec.ts new file mode 100644 index 000000000..3bc2f7f05 --- /dev/null +++ b/e2e-ui/specs/guest/ua14-forged-seat-fare.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-14 ✅ — a GUEST (unauthenticated) booking with forged per-passenger seat fares (ISSUES C-1), + * guarded. We intercept POST /bookings/guest and rewrite every seatFareMinor (and reviewedTotalMinor) + * to 0. The server must recompute the authoritative fare and REJECT the underpayment with a 4xx — + * no free ride, nothing persisted. + */ +test("UA-14: server rejects a guest booking with forged seatFareMinor=0 (C-1)", async ({ page }) => { + const r = await bookTrip(page, { + paymentMethod: "WALLET", + tolerateBookingError: true, + mutateBookingBody: (body) => ({ + ...body, + reviewedTotalMinor: 0, + passengers: (body.passengers ?? []).map((p: any) => ({ ...p, seatFareMinor: 0 })), + }), + }); + + expect(r.guest).toBe(true); // proves the /bookings/guest path was used + expect(r.cardBaseFareMinor).toBeGreaterThan(1000); + + // The server must REFUSE the forged 0-fare booking with a 4xx… + expect(r.bookingStatus).toBeGreaterThanOrEqual(400); + expect(r.bookingStatus).toBeLessThan(500); + // …return no booking id and persist no free (0-minor) booking. + expect(r.bookingId).toBeFalsy(); + const forged = await prisma.booking.findFirst({ where: { totalMinor: 0 } }); + expect(forged).toBeNull(); +}); diff --git a/e2e-ui/specs/portal/ua1.spec.ts b/e2e-ui/specs/portal/ua1.spec.ts new file mode 100644 index 000000000..75f267965 --- /dev/null +++ b/e2e-ui/specs/portal/ua1.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookOneAdult } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-1 — one-way, 1 adult, ETB, WALLET. The full real-browser booking flow, asserting the money + * chain: card fare > 0, and reviewedTotalMinor == Booking.totalMinor == displayTotalMinor == + * PaymentIntent.amountMinor == wallet DEBIT, booking CONFIRMED. + */ +test("UA-1: one-way WALLET booking, price cross-check holds end to end", async ({ page }) => { + const r = await bookOneAdult(page, { nationality: "Ethiopian", paymentMethod: "WALLET" }); + expect(r.confirmed).toBe(true); + expect([200, 201]).toContain(r.initiateStatus); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId: r.bookingId } }); + const debit = await prisma.walletLedgerEntry.findFirst({ + where: { relatedBookingId: r.bookingId, type: "DEBIT" }, + }); + + expect(booking.totalMinor).toBe(r.reviewedTotalMinor); + expect(booking.displayTotalMinor).toBe(r.reviewedTotalMinor); + expect(intent.amountMinor).toBe(r.reviewedTotalMinor); + expect(debit?.amountMinor).toBe(r.reviewedTotalMinor); + expect(booking.status).toBe("CONFIRMED"); +}); diff --git a/e2e-ui/specs/portal/ua11-expired-promo.spec.ts b/e2e-ui/specs/portal/ua11-expired-promo.spec.ts new file mode 100644 index 000000000..fe3657435 --- /dev/null +++ b/e2e-ui/specs/portal/ua11-expired-promo.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; +import { PROMO_EXPIRED } from "../../fixtures/data"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-11 — one-way, ETB, an EXPIRED promo injected via ?promoCode=. The expired code must not discount + * anything: the fare breakdown reports no discount and the booked total is the full fare (consistent + * with the UA-8 promo-drop behaviour, but here the promo is correctly rejected as expired). + */ +test("UA-11: an expired promo code is ignored — full fare is booked", async ({ page }) => { + const r = await bookTrip(page, { paymentMethod: "WALLET", promoCode: PROMO_EXPIRED }); + expect(r.confirmed).toBe(true); + + // No discount from the expired code. + if (r.fareBreakdown) expect(r.fareBreakdown.discountMinor ?? 0).toBe(0); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.totalMinor).toBe(r.cardBaseFareMinor); // full fare, no discount applied +}); diff --git a/e2e-ui/specs/portal/ua13-forged-total.spec.ts b/e2e-ui/specs/portal/ua13-forged-total.spec.ts new file mode 100644 index 000000000..3e8b76127 --- /dev/null +++ b/e2e-ui/specs/portal/ua13-forged-total.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookOneAdult } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-13 ✅ — client-forged booking total (matrix A1 / ISSUES C-1), guarded through the REAL browser. + * We intercept the outgoing POST /bookings and rewrite reviewedTotalMinor (and every per-seat + * seatFareMinor) to 1. The server has two trust branches — sum-of-seatFareMinor when all are present, + * else reviewedTotalMinor — so the forge targets both. The server must recompute the authoritative + * fare and REJECT the mismatched client amount with a 4xx, persisting nothing. + */ +test("UA-13: server rejects a client-forged reviewedTotalMinor=1 (C-1)", async ({ page }) => { + const r = await bookOneAdult(page, { + nationality: "Ethiopian", + paymentMethod: "WALLET", + tolerateBookingError: true, + // Forge both the per-seat fares and the reviewed total → 1. + mutateBookingBody: (body) => ({ + ...body, + reviewedTotalMinor: 1, + passengers: (body.passengers ?? []).map((p: any) => ({ ...p, seatFareMinor: 1 })), + }), + }); + + // The real fare the engine computed is far above 1… + expect(r.cardFareMinor).toBeGreaterThan(1000); + // …the browser forced reviewedTotalMinor=1, and the server must REFUSE it with a 4xx. + expect(r.reviewedTotalMinor).toBe(1); + expect(r.bookingStatus).toBeGreaterThanOrEqual(400); + expect(r.bookingStatus).toBeLessThan(500); + // No booking id was returned, and no 1-minor booking was persisted. + expect(r.bookingId).toBeFalsy(); + const forged = await prisma.booking.findFirst({ where: { totalMinor: 1 } }); + expect(forged).toBeNull(); +}); diff --git a/e2e-ui/specs/portal/ua15-telebirr-shortpay.spec.ts b/e2e-ui/specs/portal/ua15-telebirr-shortpay.spec.ts new file mode 100644 index 000000000..6507ca478 --- /dev/null +++ b/e2e-ui/specs/portal/ua15-telebirr-shortpay.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-15 ✅ — forged gateway SHORT-PAY (ISSUES C-4), guarded. A booking with a real fare in the + * thousands is settled by a forged payment.succeeded event carrying amountMinor = 1. The server must + * compare the settled amount against what the passenger was quoted (the booking's display total) and + * REFUSE to confirm a short payment — the booking stays unconfirmed and no ticket is issued. + */ +test("UA-15: a short-paid gateway settlement does NOT confirm the booking (C-4)", async ({ page }) => { + const r = await bookTrip(page, { + paymentMethod: "TELEBIRR", + forgeSettlement: { amountMinor: 1 }, // settle for 1 minor against a multi-thousand fare + }); + + expect(r.cardBaseFareMinor).toBeGreaterThan(1000); + expect(r.confirmed).toBe(false); // short-pay must NOT confirm the booking + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.status).not.toBe("CONFIRMED"); +}); diff --git a/e2e-ui/specs/portal/ua16-family-mix.spec.ts b/e2e-ui/specs/portal/ua16-family-mix.spec.ts new file mode 100644 index 000000000..a0323c342 --- /dev/null +++ b/e2e-ui/specs/portal/ua16-family-mix.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-16 — one-way, 2 adults + 3 children under 5, ETB, WALLET (max passenger spread). One free child + * per adult → 2 free children, 1 paid. Total = 3 fares (2 adults + 1 paid child); 3 seats booked. + * Stresses the free-child reduce + multi-passenger seat assignment through the real browser. + */ +test("UA-16: 2 adults + 3 children — two children free, one paid", async ({ page }) => { + const r = await bookTrip(page, { adults: 2, children: 3, paymentMethod: "WALLET" }); + expect(r.confirmed).toBe(true); + + const fare = r.cardBaseFareMinor; + expect(r.reviewedTotalMinor).toBe(fare * 3); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.totalMinor).toBe(fare * 3); + + const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } }); + expect(seats.length).toBe(3); +}); diff --git a/e2e-ui/specs/portal/ua1b-usd-divergence.spec.ts b/e2e-ui/specs/portal/ua1b-usd-divergence.spec.ts new file mode 100644 index 000000000..5441942f6 --- /dev/null +++ b/e2e-ui/specs/portal/ua1b-usd-divergence.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "@playwright/test"; +import { resultsUrl } from "../../fixtures/data"; + +/** + * UA-1b ✅ — for a non-Ethiopian (USD) search the results card shows the USD-converted + * `displayAmountMinor`, and the internal `baseFareMinor` is the ETB source it was converted from + * (exactly the USD→ETB rate apart — a correct conversion, not a mislabel). The passenger sees and + * carries forward the USD value; the ETB basis is stored honestly on the booking as `currency: ETB` + * (proven end-to-end by UA-2). This pins the display layer so a regression that shows the raw ETB + * number, or drops the conversion, is caught. + */ +test("UA-1b: USD card shows the USD fare, correctly converted from the internal ETB base", async ({ page }) => { + const searchDone = page.waitForResponse( + (r) => r.url().includes("/search") && r.request().method() === "POST", + ); + await page.goto(resultsUrl({ nationality: "Other" })); + const out = (await (await searchDone).json())?.data?.outbound?.[0]; + const cls = out?.faresByClass?.[0]; + + expect(out.displayCurrency).toBe("USD"); + // The USD display fare is the ETB base converted at the USD→ETB rate (100×), not a parity mislabel. + expect(cls.displayAmountMinor).toBeGreaterThan(0); + expect(cls.displayAmountMinor).toBeLessThan(cls.baseFareMinor); + expect(cls.baseFareMinor).toBe(cls.displayAmountMinor * 100); + + // The DOM shows the USD value the passenger pays (formatFare divides by 100, 2dp) — e.g. "USD 12.50". + const usdMajor = (cls.displayAmountMinor / 100).toFixed(2); + await expect(page.getByText(new RegExp(`USD\\s*${usdMajor.replace(".", "\\.")}`)).first()).toBeVisible({ + timeout: 20_000, + }); +}); diff --git a/e2e-ui/specs/portal/ua2-usd-booking.spec.ts b/e2e-ui/specs/portal/ua2-usd-booking.spec.ts new file mode 100644 index 000000000..7e2c83fee --- /dev/null +++ b/e2e-ui/specs/portal/ua2-usd-booking.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-2 ✅ — full one-way USD booking (Other nationality, INTERNATIONAL class, WALLET). The money chain + * is now COHERENT: the passenger sees and agrees to a USD amount (displayCurrency/displayTotalMinor), + * while the stored charge basis is honestly labeled ETB (currency/totalMinor). The two are the same + * fare at the USD→ETB rate — no longer a mislabeled 100× divergence. + */ +test("UA-2: USD booking — passenger amount in USD, charge basis stored coherently in ETB", async ({ page }) => { + const r = await bookTrip(page, { nationality: "Other", paymentMethod: "WALLET" }); + expect(r.displayCurrency).toBe("USD"); + expect(r.confirmed).toBe(true); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId: r.bookingId } }); + + // Passenger-facing: the USD amount they saw and agreed to (what the browser reviewed). + expect(booking.displayCurrency).toBe("USD"); + expect(booking.displayTotalMinor).toBe(r.reviewedTotalMinor); + expect(r.reviewedTotalMinor).toBe(r.cardDisplayMinor); + + // Stored charge basis: ETB, coherently labeled (no more USD mislabel). + expect(booking.currency).toBe("ETB"); + expect(booking.totalMinor).toBe(r.cardBaseFareMinor); // the ETB fare + expect(booking.totalMinor).toBe(r.reviewedTotalMinor * 100); // ETB == USD display × rate + + // The charge/intent moves the ETB amount; booking is confirmed. + expect(intent.amountMinor).toBe(booking.totalMinor); + expect(booking.status).toBe("CONFIRMED"); +}); diff --git a/e2e-ui/specs/portal/ua3-djf.spec.ts b/e2e-ui/specs/portal/ua3-djf.spec.ts new file mode 100644 index 000000000..a026cb8fd --- /dev/null +++ b/e2e-ui/specs/portal/ua3-djf.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-3w ✅ — Djiboutian/DJF, WALLET. The money chain is now COHERENT: the passenger sees and agrees + * to a DJF amount (displayCurrency/displayTotalMinor), while the stored charge basis is honestly + * labeled ETB (currency/totalMinor). Same fare, two correctly-labeled currencies — no mislabel. + */ +test("UA-3w: DJF WALLET booking — passenger amount in DJF, charge basis stored coherently in ETB", async ({ page }) => { + const r = await bookTrip(page, { nationality: "Djiboutian", paymentMethod: "WALLET" }); + expect(r.displayCurrency).toBe("DJF"); + expect(r.confirmed).toBe(true); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + // Passenger-facing: the DJF amount they saw and agreed to. + expect(booking.displayCurrency).toBe("DJF"); + expect(booking.displayTotalMinor).toBe(r.reviewedTotalMinor); + expect(r.reviewedTotalMinor).toBe(r.cardDisplayMinor); + // Stored charge basis: ETB, coherently labeled (no more DJF mislabel). + expect(booking.currency).toBe("ETB"); + expect(booking.totalMinor).toBe(r.cardBaseFareMinor); + expect(booking.status).toBe("CONFIRMED"); +}); + +/** + * UA-3 — Djiboutian/DJF paid via a forged gateway settlement. The real telebirr gateway is + * unreachable in the test env, so (per the matrix's settlement-injection plan) the booking is created + * through the real browser flow and settled by forging the payment.succeeded event. Proves the DJF + * booking reaches a CONFIRMED, ticketed state through the gateway (non-WALLET) path. + */ +test("UA-3: DJF booking settles through a forged gateway payment", async ({ page }) => { + const r = await bookTrip(page, { nationality: "Djiboutian", paymentMethod: "TELEBIRR" }); + expect(r.displayCurrency).toBe("DJF"); + expect(r.confirmed).toBe(true); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.displayCurrency).toBe("DJF"); + expect(booking.status).toBe("CONFIRMED"); +}); diff --git a/e2e-ui/specs/portal/ua4-child-free.spec.ts b/e2e-ui/specs/portal/ua4-child-free.spec.ts new file mode 100644 index 000000000..522976f2f --- /dev/null +++ b/e2e-ui/specs/portal/ua4-child-free.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-4 — one-way, 1 adult + 1 child under 5, ETB, WALLET. The "first child per adult" policy makes + * the child free: the booked total is exactly one adult fare and the free child is not seated. + */ +test("UA-4: first child under 5 travels free, total = one adult fare", async ({ page }) => { + const r = await bookTrip(page, { adults: 1, children: 1, paymentMethod: "WALLET" }); + expect(r.confirmed).toBe(true); + + // The child is free → the browser sent one adult fare as the reviewed total. + expect(r.reviewedTotalMinor).toBe(r.cardBaseFareMinor); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.totalMinor).toBe(r.cardBaseFareMinor); + + // The free first-child is filtered out of the booked passengers → only the adult is seated. + const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } }); + expect(seats.length).toBe(1); +}); diff --git a/e2e-ui/specs/portal/ua5-second-child-paid.spec.ts b/e2e-ui/specs/portal/ua5-second-child-paid.spec.ts new file mode 100644 index 000000000..dcd77ba66 --- /dev/null +++ b/e2e-ui/specs/portal/ua5-second-child-paid.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-5 — one-way, 1 adult + 2 children under 5, ETB, WALLET. One free child per adult: the first + * child is free, the second is charged a full adult fare. Total = 2 fares; 2 passengers are seated. + */ +test("UA-5: with 1 adult + 2 children, the second child pays full fare", async ({ page }) => { + const r = await bookTrip(page, { adults: 1, children: 2, paymentMethod: "WALLET" }); + expect(r.confirmed).toBe(true); + + const fare = r.cardBaseFareMinor; + // adult (paid) + first child (free) + second child (paid) = 2 fares. + expect(r.reviewedTotalMinor).toBe(fare * 2); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.totalMinor).toBe(fare * 2); + + // Only the free first-child is dropped → adult + paid second child are seated. + const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } }); + expect(seats.length).toBe(2); +}); diff --git a/e2e-ui/specs/portal/ua6-round-trip.spec.ts b/e2e-ui/specs/portal/ua6-round-trip.spec.ts new file mode 100644 index 000000000..207164c30 --- /dev/null +++ b/e2e-ui/specs/portal/ua6-round-trip.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-6 ✅ — round-trip books BOTH legs. The return leg (C→A) traverses the seeded route high→low; the + * fare engine now prices the reverse direction by absolute distance (previously it threw "origin must + * come before destination" and dropped every class, leaving the inbound leg with seats but no priced + * coach — unbookable). The full two-leg wizard now completes: outbound + return seat, and a total of + * 2× the one-way fare. + */ +test("UA-6: round-trip books both legs — return leg priced, one seat per leg, total = 2× one-way fare", async ({ + page, +}) => { + const r = await bookTrip(page, { tripType: "ROUND_TRIP", paymentMethod: "WALLET" }); + expect(r.confirmed).toBe(true); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.bookingType).toBe("ROUND_TRIP"); + expect(booking.status).toBe("CONFIRMED"); + + // One seat per leg (leg 1 outbound + leg 2 return) for a single passenger. + const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } }); + expect(seats.length).toBe(2); + expect(new Set(seats.map((s) => s.leg)).size).toBe(2); + + // Both legs cover the same A↔C distance, so the round-trip total is 2× the one-way base fare (ETB). + expect(r.cardBaseFareMinor).toBeGreaterThan(0); + expect(booking.totalMinor).toBe(r.cardBaseFareMinor * 2); +}); diff --git a/e2e-ui/specs/portal/ua7-berth.spec.ts b/e2e-ui/specs/portal/ua7-berth.spec.ts new file mode 100644 index 000000000..a9c384271 --- /dev/null +++ b/e2e-ui/specs/portal/ua7-berth.spec.ts @@ -0,0 +1,17 @@ +import { test } from "@playwright/test"; + +/** + * UA-7 — round trip, berth/bed class, INTERNATIONAL/USD. DEFERRED (documented, not silently omitted). + * + * A berth booking needs a bed coach type whose seat classes carry a bedPosition the fare engine can + * price. The current seed has only regular (bedPosition=null) classes, and UA-6 already shows the + * reverse-leg (round-trip) pricing returns empty coach types on this route. Enabling UA-7 requires + * two backend/seed prerequisites that are out of scope here: + * 1. A bed CoachType + LOCAL/INTL SeatClasses with bedPosition IN (UPPER,MIDDLE,LOWER) + a bed + * Coach with lowercase-bedPosition Seats (matrix §5.4), priced by the fare engine. + * 2. Reverse-direction (return-leg) fare resolution, currently unsupported (see UA-6). + * + * Once both exist, drive: bookTrip with a bed seat class + tripType ROUND_TRIP, asserting the berth + * surcharge is applied consistently on both legs. + */ +test.skip("UA-7: round-trip berth booking (needs bed coach-type seed + reverse-leg pricing)", () => {}); diff --git a/e2e-ui/specs/portal/ua8-promo-drop.spec.ts b/e2e-ui/specs/portal/ua8-promo-drop.spec.ts new file mode 100644 index 000000000..c46ab3e7b --- /dev/null +++ b/e2e-ui/specs/portal/ua8-promo-drop.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookOneAdult } from "../../fixtures/booking-flow"; +import { PROMO_VALID } from "../../fixtures/data"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * UA-8 ✅ — a VALID promo is applied server-side even though the browser drops it (H-13). The portal + * still sums UNDISCOUNTED per-passenger fares into reviewedTotalMinor, but the server recomputes the + * authoritative fare (promo included, via the promoCode it forwards) and books the DISCOUNTED total — + * so the customer is charged the promo price, not full price. + */ +test("UA-8: valid promo is applied server-side to the booked total (H-13)", async ({ page }) => { + const r = await bookOneAdult(page, { + nationality: "Ethiopian", + paymentMethod: "WALLET", + promoCode: PROMO_VALID, + }); + + const fb = r.fareBreakdown; + expect(fb).toBeTruthy(); + + // The breakdown recognized the promo and computed a discount… + expect(fb.discountMinor).toBeGreaterThan(0); + expect(fb.totalMinor).toBeLessThan(fb.subtotalMinor); + + // The browser still sends the UNDISCOUNTED subtotal (the frontend drops the promo)… + expect(r.reviewedTotalMinor).toBe(fb.subtotalMinor); + // …but the SERVER now applies the promo: the booking is stored at the discounted total. + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.totalMinor).toBeLessThan(fb.subtotalMinor); // ✅ discount honored + expect(booking.totalMinor).toBe(fb.subtotalMinor - fb.discountMinor); +}); diff --git a/e2e-ui/specs/propagation/pb-config-propagation.spec.ts b/e2e-ui/specs/propagation/pb-config-propagation.spec.ts new file mode 100644 index 000000000..2e7fb7700 --- /dev/null +++ b/e2e-ui/specs/propagation/pb-config-propagation.spec.ts @@ -0,0 +1,200 @@ +import { test, expect, type APIRequestContext, type Page } from "@playwright/test"; +import { API_URL, SEAT_CLASS_LOCAL, STATIONS, resultsUrl, sampleDepartDate, staffToken, passengerToken } from "../../fixtures/data"; + +/** + * Track B — backoffice config → portal propagation. A staff user changes config via the same API the + * backoffice calls; the passenger portal is then observed. Each test restores what it changed so the + * shared seeded DB stays consistent for other specs. + */ + +/** The "starting from" fare the portal shows for the seeded ETB trip (captured from POST /search). */ +async function portalCardFareMinor(page: Page): Promise { + const done = page.waitForResponse( + (r) => r.url().includes("/search") && r.request().method() === "POST", + ); + await page.goto(resultsUrl(), { waitUntil: "domcontentloaded" }); + const body = await (await done).json(); + return body?.data?.outbound?.[0]?.faresByClass?.[0]?.baseFareMinor; +} + +/** The USD display fare the portal shows for a non-Ethiopian (Other) search. */ +async function portalUsdFare(page: Page): Promise { + const done = page.waitForResponse( + (r) => r.url().includes("/search") && r.request().method() === "POST", + ); + await page.goto(resultsUrl({ nationality: "Other" }), { waitUntil: "domcontentloaded" }); + const body = await (await done).json(); + return body?.data?.outbound?.[0]?.faresByClass?.[0]?.displayAmountMinor; +} + +function authHeader() { + return { Authorization: `Bearer ${staffToken()}` }; +} + +/** Find a CurrencyExchangeRate row id by its currency pair. */ +async function rateId(request: APIRequestContext, from: string, to: string): Promise { + const rows = (await (await request.get(`${API_URL}/currencies`, { headers: authHeader() })).json())?.data ?? []; + const row = rows.find((r: any) => r.fromCurrency === from && r.toCurrency === to); + if (!row) throw new Error(`no ${from}->${to} currency rate`); + return row.id; +} + +test("PB-2: a backoffice seat-class base-price change propagates LIVE to portal search", async ({ + page, + request, +}) => { + const before = await portalCardFareMinor(page); + expect(before).toBeGreaterThan(0); + + // Staff doubles the base price via the API the backoffice tariff-rates form uses. + const patched = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, { + headers: authHeader(), + data: { basePrice: 600 }, // seed-core seeds 300 + }); + expect(patched.ok()).toBeTruthy(); + + try { + const after = await portalCardFareMinor(page); + // No server-side config cache → the new price shows on the very next search. + expect(after).toBe(before * 2); + } finally { + await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, { + headers: authHeader(), + data: { basePrice: 300 }, + }); + } +}); + +test("BC-11 ✅ a non-admin PASSENGER is forbidden from rewriting exchange rates (C-8)", async ({ + request, +}) => { + // A global JwtGuard (SharedAuthModule) means anonymous requests get 401 — so this is NOT an + // unauthenticated hole. The PUT/PATCH handlers must ALSO carry @PassengerAdmin (as DELETE does) so + // a regular authenticated passenger cannot rewrite FX rates. + const anon = await request.put(`${API_URL}/fare-engine/exchange-rates`, { + data: { fromCurrency: "USD", toCurrency: "ETB", rate: 999 }, + }); + expect(anon.status()).toBe(401); // authentication IS required + + const asPassenger = await request.put(`${API_URL}/fare-engine/exchange-rates`, { + headers: { Authorization: `Bearer ${passengerToken()}` }, + data: { fromCurrency: "USD", toCurrency: "ETB", rate: 999, source: "E2E" }, + }); + expect(asPassenger.status()).toBe(403); // ✅ a regular passenger is forbidden (admin-only) + + // The PATCH-by-id handler must be equally protected. + const patchAsPassenger = await request.patch(`${API_URL}/fare-engine/exchange-rates/${crypto.randomUUID()}`, { + headers: { Authorization: `Bearer ${passengerToken()}` }, + data: { rate: 999 }, + }); + expect(patchAsPassenger.status()).toBe(403); + + // A staff admin can still write (proves the endpoint isn't simply broken). + const asStaff = await request.put(`${API_URL}/fare-engine/exchange-rates`, { + headers: { Authorization: `Bearer ${staffToken()}` }, + data: { fromCurrency: "USD", toCurrency: "ETB", rate: 100, source: "E2E" }, + }); + expect(asStaff.ok()).toBeTruthy(); +}); + +test("BC-7 ✅ negative seat-class base price is rejected by the live API (M-1)", async ({ + request, +}) => { + const res = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, { + headers: authHeader(), + data: { basePrice: -500 }, // the API must now reject this (DTO @Min(0)), like the backoffice form + }); + expect(res.status()).toBe(400); // ✅ negative fare rejected at the DTO layer + + // The stored fare is unchanged — a valid write still succeeds and returns the seeded 300. + const restore = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, { + headers: authHeader(), + data: { basePrice: 300 }, + }); + expect(restore.ok()).toBeTruthy(); + expect(((await restore.json())?.data ?? {}).baseFareMinor).toBe(300); +}); + +test("PB-2b: base-price field-name — /seat-classes accepts `basePrice` and it drives the fare", async ({ + request, +}) => { + // Documents which field the live seat-class endpoint reads (basePrice → baseFareMinor). If a future + // change renames it, this fails loudly (the tariff-rates vs /fleet/classes split, matrix §7 Q9). + const res = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, { + headers: authHeader(), + data: { basePrice: 300 }, // no-op value, just asserts the field is accepted + }); + expect(res.ok()).toBeTruthy(); + const json = await res.json(); + const updated = json?.data ?? json; + expect(updated.baseFareMinor ?? updated.basePrice).toBe(300); +}); + +test("PB-1: a backoffice FX-rate change propagates LIVE to portal USD pricing", async ({ page, request }) => { + const id = await rateId(request, "USD", "ETB"); + const before = await portalUsdFare(page); + expect(before).toBeGreaterThan(0); + try { + // Doubling the USD→ETB rate doubles the fare-engine's ETB fare and therefore the USD display fare. + const patched = await request.patch(`${API_URL}/currencies/${id}`, { headers: authHeader(), data: { rate: 200 } }); + expect(patched.ok()).toBeTruthy(); + const after = await portalUsdFare(page); + expect(after).toBe(before * 2); // search has no cache → the new rate shows immediately + } finally { + await request.patch(`${API_URL}/currencies/${id}`, { headers: authHeader(), data: { rate: 100 } }); + } +}); + +test("PB-4: a station added in the backoffice appears in the portal station list", async ({ request }) => { + const code = `E2E${Date.now() % 100000}`; + const created = await request.post(`${API_URL}/stations`, { + headers: authHeader(), + data: { code, name: `E2E Station ${code}`, city: "Testville", countryCode: "ET", sequence: 99, isOperational: true }, + }); + expect(created.ok()).toBeTruthy(); + const id = ((await created.json())?.data ?? {}).id; + try { + const rows = (await (await request.get(`${API_URL}/stations`)).json())?.data ?? []; + expect(rows.some((s: any) => s.code === code)).toBe(true); // portal SearchWidget reads this list + } finally { + await request.delete(`${API_URL}/stations/${id}?cascade=true`, { headers: authHeader() }).catch(() => {}); + } +}); + +test("PB-10 ✅ deleting an FX rate makes pricing FAIL CLOSED, not a silent 1.0 fallback (M-5/H-2)", async ({ request }) => { + const searchBody = { + originStationId: STATIONS.A, + destinationStationId: STATIONS.C, + date: sampleDepartDate(), + adultCount: 1, + nationality: "OTHER", // USD — the fare engine needs the USD↔ETB rate to price + }; + const usdFaresByClass = async (): Promise => { + const res = await request.post(`${API_URL}/search`, { headers: authHeader(), data: searchBody }); + expect(res.ok()).toBeTruthy(); + return (await res.json())?.data?.outbound?.[0]?.faresByClass ?? []; + }; + // Control: with the USD→ETB rate present, the USD search returns a real priced fare. + const before = await usdFaresByClass(); + expect(before.length).toBeGreaterThan(0); + expect(before[0].displayAmountMinor).toBeGreaterThan(0); + + try { + // Remove EVERY USD→ETB rate row (an earlier spec may have left a duplicate) so the pair is truly gone. + const rows = (await (await request.get(`${API_URL}/currencies`, { headers: authHeader() })).json())?.data ?? []; + for (const r of rows.filter((x: any) => x.fromCurrency === "USD" && x.toCurrency === "ETB")) { + await request.delete(`${API_URL}/currencies/${r.id}`, { headers: authHeader() }); + } + // With the USD→ETB pair gone, the fare engine must NOT silently substitute rate 1.0 (~100× + // underpricing). It fails closed — no priced class is returned for the USD trip, instead of a + // bogus parity-priced fare. (A booking attempt would likewise be rejected, not swallowed.) + const after = await usdFaresByClass(); + expect(after.length).toBe(0); // ✅ no silent underpricing — no bogus fare offered + } finally { + // Restore the pair so later specs price correctly. + await request.post(`${API_URL}/currencies`, { + headers: authHeader(), + data: { fromCurrency: "USD", toCurrency: "ETB", rate: 100 }, + }); + } +}); diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000..7671263ff --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,76 @@ +# EDR Passenger — Pricing/Config E2E Harness + +Hermetic, bug-hunting test harness for the passenger platform. Targets **pricing integrity** and +**backoffice configuration**. Never touches a real database. + +## Quick start + +```bash +# 1. Bring up the isolated test Postgres (port 5544) and apply all migrations +bash e2e/prepare.sh +# (or: pnpm --filter @edr/passenger-api test:e2e:prepare) + +# 2. Run the suites +pnpm --filter @edr/passenger-api test:e2e + +# 3. Tear down +pnpm --filter @edr/passenger-api test:e2e:db:down +``` + +## What's isolated + +- `e2e/docker-compose.yml` — Postgres 17 on host port **5544**, container `edr-passenger-e2e-db`, + `tmpfs` data (wiped on `down`). Distinct from any dev/prod DB. Schemas `passenger`, `iam`, + `edr_payment` created by `e2e/init/01-schemas.sql`. +- `apps/edr-passenger-api/.env.test` — points every connection at 5544; brokers/IAM/Fayda OFF. + Loaded by `test/setup/load-env.ts` before the app boots. + +## Architecture — why two tiers + +The full `AppModule` cannot be booted in-process under jest: +- `@tria-plc/api-common` (pulled via IAM) `require("file-type")`, which is ESM-only → jest's + CommonJS resolver fails. (Worked around with a `moduleNameMapper` stub, but…) +- `@golevelup/nestjs-rabbitmq` + microservice RMQ clients + `onApplicationBootstrap` seeders hang + the boot waiting on a broker that isn't there. + +So tests use one of two tiers: + +**Tier 1 — slim module harness** (`test/setup/slim-app.ts`). Boots ONLY the pricing/config domain +modules that are free of the IAM/RabbitMQ chain: `fare-engine, currency, currencies, promos, +seat-classes, stations, schedules, segments, system-config`. Two entry points: +- `createServiceHarness()` — resolve services (e.g. `FareEngineService`) for direct method calls. +- `createHttpHarness()` — full HTTP app with the SAME `ValidationPipe` as `src/main.ts`, for + controller/DTO/pipe (client-trust, validation) tests over supertest. + +**Tier 2 — direct instantiation** (`test/setup/prisma.ts`). For services behind the wall +(`BookingsService, PaymentsService, WalletService, LoyaltyService, ExcessBaggageService`): +`new TheService(getTestPrisma(), ...mockedCollaborators)` and assert the money logic. Avoids booting +the module graph entirely. + +## Fixtures + +`test/fixtures/seed-core.ts` — deterministic graph (coach type → LOCAL/INTERNATIONAL seat classes → +3 stations → route with distance-bearing stops → FX rates) with fixed UUIDs in `IDS`. Call +`resetAndSeedCore(prisma)` in `beforeEach`. The repo's `prisma/seed.ts` is disabled (all steps +commented out) and is intentionally NOT used. + +## Suites (see `docs/e2e-test-matrix.md` for the full matrix) + +Spec files are `test/*.e2e-spec.ts`. Each is tagged with the matrix IDs it covers. 🔴 in a test name +marks a confirmed defect the test documents/reproduces (the assertion encodes the BUGGY behavior; +a passing 🔴 test = the bug is present). + +Current suites (all green): +- `pricing-fare-engine.e2e-spec.ts` — baseline + D1/D2/D4 (promo → negative total), C1 (FX fallback) +- `pricing-currency.e2e-spec.ts` — C2/C2b (display↔charge FX divergence), C3 (future rate), C5 (unit divergence) +- `money-integrity.e2e-spec.ts` — F1/F2 (free wallet top-up), G4/G5 (refund never disbursed), E1/E2 (baggage) +- `config-validation.e2e-spec.ts` — H1/H2 (negative fares), H4/H5 (promo bounds/date) +- `auth-gaps.e2e-spec.ts` — J1 (unauthenticated FX writes) +- `critical-repro.e2e-spec.ts` — C-1 (client-controlled booking total), C-4 (payment amount never + validated), C-6 (wallet double-spend via a deterministic race barrier) + +`test/app.e2e-spec.ts` is a pre-existing repo test that boots the FULL AppModule; it is excluded via +`testPathIgnorePatterns` because that boot hangs in-process (RabbitMQ connect + ESM `file-type`) — a +harness limitation documented above, not a product bug. + +Findings are catalogued in `docs/ISSUES.md`. diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 000000000..8ecd7eef3 --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,41 @@ +# Hermetic test database for the EDR passenger E2E harness. +# Isolated from any dev/prod Postgres: distinct container name + non-standard host port (5544). +# Single database `edr_database` with schemas `passenger`, `iam`, `edr_payment` (see init/01-schemas.sql). +services: + postgres-e2e: + image: postgres:17 + container_name: edr-passenger-e2e-db + environment: + POSTGRES_USER: edr + POSTGRES_PASSWORD: edr_secret + POSTGRES_DB: edr_database + ports: + - "5544:5432" + volumes: + - ./init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U edr -d edr_database"] + interval: 3s + timeout: 3s + retries: 20 + tmpfs: + # Ephemeral storage — every `docker compose down` wipes the DB. Nothing to clean up. + - /var/lib/postgresql/data + + # Broker for the passenger-api payment-events consumer (golevelup RabbitMQ). The API blocks boot + # until this connects. Pre-creates the `payment` vhost that PAYMENT_RABBITMQ_URL points at. + rabbitmq-e2e: + image: rabbitmq:3-management + container_name: edr-passenger-e2e-rmq + environment: + RABBITMQ_DEFAULT_USER: edr + RABBITMQ_DEFAULT_PASS: edr_secret + RABBITMQ_DEFAULT_VHOST: payment + ports: + - "5672:5672" + - "15672:15672" + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 5s + timeout: 5s + retries: 20 diff --git a/e2e/init/01-schemas.sql b/e2e/init/01-schemas.sql new file mode 100644 index 000000000..9a3561e87 --- /dev/null +++ b/e2e/init/01-schemas.sql @@ -0,0 +1,6 @@ +-- Runs once on first container start (Postgres initdb hook). +-- Prisma migrate (passenger) and TypeORM migrate (iam) create their own tables, +-- but the schemas must exist first. edr_payment is owned by the payment-api. +CREATE SCHEMA IF NOT EXISTS passenger; +CREATE SCHEMA IF NOT EXISTS iam; +CREATE SCHEMA IF NOT EXISTS edr_payment; diff --git a/e2e/prepare.sh b/e2e/prepare.sh new file mode 100755 index 000000000..88e07bf29 --- /dev/null +++ b/e2e/prepare.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Bring up the hermetic test DB and apply all migrations. Idempotent — safe to re-run. +# Usage: bash e2e/prepare.sh (from repo root or anywhere) +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +API="$HERE/../apps/edr-passenger-api" + +export DATABASE_URL="postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger" +export DATABASE_HOST=localhost DATABASE_PORT=5544 DATABASE_NAME=edr_database +export DATABASE_USER=edr DATABASE_PASSWORD=edr_secret DATABASE_SCHEMA=iam + +echo "==> Starting test Postgres (5544) + RabbitMQ (5672)" +docker compose -f "$HERE/docker-compose.yml" up -d + +echo "==> Waiting for Postgres healthy" +for i in $(seq 1 30); do + status="$(docker inspect --format '{{.State.Health.Status}}' edr-passenger-e2e-db 2>/dev/null || echo none)" + [ "$status" = "healthy" ] && break + sleep 2 +done +[ "${status:-}" = "healthy" ] || { echo "DB did not become healthy"; exit 1; } + +echo "==> Prisma migrate deploy (passenger schema)" +( cd "$API" && npx prisma migrate deploy ) + +echo "==> IAM TypeORM migrations (iam schema)" +( cd "$API" && node scripts/run-iam-migrations.cjs ) + +echo "==> Prisma client generate" +( cd "$API" && npx prisma generate >/dev/null ) + +echo "==> Ready. Run: pnpm --filter @edr/passenger-api test:e2e" diff --git a/e2e/run.sh b/e2e/run.sh new file mode 100755 index 000000000..c5b344f0d --- /dev/null +++ b/e2e/run.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# One-shot E2E: ensure Docker is up → start the test DB + migrations → run all suites → open the +# HTML dashboard. Safe to re-run. The DB is left running for fast subsequent runs unless --down. +# +# bash e2e/run.sh # run everything, leave the DB up, open the report +# bash e2e/run.sh --down # same, but tear the DB down afterwards +# bash e2e/run.sh --no-open # don't auto-open the browser (just print the path) +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +API="$HERE/../apps/edr-passenger-api" +REPORT="$API/e2e-report/index.html" + +DOWN=0; OPEN=1 +for arg in "$@"; do + case "$arg" in + --down) DOWN=1 ;; + --no-open) OPEN=0 ;; + *) echo "unknown flag: $arg" >&2; exit 2 ;; + esac +done + +# 1. Ensure the Docker daemon is running (start Docker Desktop on macOS if needed). +if ! docker info >/dev/null 2>&1; then + echo "==> Docker daemon not running; attempting to start Docker Desktop…" + open -a Docker 2>/dev/null || { echo "Could not launch Docker. Start it manually and re-run."; exit 1; } + printf " waiting for Docker" + for _ in $(seq 1 40); do + if docker info >/dev/null 2>&1; then echo " — up"; break; fi + printf "."; sleep 2 + done + docker info >/dev/null 2>&1 || { echo; echo "Docker did not start in time."; exit 1; } +fi + +# 2. Bring up the test DB + apply migrations (idempotent). +bash "$HERE/prepare.sh" + +# 3. Run all suites (this also writes the HTML report via the jest-html-reporters config). +# Don't let a test failure abort the script — we still want to open the report. +set +e +( cd "$API" && npx jest --config ./test/jest-e2e.json ) +JEST_EXIT=$? +set -e + +# 4. Open (or print) the report. +if [ -f "$REPORT" ]; then + if [ "$OPEN" -eq 1 ]; then + echo "==> Opening report: $REPORT" + open "$REPORT" 2>/dev/null || echo " (open it manually: $REPORT)" + else + echo "==> Report written: $REPORT" + fi +else + echo "!! No report generated (tests may have failed to run)." +fi + +# 5. Optional teardown. +if [ "$DOWN" -eq 1 ]; then + echo "==> Tearing down the test DB" + docker compose -f "$HERE/docker-compose.yml" down +fi + +exit "$JEST_EXIT" diff --git a/package.json b/package.json index 193732907..cfa584a12 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "build:passenger": "turbo run build --filter=@edr/passenger-api... --filter=@edr/passenger-portal... --filter=@edr/passenger-backoffice...", "clean": "find . -type d -name dist -prune -exec rm -rf '{}' + && find . -type f -name '*.tsbuildinfo' -delete", "test": "turbo run test", + "test:e2e:passenger": "bash e2e/run.sh", + "test:e2e:ui": "bash e2e-ui/run.sh", + "test:e2e:ui:only": "playwright test -c e2e-ui/playwright.config.ts", "lint": "turbo run lint", "type-check": "turbo run type-check", "format": "prettier --write \"**/*.{ts,tsx,json,md}\"", @@ -33,6 +36,7 @@ "devDependencies": { "@commitlint/cli": "^19.5.0", "@commitlint/config-conventional": "^19.5.0", + "@playwright/test": "^1.61.1", "husky": "^9.1.6", "lint-staged": "^15.2.10", "prettier": "^3.3.3", diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index e7d78f752..dd5978021 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -135,15 +135,18 @@ export class WaafiProvider implements PaymentProvider, OnModuleInit { // Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an // unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206 - // "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING), - // never terminal — so the intent keeps waiting for the webhook / its expiry rather than being - // wrongly resolved off a "no info" response. + // "Failed to get transaction info") with no status — i.e. the payer hasn't done anything at + // the hosted page yet. That's REQUIRES_ACTION (still awaiting the payer), NOT PROCESSING: + // returning PROCESSING here would let the reconciliation sweep persist that guess and block + // the payer from switching providers on a session they never touched (see cac-bank.provider's + // queryStatus for the same convention). The intent still resolves correctly either way — via + // the webhook on a genuine payment, or via expiresAt once the 5-minute HPP session lapses. if (response.responseCode !== WAAFI_SUCCESS_CODE) { this.logger.warn( - `Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`, + `Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as still awaiting the payer`, ); return { - status: ProviderPaymentStatus.PROCESSING, + status: ProviderPaymentStatus.REQUIRES_ACTION, rawResponse: response as unknown as Record, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72daf6a26..3b89d65b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@commitlint/config-conventional': specifier: ^19.5.0 version: 19.8.1 + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 husky: specifier: ^9.1.6 version: 9.1.7 @@ -914,6 +917,9 @@ importers: jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + jest-html-reporters: + specifier: ^3.1.7 + version: 3.1.7 prisma: specifier: ^6.19.3 version: 6.19.3(typescript@5.9.3) @@ -955,7 +961,7 @@ importers: version: 0.446.0(react@18.3.1) next: specifier: ^14.2.0 - version: 14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -1040,7 +1046,7 @@ importers: version: 0.446.0(react@18.3.1) next: specifier: ^14.2.0 - version: 14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -3168,6 +3174,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -6539,6 +6550,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} @@ -7402,6 +7417,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -7947,6 +7967,11 @@ packages: resolution: {integrity: sha512-bv5z95W0dDtLfKwDfkTNxaRxmISBD3eQBKJeVxv2AQ7MjuUnDNG7cIQqvFtMOUYhsILWHhMayWdoGqNqYYYjww==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -8164,6 +8189,10 @@ packages: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -8292,6 +8321,9 @@ packages: resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-html-reporters@3.1.7: + resolution: {integrity: sha512-GTmjqK6muQ0S0Mnksf9QkL9X9z2FGIpNSxC52E0PHDzjPQ1XDu2+XTI3B3FS43ZiUzD1f354/5FfwbNIBzT7ew==} + jest-leak-detector@29.7.0: resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9363,6 +9395,10 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -9632,6 +9668,16 @@ packages: resolution: {integrity: sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ==} engines: {node: '>= 0.4.0'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -14039,6 +14085,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@popperjs/core@2.11.8': {} '@posthog/core@1.41.1': @@ -18758,6 +18808,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-lazy-prop@3.0.0: {} define-properties@1.2.1: @@ -19913,6 +19965,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -20501,6 +20556,8 @@ snapshots: is-accessor-descriptor: 1.0.2 is-data-descriptor: 1.0.1 + is-docker@2.2.1: {} + is-docker@3.0.0: {} is-even@1.0.0: @@ -20670,6 +20727,10 @@ snapshots: is-windows@1.0.2: {} + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -20888,6 +20949,11 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + jest-html-reporters@3.1.7: + dependencies: + fs-extra: 10.1.0 + open: 8.4.2 + jest-leak-detector@29.7.0: dependencies: jest-get-type: 29.6.3 @@ -21884,7 +21950,7 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - next@14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.35 '@swc/helpers': 0.5.5 @@ -21905,6 +21971,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 14.2.33 '@next/swc-win32-ia32-msvc': 14.2.33 '@next/swc-win32-x64-msvc': 14.2.33 + '@playwright/test': 1.61.1 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -22080,6 +22147,12 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -22343,6 +22416,14 @@ snapshots: pkginfo@0.4.1: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} png-js@2.0.0: