diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 397845cb3..a310e496b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -35,6 +35,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module"; import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; import { TruckTypesModule } from "./modules/truck-types/truck-types.module"; import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module"; +import { TransitAssignmentsModule } from "./modules/transit-assignments/transit-assignments.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"; @@ -205,6 +206,7 @@ if (!process.env.APPLICATION_NAME) { LocomotivesModule, TruckTypesModule, TransitAgentsModule, + TransitAssignmentsModule, WagonTypesModule, TrainSetsModule, TrainSchedulesModule, diff --git a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts index 4f060ca11..c4588af07 100644 --- a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts +++ b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts @@ -4,25 +4,29 @@ import { ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, -} from 'class-validator'; -import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js'; +} from "class-validator"; +import { + isValidPhoneNumber, + parsePhoneNumberFromString, +} from "libphonenumber-js"; /** * Country-aware phone validation. The value is expected as a full international - * number (E.164, e.g. "+251911223344"), so the country is derived from the - * value itself — no separate country field needed. + * number (E.164, e.g. "+25377834567" for Djibouti or "+251911223344" for + * Ethiopia), so the country is derived from the value itself — no separate + * country field needed. */ -@ValidatorConstraint({ name: 'IsValidPhone', async: false }) +@ValidatorConstraint({ name: "IsValidPhone", async: false }) export class IsValidPhoneConstraint implements ValidatorConstraintInterface { validate(value: unknown): boolean { // Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed. - if (value === undefined || value === null || value === '') return true; - if (typeof value !== 'string') return false; + if (value === undefined || value === null || value === "") return true; + if (typeof value !== "string") return false; return isValidPhoneNumber(value); } defaultMessage(args: ValidationArguments): string { - return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`; + return `${args.property} must be a complete international phone number (E.164, e.g. +25377834567 or +251911223344)`; } } @@ -53,7 +57,7 @@ export function IsValidPhone(validationOptions?: ValidationOptions) { export function normalizeE164( value: string | null | undefined, ): string | null | undefined { - if (value === undefined || value === null || value === '') return value; - const parsed = parsePhoneNumberFromString(value, 'ET'); + if (value === undefined || value === null || value === "") return value; + const parsed = parsePhoneNumberFromString(value, "ET"); return parsed?.isValid() ? parsed.number : value.trim(); } diff --git a/apps/edr-freight-api/src/migrations/3790000000000-TransitAgentAccount.ts b/apps/edr-freight-api/src/migrations/3790000000000-TransitAgentAccount.ts new file mode 100644 index 000000000..eedf8b149 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3790000000000-TransitAgentAccount.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Give a transit agent a portal login. + * + * Every column is NULLABLE and nothing is backfilled: production already holds + * transit agents that exist only as a GL-assignable roster entry, and they must + * keep working untouched. An agent gains an account when staff invite it — at + * which point `user_id` is filled in — so "has a login" is exactly + * `user_id IS NOT NULL`, and the assignment flow never has to care. + * + * The unique indexes are partial (`WHERE ... IS NOT NULL`) because Postgres + * treats NULLs as distinct in a plain unique index only per-row; being explicit + * documents that many account-less agents are expected to coexist. + */ +export class TransitAgentAccount3790000000000 implements MigrationInterface { + name = "TransitAgentAccount3790000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.transit_agents + ADD COLUMN IF NOT EXISTS user_id uuid, + ADD COLUMN IF NOT EXISTS email varchar(150), + ADD COLUMN IF NOT EXISTS phone_number varchar(30)`, + ); + // One IAM account can back at most one transit agent — otherwise a single + // login would resolve to two agents in `findByUserId`. + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_user_id + ON freight.transit_agents (user_id) + WHERE user_id IS NOT NULL AND deleted_at IS NULL`, + ); + // Case-insensitive, matching how the repository checks for duplicates. + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_email + ON freight.transit_agents (lower(email)) + WHERE email IS NOT NULL AND deleted_at IS NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.ux_transit_agents_email`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS freight.ux_transit_agents_user_id`, + ); + await queryRunner.query( + `ALTER TABLE freight.transit_agents + DROP COLUMN IF EXISTS phone_number, + DROP COLUMN IF EXISTS email, + DROP COLUMN IF EXISTS user_id`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3800000000000-BookingCancellationWagons.ts b/apps/edr-freight-api/src/migrations/3800000000000-BookingCancellationWagons.ts new file mode 100644 index 000000000..df994d832 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3800000000000-BookingCancellationWagons.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagon footprint pinned for cancellation pricing. `wagons_required` is a LIVE + * scheduling field — unassign clears it to NULL — so a paid booking pulled off + * a train had nothing left to price a cancellation fee or credit against + * ("This booking has no wagon requirement to cancel from."). This column is + * stamped once, at first allocation, and never cleared: cancellation reads it + * (falling back to a computed count for bookings never allocated). + */ +export class BookingCancellationWagons3800000000000 implements MigrationInterface { + name = 'BookingCancellationWagons3800000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS cancellation_wagons numeric(6,2) + `); + // Backfill the bookings that still carry a live stamp. + await queryRunner.query(` + UPDATE freight.bookings + SET cancellation_wagons = wagons_required + WHERE cancellation_wagons IS NULL + AND wagons_required IS NOT NULL + AND wagons_required > 0 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS cancellation_wagons + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts b/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts new file mode 100644 index 000000000..9d0116681 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts @@ -0,0 +1,72 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Transit assignments — one row per (booking × transit agent), so an agent + * handles many bookings. + * + * Deliberately NOT the existing transit-assignee handshake on bookings + * (`/bookings/:id/clearance/transit-assignee/...`, which stores its answer on + * the booking itself): that is a pre-declaration agreement between GL Ethiopia + * and GL Djibouti about WHO will handle customs. This is the work record — + * status, timings and documents — and nothing here reads or writes that flow. + * + * There is no duration column on purpose. The time taken after the train + * arrives is `finished_at − bookings.arrived_at`, and both halves already + * exist; storing the difference would be a third source of truth that goes + * stale the moment either timestamp is corrected. It is computed on read. + * + * Documents hang off `freight.files` with `resource = 'transit_assignments'` + * and `resource_id = transit_assignments.id`. That table already carries the + * MinIO object, the upload time (`created_at`), the uploader, the edit time + * (`updated_at`) and the supersede history, so no file table is added here. + */ +export class TransitAssignments3810000000000 implements MigrationInterface { + name = "TransitAssignments3810000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.transit_assignments ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL, + transit_agent_id uuid NOT NULL, + status varchar(32) NOT NULL DEFAULT 'NOT_STARTED', + started_at timestamptz, + finished_at timestamptz, + assigned_by_user_id uuid, + assigned_at timestamptz NOT NULL DEFAULT now(), + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_transit_assignments PRIMARY KEY (id), + CONSTRAINT fk_transit_assignments_booking + FOREIGN KEY (booking_id) REFERENCES freight.bookings (id), + CONSTRAINT fk_transit_assignments_agent + FOREIGN KEY (transit_agent_id) REFERENCES freight.transit_agents (id) + ) + `); + + // One live assignment per (booking, agent). Partial so a soft-deleted row + // never blocks re-assigning the same agent to the same booking later. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_assignments_booking_agent + ON freight.transit_assignments (booking_id, transit_agent_id) + WHERE deleted_at IS NULL + `); + + // The two list directions: a booking's assignments, and an agent's workload. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_transit_assignments_booking + ON freight.transit_assignments (booking_id) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_transit_assignments_agent_status + ON freight.transit_assignments (transit_agent_id, status) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_assignments`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts index 66ee2cbbb..314d31a7f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -17,6 +17,7 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => { wagons: number; weightTons: number; quantities: { bulkTons?: number }; + totalWagons: number; }>; }; const booking = { @@ -29,7 +30,39 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => { it('cancels every wagon with the exact total tonnage', async () => { const cut = await svc.resolveRequestedCut(booking, { wagons: 4 }); - expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } }); + expect(cut).toEqual({ + wagons: 4, + weightTons: 250.5, + quantities: { bulkTons: 250.5 }, + totalWagons: 4, + }); + }); + + /** + * Unassigning a paid booking from a train clears `wagonsRequired` to NULL, so + * cancellation used to reject it outright ("no wagon requirement to cancel + * from"). The pinned `cancellationWagons`, stamped at first allocation, keeps + * the footprint through the unassign. + */ + it('falls back to the pinned cancellation footprint when wagonsRequired is cleared', async () => { + const unassigned = { ...booking, wagonsRequired: null, cancellationWagons: 4 }; + const cut = await svc.resolveRequestedCut(unassigned, { wagons: 4 }); + expect(cut.wagons).toBe(4); + expect(cut.totalWagons).toBe(4); + expect(cut.weightTons).toBe(250.5); + }); + + /** NUMBER_OF_WAGONS bulk never allocated: the customer's pinned count sizes it. */ + it('sizes a never-allocated NUMBER_OF_WAGONS booking from bulkRequestedWagons', async () => { + const fresh = { + ...booking, + wagonsRequired: null, + cancellationWagons: null, + bulkRequestedWagons: 3, + }; + const cut = await svc.resolveRequestedCut(fresh, { wagons: 3 }); + expect(cut.totalWagons).toBe(3); + expect(cut.weightTons).toBe(250.5); }); it('rejects more wagons than the booking has', async () => { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 86b9dedc6..a84f0907f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -23,6 +23,8 @@ import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Rate } from '../rule-engine/entities/rate.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { requestedBulkWagons } from '../train-scheduling/train-capacity.util'; +import { wagonsRequiredForBooking } from '../train-scheduling/utils/fleet-plan.util'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -74,6 +76,8 @@ interface RequestedCut { wagons: number; weightTons: number; quantities: CancelledQuantities; + /** The booking's whole wagon footprint the cut came out of — credit divides by it. */ + totalWagons: number; } /** The priced fee for a cut: total, currency and the rate(s) it came from. */ @@ -165,7 +169,7 @@ export class BookingWagonCancellationService { feePerWagon: fee.perWagon, feeAmount: fee.amount, feeCurrency: fee.currency, - creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + creditAmount: round2(Number(booking.totalAmount ?? 0)), }; } this.assertCutSparesSharedWagon(cut); @@ -177,7 +181,7 @@ export class BookingWagonCancellationService { feePerWagon: fee.perWagon, feeAmount: fee.amount, feeCurrency: fee.currency, - creditAmount: this.creditFor(booking, cut.wagons), + creditAmount: this.creditFor(booking, cut.wagons, cut.totalWagons), }; } @@ -218,7 +222,7 @@ export class BookingWagonCancellationService { : await this.resolveRequestedCut(booking, dto); const fee = await this.priceFee(booking, cut); const feeAmount = fee.amount; - const creditAmount = this.creditFor(booking, cut.wagons); + const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons); const row = await this.repo.create({ bookingId, @@ -318,7 +322,7 @@ export class BookingWagonCancellationService { const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({ where: { bookingId: row.bookingId }, }); - if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) { + if (rows < Math.round(await this.wagonFootprint(booking))) { throw new ConflictException( 'The train has no free wagon space left to restore the cancelled wagons — the request cannot be withdrawn. Pay the cancellation fee and rebook the credit on another day instead.', ); @@ -363,7 +367,7 @@ export class BookingWagonCancellationService { const row = await this.openConsolidationBreak( booking, 'ceil', - this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + round2(Number(booking.totalAmount ?? 0)), reason ?? 'Consolidated pair cancelled', userId, ); @@ -371,7 +375,7 @@ export class BookingWagonCancellationService { await this.openConsolidationBreak( partner, 'floor', - this.creditFor(partner, Number(partner.wagonsRequired ?? 0)), + round2(Number(partner.totalAmount ?? 0)), `Cancelled with its consolidation partner ${booking.reference}`, userId, ); @@ -540,7 +544,8 @@ export class BookingWagonCancellationService { } as RequestWagonCancellationDto); } return this.resolveRequestedCut(booking, { - wagons: Number(booking.wagonsRequired ?? 0), + // Footprint, not the live wagonsRequired: unassign clears that to NULL. + wagons: await this.wagonFootprint(booking), } as RequestWagonCancellationDto); } @@ -568,7 +573,7 @@ export class BookingWagonCancellationService { const row = await this.openConsolidationBreak( booking, 'ceil', - this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + round2(Number(booking.totalAmount ?? 0)), 'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies', ); await this.dataSource.getRepository(Booking).update(booking.id, { @@ -729,9 +734,10 @@ export class BookingWagonCancellationService { // Whole-booking cut: nothing is left to ship, so the booking ends // CANCELLED (frees the contract slot/cap for the rebook) and drops off its // train. The credit row still points at it for T3. - const wagonsLeft = round2( - Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled), - ); + // Off the pinned footprint, not the live wagonsRequired — unassign + // clears that to NULL, which read as a full cut on any partial cancel. + const footprint = await this.wagonFootprint(booking); + const wagonsLeft = round2(footprint - Number(row.wagonsCancelled)); const isFull = wagonsLeft <= 0; // NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which // bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the @@ -745,6 +751,9 @@ export class BookingWagonCancellationService { : null; await manager.getRepository(Booking).update(booking.id, { wagonsRequired: Math.max(0, wagonsLeft), + // Keep the cancellation footprint in step, so a second partial cancel + // prices against what is actually left, not the original booking. + cancellationWagons: Math.max(0, wagonsLeft), ...(requestedWagonsLeft !== null ? { bulkRequestedWagons: requestedWagonsLeft } : {}), @@ -853,14 +862,37 @@ export class BookingWagonCancellationService { ); } + // Staff may cut a SUBSET of the never-loaded wagons (picked in the loading + // modal) instead of the whole remainder. Anything already LOADED is + // rejected rather than silently dropped: the operator believes they are + // cancelling that wagon, and it is on the train. + let target = remaining; + if (dto.wagonAllocationIds?.length) { + const wanted = new Set(dto.wagonAllocationIds); + const known = new Set(allocations.map((a) => a.id)); + const unknown = dto.wagonAllocationIds.filter((id) => !known.has(id)); + if (unknown.length) { + throw new BadRequestException( + 'Some selected wagons are not allocated to this booking on this schedule.', + ); + } + const loaded = allocations.filter((a) => wanted.has(a.id) && !remaining.includes(a)); + if (loaded.length) { + throw new BadRequestException( + `${loaded.length} selected wagon(s) are already loaded and cannot be cancelled.`, + ); + } + target = remaining.filter((a) => wanted.has(a.id)); + } + const cut = await this.resolveRequestedCut(booking, { - wagonAllocationIds: remaining.map((r) => r.id), + wagonAllocationIds: target.map((r) => r.id), } as RequestWagonCancellationDto); if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut); const edrFault = !!dto.edrFault; const fee = edrFault ? null : await this.priceFee(booking, cut); - const creditAmount = this.creditFor(booking, cut.wagons); + const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons); const row = await this.repo.create({ bookingId, @@ -1253,7 +1285,7 @@ export class BookingWagonCancellationService { booking: Booking, dto: RequestWagonCancellationDto, ): Promise { - const totalWagons = Number(booking.wagonsRequired ?? 0); + const totalWagons = await this.wagonFootprint(booking); if (totalWagons <= 0) { throw new BadRequestException('This booking has no wagon requirement to cancel from.'); } @@ -1335,6 +1367,7 @@ export class BookingWagonCancellationService { weightTons: weightShare, // Bookings without unit records fall back to the T2 LIFO trim. quantities: { bySize, ...(units.length === requested ? { units } : {}) }, + totalWagons, }; } @@ -1360,7 +1393,7 @@ export class BookingWagonCancellationService { if (tons <= 0) { throw new BadRequestException('The requested cut is too small to release cargo.'); } - return { wagons, weightTons: tons, quantities: { bulkTons: tons } }; + return { wagons, weightTons: tons, quantities: { bulkTons: tons }, totalWagons }; } /** @@ -1416,6 +1449,7 @@ export class BookingWagonCancellationService { wagons, weightTons: tons, quantities: { bulkTons: tons, allocationIds }, + totalWagons, }; } @@ -1460,12 +1494,54 @@ export class BookingWagonCancellationService { wagons, weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)), quantities: { bySize, units, allocationIds }, + totalWagons, }; } + /** + * The booking's wagon footprint for cancellation pricing. + * + * `wagonsRequired` is a LIVE scheduling field: unassign clears it to NULL, so + * a paid booking pulled off a train read 0 wagons and could not be cancelled + * at all. `cancellationWagons` is stamped once at first allocation and never + * cleared — read it first. A booking never allocated has neither, so size it + * from the cargo the same way the scheduler would: TEU geometry for + * containers, the customer's pinned count for NUMBER_OF_WAGONS bulk, tonnage + * ÷ wagon capacity for PER_TON bulk. + */ + private async wagonFootprint(booking: Booking): Promise { + const pinned = Number(booking.cancellationWagons ?? 0); + if (pinned > 0) return round2(pinned); + const stored = Number(booking.wagonsRequired ?? 0); + if (stored > 0) return round2(stored); + + const requested = requestedBulkWagons(booking); + if (requested > 0) return requested; + + // Cargo relations drive the sizing — reload when the caller passed a bare + // booking (findById does not always hydrate them). + const full = + booking.bookingContainers || booking.cargoType + ? booking + : ((await this.dataSource.getRepository(Booking).findOne({ + where: { id: booking.id }, + relations: { + bookingContainers: { containerType: true }, + cargoType: { wagonTypes: true }, + }, + })) ?? booking); + const capacities = (full.cargoType?.wagonTypes ?? []) + .map((wt) => Number(wt.capacityTons)) + .filter((c) => c > 0); + const bulkCapacity = + full.freightType === 'BULK' && capacities.length + ? Math.max(...capacities) + : undefined; + return round2(wagonsRequiredForBooking(full, bulkCapacity)); + } + /** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */ - private creditFor(booking: Booking, wagons: number): number { - const totalWagons = Number(booking.wagonsRequired ?? 0); + private creditFor(booking: Booking, wagons: number, totalWagons: number): number { if (totalWagons <= 0) return 0; return round2(Number(booking.totalAmount) * (wagons / totalWagons)); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 5e0e3c994..863b61fee 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1786,6 +1786,7 @@ export class BookingsRepository extends BaseRepository { Booking, | 'schedulingStatus' | 'wagonsRequired' + | 'cancellationWagons' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt' diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts index f86c3e4bf..7b4d3ca19 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -183,6 +183,19 @@ export class CancelRemainingWagonsDto { @IsUUID('4') scheduleId!: string; + @ApiPropertyOptional({ + description: + 'Cancel only THESE never-loaded wagons (wagon_booking_allocation ids from ' + + 'GET /bookings/:id/wagons). Omit to cancel the whole unloaded remainder. ' + + 'Already-loaded wagons are rejected — they are riding.', + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonAllocationIds?: string[]; + @ApiProperty({ description: 'Why the remaining wagons are not riding' }) @IsString() @IsNotEmpty() diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index b86e1c3f3..9cf728a0d 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -543,6 +543,13 @@ export class Booking extends BaseEntity { @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true }) wagonsRequired?: number | null; + // Wagon footprint pinned for cancellation pricing. `wagonsRequired` above is + // a LIVE scheduling field that unassign clears; this one is stamped once at + // first allocation and never cleared, so a paid booking pulled off a train + // can still price its cancellation fee and credit. + @Column({ name: 'cancellation_wagons', type: 'numeric', precision: 6, scale: 2, nullable: true }) + cancellationWagons?: number | null; + @Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' }) schedulingStatus!: string; 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 000b0733f..733b6e441 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -57,8 +57,10 @@ import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { AccountInfoResponse, ShippingLineInfoResponseDto, + TransitAgentInfoResponseDto, } from "./dto/account-info-response.dto"; import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service"; +import { TransitAgentsService } from "../transit-agents/transit-agents.service"; import { UpdateProfileDto } from "./dto/update-profile.dto"; import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; @@ -104,6 +106,7 @@ export class CompaniesController { private readonly companiesService: CompaniesService, private readonly filesService: FilesService, private readonly shippingLineCompaniesService: ShippingLineCompaniesService, + private readonly transitAgentsService: TransitAgentsService, ) { } /** @@ -133,10 +136,10 @@ export class CompaniesController { async getInfo( @CurrentUser() user: CurrentIamUser, ): Promise { - // A shipping line has no company and no external profile, so the customer - // lookup below would 404. Checked first, and reported with an explicit - // `accountKind` so the portal can skip onboarding for shipping lines - // without inferring it from a missing company. + // Neither a shipping line nor a transit agent has a company or an external + // profile, so the customer lookup below would 404 for both. Checked first, + // and reported with an explicit `accountKind` so the portal can skip + // onboarding for them without inferring it from a missing company. const shippingLine = await this.shippingLineCompaniesService.findByUserId( user.id, ); @@ -144,6 +147,11 @@ export class CompaniesController { return new ShippingLineInfoResponseDto(shippingLine); } + const transitAgent = await this.transitAgentsService.findByUserId(user.id); + if (transitAgent) { + return new TransitAgentInfoResponseDto(transitAgent); + } + const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); const review = await this.companiesService.getOpenChangeRequestForCompany( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 666634cb8..3a3d6c1c4 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -18,6 +18,7 @@ import { CompanyChangeRequest } from "./entities/company-change-request.entity"; import { CompanyRevision } from "./entities/company-revision.entity"; import { Booking } from "../bookings/entities/booking.entity"; import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module"; +import { TransitAgentsModule } from "../transit-agents/transit-agents.module"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { CompanyRevisionRepository } from "./company-revision.repository"; @@ -49,6 +50,10 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; // shipping-line session, which has no company row to look up. forwardRef // because that module imports BillingModule, which imports this one. forwardRef(() => ShippingLineCompaniesModule), + // `GET /companies/getInfo` resolves a transit-agent session before falling + // through to the customer lookup. TransitAgentsModule is a leaf here — it + // does not import CompaniesModule — so no forwardRef is needed. + TransitAgentsModule, ], controllers: [CompaniesController], providers: [ diff --git a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts index ab680f8f3..9a43e65d4 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity"; +import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity"; import { CompanyInfoResponseDto } from "./company-info-response.dto"; /** @@ -9,10 +10,10 @@ import { CompanyInfoResponseDto } from "./company-info-response.dto"; * The portal keys its onboarding gate off this rather than off "is `company` * missing?": a failed or slow company fetch also leaves `company` empty, and * treating that as "no onboarding needed" would let customers skip onboarding - * whenever the request failed. A shipping line is identified positively, and - * anything else defaults to `customer`. + * whenever the request failed. A shipping line and a transit agent are each + * identified positively, and anything else defaults to `customer`. */ -export type AccountKind = "customer" | "shipping_line"; +export type AccountKind = "customer" | "shipping_line" | "transit_agent"; /** The signed-in shipping line. No company, no profile, no onboarding. */ export class ShippingLineInfoResponseDto { @@ -61,6 +62,62 @@ export class ShippingLineInfoResponseDto { } } +/** + * The signed-in transit agent. Like a shipping line: no company, no profile, no + * onboarding — but a separate account kind because the two share nothing beyond + * that, and the portal shows each a different (much smaller) set of tabs. + */ +export class TransitAgentInfoResponseDto { + @ApiProperty({ enum: ["transit_agent"] }) + accountKind: "transit_agent" = "transit_agent"; + + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + email?: string | null; + + @ApiPropertyOptional() + phoneNumber?: string | null; + + @ApiProperty() + isActive: boolean; + + @ApiProperty({ + description: "Start of the agent's validity window (yyyy-MM-dd)", + }) + validFrom: string; + + @ApiProperty({ + description: "End of the agent's validity window (yyyy-MM-dd)", + }) + validTo: string; + + /** Always null — see {@link ShippingLineInfoResponseDto.company}. */ + @ApiProperty({ nullable: true }) + company: null = null; + + @ApiProperty({ nullable: true }) + profile: null = null; + + @ApiProperty({ nullable: true }) + review: null = null; + + constructor(entity: TransitAgent) { + this.id = entity.id; + this.name = entity.name; + this.email = entity.email ?? null; + this.phoneNumber = entity.phoneNumber ?? null; + this.isActive = entity.isActive; + this.validFrom = entity.validFrom; + this.validTo = entity.validTo; + } +} + export type AccountInfoResponse = | (CompanyInfoResponseDto & { accountKind: "customer" }) - | ShippingLineInfoResponseDto; + | ShippingLineInfoResponseDto + | TransitAgentInfoResponseDto; diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts index 00f8d107a..086baef66 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -35,9 +35,24 @@ describe("isDomesticPhone", () => { (phone) => expect(isDomesticPhone(phone)).toBe(true), ); - it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])( - "rejects non-domestic or malformed %s", - (phone) => expect(isDomesticPhone(phone)).toBe(false), + // Djibouti is the line's other end: the gateway reaches its 77x mobiles. + it.each(["+25377123456", "25377123456", "77123456"])( + "accepts Djibouti mobile form %s", + (phone) => expect(isDomesticPhone(phone)).toBe(true), + ); + + it.each([ + "+14155550123", + "+447911123456", + "0712345678", + "+2519866", + "12345", + // Djibouti fixed line (2x) — valid number, not a mobile the gateway serves. + "+25321350000", + // Right length, wrong Djibouti prefix. + "+25366123456", + ])("rejects unreachable or malformed %s", (phone) => + expect(isDomesticPhone(phone)).toBe(false), ); }); diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index b27520c25..6b5a0e0d9 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -42,20 +42,42 @@ function normalizePhone(rawPhone: string): string { if (digits.startsWith("+")) return digits; const bare = digits.replace(/^0+/, ""); if (/^251\d{9}$/.test(digits)) return `+${digits}`; + if (/^253\d{8}$/.test(digits)) return `+${digits}`; if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`; + // Djibouti mobiles are 8 digits starting 77 and have no trunk prefix, so a + // bare "77…" is unambiguous — it cannot be an Ethiopian local number, which + // is always 9 digits after the trunk zero. + if (/^77\d{6}$/.test(bare)) return `+253${bare}`; // Unknown shape (foreign number, already-clean intl without +) — prefix + if // it looks like a full international number, else leave as typed. return digits.length >= 11 ? `+${digits}` : raw; } /** - * Whether a phone is an Ethiopian mobile the SMS gateway can actually reach — - * the carrier integration is domestic-only, so a send to anything else is - * queued and silently lost. Callers use this to fall back to email instead of - * pretending an SMS is on its way. + * Mobile ranges the SMS gateway is contracted to reach, as E.164 patterns. + * + * The gateway itself is opaque from here — `SmsClientService` publishes to + * RabbitMQ and the carrier sits several hops downstream — so this list is a + * policy statement, not a capability probe: a number outside it is treated as + * unreachable and callers fall back to email rather than promising an SMS that + * would be queued and silently dropped. + * + * - Ethiopia: `+2519…` mobiles only. `+2517…` is deliberately absent; it parses + * as a valid ET number but is not a range this gateway delivers to. + * - Djibouti: `+25377…`, the country's only mobile range (2x is fixed-line). + */ +const REACHABLE_MOBILE_PATTERNS = [/^\+2519\d{8}$/, /^\+25377\d{6}$/]; + +/** + * Whether a phone sits in a mobile range the SMS gateway can actually reach. + * + * Named "domestic" for the Ethiopian-only era this predates; it now covers both + * countries the railway runs through. Callers use it to fall back to email + * instead of pretending an SMS is on its way. */ export function isDomesticPhone(rawPhone: string): boolean { - return /^\+2519\d{8}$/.test(normalizePhone(rawPhone)); + const normalized = normalizePhone(rawPhone); + return REACHABLE_MOBILE_PATTERNS.some((p) => p.test(normalized)); } /** @@ -99,7 +121,7 @@ export class OtpService { private readonly otpRepository: OtpRepository, private readonly notifications: NotificationsService, private readonly emailClient: EmailClientService, - ) { } + ) {} // --------------------------------------------------------------------------- // Generate OTP @@ -197,8 +219,10 @@ export class OtpService { for (const outcome of outcomes) { this.logger.log( - `otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued - } latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : "" + `otp.dispatch channel=${outcome.channel} target=${label} queued=${ + outcome.queued + } latencyMs=${Date.now() - startedAt}${ + outcome.error ? ` error=${outcome.error}` : "" }`, ); } @@ -222,7 +246,8 @@ export class OtpService { // user who never receives a code — indistinguishable from carrier loss, // and the misleading success response makes it look like our side worked. this.logger.error( - `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset" + `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${ + process.env.RABBITMQ_ENABLED ?? "unset" } — no transport reported hand-off; no code will arrive for this send`, ); } @@ -247,7 +272,8 @@ export class OtpService { // Log the real cause (DB/SMS/email failure) with its stack so a deployed // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. this.logger.error( - `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt + `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${ + Date.now() - startedAt }: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, ); @@ -330,8 +356,9 @@ export class OtpService { ) { const line = `otp.verify channels=${channelsOf(target).join( "+", - )} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : "" - }`; + )} target=${this.targetLabel(target)} mode=${mode} result=${result}${ + detail ? ` ${detail}` : "" + }`; if (result === "ok") this.logger.log(line); else this.logger.warn(line); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index bf9c0aa5f..d9bae156a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -3967,6 +3967,11 @@ export class BookingBatchService implements OnModuleInit { schedulingStatus: "SCHEDULED", scheduledAt: new Date(), wagonsRequired, + // Pinned for cancellation pricing: unassign clears wagonsRequired, this + // stays. Written once — a later re-allocation keeps the first stamp. + ...(Number(booking.cancellationWagons ?? 0) > 0 + ? {} + : { cancellationWagons: wagonsRequired }), paymentDeadline: null, selectedForBatchAt: null, } as never); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 1c2d99401..4600f7f38 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -39,15 +39,30 @@ export class BookingNotifierService { try { const s = await this.trainSchedules.findByIdWithStations(scheduleId); if (!s) return fallback; - const ref = s.reference ?? s.trainNumber ?? null; - const route = + // Customers know the train by its operating number (8001), not the + // schedule reference — lead with it and keep S-… as the secondary id. + const parts = [ + s.reference, s.originStation?.label && s.destinationStation?.label - ? ` (${s.originStation.label} → ${s.destinationStation.label})` - : ''; + ? `${s.originStation.label} → ${s.destinationStation.label}` + : null, + ].filter(Boolean); + const detail = parts.length ? ` (${parts.join(', ')})` : ''; const departure = s.scheduledDepartureDate - ? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}` + ? `, departing ${new Date(s.scheduledDepartureDate).toLocaleString('en-GB', { + timeZone: BATCH_TIMEZONE, + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + })} EAT` : ''; - return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`; + const number = s.trainNumber ?? s.reference ?? null; + return number + ? `train ${number}${number === s.reference ? '' : detail}${departure}` + : `${fallback}${detail}${departure}`; } catch (err) { this.logger.warn( `scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index dbf0c7f7c..19140d822 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -2320,12 +2320,18 @@ export class TrainSchedulingService { // batch fill, which unlinks it and frees its wagons on the next window cycle. const scheduledAt = new Date(); for (const booking of bookings) { + const wagonsRequired = sumWagonsRequired(booking, wagonPlan); await this.bookingsRepository.updateSchedulingFields( booking.id, { schedulingStatus: SchedulingStatus.Scheduled, scheduledAt, - wagonsRequired: sumWagonsRequired(booking, wagonPlan), + wagonsRequired, + // Pinned for cancellation pricing: unassign clears wagonsRequired, + // this stays. Written once — re-allocation keeps the first stamp. + ...(Number(booking.cancellationWagons ?? 0) > 0 + ? {} + : { cancellationWagons: wagonsRequired }), }, manager, ); diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts index be3810c0b..d035c53f8 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts @@ -1,25 +1,34 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { + IsBoolean, + IsDateString, + IsEmail, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; + +import { IsValidPhone } from "../../../common/validators/is-phone-number.validator"; const toBoolean = ({ value }: { value: unknown }) => { - if (typeof value === 'boolean') return value; - if (value === 'true') return true; - if (value === 'false') return false; + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; return value; }; export class CreateTransitAgentDto { - @ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' }) + @ApiProperty({ maxLength: 150, example: "Ahmed Bourhan" }) @IsString() @MaxLength(150) name!: string; - @ApiProperty({ example: '2026-01-01' }) + @ApiProperty({ example: "2026-01-01" }) @IsDateString() validFrom!: string; - @ApiProperty({ example: '2026-12-31' }) + @ApiProperty({ example: "2026-12-31" }) @IsDateString() validTo!: string; @@ -28,4 +37,33 @@ export class CreateTransitAgentDto { @Transform(toBoolean) @IsBoolean() isActive?: boolean; + + /** + * Becomes the IAM account's email and is where the activation link is sent. + * Optional: an agent may be created as a GL-assignable roster entry only, and + * invited later. Supplying it creates the portal account right away. + */ + @ApiPropertyOptional({ example: "a.bourhan@transit.dj" }) + @IsOptional() + @IsEmail() + @MaxLength(150) + email?: string; + + @ApiPropertyOptional({ + example: "+25377834567", + description: + "E.164. Djiboutian (+253 77…) and Ethiopian (+251 9…) mobiles also receive the activation link by SMS.", + }) + @IsOptional() + @IsString() + @MaxLength(30) + @IsValidPhone() + phoneNumber?: string; + + /** Login name. Defaults to the email, which is what the agent tries first. */ + @ApiPropertyOptional({ example: "a-bourhan" }) + @IsOptional() + @IsString() + @MaxLength(100) + username?: string; } diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/invite-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/invite-transit-agent.dto.ts new file mode 100644 index 000000000..7b317c58c --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/invite-transit-agent.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEmail, IsOptional, IsString, MaxLength } from "class-validator"; + +import { IsValidPhone } from "../../../common/validators/is-phone-number.validator"; + +/** + * Give an EXISTING roster-only transit agent a portal login. + * + * Email is required here even though it is optional on the agent itself: this + * endpoint's whole job is to send the activation link, and email is the only + * channel guaranteed to reach a Djibouti-registered officer. Omitting a field + * keeps whatever the agent already has. + */ +export class InviteTransitAgentDto { + @ApiProperty({ example: "a.bourhan@transit.dj" }) + @IsEmail() + @MaxLength(150) + email!: string; + + @ApiPropertyOptional({ + example: "+25377834567", + description: + "E.164. Djiboutian (+253 77…) and Ethiopian (+251 9…) mobiles also receive the activation link by SMS.", + }) + @IsOptional() + @IsString() + @MaxLength(30) + @IsValidPhone() + phoneNumber?: string; + + @ApiPropertyOptional({ example: "a-bourhan" }) + @IsOptional() + @IsString() + @MaxLength(100) + username?: string; +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts index 7e18a93da..08f28473c 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts @@ -1,5 +1,5 @@ -import { PartialType } from '@nestjs/mapped-types'; +import { PartialType } from "@nestjs/mapped-types"; -import { CreateTransitAgentDto } from './create-transit-agent.dto'; +import { CreateTransitAgentDto } from "./create-transit-agent.dto"; export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {} diff --git a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts index 6d0ef9158..433b6587f 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts @@ -1,5 +1,5 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; /** * Djibouti transit officer GL Djibouti may assign against a shipment's @@ -7,18 +7,43 @@ import { Column, Entity, Index } from 'typeorm'; * validity window arrive without a code change; `isActive` is the manual * suspend/reactivate switch, independent of the validity window. */ -@Entity({ schema: 'freight', name: 'transit_agents' }) -@Index(['isActive']) +@Entity({ schema: "freight", name: "transit_agents" }) +@Index(["isActive"]) export class TransitAgent extends BaseEntity { - @Column({ name: 'name', type: 'varchar', length: 150 }) + @Column({ name: "name", type: "varchar", length: 150 }) name!: string; - @Column({ name: 'valid_from', type: 'date' }) + @Column({ name: "valid_from", type: "date" }) validFrom!: string; - @Column({ name: 'valid_to', type: 'date' }) + @Column({ name: "valid_to", type: "date" }) validTo!: string; - @Column({ name: 'is_active', type: 'boolean', default: true }) + @Column({ name: "is_active", type: "boolean", default: true }) isActive!: boolean; + + /** + * The IAM account (`iam.users`, userType `individual`) that signs in to the + * portal as this agent. No FK: `iam` is a separate schema owned by the IAM + * service, and the rest of the codebase reaches it by query rather than by + * relation. + * + * NULL for every agent that exists only as a GL-assignable roster entry — + * which is all of them before this feature, and stays legal afterwards. An + * agent gains an account when staff invite it, so `userId !== null` IS the + * "has a portal login" predicate; nothing else needs to track it. + */ + @Column({ name: "user_id", type: "uuid", nullable: true }) + userId?: string | null; + + /** + * Mirrors the IAM account's email; the activation link is sent here. Nullable + * because a roster-only agent has never needed one — but an invite cannot be + * sent without it, so {@link TransitAgentsService.invite} requires it. + */ + @Column({ name: "email", type: "varchar", length: 150, nullable: true }) + email?: string | null; + + @Column({ name: "phone_number", type: "varchar", length: 30, nullable: true }) + phoneNumber?: string | null; } diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts index 4f4b90c7b..d254f7982 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts @@ -10,36 +10,38 @@ import { Patch, Post, Query, -} from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView, -} from '../../common/rule-engine-guards'; +} from "../../common/rule-engine-guards"; -import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; -import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; -import { TransitAgentsService } from './transit-agents.service'; +import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto"; +import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto"; +import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto"; +import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto"; +import { TransitAgentsService } from "./transit-agents.service"; -@ApiTags('transit-agents') -@Controller('transit-agents') +@ApiTags("transit-agents") +@Controller("transit-agents") @ApiBearerAuth() export class TransitAgentsController { constructor(private readonly transitAgentsService: TransitAgentsService) {} @Get() - @RuleEngineView('transit-agents') - @ApiOperation({ summary: 'List transit agents' }) + @RuleEngineView("transit-agents") + @ApiOperation({ summary: "List transit agents" }) findAll(@Query() query: Record) { return this.transitAgentsService.findAll({ isActive: - query.isActive === 'all' + query.isActive === "all" ? undefined : query.isActive !== undefined - ? query.isActive === 'true' + ? query.isActive === "true" : undefined, page: query.page ? parseInt(query.page, 10) : undefined, pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, @@ -49,39 +51,77 @@ export class TransitAgentsController { } /** Active + currently valid officers — the transit-assignee assignment dropdown. */ - @Get('assignable') - @RuleEngineView('transit-agents') - @ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' }) + @Get("assignable") + @RuleEngineView("transit-agents") + @ApiOperation({ + summary: "List transit agents assignable right now (active and in-window)", + }) findAssignable() { return this.transitAgentsService.findAssignable(); } - @Get(':id') - @RuleEngineView('transit-agents') - @ApiOperation({ summary: 'Get a transit agent by ID' }) - findOne(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id") + @RuleEngineView("transit-agents") + @ApiOperation({ summary: "Get a transit agent by ID" }) + findOne(@Param("id", ParseUUIDPipe) id: string) { return this.transitAgentsService.findById(id); } @Post() - @RuleEngineCreate('transit-agents') - @ApiOperation({ summary: 'Create a transit agent' }) + @RuleEngineCreate("transit-agents") + @ApiOperation({ + summary: + "Create a transit agent; with an email, also creates its portal account and sends the activation link", + }) create(@Body() dto: CreateTransitAgentDto) { - return this.transitAgentsService.create(dto); + return this.transitAgentsService.createWithInvite(dto); } - @Patch(':id') - @RuleEngineUpdate('transit-agents') - @ApiOperation({ summary: 'Update a transit agent' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) { + /** + * The path for the roster entries already in production: they were created + * before transit agents had logins, so they get their account here rather + * than at create time. + */ + @Post(":id/invite") + @RuleEngineUpdate("transit-agents") + @ApiOperation({ + summary: + "Create a portal account for an existing transit agent and send the activation link", + }) + invite( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: InviteTransitAgentDto, + ) { + return this.transitAgentsService.invite(id, dto); + } + + @Post(":id/resend-activation") + @RuleEngineUpdate("transit-agents") + @ApiOperation({ + summary: "Resend a transit agent's activation / password-reset link", + }) + resendActivation( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: BackofficeResetPasswordDto, + ) { + return this.transitAgentsService.resendActivation(id, dto.channel); + } + + @Patch(":id") + @RuleEngineUpdate("transit-agents") + @ApiOperation({ summary: "Update a transit agent" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateTransitAgentDto, + ) { return this.transitAgentsService.update(id, dto); } - @Delete(':id') - @RuleEngineDelete('transit-agents') + @Delete(":id") + @RuleEngineDelete("transit-agents") @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Soft-delete a transit agent' }) - remove(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Soft-delete a transit agent" }) + remove(@Param("id", ParseUUIDPipe) id: string) { return this.transitAgentsService.remove(id); } } diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts index 47e655e94..425971170 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts @@ -1,13 +1,24 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; -import { TransitAgent } from './entities/transit-agent.entity'; -import { TransitAgentsController } from './transit-agents.controller'; -import { TransitAgentsRepository } from './transit-agents.repository'; -import { TransitAgentsService } from './transit-agents.service'; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { FreightAuthModule } from "../auth/freight-auth.module"; +import { OtpModule } from "../otp/otp.module"; +import { TransitAgent } from "./entities/transit-agent.entity"; +import { TransitAgentsController } from "./transit-agents.controller"; +import { TransitAgentsRepository } from "./transit-agents.repository"; +import { TransitAgentsService } from "./transit-agents.service"; @Module({ - imports: [TypeOrmModule.forFeature([TransitAgent])], + imports: [ + // `User` is registered here so this module can create the IAM account that + // backs an invited transit agent, in the same transaction as the agent row. + TypeOrmModule.forFeature([TransitAgent, User]), + // CustomerResetService — activation links reuse the staff-triggered reset path. + FreightAuthModule, + OtpModule, + ], controllers: [TransitAgentsController], providers: [TransitAgentsRepository, TransitAgentsService], exports: [TransitAgentsRepository, TransitAgentsService], diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts index 4418ad938..5ae4191eb 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts @@ -1,9 +1,14 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + EntityManager, + LessThanOrEqual, + MoreThanOrEqual, + Repository, +} from "typeorm"; -import { TransitAgent } from './entities/transit-agent.entity'; +import { TransitAgent } from "./entities/transit-agent.entity"; @Injectable() export class TransitAgentsRepository extends BaseRepository { @@ -22,7 +27,48 @@ export class TransitAgentsRepository extends BaseRepository { validFrom: LessThanOrEqual(today), validTo: MoreThanOrEqual(today), }, - order: { name: 'ASC' }, + order: { name: "ASC" }, }); } + + /** The transit agent signed in as `userId`, or null for any other account. */ + findByUserId(userId: string): Promise { + return this.repository.findOne({ where: { userId } }); + } + + /** + * Case-insensitive, matching the `lower(email)` unique index. `exceptId` lets + * an update re-save its own address without colliding with itself. + */ + async existsByEmail(email: string, exceptId?: string): Promise { + const qb = this.repository + .createQueryBuilder("ta") + .where("lower(ta.email) = lower(:email)", { email }); + if (exceptId) qb.andWhere("ta.id != :exceptId", { exceptId }); + return (await qb.getCount()) > 0; + } + + /** + * Insert inside a caller-supplied transaction, so the agent row and the IAM + * user it points at commit together — a row referencing a user that was + * rolled back (or vice versa) is an account nobody can sign in to. + */ + createInTransaction( + manager: EntityManager, + data: Partial, + ): Promise { + const repo = manager.getRepository(TransitAgent); + return repo.save(repo.create(data)); + } + + /** Attach an IAM account to an existing agent, inside the caller's transaction. */ + async linkAccountInTransaction( + manager: EntityManager, + id: string, + data: Pick, + ): Promise { + const repo = manager.getRepository(TransitAgent); + await repo.update(id, data); + return repo.findOneOrFail({ where: { id } }); + } } diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts new file mode 100644 index 000000000..3aa5e6e02 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts @@ -0,0 +1,346 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; +import { + EUserStatus, + EUserType, +} from "@tria-plc/api-common/utils/enums/user.enum"; + +import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { TransitAgentsService } from "./transit-agents.service"; + +/** + * The account half of a transit agent. The roster half (validity window, + * assignability) predates this and is untouched — what these lock is that + * adding a login did not make an account MANDATORY, since production is full of + * roster-only agents that must keep working. + */ +describe("TransitAgentsService accounts", () => { + const savedUser = { id: "user-1" }; + + let repo: { + existsByEmail: jest.Mock; + createInTransaction: jest.Mock; + linkAccountInTransaction: jest.Mock; + findById: jest.Mock; + findByUserId: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + let userRepository: { findOne: jest.Mock; update: jest.Mock }; + let customerResetService: { + sendResetLinkToUser: jest.Mock; + sendResetLinkToUserOnChannels: jest.Mock; + }; + let dataSource: { transaction: jest.Mock }; + let userRepoInTx: { create: jest.Mock; save: jest.Mock }; + let service: TransitAgentsService; + + const base = { + name: "Ahmed Bourhan", + validFrom: "2026-01-01", + validTo: "2026-12-31", + }; + + beforeEach(() => { + userRepoInTx = { + create: jest.fn((v) => v), + save: jest.fn().mockResolvedValue(savedUser), + }; + + repo = { + existsByEmail: jest.fn().mockResolvedValue(false), + createInTransaction: jest.fn(async (_m, data) => ({ + id: "ta-1", + ...data, + })), + linkAccountInTransaction: jest.fn(async (_m, id, data) => ({ + id, + ...base, + isActive: true, + ...data, + })), + findById: jest.fn(), + findByUserId: jest.fn(), + create: jest.fn(async (data) => ({ id: "ta-1", ...data })), + // `BaseRepository.update` re-reads the row via `findById`, so the result + // carries columns the caller never passed — `userId` above all, which is + // what decides whether IAM gets synced. + update: jest.fn(async (id, data) => ({ + ...(await repo.findById(id)), + id, + ...data, + })), + }; + userRepository = { + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn(), + }; + customerResetService = { + sendResetLinkToUser: jest + .fn() + .mockResolvedValue({ + maskedTarget: "a**@transit.dj", + channel: ResetChannel.Email, + }), + sendResetLinkToUserOnChannels: jest + .fn() + .mockResolvedValue([ + { maskedTarget: "a**@transit.dj", channel: ResetChannel.Email }, + ]), + }; + dataSource = { + transaction: jest.fn(async (cb) => + cb({ getRepository: () => userRepoInTx } as never), + ), + }; + + service = new TransitAgentsService( + repo as never, + userRepository as never, + customerResetService as never, + dataSource as never, + ); + }); + + describe("create", () => { + it("creates a roster-only agent with no account when no email is given", async () => { + const { agent, activationSentTo } = await service.createWithInvite(base); + + expect(dataSource.transaction).not.toHaveBeenCalled(); + expect( + customerResetService.sendResetLinkToUserOnChannels, + ).not.toHaveBeenCalled(); + expect(agent.hasAccount).toBe(false); + expect(activationSentTo).toBeNull(); + }); + + it("creates the IAM account with no password set when an email is given", async () => { + await service.createWithInvite({ + ...base, + email: "A.Bourhan@Transit.DJ", + }); + + expect(userRepoInTx.save).toHaveBeenCalledWith( + expect.objectContaining({ + email: "a.bourhan@transit.dj", + username: "a.bourhan@transit.dj", + userType: EUserType.INDIVIDUAL, + hasSetPassword: false, + status: EUserStatus.ACCEPTED, + }), + ); + }); + + it("sends the activation link only after the transaction commits", async () => { + const order: string[] = []; + dataSource.transaction.mockImplementation( + async (cb: (m: unknown) => unknown) => { + const result = await cb({ getRepository: () => userRepoInTx }); + order.push("commit"); + return result; + }, + ); + customerResetService.sendResetLinkToUserOnChannels.mockImplementation( + async () => { + order.push("send"); + return [ + { maskedTarget: "a**@transit.dj", channel: ResetChannel.Email }, + ]; + }, + ); + + await service.createWithInvite({ ...base, email: "a@transit.dj" }); + + expect(order).toEqual(["commit", "send"]); + }); + }); + + describe("invite", () => { + it("attaches an account to an existing roster-only agent and sends the link", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + const { agent, activationSentTo } = await service.invite("ta-1", { + email: "a@transit.dj", + }); + + expect(repo.linkAccountInTransaction).toHaveBeenCalledWith( + expect.anything(), + "ta-1", + expect.objectContaining({ userId: "user-1", email: "a@transit.dj" }), + ); + expect(agent.hasAccount).toBe(true); + expect(activationSentTo).toBe("a**@transit.dj"); + }); + + it("refuses to mint a second account for an agent that already has one", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: "user-9", + }); + + await expect( + service.invite("ta-1", { email: "a@transit.dj" }), + ).rejects.toThrow(ConflictException); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("refuses credentials that already belong to another account", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + userRepository.findOne.mockResolvedValue({ id: "someone-else" }); + + await expect( + service.invite("ta-1", { email: "a@transit.dj" }), + ).rejects.toThrow(ConflictException); + }); + + it("texts the link as well when the number is domestic", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + await service.invite("ta-1", { + email: "a@transit.dj", + phoneNumber: "+251911223344", + }); + + expect( + customerResetService.sendResetLinkToUserOnChannels, + ).toHaveBeenCalledWith( + "user-1", + [ResetChannel.Email, ResetChannel.Phone], + expect.objectContaining({ allowWithoutCredential: true }), + ); + }); + + it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + await service.invite("ta-1", { + email: "a@transit.dj", + phoneNumber: "+33612345678", + }); + + expect( + customerResetService.sendResetLinkToUserOnChannels, + ).toHaveBeenCalledWith("user-1", [ResetChannel.Email], expect.anything()); + }); + }); + + describe("update", () => { + it("mirrors an edited email onto the linked IAM account", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: "user-1", + }); + + await service.update("ta-1", { email: "New@Transit.DJ" }); + + expect(repo.update).toHaveBeenCalledWith( + "ta-1", + expect.objectContaining({ email: "new@transit.dj" }), + ); + expect(userRepository.update).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ email: "new@transit.dj" }), + ); + }); + + it("never writes username — it names an IAM account, not a column on this table", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + await service.update("ta-1", { username: "nope" } as never); + + expect(repo.update).toHaveBeenCalledWith( + "ta-1", + expect.not.objectContaining({ username: expect.anything() }), + ); + }); + + it("leaves IAM alone for a roster-only agent", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + await service.update("ta-1", { email: "a@transit.dj" }); + + expect(userRepository.update).not.toHaveBeenCalled(); + }); + }); + + describe("resendActivation", () => { + it("refuses for an agent that has no account yet", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + await expect( + service.resendActivation("ta-1", ResetChannel.Email), + ).rejects.toThrow(BadRequestException); + }); + + it("refuses an SMS resend to a foreign number", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: "user-1", + phoneNumber: "+33612345678", + }); + + await expect( + service.resendActivation("ta-1", ResetChannel.Phone), + ).rejects.toThrow(BadRequestException); + }); + + it("reuses the existing account rather than minting a new one", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: "user-1", + email: "a@transit.dj", + }); + + await service.resendActivation("ta-1", ResetChannel.Email); + + expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith( + "user-1", + ResetChannel.Email, + expect.objectContaining({ allowWithoutCredential: true }), + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts index ec9c24e9d..b54f1fddb 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts @@ -1,17 +1,49 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { FindOptionsOrder } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + EUserStatus, + EUserType, +} from "@tria-plc/api-common/utils/enums/user.enum"; +// Subpath import (not the package root) so ts-jest can resolve it when this +// file lands in a spec's compile graph — same reason as backoffice.service.ts. +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { + DataSource, + EntityManager, + FindOptionsOrder, + Repository, +} from "typeorm"; -import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; -import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; -import { TransitAgent } from './entities/transit-agent.entity'; -import { TransitAgentsRepository } from './transit-agents.repository'; +import { CustomerResetService } from "../auth/customer-reset.service"; +import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { isDomesticPhone } from "../otp/otp.service"; +import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto"; +import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto"; +import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto"; +import { TransitAgent } from "./entities/transit-agent.entity"; +import { TransitAgentsRepository } from "./transit-agents.repository"; -export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED'; +export type TransitAgentValidityStatus = "VALID" | "NOT_STARTED" | "EXPIRED"; export type TransitAgentView = TransitAgent & { validityStatus: TransitAgentValidityStatus; + /** True once an IAM account backs this agent — i.e. it can sign in. */ + hasAccount: boolean; }; +export interface InvitedTransitAgent { + agent: TransitAgentView; + /** Masked destination of the activation link, or null if none was sent. */ + activationSentTo: string | null; + activationChannel: ResetChannel | null; +} + type TransitAgentListFilter = { isActive?: boolean; page?: number; @@ -25,20 +57,34 @@ function todayISODate(): string { return new Date().toISOString().slice(0, 10); } -function validityStatus(agent: Pick): TransitAgentValidityStatus { +function validityStatus( + agent: Pick, +): TransitAgentValidityStatus { const today = todayISODate(); - if (today < agent.validFrom) return 'NOT_STARTED'; - if (today > agent.validTo) return 'EXPIRED'; - return 'VALID'; + if (today < agent.validFrom) return "NOT_STARTED"; + if (today > agent.validTo) return "EXPIRED"; + return "VALID"; } function withValidityStatus(agent: TransitAgent): TransitAgentView { - return { ...agent, validityStatus: validityStatus(agent) }; + return { + ...agent, + validityStatus: validityStatus(agent), + hasAccount: Boolean(agent.userId), + }; } @Injectable() export class TransitAgentsService { - constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {} + private readonly logger = new Logger(TransitAgentsService.name); + + constructor( + private readonly transitAgentsRepository: TransitAgentsRepository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly customerResetService: CustomerResetService, + private readonly dataSource: DataSource, + ) {} async findAll(filter: TransitAgentListFilter = {}): Promise<{ data: TransitAgentView[]; @@ -46,10 +92,13 @@ export class TransitAgentsService { }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 500; - const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '') + const sortBy = ["name", "validFrom", "validTo", "isActive"].includes( + filter.sortBy ?? "", + ) ? (filter.sortBy as keyof TransitAgent) - : 'name'; - const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + : "name"; + const sortOrder = + filter.sortOrder?.toUpperCase() === "DESC" ? "DESC" : "ASC"; const [data, total] = await this.transitAgentsRepository.findAndCount({ where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, @@ -86,12 +135,14 @@ export class TransitAgentsService { async getAssignable(id: string): Promise { const agent = await this.transitAgentsRepository.findById(id); if (!agent) { - throw new BadRequestException('Selected transit officer was not found.'); + throw new BadRequestException("Selected transit officer was not found."); } if (!agent.isActive) { - throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`); + throw new BadRequestException( + `${agent.name} is suspended — pick another transit officer.`, + ); } - if (validityStatus(agent) !== 'VALID') { + if (validityStatus(agent) !== "VALID") { throw new BadRequestException( `${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`, ); @@ -99,38 +150,364 @@ export class TransitAgentsService { return agent; } - async create(dto: CreateTransitAgentDto): Promise { - if (dto.validTo < dto.validFrom) { - throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + /** + * Create an IAM account for a transit agent, inside the caller's transaction. + * + * Follows `ShippingLineCompaniesService.register` — same entities, same shape + * — including its one deliberate difference from employee creation: no + * `UserCredential` row is written and `hasSetPassword` stays false, so the + * agent must come through the activation link. Staff never handle a password. + */ + private async createIamAccount( + manager: EntityManager, + args: { + name: string; + email: string; + username: string; + phoneNumber?: string; + }, + ): Promise { + const userRepo = manager.getRepository(User); + const user = await userRepo.save( + userRepo.create({ + email: args.email, + username: args.username, + phoneNumber: args.phoneNumber, + name: { en: args.name }, + userType: EUserType.INDIVIDUAL, + isActive: true, + // No credential row: the account has no password until the activation + // link is used. `hasSetPassword` must stay false or the portal treats + // the account as ready to sign in with a password that does not exist. + hasSetPassword: false, + status: EUserStatus.ACCEPTED, + }), + ); + return user.id as string; + } + + /** + * Normalize and validate the account fields shared by create and invite, and + * refuse credentials that already belong to somebody. + */ + private async prepareAccountFields( + dto: { email: string; phoneNumber?: string; username?: string }, + exceptAgentId?: string, + ) { + const email = dto.email.trim().toLowerCase(); + const username = (dto.username?.trim() || email).toLowerCase(); + const phoneNumber = dto.phoneNumber?.trim() || undefined; + + if ( + await this.transitAgentsRepository.existsByEmail(email, exceptAgentId) + ) { + throw new ConflictException( + `A transit agent with email ${email} already exists`, + ); } - const agent = await this.transitAgentsRepository.create({ + + // An existing IAM account means these credentials already belong to a + // customer, a shipping line or an employee. Reusing it would let one login + // resolve to two different account kinds, so this is refused rather than + // merged. + const existingUser = await this.userRepository.findOne({ + where: [{ email }, { username }], + select: { id: true }, + }); + if (existingUser) { + throw new ConflictException("email_or_username_already_in_use"); + } + + return { email, username, phoneNumber }; + } + + /** + * Create a transit agent. + * + * With no `email` this is the pre-existing behaviour: a GL-assignable roster + * entry with no login, which is what production is full of. With an `email` + * the IAM account and the agent row are created in one transaction and the + * activation link goes out. + */ + async create(dto: CreateTransitAgentDto): Promise { + return (await this.createWithInvite(dto)).agent; + } + + /** {@link create}, also reporting where the activation link went. */ + async createWithInvite( + dto: CreateTransitAgentDto, + ): Promise { + if (dto.validTo < dto.validFrom) { + throw new BadRequestException( + "Valid-to date must be on or after valid-from date.", + ); + } + + const base = { name: dto.name.trim(), validFrom: dto.validFrom, validTo: dto.validTo, isActive: dto.isActive ?? true, + }; + + if (!dto.email) { + // Roster-only agent — no account, nothing to send. + const agent = await this.transitAgentsRepository.create(base); + return { + agent: withValidityStatus(agent), + activationSentTo: null, + activationChannel: null, + }; + } + + const { email, username, phoneNumber } = await this.prepareAccountFields({ + email: dto.email, + phoneNumber: dto.phoneNumber, + username: dto.username, }); - return withValidityStatus(agent); + + const agent = await this.dataSource.transaction(async (manager) => { + const userId = await this.createIamAccount(manager, { + name: base.name, + email, + username, + phoneNumber, + }); + return this.transitAgentsRepository.createInTransaction(manager, { + ...base, + userId, + email, + phoneNumber: phoneNumber ?? null, + }); + }); + + // Outside the transaction on purpose: a delivery failure must not roll back + // a registered agent. The link is resendable, and the account is already + // valid without it. + const activation = await this.sendActivationLink(agent); + return { + agent: withValidityStatus(agent), + activationSentTo: activation?.maskedTarget ?? null, + activationChannel: activation?.channel ?? null, + }; } - async update(id: string, dto: UpdateTransitAgentDto): Promise { + /** + * Give an EXISTING agent a portal login — the path for the roster entries + * already in production. Creates the IAM account, attaches it, and sends the + * activation link. + */ + async invite( + id: string, + dto: InviteTransitAgentDto, + ): Promise { + const current = await this.transitAgentsRepository.findById(id); + if (!current) { + throw new NotFoundException(`Transit agent ${id} not found`); + } + if (current.userId) { + // Already has an account — resending is `resendActivation`, which reuses + // the existing user instead of minting a second one for the same person. + throw new ConflictException( + "This transit agent already has a portal account — resend the activation link instead.", + ); + } + + const { email, username, phoneNumber } = await this.prepareAccountFields( + dto, + id, + ); + + const agent = await this.dataSource.transaction(async (manager) => { + const userId = await this.createIamAccount(manager, { + name: current.name, + email, + username, + phoneNumber, + }); + return this.transitAgentsRepository.linkAccountInTransaction( + manager, + id, + { + userId, + email, + phoneNumber: phoneNumber ?? null, + }, + ); + }); + + const activation = await this.sendActivationLink(agent); + return { + agent: withValidityStatus(agent), + activationSentTo: activation?.maskedTarget ?? null, + activationChannel: activation?.channel ?? null, + }; + } + + /** + * Send the activation link. + * + * Email always goes out — it is the only channel guaranteed to reach a + * foreign-registered officer. SMS is sent in addition when the number is + * domestic, since the gateway silently drops anything else. Both carry the + * SAME single-use ticket: minting retires earlier tickets, so two mints would + * kill the email link the moment the SMS went out. + * + * Reports the email send, as that is the one that is always attempted. + */ + async sendActivationLink(agent: TransitAgent) { + if (!agent.userId) return null; + + const scope = `transit agent ${agent.id}`; + const channels = [ResetChannel.Email]; + if (agent.phoneNumber && isDomesticPhone(agent.phoneNumber)) { + channels.push(ResetChannel.Phone); + } + + const sent = await this.customerResetService.sendResetLinkToUserOnChannels( + agent.userId, + channels, + { scope, allowWithoutCredential: true }, + ); + const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null; + + if (!emailed) { + this.logger.error( + `Activation email not sent for transit agent ${agent.id} — no reachable address`, + ); + } + if ( + channels.includes(ResetChannel.Phone) && + !sent.some((s) => s.channel === ResetChannel.Phone) + ) { + this.logger.warn(`Activation SMS not sent for transit agent ${agent.id}`); + } + + return emailed; + } + + async resendActivation(id: string, channel: ResetChannel) { + const agent = await this.transitAgentsRepository.findById(id); + if (!agent) { + throw new NotFoundException("Transit agent not found"); + } + if (!agent.userId) { + throw new BadRequestException( + "This transit agent has no portal account yet — invite them first.", + ); + } + + if ( + channel === ResetChannel.Phone && + (!agent.phoneNumber || !isDomesticPhone(agent.phoneNumber)) + ) { + throw new BadRequestException( + "This transit agent has no domestic phone number — the SMS gateway cannot reach it", + ); + } + + const sent = await this.customerResetService.sendResetLinkToUser( + agent.userId, + channel, + { + scope: `transit agent ${agent.id}`, + allowWithoutCredential: true, + }, + ); + + if (!sent) { + throw new NotFoundException( + `No active account with ${ + channel === ResetChannel.Email ? "an email address" : "a phone number" + } for this transit agent`, + ); + } + + return sent; + } + + /** The transit agent signed in as `userId`, or null for any other account. */ + findByUserId(userId: string): Promise { + return this.transitAgentsRepository.findByUserId(userId); + } + + async update( + id: string, + dto: UpdateTransitAgentDto, + ): Promise { const current = await this.findById(id); const nextValidFrom = dto.validFrom ?? current.validFrom; const nextValidTo = dto.validTo ?? current.validTo; if (nextValidTo < nextValidFrom) { - throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + throw new BadRequestException( + "Valid-to date must be on or after valid-from date.", + ); + } + + // `username` only ever names an IAM account, and it is chosen once at + // account creation. Accepting it here (PartialType inherits it from the + // create DTO) would write a column that does not exist on this table. + const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto; + + const contact: Partial = {}; + if (email !== undefined) { + const normalized = email.trim().toLowerCase(); + if (await this.transitAgentsRepository.existsByEmail(normalized, id)) { + throw new ConflictException( + `A transit agent with email ${normalized} already exists`, + ); + } + contact.email = normalized; + } + if (phoneNumber !== undefined) { + contact.phoneNumber = phoneNumber.trim() || null; } const updated = await this.transitAgentsRepository.update(id, { - ...dto, + ...rest, + ...contact, ...(dto.name ? { name: dto.name.trim() } : {}), }); if (!updated) { throw new NotFoundException(`Transit agent ${id} not found`); } + + // Keep the IAM account in step. Without this, an agent whose address was + // corrected here would still receive its activation link at the old one — + // the reset service reads the address off `iam.users`, not off this row. + if ( + updated.userId && + (contact.email !== undefined || contact.phoneNumber !== undefined) + ) { + await this.syncIamContact(updated); + } + return withValidityStatus(updated); } + /** + * Mirror an edited email/phone onto the linked IAM account. + * + * Best-effort: a failure here must not fail the agent edit that already + * committed, but it does mean the two are out of step, so it is logged loudly + * rather than swallowed. Re-running the edit retries it. + */ + private async syncIamContact(agent: TransitAgent): Promise { + if (!agent.userId) return; + try { + await this.userRepository.update(agent.userId, { + ...(agent.email ? { email: agent.email } : {}), + phoneNumber: agent.phoneNumber ?? undefined, + }); + } catch (error) { + this.logger.error( + `Transit agent ${agent.id} contact updated but IAM user ${agent.userId} was not — ` + + `activation links will still go to the old address: ${String(error)}`, + ); + } + } + async remove(id: string): Promise { await this.findById(id); await this.transitAgentsRepository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts new file mode 100644 index 000000000..a21f75dd9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from "class-validator"; + +import { TransitAssignmentStatus } from "../entities/transit-assignment.entity"; + +export class CreateTransitAssignmentDto { + @ApiProperty({ format: "uuid" }) + @IsUUID() + bookingId!: string; + + @ApiProperty({ format: "uuid" }) + @IsUUID() + transitAgentId!: string; + + @ApiPropertyOptional({ + enum: TransitAssignmentStatus, + default: TransitAssignmentStatus.NotStarted, + description: + "Assignments normally start NOT_STARTED; pass one only to record work already under way.", + }) + @IsOptional() + @IsEnum(TransitAssignmentStatus) + status?: TransitAssignmentStatus; + + @ApiPropertyOptional({ maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts new file mode 100644 index 000000000..4c7089780 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts @@ -0,0 +1,43 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from "class-validator"; + +import { TransitAssignmentStatus } from "../entities/transit-assignment.entity"; + +/** Filters for the transit agent's own booking list. */ +export class MyAssignmentsQueryDto { + /** Free text over the booking reference and the customer's company name. */ + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: TransitAssignmentStatus }) + @IsOptional() + @IsEnum(TransitAssignmentStatus) + status?: TransitAssignmentStatus; + + @ApiPropertyOptional({ + example: "DISPATCHED", + description: "The booking's scheduling state.", + }) + @IsOptional() + @IsString() + schedulingStatus?: string; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + // Bounded so a hand-edited query string cannot ask for the whole table. + @Max(100) + pageSize?: number; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts new file mode 100644 index 000000000..f6e91cd31 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator"; + +/** The portal's Save / Finish action on the agent's own assignment. */ +export class SubmitTransitAssignmentDto { + @ApiProperty({ + description: + "true finishes the assignment, which also locks its documents. false saves progress and leaves it open.", + }) + // Arrives as a string when posted as multipart alongside files. + @Transform(({ value }) => + value === "true" ? true : value === "false" ? false : value, + ) + @IsBoolean() + finish!: boolean; + + @ApiPropertyOptional({ maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts new file mode 100644 index 000000000..166306f2a --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts @@ -0,0 +1,36 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator"; + +import { TransitAssignmentStatus } from "../entities/transit-assignment.entity"; + +export class TransitAssignmentQueryDto { + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + bookingId?: string; + + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + transitAgentId?: string; + + @ApiPropertyOptional({ enum: TransitAssignmentStatus }) + @IsOptional() + @IsEnum(TransitAssignmentStatus) + status?: TransitAssignmentStatus; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts new file mode 100644 index 000000000..2dda6b34e --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsOptional, IsString, MaxLength } from "class-validator"; + +import { TransitAssignmentStatus } from "../entities/transit-assignment.entity"; + +/** + * `bookingId` and `transitAgentId` are absent on purpose: repointing an + * assignment at a different booking or agent would silently reattribute the + * work and the documents already filed under it. Delete and re-create instead. + */ +export class UpdateTransitAssignmentDto { + @ApiPropertyOptional({ enum: TransitAssignmentStatus }) + @IsOptional() + @IsEnum(TransitAssignmentStatus) + status?: TransitAssignmentStatus; + + @ApiPropertyOptional({ maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts b/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts new file mode 100644 index 000000000..dd7f07ac7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts @@ -0,0 +1,79 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { Booking } from "../../bookings/entities/booking.entity"; +import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity"; + +/** Where the agent's work on this booking currently stands. */ +export enum TransitAssignmentStatus { + NotStarted = "NOT_STARTED", + InProgress = "IN_PROGRESS", + Finished = "FINISHED", +} + +/** + * One transit agent's work on one booking. An agent handles many bookings, so + * this is the join between the two, carrying the work's own state: when it + * started, when it finished, and the documents produced along the way. + * + * Deliberately separate from the transit-assignee handshake on the booking + * (`/bookings/:id/clearance/transit-assignee/...`), which is a pre-declaration + * agreement between GL Ethiopia and GL Djibouti about WHO will handle customs. + * Nothing here reads or writes that flow. + * + * There is no stored duration. "Time after the train arrives" is + * `finishedAt − booking.arrivedAt`; both halves already exist, and storing the + * difference would be a third source of truth that goes stale the moment either + * timestamp is corrected. It is computed on read — see + * `TransitAssignmentsService.toView`. + * + * Documents live in `freight.files` under + * {@link TRANSIT_ASSIGNMENT_FILE_RESOURCE}, which already carries the MinIO + * object, the upload time, the uploader and the supersede history. + */ +@Entity({ schema: "freight", name: "transit_assignments" }) +@Index(["bookingId"]) +@Index(["transitAgentId", "status"]) +export class TransitAssignment extends BaseEntity { + @Column({ name: "booking_id", type: "uuid" }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: "booking_id" }) + booking?: Booking; + + @Column({ name: "transit_agent_id", type: "uuid" }) + transitAgentId!: string; + + @ManyToOne(() => TransitAgent) + @JoinColumn({ name: "transit_agent_id" }) + transitAgent?: TransitAgent; + + @Column({ + name: "status", + type: "varchar", + length: 32, + default: TransitAssignmentStatus.NotStarted, + }) + status!: TransitAssignmentStatus; + + /** Stamped on the first move to IN_PROGRESS; never overwritten afterwards. */ + @Column({ name: "started_at", type: "timestamptz", nullable: true }) + startedAt?: Date | null; + + /** Stamped on the move to FINISHED. Cleared if the work is reopened. */ + @Column({ name: "finished_at", type: "timestamptz", nullable: true }) + finishedAt?: Date | null; + + @Column({ name: "assigned_by_user_id", type: "uuid", nullable: true }) + assignedByUserId?: string | null; + + @Column({ name: "assigned_at", type: "timestamptz", default: () => "now()" }) + assignedAt!: Date; + + @Column({ name: "note", type: "text", nullable: true }) + note?: string | null; +} + +/** `files.resource` value for documents attached to a transit assignment. */ +export const TRANSIT_ASSIGNMENT_FILE_RESOURCE = "transit_assignments"; diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts new file mode 100644 index 000000000..76dd0eb9c --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts @@ -0,0 +1,255 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UploadedFiles, + UseInterceptors, +} from "@nestjs/common"; +import { AnyFilesInterceptor } from "@nestjs/platform-express"; +import { + ApiBearerAuth, + ApiConsumes, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; + +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { BookingStaff, PortalCustomer } from "../../common/booking-guards"; +import { documentUploadMulterOptions } from "../../common/document-upload.options"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto"; +import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto"; +import { SubmitTransitAssignmentDto } from "./dto/submit-transit-assignment.dto"; +import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto"; +import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto"; +import { TransitAssignmentsService } from "./transit-assignments.service"; + +/** + * Transit assignments — one transit agent's work on one booking. + * + * Distinct from the transit-assignee handshake under + * `/bookings/:id/clearance/transit-assignee/...`, which decides WHO will handle + * a shipment's customs. This is the work record that follows: status, timings + * and documents. + */ +@ApiTags("transit-assignments") +@Controller("transit-assignments") +@ApiBearerAuth() +export class TransitAssignmentsController { + constructor( + private readonly transitAssignmentsService: TransitAssignmentsService, + ) {} + + // ── Portal — the signed-in transit agent's own work ─────────────────────── + // Declared first so the literal `my` segment is matched before `:id`. + // Every route resolves the agent from the session; none accepts an agent id. + + @Get("my/stats") + @PortalCustomer() + @ApiOperation({ + summary: "Dashboard figures for the signed-in transit agent's own work", + }) + myStats(@CurrentUser() user: TCurrentUser) { + return this.transitAssignmentsService.myStats(user.id); + } + + @Get("my") + @PortalCustomer() + @ApiOperation({ + summary: + "The signed-in transit agent's assigned bookings (paginated, filterable)", + }) + findMine( + @CurrentUser() user: TCurrentUser, + @Query() query: MyAssignmentsQueryDto, + ) { + return this.transitAssignmentsService.findMine(user.id, query); + } + + @Get("my/:id") + @PortalCustomer() + @ApiOperation({ summary: "One of my assignments, with its documents" }) + findMineById( + @CurrentUser() user: TCurrentUser, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.transitAssignmentsService.findMineById(user.id, id); + } + + @Post("my/:id/files") + @PortalCustomer() + @ApiConsumes("multipart/form-data") + @UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions)) + @ApiOperation({ + summary: + "Upload documents to my assignment. Allowed only while the booking is DISPATCHED and the assignment is not finished.", + }) + uploadMyFiles( + @CurrentUser() user: TCurrentUser, + @Param("id", ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + // One `titles` part per file, in the same order. A single-file upload posts + // one part, which multipart parsing hands back as a bare string rather than + // an array — normalised here so the service always sees a positional list. + @Body("titles") titles?: string | string[], + ) { + return this.transitAssignmentsService.uploadMyFiles( + user.id, + id, + files, + { userId: user.id, name: user.name?.en ?? undefined }, + titles === undefined ? undefined : ([] as string[]).concat(titles), + ); + } + + @Delete("my/:id/files/:fileId") + @PortalCustomer() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Remove a document from my assignment" }) + removeMyFile( + @CurrentUser() user: TCurrentUser, + @Param("id", ParseUUIDPipe) id: string, + @Param("fileId", ParseUUIDPipe) fileId: string, + ) { + return this.transitAssignmentsService.removeMyFile(user.id, id, fileId); + } + + @Post("my/:id/submit") + @PortalCustomer() + @ApiOperation({ + summary: + "Save progress, or finish the assignment (which locks its documents)", + }) + submitMine( + @CurrentUser() user: TCurrentUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: SubmitTransitAssignmentDto, + ) { + return this.transitAssignmentsService.submitMine(user.id, id, dto); + } + + // ── Backoffice ──────────────────────────────────────────────────────────── + + @Get() + @BookingStaff(FREIGHT_PERMS.transitAssignments.view) + @ApiOperation({ + summary: + "List transit assignments (paginated, filterable by booking / agent / status)", + }) + findAll(@Query() query: TransitAssignmentQueryDto) { + return this.transitAssignmentsService.findAll(query); + } + + /** + * Declared before `:id` — Nest matches routes in order, so a literal segment + * registered after a parameter would be swallowed by it. + */ + @Get("by-agent/:transitAgentId") + @BookingStaff(FREIGHT_PERMS.transitAssignments.view) + @ApiOperation({ summary: "Every assignment handed to one transit agent" }) + findByTransitAgent( + @Param("transitAgentId", ParseUUIDPipe) transitAgentId: string, + ) { + return this.transitAssignmentsService.findByTransitAgent(transitAgentId); + } + + @Get("by-booking/:bookingId") + @BookingStaff(FREIGHT_PERMS.transitAssignments.view) + @ApiOperation({ summary: "Every transit agent assigned to one booking" }) + findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) { + return this.transitAssignmentsService.findByBooking(bookingId); + } + + @Get(":id") + @BookingStaff(FREIGHT_PERMS.transitAssignments.view) + @ApiOperation({ + summary: + "One assignment, with its attached documents and computed duration", + }) + findOne(@Param("id", ParseUUIDPipe) id: string) { + return this.transitAssignmentsService.findById(id); + } + + @Post() + @BookingStaff(FREIGHT_PERMS.transitAssignments.create) + @ApiOperation({ summary: "Assign a transit agent to a booking" }) + create( + @Body() dto: CreateTransitAssignmentDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitAssignmentsService.create(dto, user?.id); + } + + @Patch(":id") + @BookingStaff(FREIGHT_PERMS.transitAssignments.update) + @ApiOperation({ + summary: + "Update status or note — status changes stamp the start/finish clocks", + }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateTransitAssignmentDto, + ) { + return this.transitAssignmentsService.update(id, dto); + } + + @Delete(":id") + @BookingStaff(FREIGHT_PERMS.transitAssignments.delete) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Soft-delete an assignment" }) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.transitAssignmentsService.remove(id); + } + + // ── Documents ───────────────────────────────────────────────────────────── + + @Get(":id/files") + @BookingStaff(FREIGHT_PERMS.transitAssignments.view) + @ApiOperation({ summary: "An assignment's uploaded documents" }) + listFiles(@Param("id", ParseUUIDPipe) id: string) { + return this.transitAssignmentsService.listFiles(id); + } + + @Post(":id/files") + @BookingStaff(FREIGHT_PERMS.transitAssignments.update) + @ApiConsumes("multipart/form-data") + @UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions)) + @ApiOperation({ + summary: + "Upload one or more documents; re-uploading adds a version, it does not overwrite", + }) + uploadFiles( + @Param("id", ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + @Body("titles") titles?: string | string[], + ) { + return this.transitAssignmentsService.uploadFiles( + id, + files, + { userId: user?.id, name: user?.name?.en ?? undefined }, + titles === undefined ? undefined : ([] as string[]).concat(titles), + ); + } + + @Delete(":id/files/:fileId") + @BookingStaff(FREIGHT_PERMS.transitAssignments.update) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Remove one document from an assignment" }) + removeFile( + @Param("id", ParseUUIDPipe) id: string, + @Param("fileId", ParseUUIDPipe) fileId: string, + ) { + return this.transitAssignmentsService.removeFile(id, fileId); + } +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts new file mode 100644 index 000000000..65edc5868 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts @@ -0,0 +1,25 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { Booking } from "../bookings/entities/booking.entity"; +import { FilesModule } from "../files/files.module"; +import { TransitAgentsModule } from "../transit-agents/transit-agents.module"; +import { TransitAssignment } from "./entities/transit-assignment.entity"; +import { TransitAssignmentsController } from "./transit-assignments.controller"; +import { TransitAssignmentsRepository } from "./transit-assignments.repository"; +import { TransitAssignmentsService } from "./transit-assignments.service"; + +@Module({ + imports: [ + // `Booking` is registered as an ENTITY rather than importing BookingsModule: + // this module only confirms a booking id exists, and that module would drag + // its whole graph (billing, contracts, scheduling, first/last mile) along. + TypeOrmModule.forFeature([TransitAssignment, Booking]), + FilesModule, + TransitAgentsModule, + ], + controllers: [TransitAssignmentsController], + providers: [TransitAssignmentsService, TransitAssignmentsRepository], + exports: [TransitAssignmentsService, TransitAssignmentsRepository], +}) +export class TransitAssignmentsModule {} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts new file mode 100644 index 000000000..9030d58f7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts @@ -0,0 +1,139 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { + TransitAssignment, + TransitAssignmentStatus, +} from "./entities/transit-assignment.entity"; + +export interface TransitAssignmentFilter { + bookingId?: string; + transitAgentId?: string; + status?: TransitAssignmentStatus; + /** The booking's scheduling state (DISPATCHED / SCHEDULED / …). */ + schedulingStatus?: string; + /** Free text over the booking reference and the customer's company name. */ + search?: string; +} + +@Injectable() +export class TransitAssignmentsRepository extends BaseRepository { + constructor( + @InjectRepository(TransitAssignment) + private readonly assignmentsRepo: Repository, + ) { + super(assignmentsRepo); + } + + /** + * The booking is joined rather than lazily loaded because every read needs + * its `arrivedAt` — that is the other half of the computed + * "time after the train arrives", so a list without it would be N+1 queries + * or a column of nulls. The customer's company rides along for the same + * reason: the agent's list is read by reference AND by whose cargo it is. + */ + private baseQuery() { + return this.assignmentsRepo + .createQueryBuilder("ta") + .leftJoinAndSelect("ta.booking", "booking") + .leftJoinAndSelect("booking.company", "company") + .leftJoinAndSelect("ta.transitAgent", "agent") + .where("ta.deletedAt IS NULL"); + } + + /** Shared filter application, so a list and its count can never diverge. */ + private applyFilters( + qb: ReturnType, + filter: TransitAssignmentFilter, + ) { + if (filter.bookingId) { + qb.andWhere("ta.bookingId = :bookingId", { bookingId: filter.bookingId }); + } + if (filter.transitAgentId) { + qb.andWhere("ta.transitAgentId = :transitAgentId", { + transitAgentId: filter.transitAgentId, + }); + } + if (filter.status) { + qb.andWhere("ta.status = :status", { status: filter.status }); + } + if (filter.schedulingStatus) { + qb.andWhere("booking.schedulingStatus = :schedulingStatus", { + schedulingStatus: filter.schedulingStatus, + }); + } + if (filter.search?.trim()) { + qb.andWhere( + "(booking.reference ILIKE :search OR company.name ILIKE :search)", + { search: `%${filter.search.trim()}%` }, + ); + } + return qb; + } + + async findPaginated( + filter: TransitAssignmentFilter, + skip: number, + take: number, + ): Promise<[TransitAssignment[], number]> { + return this.applyFilters(this.baseQuery(), filter) + .orderBy("ta.assignedAt", "DESC") + .skip(skip) + .take(take) + .getManyAndCount(); + } + + /** + * One agent's own list, filtered and paginated. Differs from + * {@link findPaginated} only in that the agent is pinned by the caller from + * the session, so it can never be widened by a query parameter. + */ + async findByTransitAgentPaginated( + transitAgentId: string, + filter: Omit, + skip: number, + take: number, + ): Promise<[TransitAssignment[], number]> { + return this.applyFilters(this.baseQuery(), { ...filter, transitAgentId }) + .orderBy("ta.assignedAt", "DESC") + .skip(skip) + .take(take) + .getManyAndCount(); + } + + findOneWithRelations(id: string): Promise { + return this.baseQuery().andWhere("ta.id = :id", { id }).getOne(); + } + + /** Every live assignment for one agent — the agent's own workload list. */ + findByTransitAgent(transitAgentId: string): Promise { + return this.baseQuery() + .andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId }) + .orderBy("ta.assignedAt", "DESC") + .getMany(); + } + + /** Every live assignment on one booking. */ + findByBooking(bookingId: string): Promise { + return this.baseQuery() + .andWhere("ta.bookingId = :bookingId", { bookingId }) + .orderBy("ta.assignedAt", "DESC") + .getMany(); + } + + /** Guards the unique (booking, agent) pair before an insert 23505s. */ + async existsForPair( + bookingId: string, + transitAgentId: string, + ): Promise { + const count = await this.assignmentsRepo + .createQueryBuilder("ta") + .where("ta.bookingId = :bookingId", { bookingId }) + .andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId }) + .andWhere("ta.deletedAt IS NULL") + .getCount(); + return count > 0; + } +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts new file mode 100644 index 000000000..f23a8426a --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts @@ -0,0 +1,515 @@ +import { + ConflictException, + ForbiddenException, + NotFoundException, +} from "@nestjs/common"; + +import { + TransitAssignment, + TransitAssignmentStatus, +} from "./entities/transit-assignment.entity"; +import { TransitAssignmentsService } from "./transit-assignments.service"; + +/** + * The two things this module gets wrong quietly: the status transitions that + * stamp the clocks, and the duration computed from them. Both are invisible + * until a report reads a null or a negative number months later. + */ +describe("TransitAssignmentsService", () => { + const ARRIVED = new Date("2026-08-28T09:00:00Z"); + + let assignments: { + findPaginated: jest.Mock; + findOneWithRelations: jest.Mock; + findByTransitAgent: jest.Mock; + findByTransitAgentPaginated: jest.Mock; + findByBooking: jest.Mock; + existsForPair: jest.Mock; + create: jest.Mock; + update: jest.Mock; + softDelete: jest.Mock; + }; + let agents: { findById: jest.Mock; findByUserId: jest.Mock }; + let bookings: { findOne: jest.Mock }; + let files: { + findByResource: jest.Mock; + findByResourceIdsGrouped: jest.Mock; + upload: jest.Mock; + remove: jest.Mock; + }; + let service: TransitAssignmentsService; + + const row = (over: Partial = {}) => + ({ + id: "ta-1", + bookingId: "bk-1", + transitAgentId: "ag-1", + status: TransitAssignmentStatus.NotStarted, + startedAt: null, + finishedAt: null, + // DISPATCHED by default: uploads are gated on it, so a fixture without it + // would fail every document test for the wrong reason. + booking: { + id: "bk-1", + arrivedAt: ARRIVED, + schedulingStatus: "DISPATCHED", + }, + ...over, + }) as TransitAssignment; + + beforeEach(() => { + assignments = { + findPaginated: jest.fn(), + findOneWithRelations: jest.fn().mockResolvedValue(row()), + findByTransitAgent: jest.fn().mockResolvedValue([]), + findByTransitAgentPaginated: jest.fn().mockResolvedValue([[], 0]), + findByBooking: jest.fn().mockResolvedValue([]), + existsForPair: jest.fn().mockResolvedValue(false), + create: jest.fn(async (data) => ({ id: "ta-1", ...data })), + update: jest.fn(async (id, data) => ({ id, ...data })), + softDelete: jest.fn(), + }; + agents = { + findById: jest.fn().mockResolvedValue({ id: "ag-1", name: "Ahmed" }), + findByUserId: jest.fn().mockResolvedValue({ id: "ag-1", name: "Ahmed" }), + }; + bookings = { findOne: jest.fn().mockResolvedValue({ id: "bk-1" }) }; + files = { + findByResource: jest.fn().mockResolvedValue([]), + findByResourceIdsGrouped: jest.fn().mockResolvedValue(new Map()), + upload: jest.fn(), + remove: jest.fn(), + }; + + service = new TransitAssignmentsService( + assignments as never, + agents as never, + bookings as never, + files as never, + ); + }); + + describe("timeAfterTrainArrives", () => { + it("reports whole minutes between arrival and finish", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + finishedAt: new Date("2026-08-28T14:30:00Z"), + }), + ); + + const view = await service.findById("ta-1"); + + expect(view.timeAfterTrainArrives).toBe(330); + }); + + it("is null while the work is unfinished", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ status: TransitAssignmentStatus.InProgress, startedAt: ARRIVED }), + ); + + expect((await service.findById("ta-1")).timeAfterTrainArrives).toBeNull(); + }); + + it("is null when the booking never recorded an arrival", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + finishedAt: new Date("2026-08-28T14:30:00Z"), + booking: { + id: "bk-1", + arrivedAt: null, + schedulingStatus: "DISPATCHED", + } as never, + }), + ); + + expect((await service.findById("ta-1")).timeAfterTrainArrives).toBeNull(); + }); + }); + + describe("status transitions", () => { + it("stamps startedAt on the move to IN_PROGRESS", async () => { + await service.update("ta-1", { + status: TransitAssignmentStatus.InProgress, + }); + + const patch = assignments.update.mock.calls[0][1]; + expect(patch.startedAt).toBeInstanceOf(Date); + expect(patch.finishedAt).toBeNull(); + }); + + it("keeps the ORIGINAL startedAt when finished work is reopened", async () => { + const original = new Date("2026-08-28T10:00:00Z"); + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + startedAt: original, + finishedAt: new Date("2026-08-28T12:00:00Z"), + }), + ); + + await service.update("ta-1", { + status: TransitAssignmentStatus.InProgress, + }); + + const patch = assignments.update.mock.calls[0][1]; + // Reopening must not restart the clock, or the elapsed time would only + // cover the second attempt rather than the whole job. + expect(patch.startedAt).toBe(original); + expect(patch.finishedAt).toBeNull(); + }); + + it("stamps both clocks when finishing work that was never started", async () => { + await service.update("ta-1", { + status: TransitAssignmentStatus.Finished, + }); + + const patch = assignments.update.mock.calls[0][1]; + expect(patch.startedAt).toBeInstanceOf(Date); + expect(patch.finishedAt).toBeInstanceOf(Date); + }); + + it("clears both clocks on a reset to NOT_STARTED", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + startedAt: ARRIVED, + finishedAt: new Date(), + }), + ); + + await service.update("ta-1", { + status: TransitAssignmentStatus.NotStarted, + }); + + const patch = assignments.update.mock.calls[0][1]; + expect(patch.startedAt).toBeNull(); + expect(patch.finishedAt).toBeNull(); + }); + }); + + describe("create", () => { + it("refuses to assign the same agent to one booking twice", async () => { + assignments.existsForPair.mockResolvedValue(true); + + await expect( + service.create({ bookingId: "bk-1", transitAgentId: "ag-1" }), + ).rejects.toThrow(ConflictException); + expect(assignments.create).not.toHaveBeenCalled(); + }); + + it("rejects an unknown booking", async () => { + bookings.findOne.mockResolvedValue(null); + + await expect( + service.create({ bookingId: "nope", transitAgentId: "ag-1" }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("files", () => { + it("refuses to delete a file belonging to another assignment", async () => { + files.findByResource.mockResolvedValue([{ id: "file-1" }]); + + await expect(service.removeFile("ta-1", "file-2")).rejects.toThrow( + NotFoundException, + ); + expect(files.remove).not.toHaveBeenCalled(); + }); + + it("names each uploaded file from its positional title", async () => { + await service.uploadFiles( + "ta-1", + [ + { originalname: "a.pdf" } as Express.Multer.File, + { originalname: "b.pdf" } as Express.Multer.File, + { originalname: "c.pdf" } as Express.Multer.File, + ], + {}, + ["Bill of lading", " ", "Packing list"], + ); + + const titles = files.upload.mock.calls.map((call) => call[0].title); + // Index N names file N; a blank entry falls back to null so the record + // shows its original filename rather than an empty label. + expect(titles).toEqual(["Bill of lading", null, "Packing list"]); + }); + + it("stores no title when none were sent", async () => { + await service.uploadFiles( + "ta-1", + [{ originalname: "a.pdf" } as Express.Multer.File], + {}, + ); + + expect(files.upload.mock.calls[0][0].title).toBeNull(); + }); + + it("refuses an upload before the booking is dispatched", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + booking: { + id: "bk-1", + arrivedAt: null, + schedulingStatus: "SCHEDULED", + } as never, + }), + ); + + await expect( + service.uploadFiles("ta-1", [{} as Express.Multer.File], {}), + ).rejects.toThrow(ForbiddenException); + expect(files.upload).not.toHaveBeenCalled(); + }); + + it("refuses an upload once the assignment is finished", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + finishedAt: new Date(), + }), + ); + + await expect( + service.uploadFiles("ta-1", [{} as Express.Multer.File], {}), + ).rejects.toThrow(ForbiddenException); + }); + + it("refuses to remove a document once the assignment is finished", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + finishedAt: new Date(), + }), + ); + files.findByResource.mockResolvedValue([{ id: "file-1" }]); + + await expect(service.removeFile("ta-1", "file-1")).rejects.toThrow( + ForbiddenException, + ); + expect(files.remove).not.toHaveBeenCalled(); + }); + }); + + describe("myStats", () => { + const at = (iso: string) => new Date(iso); + + const withRows = (rows: Record[]) => { + assignments.findByTransitAgent.mockResolvedValue( + rows.map((r, i) => row({ id: `ta-${i}`, ...r } as never)), + ); + files.findByResourceIdsGrouped.mockResolvedValue(new Map()); + }; + + it("uses the median, so one reopened assignment cannot skew the headline", async () => { + withRows([ + { + status: TransitAssignmentStatus.Finished, + finishedAt: at("2026-08-28T10:35:00Z"), + }, + { + status: TransitAssignmentStatus.Finished, + finishedAt: at("2026-08-28T12:10:00Z"), + }, + { + status: TransitAssignmentStatus.Finished, + finishedAt: at("2026-08-28T13:45:00Z"), + }, + // 47h outlier: a mean would report ~12h, which describes nobody. + { + status: TransitAssignmentStatus.Finished, + finishedAt: at("2026-08-30T08:00:00Z"), + }, + ]); + + const stats = await service.myStats("user-1"); + + // 95/190/285/2820 -> even count, so the median averages the middle two. + // A mean would be 848 minutes, describing none of the four. + expect(stats.performance.medianClearanceMinutes).toBe(238); + expect(stats.performance.slowestClearanceMinutes).toBe(2820); + }); + + it("bands clearance times into the SLA buckets", async () => { + withRows([ + { + status: TransitAssignmentStatus.Finished, + finishedAt: at("2026-08-28T10:30:00Z"), + }, + { + status: TransitAssignmentStatus.Finished, + finishedAt: at("2026-08-28T13:00:00Z"), + }, + { + status: TransitAssignmentStatus.Finished, + finishedAt: at("2026-08-29T09:00:00Z"), + }, + ]); + + const stats = await service.myStats("user-1"); + + expect(stats.sla).toEqual({ under2h: 1, under6h: 1, over6h: 1 }); + expect(stats.performance.onTimeRate).toBe(67); + }); + + it("counts coverage only over dispatched bookings", async () => { + withRows([ + { booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } }, + { booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } }, + // Scheduled bookings cannot receive documents yet, so counting them + // would report a failure the agent could not have avoided. + { booking: { arrivedAt: null, schedulingStatus: "SCHEDULED" } }, + ]); + + const stats = await service.myStats("user-1"); + + expect(stats.coverage.dispatched).toBe(2); + expect(stats.coverage.withDocuments).toBe(0); + }); + + it("reports nulls rather than zero when nothing has been measured", async () => { + withRows([{ status: TransitAssignmentStatus.NotStarted }]); + + const stats = await service.myStats("user-1"); + + expect(stats.performance.medianClearanceMinutes).toBeNull(); + expect(stats.performance.onTimeRate).toBeNull(); + expect(stats.totals.open).toBe(1); + }); + }); + + describe("customerName", () => { + it("flattens the booking's company name", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + booking: { + id: "bk-1", + arrivedAt: ARRIVED, + schedulingStatus: "DISPATCHED", + company: { name: "SHAFICI PHARMACEUTICAL" }, + } as never, + }), + ); + + expect((await service.findById("ta-1")).customerName).toBe( + "SHAFICI PHARMACEUTICAL", + ); + }); + + it("is null when the booking has no company", async () => { + expect((await service.findById("ta-1")).customerName).toBeNull(); + }); + }); + + describe("canUploadDocuments", () => { + it("is true for an open assignment on a dispatched booking", async () => { + expect((await service.findById("ta-1")).canUploadDocuments).toBe(true); + }); + + it("is false before dispatch", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + booking: { + id: "bk-1", + arrivedAt: null, + schedulingStatus: "SCHEDULED", + } as never, + }), + ); + expect((await service.findById("ta-1")).canUploadDocuments).toBe(false); + }); + + it("is false once finished", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + finishedAt: new Date(), + }), + ); + expect((await service.findById("ta-1")).canUploadDocuments).toBe(false); + }); + }); + + describe("portal scoping", () => { + it("hides another agent's assignment behind a NotFound", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ transitAgentId: "someone-else" }), + ); + + await expect(service.findMineById("user-1", "ta-1")).rejects.toThrow( + NotFoundException, + ); + }); + + it("rejects an account that is not a transit agent", async () => { + agents.findByUserId.mockResolvedValue(null); + + await expect(service.findMine("user-1")).rejects.toThrow( + ForbiddenException, + ); + }); + + it("pins the query to the session's agent and passes the filters through", async () => { + await service.findMine("user-1", { + search: "BK-2026", + status: TransitAssignmentStatus.InProgress, + schedulingStatus: "DISPATCHED", + page: 2, + pageSize: 10, + }); + + const [agentId, filter, skip, take] = + assignments.findByTransitAgentPaginated.mock.calls[0]; + // The agent id comes from the session, never from the query — otherwise + // one agent could page through another agent's work. + expect(agentId).toBe("ag-1"); + expect(filter).toMatchObject({ + search: "BK-2026", + status: TransitAssignmentStatus.InProgress, + schedulingStatus: "DISPATCHED", + }); + expect(skip).toBe(10); + expect(take).toBe(10); + }); + + it("reports pagination meta", async () => { + assignments.findByTransitAgentPaginated.mockResolvedValue([[], 45]); + + const result = await service.findMine("user-1", { pageSize: 20 }); + + expect(result.meta).toEqual({ + total: 45, + page: 1, + pageSize: 20, + totalPages: 3, + }); + }); + + it("save moves the assignment to IN_PROGRESS, finish closes it", async () => { + await service.submitMine("user-1", "ta-1", { finish: false }); + expect(assignments.update.mock.calls[0][1].status).toBe( + TransitAssignmentStatus.InProgress, + ); + + assignments.update.mockClear(); + await service.submitMine("user-1", "ta-1", { finish: true }); + expect(assignments.update.mock.calls[0][1].status).toBe( + TransitAssignmentStatus.Finished, + ); + }); + + it("refuses to re-submit an already finished assignment", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + finishedAt: new Date(), + }), + ); + + await expect( + service.submitMine("user-1", "ta-1", { finish: true }), + ).rejects.toThrow(ForbiddenException); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts new file mode 100644 index 000000000..389e08fac --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts @@ -0,0 +1,596 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Booking } from "../bookings/entities/booking.entity"; +import { FilesService } from "../files/files.service"; +import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository"; +import { FileRecord } from "../files/entities/file.entity"; +import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto"; +import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto"; +import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto"; +import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto"; +import { + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + TransitAssignment, + TransitAssignmentStatus, +} from "./entities/transit-assignment.entity"; +import { TransitAssignmentsRepository } from "./transit-assignments.repository"; + +/** One attached document, flattened for the API. */ +export interface TransitAssignmentFileView { + id: string; + name: string; + title: string | null; + url: string; + size: number; + mimeType: string; + /** When the file was first uploaded. */ + uploadedAt: string; + /** When its metadata was last edited — equal to `uploadedAt` if never. */ + updatedAt: string; + uploadedByUserId: string | null; + uploadedByName: string | null; +} + +export type TransitAssignmentView = TransitAssignment & { + /** + * Minutes between the train arriving and the transit work finishing — + * `finishedAt − booking.arrivedAt`, floored to whole minutes. + * + * Null until BOTH exist: an unfinished assignment has no end, and a booking + * whose arrival was never stamped has no start. Computed rather than stored + * so a corrected timestamp cannot leave a stale number behind. + */ + timeAfterTrainArrives: number | null; + /** + * Whether documents may still be added or removed right now. Mirrors + * `assertUploadAllowed` so the portal can disable its controls instead of + * letting the agent discover the rule through a 403. + */ + canUploadDocuments: boolean; + /** + * Whose cargo this is. Flattened off the joined company so the portal grid + * does not have to reach through `booking.company` — and so a booking with no + * company (shipping-line bookings carry none) renders as a blank rather than + * throwing. + */ + customerName: string | null; + files?: TransitAssignmentFileView[]; +}; + +@Injectable() +export class TransitAssignmentsService { + constructor( + private readonly assignmentsRepository: TransitAssignmentsRepository, + private readonly transitAgentsRepository: TransitAgentsRepository, + // The Booking ENTITY, not BookingsModule: this only needs to confirm a + // booking id exists, and importing that module would pull its whole graph + // (billing, contracts, scheduling, first/last mile) in behind it. + @InjectRepository(Booking) + private readonly bookingsRepository: Repository, + private readonly filesService: FilesService, + ) {} + + private static minutesBetween( + from?: Date | null, + to?: Date | null, + ): number | null { + if (!from || !to) return null; + return Math.floor((to.getTime() - from.getTime()) / 60_000); + } + + private toView(assignment: TransitAssignment): TransitAssignmentView { + return { + ...assignment, + timeAfterTrainArrives: TransitAssignmentsService.minutesBetween( + assignment.booking?.arrivedAt, + assignment.finishedAt, + ), + canUploadDocuments: + assignment.status !== TransitAssignmentStatus.Finished && + assignment.booking?.schedulingStatus === "DISPATCHED", + customerName: assignment.booking?.company?.name ?? null, + }; + } + + async findAll(query: TransitAssignmentQueryDto) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const [items, total] = await this.assignmentsRepository.findPaginated( + { + bookingId: query.bookingId, + transitAgentId: query.transitAgentId, + status: query.status, + }, + (page - 1) * pageSize, + pageSize, + ); + + return { + items: items.map((item) => this.toView(item)), + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + /** Detail read — the only one that carries the attached documents. */ + async findById(id: string): Promise { + const assignment = + await this.assignmentsRepository.findOneWithRelations(id); + if (!assignment) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + return { ...this.toView(assignment), files: await this.listFiles(id) }; + } + + /** Every assignment handed to one transit agent — their workload list. */ + async findByTransitAgent( + transitAgentId: string, + ): Promise { + const agent = await this.transitAgentsRepository.findById(transitAgentId); + if (!agent) { + throw new NotFoundException(`Transit agent ${transitAgentId} not found`); + } + const rows = + await this.assignmentsRepository.findByTransitAgent(transitAgentId); + return rows.map((row) => this.toView(row)); + } + + /** Every agent assigned to one booking. */ + async findByBooking(bookingId: string): Promise { + const rows = await this.assignmentsRepository.findByBooking(bookingId); + return rows.map((row) => this.toView(row)); + } + + // ── Portal (the signed-in transit agent's own work) ─────────────────────── + // Every one of these resolves the agent from the SESSION and never from a + // client-supplied id: an agent must not be able to read or edit another + // agent's assignments by guessing one. + + /** The transit agent this portal user signs in as. */ + private async requireAgentForUser(userId: string) { + const agent = await this.transitAgentsRepository.findByUserId(userId); + if (!agent) { + throw new ForbiddenException("This account is not a transit agent"); + } + return agent; + } + + /** + * Dashboard figures for the signed-in agent's own work. + * + * Every interval is derived from timestamps that already exist — nothing is + * stored, so a corrected arrival or finish time changes these on the next + * read rather than leaving a stale metric behind. + * + * The median is used rather than the mean on purpose: one assignment + * reopened days later drags an average far enough to make the whole panel + * lie about typical performance. + */ + async myStats(userId: string) { + const agent = await this.requireAgentForUser(userId); + const rows = await this.assignmentsRepository.findByTransitAgent(agent.id); + + const docCounts = rows.length + ? await this.filesService.findByResourceIdsGrouped( + rows.map((r) => r.id), + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + ) + : new Map(); + + const minutes = (from?: Date | null, to?: Date | null) => + from && to ? Math.floor((to.getTime() - from.getTime()) / 60_000) : null; + + const items = rows.map((row) => { + const arrivedAt = row.booking?.arrivedAt ?? null; + return { + id: row.id, + reference: row.booking?.reference ?? null, + customerName: row.booking?.company?.name ?? null, + status: row.status, + schedulingStatus: row.booking?.schedulingStatus ?? null, + /** Dispatch (cargo loaded) to the train arriving. */ + transitMinutes: minutes(row.booking?.loadedAt, arrivedAt), + /** Arrival to the agent picking the work up. */ + pickupMinutes: minutes(arrivedAt, row.startedAt), + /** Arrival to the work being finished — the headline metric. */ + clearanceMinutes: minutes(arrivedAt, row.finishedAt), + documentCount: (docCounts.get(row.id) ?? []).length, + }; + }); + + const median = (values: number[]): number | null => { + if (!values.length) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 + ? sorted[mid] + : Math.round((sorted[mid - 1] + sorted[mid]) / 2); + }; + + const cleared = items + .map((i) => i.clearanceMinutes) + .filter((v): v is number => v !== null); + const pickups = items + .map((i) => i.pickupMinutes) + .filter((v): v is number => v !== null); + + // SLA bands, in minutes: inside 2h, inside 6h, beyond. + const sla = { + under2h: cleared.filter((v) => v <= 120).length, + under6h: cleared.filter((v) => v > 120 && v <= 360).length, + over6h: cleared.filter((v) => v > 360).length, + }; + + // Coverage counts only bookings that COULD have documents — uploads are + // gated on dispatch, so counting scheduled ones would invent a failure. + const dispatched = items.filter((i) => i.schedulingStatus === "DISPATCHED"); + const withDocs = dispatched.filter((i) => i.documentCount > 0).length; + + return { + totals: { + assignments: items.length, + open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished) + .length, + finished: items.filter( + (i) => i.status === TransitAssignmentStatus.Finished, + ).length, + readyForDocuments: items.filter( + (i) => + i.schedulingStatus === "DISPATCHED" && + i.status !== TransitAssignmentStatus.Finished, + ).length, + documents: items.reduce((sum, i) => sum + i.documentCount, 0), + }, + performance: { + medianClearanceMinutes: median(cleared), + medianPickupMinutes: median(pickups), + fastestClearanceMinutes: cleared.length ? Math.min(...cleared) : null, + slowestClearanceMinutes: cleared.length ? Math.max(...cleared) : null, + onTimeRate: cleared.length + ? Math.round(((sla.under2h + sla.under6h) / cleared.length) * 100) + : null, + measured: cleared.length, + }, + sla, + coverage: { + dispatched: dispatched.length, + withDocuments: withDocs, + }, + /** Newest first, for the timeline and the recent-activity list. */ + items: items.slice(0, 12), + }; + } + + async findMine(userId: string, query: MyAssignmentsQueryDto = {}) { + const agent = await this.requireAgentForUser(userId); + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + + const [rows, total] = + await this.assignmentsRepository.findByTransitAgentPaginated( + agent.id, + { + status: query.status, + schedulingStatus: query.schedulingStatus, + search: query.search, + }, + (page - 1) * pageSize, + pageSize, + ); + + // Documents come back with the list so the grid can show a per-row count. + // Batched deliberately: one lookup for the page, not one per assignment. + const grouped = rows.length + ? await this.filesService.findByResourceIdsGrouped( + rows.map((row) => row.id), + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + ) + : new Map(); + + return { + items: rows.map((row) => ({ + ...this.toView(row), + files: (grouped.get(row.id) ?? []).map((record: FileRecord) => ({ + id: record.id, + name: record.name, + title: record.title, + url: record.url, + size: record.size, + mimeType: record.mimeType, + uploadedAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + uploadedByUserId: record.uploadedByUserId, + uploadedByName: record.uploadedByName, + })), + })), + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + /** + * One of the signed-in agent's own assignments, with its documents. + * Ownership is asserted rather than filtered: a mismatch is hidden behind a + * NotFound so assignment ids cannot be probed. + */ + async findMineById( + userId: string, + id: string, + ): Promise { + const agent = await this.requireAgentForUser(userId); + const assignment = + await this.assignmentsRepository.findOneWithRelations(id); + if (!assignment || assignment.transitAgentId !== agent.id) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + return { ...this.toView(assignment), files: await this.listFiles(id) }; + } + + /** Assert the assignment is this user's before any write reaches it. */ + private async assertMine(userId: string, id: string): Promise { + await this.findMineById(userId, id); + } + + async uploadMyFiles( + userId: string, + id: string, + files: Express.Multer.File[], + uploader: { userId?: string; name?: string }, + titles?: string[], + ): Promise { + await this.assertMine(userId, id); + return this.uploadFiles(id, files, uploader, titles); + } + + async removeMyFile( + userId: string, + id: string, + fileId: string, + ): Promise { + await this.assertMine(userId, id); + return this.removeFile(id, fileId); + } + + /** + * The portal's Save / Finish action. + * + * Save keeps the assignment open (moving it to IN_PROGRESS so the work reads + * as under way); Finish closes it, which also locks its documents — see + * `assertUploadAllowed`. + */ + async submitMine( + userId: string, + id: string, + input: { finish: boolean; note?: string }, + ): Promise { + const current = await this.findMineById(userId, id); + if (current.status === TransitAssignmentStatus.Finished) { + throw new ForbiddenException("This assignment is already finished."); + } + await this.update(id, { + status: input.finish + ? TransitAssignmentStatus.Finished + : TransitAssignmentStatus.InProgress, + note: input.note, + }); + return this.findMineById(userId, id); + } + + async create( + dto: CreateTransitAssignmentDto, + assignedByUserId?: string, + ): Promise { + const booking = await this.bookingsRepository.findOne({ + where: { id: dto.bookingId }, + select: { id: true }, + }); + if (!booking) { + throw new NotFoundException(`Booking ${dto.bookingId} not found`); + } + const agent = await this.transitAgentsRepository.findById( + dto.transitAgentId, + ); + if (!agent) { + throw new NotFoundException( + `Transit agent ${dto.transitAgentId} not found`, + ); + } + if ( + await this.assignmentsRepository.existsForPair( + dto.bookingId, + dto.transitAgentId, + ) + ) { + throw new ConflictException( + `${agent.name} is already assigned to this booking`, + ); + } + + const status = dto.status ?? TransitAssignmentStatus.NotStarted; + const created = await this.assignmentsRepository.create({ + bookingId: dto.bookingId, + transitAgentId: dto.transitAgentId, + status, + // Creating straight into a working state still has to stamp its clock, or + // the assignment would report no start. + startedAt: + status === TransitAssignmentStatus.NotStarted ? null : new Date(), + finishedAt: + status === TransitAssignmentStatus.Finished ? new Date() : null, + assignedByUserId: assignedByUserId ?? null, + note: dto.note?.trim() || null, + }); + + return this.findById(created.id); + } + + async update( + id: string, + dto: UpdateTransitAssignmentDto, + ): Promise { + const current = await this.assignmentsRepository.findOneWithRelations(id); + if (!current) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + + const patch: Partial = {}; + if (dto.note !== undefined) patch.note = dto.note.trim() || null; + + if (dto.status && dto.status !== current.status) { + patch.status = dto.status; + if (dto.status === TransitAssignmentStatus.InProgress) { + // Only the FIRST start is recorded — reopening finished work keeps the + // original start, so the elapsed time still spans the whole job. + patch.startedAt = current.startedAt ?? new Date(); + patch.finishedAt = null; + } else if (dto.status === TransitAssignmentStatus.Finished) { + patch.startedAt = current.startedAt ?? new Date(); + patch.finishedAt = new Date(); + } else { + // Back to NOT_STARTED — the work is being reset, so both clocks clear + // rather than leaving a duration for work that no longer happened. + patch.startedAt = null; + patch.finishedAt = null; + } + } + + const updated = await this.assignmentsRepository.update(id, patch); + if (!updated) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + return this.findById(id); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.assignmentsRepository.softDelete(id); + } + + // ── Documents ───────────────────────────────────────────────────────────── + // Stored in `freight.files` under TRANSIT_ASSIGNMENT_FILE_RESOURCE rather + // than a table of their own: that one already carries the MinIO object, the + // upload time, the uploader and the supersede history. + + /** + * Whether an assignment may still receive documents. + * + * Two gates, both business rules rather than UI conveniences: + * - the booking must actually be on its way (`DISPATCHED`), since there is + * nothing to clear before the train leaves; + * - the assignment must not be FINISHED — filing closes with the work, so a + * finished record cannot grow new paperwork afterwards. + */ + private assertUploadAllowed(assignment: TransitAssignment): void { + if (assignment.status === TransitAssignmentStatus.Finished) { + throw new ForbiddenException( + "This assignment is finished — its documents can no longer be changed.", + ); + } + if (assignment.booking?.schedulingStatus !== "DISPATCHED") { + throw new ForbiddenException( + "Documents can only be uploaded once the booking has been dispatched.", + ); + } + } + + async listFiles(id: string): Promise { + const records = await this.filesService.findByResource( + id, + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + ); + return records.map((record) => ({ + id: record.id, + name: record.name, + title: record.title, + url: record.url, + size: record.size, + mimeType: record.mimeType, + uploadedAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + uploadedByUserId: record.uploadedByUserId, + uploadedByName: record.uploadedByName, + })); + } + + async uploadFiles( + id: string, + files: Express.Multer.File[], + uploader: { userId?: string; name?: string }, + /** + * A display name per file, positionally matched to `files`. Multer preserves + * the multipart part order, and the client appends one `titles` entry per + * file in the same order, so index N names file N. A missing or blank entry + * falls back to the original filename. + */ + titles?: string[], + ): Promise { + if (!files?.length) { + throw new BadRequestException("No files were uploaded"); + } + // Asserts the assignment exists before anything reaches MinIO — an upload + // keyed to a missing row would be unreachable storage nobody ever lists. + const assignment = + await this.assignmentsRepository.findOneWithRelations(id); + if (!assignment) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + this.assertUploadAllowed(assignment); + + await Promise.all( + files.map((file, index) => + this.filesService.upload({ + resourceId: id, + resource: TRANSIT_ASSIGNMENT_FILE_RESOURCE, + code: file.fieldname || "document", + file, + title: titles?.[index]?.trim() || null, + uploadedByUserId: uploader.userId ?? null, + uploadedByName: uploader.name ?? null, + }), + ), + ); + + return this.listFiles(id); + } + + async removeFile(id: string, fileId: string): Promise { + const assignment = + await this.assignmentsRepository.findOneWithRelations(id); + if (!assignment) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + // Same gate as upload: a finished assignment's paperwork is fixed, and + // removal is as much a change as adding. + this.assertUploadAllowed(assignment); + + const files = await this.filesService.findByResource( + id, + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + ); + // Scoped to this assignment's own documents: a bare file id would let one + // assignment delete another's paperwork. + if (!files.some((file) => file.id === fileId)) { + throw new NotFoundException( + `File ${fileId} not found on this assignment`, + ); + } + await this.filesService.remove(fileId); + } +} 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 bb83518df..90a7310c8 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -652,6 +652,32 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// C3. Transit assignments — a transit agent's work on one booking: status, +// timings and documents. Separate from the booking's transit-assignee handshake, +// which only decides who will handle customs. +export const TRANSIT_ASSIGNMENT_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "d1a00003-0001-4000-8000-000000000001", + "edr_freight_app:transit_assignments:view", + "View transit assignments", + ), + perm( + "d1a00003-0001-4000-8000-000000000002", + "edr_freight_app:transit_assignments:create", + "Assign a transit agent to a booking", + ), + perm( + "d1a00003-0001-4000-8000-000000000003", + "edr_freight_app:transit_assignments:update", + "Update a transit assignment and its documents", + ), + perm( + "d1a00003-0001-4000-8000-000000000004", + "edr_freight_app:transit_assignments:delete", + "Remove a transit assignment", + ), +]; + // Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger. export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1890,6 +1916,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...OVERVIEW_LAYOUT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, + ...TRANSIT_ASSIGNMENT_PERMISSIONS, ...CHAT_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, @@ -2118,6 +2145,12 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:customers:get_notification", }, + transitAssignments: { + view: "edr_freight_app:transit_assignments:view", + create: "edr_freight_app:transit_assignments:create", + update: "edr_freight_app:transit_assignments:update", + delete: "edr_freight_app:transit_assignments:delete", + }, shippingLines: { view: "edr_freight_app:shipping_lines:view", create: "edr_freight_app:shipping_lines:create", diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index e0ef813e9..7674f924c 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -97,6 +97,7 @@ "react-markdown": "^9.1.0", "react-pdf": "^10.4.1", "react-pdf-html": "^2.1.5", + "react-phone-number-input": "^3.4.17", "react-quill-new": "^3.8.3", "react-resizable-panels": "^3.0.6", "react-router-dom": "^6.27.0", diff --git a/apps/edr-freight-web/backoffice/src/components/PhoneField.test.ts b/apps/edr-freight-web/backoffice/src/components/PhoneField.test.ts new file mode 100644 index 000000000..b6576f946 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/PhoneField.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { isSmsReachable, isValidPhone } from "./PhoneField"; + +/** + * `isSmsReachable` mirrors `isDomesticPhone` in the API's otp.service. The two + * must agree: this one greys out the SMS option, that one decides whether the + * message is actually sent, and a disagreement means the UI promises a text + * nobody sends (or hides one that would have worked). These cases are the same + * ones the API spec asserts. + */ +describe("isSmsReachable", () => { + it.each(["+251986680099", "0986680099", "251986680099"])( + "accepts Ethiopian mobile form %s", + (phone) => expect(isSmsReachable(phone)).toBe(true), + ); + + it.each(["+25377123456", "25377123456", "77123456"])( + "accepts Djibouti mobile form %s", + (phone) => expect(isSmsReachable(phone)).toBe(true), + ); + + it.each([ + "+14155550123", + "+447911123456", + "0712345678", + "+2519866", + "12345", + // Djibouti fixed line — valid number, not a mobile the gateway serves. + "+25321350000", + "+25366123456", + ])("rejects unreachable or malformed %s", (phone) => + expect(isSmsReachable(phone)).toBe(false), + ); + + it.each([undefined, null, ""])("treats %s as unreachable", (phone) => + expect(isSmsReachable(phone)).toBe(false), + ); +}); + +/** + * The country-picker input emits a PARTIAL E.164 while the user is still + * typing — "+25377" is a non-empty string that will post happily and come back + * as a 400 from the API's own IsValidPhone. Forms must treat "non-empty" and + * "complete" as different questions, so this is the check they call. + */ +describe("isValidPhone", () => { + it.each(["+25377834567", "+251911223344"])( + "accepts the complete number %s", + (phone) => expect(isValidPhone(phone)).toBe(true), + ); + + it.each(["+253", "+25377", "+2537712", "+251", "+2519112"])( + "rejects the partial number %s the picker emits mid-typing", + (phone) => expect(isValidPhone(phone)).toBe(false), + ); + + it.each([undefined, null, ""])("treats %s as invalid", (phone) => + expect(isValidPhone(phone)).toBe(false), + ); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/PhoneField.tsx b/apps/edr-freight-web/backoffice/src/components/PhoneField.tsx new file mode 100644 index 000000000..8714ccd8d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/PhoneField.tsx @@ -0,0 +1,107 @@ +import { Input, TextInput } from "@mantine/core"; +import RPNInput, { isValidPhoneNumber } from "react-phone-number-input"; +import "react-phone-number-input/style.css"; +import "./phone-field.css"; + +/** + * The countries the railway operates between, and the only two the SMS gateway + * is contracted to reach (see `REACHABLE_MOBILE_PATTERNS` in the API's + * otp.service). Restricting the picker to them keeps staff from entering a + * number that would validate but could never receive an activation link. + */ +export const SUPPORTED_PHONE_COUNTRIES = ["DJ", "ET"] as const; + +/** + * Djibouti — most accounts entered here (transit agents above all) are + * Djibouti-side, so it saves the picker interaction on the common case. + */ +export const DEFAULT_PHONE_COUNTRY = "DJ"; + +/** + * Re-exported so callers can validate before submitting. + * + * Needed because the input emits a PARTIAL E.164 while the user is still + * typing — "+25377" and "+2537712" are non-empty strings that reach a payload + * happily and then come back as a 400 from the API's own `IsValidPhone`. A + * caller must treat "non-empty" and "complete" as different questions. + */ +export const isValidPhone = (value?: string | null): boolean => + !!value && isValidPhoneNumber(value); + +/** + * Whether the SMS gateway can actually reach this number. + * + * Mirrors `isDomesticPhone` in the API's otp.service — Ethiopian `+2519…` and + * Djiboutian `+25377…` mobiles. Anything else (a landline, another country) is + * queued and silently lost, so the UI offers email instead of promising an SMS. + */ +export function isSmsReachable(rawPhone?: string | null): boolean { + if (!rawPhone) return false; + const digits = rawPhone.trim().replace(/[^\d+]/g, ""); + const bare = digits.replace(/^\+/, "").replace(/^0+/, ""); + const normalized = digits.startsWith("+") + ? digits + : /^251\d{9}$|^253\d{8}$/.test(digits) + ? `+${digits}` + : /^9\d{8}$|^7\d{8}$/.test(bare) + ? `+251${bare}` + : /^77\d{6}$/.test(bare) + ? `+253${bare}` + : digits; + return /^\+2519\d{8}$/.test(normalized) || /^\+25377\d{6}$/.test(normalized); +} + +export interface PhoneFieldProps { + label?: string; + value?: string; + onChange: (value: string | undefined) => void; + error?: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; + description?: string; +} + +/** + * Phone input with a country selector, limited to Ethiopia and Djibouti. + * Emits a single E.164 value (e.g. +251912345678, +25377123456) so the API + * never has to guess a country from a bare local number. + */ +export function PhoneField({ + label, + value, + onChange, + error, + required, + disabled, + placeholder = "77 83 45 67", + description, +}: PhoneFieldProps) { + return ( + +
+ +
+
+ ); +} + +export default PhoneField; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx index dc7286b5c..f2d6c07d4 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx @@ -30,6 +30,13 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { ); const isBulk = booking.freightType === "BULK"; + // NUMBER_OF_WAGONS cargo is booked by a wagon COUNT, not by tonnage — the + // count the customer fixed is what allocation and per-wagon pricing use, so + // it belongs on the card next to the weight. + const requestedWagons = + isBulk && booking.cargoType?.unitOfMeasure === "NUMBER_OF_WAGONS" + ? Number(booking.bulkRequestedWagons ?? 0) || null + : null; // Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers: // the freight kind, with the shipper's own description alongside. const cargoHeadline = isBulk @@ -46,6 +53,11 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { {isBulk ? "Bulk" : "Container"} + {requestedWagons != null ? ( + + {requestedWagons} wagon{requestedWagons === 1 ? "" : "s"} + + ) : null} {cargoDescription ? ( — {cargoDescription} @@ -62,6 +74,9 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { } /> + {requestedWagons != null && ( + + )} {items != null && } ({ minKm: fromKm, maxKm: "", rateValue: "" }); +const emptyTier = (fromKm = ""): TierRow => ({ + minKm: fromKm, + maxKm: "", + rateValue: "", +}); /** * Validate a tier set before submit: every tier complete, ranges sane, no @@ -87,7 +92,11 @@ const buildFormRows = (fields: FormFieldDef[]): FormRow[] => { while (index < fields.length) { const field = fields[index]; - if (field.type === "textarea" || field.type === "boolean" || field.type === "tierList") { + if ( + field.type === "textarea" || + field.type === "boolean" || + field.type === "tierList" + ) { rows.push({ kind: "single", field }); index += 1; continue; @@ -113,9 +122,10 @@ const buildInitialValues = ( ): Record => { const values: Record = {}; for (const field of fields) { - const raw = field.getInitialValue && record - ? field.getInitialValue(record) - : record?.[field.name]; + const raw = + field.getInitialValue && record + ? field.getInitialValue(record) + : record?.[field.name]; if (field.type === "multiselect") { values[field.name] = Array.isArray(raw) ? raw.map(String) : []; } else if (field.type === "tierList") { @@ -160,10 +170,13 @@ const resolveSelectValue = ( }; const inputStyles = { - label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" }, + label: { + fontWeight: 600, + marginBottom: 6, + color: "var(--mantine-color-gray-8)", + }, } as const; - const RuleEngineFormDialog = ({ open, onOpenChange, @@ -195,13 +208,17 @@ const RuleEngineFormDialog = ({ fields.filter((field) => { if ( field.hideWhen && - field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")) + field.hideWhen.equals.includes( + String(values[field.hideWhen.field] ?? ""), + ) ) { return false; } if ( field.showWhen && - !field.showWhen.equals.includes(String(values[field.showWhen.field] ?? "")) + !field.showWhen.equals.includes( + String(values[field.showWhen.field] ?? ""), + ) ) { return false; } @@ -228,7 +245,10 @@ const RuleEngineFormDialog = ({ // Changing what a rate applies to (or its surcharge trigger) can invalidate // the previously-chosen unit — reset it so the admin re-picks from the new // allowed set instead of submitting a stale, rejected unit. - if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) { + if ( + (name === "appliesTo" || name === "trigger") && + "rateUnit" in current + ) { next.rateUnit = ""; } // The legal yards depend on what the rate is for and which way it runs, so @@ -342,6 +362,20 @@ const RuleEngineFormDialog = ({ [field.name]: `${field.label} is required.`, })); blocked = true; + } else if (field.type === "phone" && raw !== "" && raw !== undefined) { + // The country-picker input emits a PARTIAL E.164 while the user is + // still typing ("+25377"), which is non-empty and would post straight + // through to a 400 from the API's own validator. Reject it here, on the + // field, instead of as a server error the admin has to decode. + if (!isValidPhone(String(raw))) { + setFieldErrors((current) => ({ + ...current, + [field.name]: `${field.label} is not a complete phone number.`, + })); + blocked = true; + } else { + payload[field.name] = raw; + } } else if (raw === "" || raw === undefined) { if (!field.required) continue; payload[field.name] = raw; @@ -350,13 +384,19 @@ const RuleEngineFormDialog = ({ } } - if (fields.some((f) => f.name === "code" && typeof payload.code === "string")) { + if ( + fields.some((f) => f.name === "code" && typeof payload.code === "string") + ) { payload.code = String(payload.code).toUpperCase(); } if (blocked) return; - if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) { + if ( + !initialRecord && + positionOptions && + position !== RULE_ENGINE_POSITION_END + ) { payload.insertAfterId = position; } @@ -421,7 +461,9 @@ const RuleEngineFormDialog = ({ const setRows = (next: TierRow[]) => setField(field.name, next); const setRow = (index: number, key: keyof TierRow, value: string) => { if (value.trim().startsWith("-")) return; - setRows(rows.map((row, i) => (i === index ? { ...row, [key]: value } : row))); + setRows( + rows.map((row, i) => (i === index ? { ...row, [key]: value } : row)), + ); }; return ( @@ -443,7 +485,9 @@ const RuleEngineFormDialog = ({ step="any" placeholder="0" value={row.minKm} - onChange={(e) => setRow(index, "minKm", e.currentTarget.value)} + onChange={(e) => + setRow(index, "minKm", e.currentTarget.value) + } size="md" radius="md" styles={inputStyles} @@ -456,7 +500,9 @@ const RuleEngineFormDialog = ({ step="any" placeholder="No limit" value={row.maxKm} - onChange={(e) => setRow(index, "maxKm", e.currentTarget.value)} + onChange={(e) => + setRow(index, "maxKm", e.currentTarget.value) + } size="md" radius="md" styles={inputStyles} @@ -469,7 +515,9 @@ const RuleEngineFormDialog = ({ step="any" placeholder="Rate per km" value={row.rateValue} - onChange={(e) => setRow(index, "rateValue", e.currentTarget.value)} + onChange={(e) => + setRow(index, "rateValue", e.currentTarget.value) + } size="md" radius="md" styles={inputStyles} @@ -494,7 +542,12 @@ const RuleEngineFormDialog = ({ size="xs" leftSection={} // The next tier naturally starts where the previous one ends. - onClick={() => setRows([...rows, emptyTier(rows[rows.length - 1]?.maxKm ?? "")])} + onClick={() => + setRows([ + ...rows, + emptyTier(rows[rows.length - 1]?.maxKm ?? ""), + ]) + } > Add tier @@ -531,7 +584,10 @@ const RuleEngineFormDialog = ({ onChange={(v) => setField(field.name, v)} disabled={selectOptionsLoading} data={options - .filter((opt) => opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE) + .filter( + (opt) => + opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE, + ) .map((opt) => ({ label: opt.label, value: opt.value }))} searchable clearable @@ -545,7 +601,9 @@ const RuleEngineFormDialog = ({ if (field.type === "select") { // Dynamic options (e.g. rate unit) resolve from the live form values so // the choices track the other fields the admin has picked. - const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []); + const options = field.optionsFromValues + ? field.optionsFromValues(values) + : (field.options ?? []); // A derived select shows (and submits) its computed value and is locked, // matching the text-input branch — used by fields the shape decides on the // admin's behalf, e.g. a shipping-line rate's import-only direction. @@ -558,14 +616,18 @@ const RuleEngineFormDialog = ({ label={label} description={field.description} placeholder={ - selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option") + selectOptionsLoading + ? "Loading options..." + : (field.placeholder ?? "Select an option") } value={ computedSelect !== undefined ? computedSelect : resolveSelectValue(field, values) } - onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)} + onChange={(v) => + setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v) + } disabled={ selectOptionsLoading || field.disabled || @@ -635,8 +697,29 @@ const RuleEngineFormDialog = ({ ); } + if (field.type === "phone") { + // Country-picker input restricted to Ethiopia and Djibouti — the two the + // SMS gateway reaches. Emits E.164, so the API never guesses a country + // from a bare local number. + return ( + setField(field.name, v ?? "")} + disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)} + required={field.required} + error={fieldErrors[field.name] || undefined} + placeholder={field.placeholder} + /> + ); + } + const isNumber = field.type === "number"; - const computed = field.computeValue ? field.computeValue(values) : undefined; + const computed = field.computeValue + ? field.computeValue(values) + : undefined; return ( { const next = e.currentTarget.value; if (isNumber && next.trim().startsWith("-")) return; @@ -703,16 +792,27 @@ const RuleEngineFormDialog = ({ >
- + {!initialRecord && positionOptions ? (