Merge pull request #1453 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-29 10:40:53 +03:00
committed by GitHub
66 changed files with 6593 additions and 337 deletions

View File

@@ -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,

View File

@@ -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();
}

View File

@@ -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<void> {
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<void> {
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`,
);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS cancellation_wagons
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_assignments`);
}
}

View File

@@ -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 () => {

View File

@@ -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<RequestedCut> {
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<number> {
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));
}

View File

@@ -1786,6 +1786,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
Booking,
| 'schedulingStatus'
| 'wagonsRequired'
| 'cancellationWagons'
| 'scheduledAt'
| 'holdStartedAt'
| 'holdExpiresAt'

View File

@@ -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()

View File

@@ -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;

View File

@@ -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<AccountInfoResponse> {
// 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(

View File

@@ -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: [

View File

@@ -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;

View File

@@ -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),
);
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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}`,

View File

@@ -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,
);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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) {}

View File

@@ -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;
}

View File

@@ -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<string, string | undefined>) {
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);
}
}

View File

@@ -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],

View File

@@ -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<TransitAgent> {
@@ -22,7 +27,48 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
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<TransitAgent | null> {
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<boolean> {
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<TransitAgent>,
): Promise<TransitAgent> {
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<TransitAgent, "userId" | "email" | "phoneNumber">,
): Promise<TransitAgent> {
const repo = manager.getRepository(TransitAgent);
await repo.update(id, data);
return repo.findOneOrFail({ where: { id } });
}
}

View File

@@ -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();
});
});
});

View File

@@ -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<TransitAgent, 'validFrom' | 'validTo'>): TransitAgentValidityStatus {
function validityStatus(
agent: Pick<TransitAgent, "validFrom" | "validTo">,
): 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<User>,
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<TransitAgent> {
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<TransitAgentView> {
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<string> {
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<TransitAgentView> {
return (await this.createWithInvite(dto)).agent;
}
/** {@link create}, also reporting where the activation link went. */
async createWithInvite(
dto: CreateTransitAgentDto,
): Promise<InvitedTransitAgent> {
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<TransitAgentView> {
/**
* 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<InvitedTransitAgent> {
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<TransitAgent | null> {
return this.transitAgentsRepository.findByUserId(userId);
}
async update(
id: string,
dto: UpdateTransitAgentDto,
): Promise<TransitAgentView> {
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<TransitAgent> = {};
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<void> {
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<void> {
await this.findById(id);
await this.transitAgentsRepository.softDelete(id);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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<TransitAssignment> {
constructor(
@InjectRepository(TransitAssignment)
private readonly assignmentsRepo: Repository<TransitAssignment>,
) {
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<TransitAssignmentsRepository["baseQuery"]>,
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<TransitAssignmentFilter, "transitAgentId">,
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<TransitAssignment | null> {
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<TransitAssignment[]> {
return this.baseQuery()
.andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId })
.orderBy("ta.assignedAt", "DESC")
.getMany();
}
/** Every live assignment on one booking. */
findByBooking(bookingId: string): Promise<TransitAssignment[]> {
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<boolean> {
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;
}
}

View File

@@ -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<TransitAssignment> = {}) =>
({
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<string, unknown>[]) => {
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);
});
});
});

View File

@@ -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<Booking>,
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<TransitAssignmentView> {
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<TransitAssignmentView[]> {
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<TransitAssignmentView[]> {
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<string, unknown[]>();
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<TransitAssignmentView> {
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<void> {
await this.findMineById(userId, id);
}
async uploadMyFiles(
userId: string,
id: string,
files: Express.Multer.File[],
uploader: { userId?: string; name?: string },
titles?: string[],
): Promise<TransitAssignmentFileView[]> {
await this.assertMine(userId, id);
return this.uploadFiles(id, files, uploader, titles);
}
async removeMyFile(
userId: string,
id: string,
fileId: string,
): Promise<void> {
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<TransitAssignmentView> {
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<TransitAssignmentView> {
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<TransitAssignmentView> {
const current = await this.assignmentsRepository.findOneWithRelations(id);
if (!current) {
throw new NotFoundException(`Transit assignment ${id} not found`);
}
const patch: Partial<TransitAssignment> = {};
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<void> {
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<TransitAssignmentFileView[]> {
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<TransitAssignmentFileView[]> {
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<void> {
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);
}
}

View File

@@ -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",