fix issue, add transit flow, fix cancellation

This commit is contained in:
Marshal
2026-08-28 22:13:49 +00:00
parent 3015de7508
commit 7e163088d9
50 changed files with 2787 additions and 337 deletions

View File

@@ -4,25 +4,29 @@ import {
ValidationOptions, ValidationOptions,
ValidatorConstraint, ValidatorConstraint,
ValidatorConstraintInterface, ValidatorConstraintInterface,
} from 'class-validator'; } from "class-validator";
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js'; import {
isValidPhoneNumber,
parsePhoneNumberFromString,
} from "libphonenumber-js";
/** /**
* Country-aware phone validation. The value is expected as a full international * 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 * number (E.164, e.g. "+25377834567" for Djibouti or "+251911223344" for
* value itself — no separate country field needed. * 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 { export class IsValidPhoneConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean { validate(value: unknown): boolean {
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed. // Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
if (value === undefined || value === null || value === '') return true; if (value === undefined || value === null || value === "") return true;
if (typeof value !== 'string') return false; if (typeof value !== "string") return false;
return isValidPhoneNumber(value); return isValidPhoneNumber(value);
} }
defaultMessage(args: ValidationArguments): string { 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( export function normalizeE164(
value: string | null | undefined, value: string | null | undefined,
): string | null | undefined { ): string | null | undefined {
if (value === undefined || value === null || value === '') return value; if (value === undefined || value === null || value === "") return value;
const parsed = parsePhoneNumberFromString(value, 'ET'); const parsed = parsePhoneNumberFromString(value, "ET");
return parsed?.isValid() ? parsed.number : value.trim(); 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

@@ -17,6 +17,7 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
wagons: number; wagons: number;
weightTons: number; weightTons: number;
quantities: { bulkTons?: number }; quantities: { bulkTons?: number };
totalWagons: number;
}>; }>;
}; };
const booking = { const booking = {
@@ -29,7 +30,39 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
it('cancels every wagon with the exact total tonnage', async () => { it('cancels every wagon with the exact total tonnage', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 }); 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 () => { 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 { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Rate } from '../rule-engine/entities/rate.entity'; import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service'; 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 { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -74,6 +76,8 @@ interface RequestedCut {
wagons: number; wagons: number;
weightTons: number; weightTons: number;
quantities: CancelledQuantities; 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. */ /** 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, feePerWagon: fee.perWagon,
feeAmount: fee.amount, feeAmount: fee.amount,
feeCurrency: fee.currency, feeCurrency: fee.currency,
creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), creditAmount: round2(Number(booking.totalAmount ?? 0)),
}; };
} }
this.assertCutSparesSharedWagon(cut); this.assertCutSparesSharedWagon(cut);
@@ -177,7 +181,7 @@ export class BookingWagonCancellationService {
feePerWagon: fee.perWagon, feePerWagon: fee.perWagon,
feeAmount: fee.amount, feeAmount: fee.amount,
feeCurrency: fee.currency, 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); : await this.resolveRequestedCut(booking, dto);
const fee = await this.priceFee(booking, cut); const fee = await this.priceFee(booking, cut);
const feeAmount = fee.amount; 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({ const row = await this.repo.create({
bookingId, bookingId,
@@ -318,7 +322,7 @@ export class BookingWagonCancellationService {
const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({ const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId: row.bookingId }, where: { bookingId: row.bookingId },
}); });
if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) { if (rows < Math.round(await this.wagonFootprint(booking))) {
throw new ConflictException( 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.', '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( const row = await this.openConsolidationBreak(
booking, booking,
'ceil', 'ceil',
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), round2(Number(booking.totalAmount ?? 0)),
reason ?? 'Consolidated pair cancelled', reason ?? 'Consolidated pair cancelled',
userId, userId,
); );
@@ -371,7 +375,7 @@ export class BookingWagonCancellationService {
await this.openConsolidationBreak( await this.openConsolidationBreak(
partner, partner,
'floor', 'floor',
this.creditFor(partner, Number(partner.wagonsRequired ?? 0)), round2(Number(partner.totalAmount ?? 0)),
`Cancelled with its consolidation partner ${booking.reference}`, `Cancelled with its consolidation partner ${booking.reference}`,
userId, userId,
); );
@@ -540,7 +544,8 @@ export class BookingWagonCancellationService {
} as RequestWagonCancellationDto); } as RequestWagonCancellationDto);
} }
return this.resolveRequestedCut(booking, { 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); } as RequestWagonCancellationDto);
} }
@@ -568,7 +573,7 @@ export class BookingWagonCancellationService {
const row = await this.openConsolidationBreak( const row = await this.openConsolidationBreak(
booking, booking,
'ceil', 'ceil',
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), round2(Number(booking.totalAmount ?? 0)),
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies', 'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
); );
await this.dataSource.getRepository(Booking).update(booking.id, { 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 // 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 // CANCELLED (frees the contract slot/cap for the rebook) and drops off its
// train. The credit row still points at it for T3. // train. The credit row still points at it for T3.
const wagonsLeft = round2( // Off the pinned footprint, not the live wagonsRequired — unassign
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled), // 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; const isFull = wagonsLeft <= 0;
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which // NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the // bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
@@ -745,6 +751,9 @@ export class BookingWagonCancellationService {
: null; : null;
await manager.getRepository(Booking).update(booking.id, { await manager.getRepository(Booking).update(booking.id, {
wagonsRequired: Math.max(0, wagonsLeft), 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 ...(requestedWagonsLeft !== null
? { bulkRequestedWagons: requestedWagonsLeft } ? { 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, { const cut = await this.resolveRequestedCut(booking, {
wagonAllocationIds: remaining.map((r) => r.id), wagonAllocationIds: target.map((r) => r.id),
} as RequestWagonCancellationDto); } as RequestWagonCancellationDto);
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut); if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
const edrFault = !!dto.edrFault; const edrFault = !!dto.edrFault;
const fee = edrFault ? null : await this.priceFee(booking, cut); 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({ const row = await this.repo.create({
bookingId, bookingId,
@@ -1253,7 +1285,7 @@ export class BookingWagonCancellationService {
booking: Booking, booking: Booking,
dto: RequestWagonCancellationDto, dto: RequestWagonCancellationDto,
): Promise<RequestedCut> { ): Promise<RequestedCut> {
const totalWagons = Number(booking.wagonsRequired ?? 0); const totalWagons = await this.wagonFootprint(booking);
if (totalWagons <= 0) { if (totalWagons <= 0) {
throw new BadRequestException('This booking has no wagon requirement to cancel from.'); throw new BadRequestException('This booking has no wagon requirement to cancel from.');
} }
@@ -1335,6 +1367,7 @@ export class BookingWagonCancellationService {
weightTons: weightShare, weightTons: weightShare,
// Bookings without unit records fall back to the T2 LIFO trim. // Bookings without unit records fall back to the T2 LIFO trim.
quantities: { bySize, ...(units.length === requested ? { units } : {}) }, quantities: { bySize, ...(units.length === requested ? { units } : {}) },
totalWagons,
}; };
} }
@@ -1360,7 +1393,7 @@ export class BookingWagonCancellationService {
if (tons <= 0) { if (tons <= 0) {
throw new BadRequestException('The requested cut is too small to release cargo.'); 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, wagons,
weightTons: tons, weightTons: tons,
quantities: { bulkTons: tons, allocationIds }, quantities: { bulkTons: tons, allocationIds },
totalWagons,
}; };
} }
@@ -1460,12 +1494,54 @@ export class BookingWagonCancellationService {
wagons, wagons,
weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)), weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)),
quantities: { bySize, units, allocationIds }, 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). */ /** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */
private creditFor(booking: Booking, wagons: number): number { private creditFor(booking: Booking, wagons: number, totalWagons: number): number {
const totalWagons = Number(booking.wagonsRequired ?? 0);
if (totalWagons <= 0) return 0; if (totalWagons <= 0) return 0;
return round2(Number(booking.totalAmount) * (wagons / totalWagons)); return round2(Number(booking.totalAmount) * (wagons / totalWagons));
} }

View File

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

View File

@@ -183,6 +183,19 @@ export class CancelRemainingWagonsDto {
@IsUUID('4') @IsUUID('4')
scheduleId!: string; 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' }) @ApiProperty({ description: 'Why the remaining wagons are not riding' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@@ -543,6 +543,13 @@ export class Booking extends BaseEntity {
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true }) @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
wagonsRequired?: number | null; 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' }) @Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' })
schedulingStatus!: string; schedulingStatus!: string;

View File

@@ -55,8 +55,10 @@ import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { import {
AccountInfoResponse, AccountInfoResponse,
ShippingLineInfoResponseDto, ShippingLineInfoResponseDto,
TransitAgentInfoResponseDto,
} from "./dto/account-info-response.dto"; } from "./dto/account-info-response.dto";
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service"; 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 { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto"; import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
@@ -102,6 +104,7 @@ export class CompaniesController {
private readonly companiesService: CompaniesService, private readonly companiesService: CompaniesService,
private readonly filesService: FilesService, private readonly filesService: FilesService,
private readonly shippingLineCompaniesService: ShippingLineCompaniesService, private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
private readonly transitAgentsService: TransitAgentsService,
) { } ) { }
/** /**
@@ -131,10 +134,10 @@ export class CompaniesController {
async getInfo( async getInfo(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
): Promise<AccountInfoResponse> { ): Promise<AccountInfoResponse> {
// A shipping line has no company and no external profile, so the customer // Neither a shipping line nor a transit agent has a company or an external
// lookup below would 404. Checked first, and reported with an explicit // profile, so the customer lookup below would 404 for both. Checked first,
// `accountKind` so the portal can skip onboarding for shipping lines // and reported with an explicit `accountKind` so the portal can skip
// without inferring it from a missing company. // onboarding for them without inferring it from a missing company.
const shippingLine = await this.shippingLineCompaniesService.findByUserId( const shippingLine = await this.shippingLineCompaniesService.findByUserId(
user.id, user.id,
); );
@@ -142,6 +145,11 @@ export class CompaniesController {
return new ShippingLineInfoResponseDto(shippingLine); return new ShippingLineInfoResponseDto(shippingLine);
} }
const transitAgent = await this.transitAgentsService.findByUserId(user.id);
if (transitAgent) {
return new TransitAgentInfoResponseDto(transitAgent);
}
const { profile, company } = const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id); await this.companiesService.getCompanyInfoByUserId(user.id);
const review = await this.companiesService.getOpenChangeRequestForCompany( 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 { CompanyRevision } from "./entities/company-revision.entity";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module"; import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { CompanyRevisionRepository } from "./company-revision.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 // shipping-line session, which has no company row to look up. forwardRef
// because that module imports BillingModule, which imports this one. // because that module imports BillingModule, which imports this one.
forwardRef(() => ShippingLineCompaniesModule), 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], controllers: [CompaniesController],
providers: [ providers: [

View File

@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity"; 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"; 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` * 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 * missing?": a failed or slow company fetch also leaves `company` empty, and
* treating that as "no onboarding needed" would let customers skip onboarding * treating that as "no onboarding needed" would let customers skip onboarding
* whenever the request failed. A shipping line is identified positively, and * whenever the request failed. A shipping line and a transit agent are each
* anything else defaults to `customer`. * 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. */ /** The signed-in shipping line. No company, no profile, no onboarding. */
export class ShippingLineInfoResponseDto { 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 = export type AccountInfoResponse =
| (CompanyInfoResponseDto & { accountKind: "customer" }) | (CompanyInfoResponseDto & { accountKind: "customer" })
| ShippingLineInfoResponseDto; | ShippingLineInfoResponseDto
| TransitAgentInfoResponseDto;

View File

@@ -35,9 +35,24 @@ describe("isDomesticPhone", () => {
(phone) => expect(isDomesticPhone(phone)).toBe(true), (phone) => expect(isDomesticPhone(phone)).toBe(true),
); );
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])( // Djibouti is the line's other end: the gateway reaches its 77x mobiles.
"rejects non-domestic or malformed %s", it.each(["+25377123456", "25377123456", "77123456"])(
(phone) => expect(isDomesticPhone(phone)).toBe(false), "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; if (digits.startsWith("+")) return digits;
const bare = digits.replace(/^0+/, ""); const bare = digits.replace(/^0+/, "");
if (/^251\d{9}$/.test(digits)) return `+${digits}`; 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}`; 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 // Unknown shape (foreign number, already-clean intl without +) — prefix + if
// it looks like a full international number, else leave as typed. // it looks like a full international number, else leave as typed.
return digits.length >= 11 ? `+${digits}` : raw; return digits.length >= 11 ? `+${digits}` : raw;
} }
/** /**
* Whether a phone is an Ethiopian mobile the SMS gateway can actually reach — * Mobile ranges the SMS gateway is contracted to reach, as E.164 patterns.
* 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 * The gateway itself is opaque from here — `SmsClientService` publishes to
* pretending an SMS is on its way. * 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 { 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 otpRepository: OtpRepository,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly emailClient: EmailClientService, private readonly emailClient: EmailClientService,
) { } ) {}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Generate OTP // Generate OTP
@@ -197,8 +219,10 @@ export class OtpService {
for (const outcome of outcomes) { for (const outcome of outcomes) {
this.logger.log( this.logger.log(
`otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued `otp.dispatch channel=${outcome.channel} target=${label} queued=${
} latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : "" 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, // user who never receives a code — indistinguishable from carrier loss,
// and the misleading success response makes it look like our side worked. // and the misleading success response makes it look like our side worked.
this.logger.error( 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`, } — 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 // 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. // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
this.logger.error( 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.message : String(error)}`,
error instanceof Error ? error.stack : undefined, error instanceof Error ? error.stack : undefined,
); );
@@ -330,8 +356,9 @@ export class OtpService {
) { ) {
const line = `otp.verify channels=${channelsOf(target).join( 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); if (result === "ok") this.logger.log(line);
else this.logger.warn(line); else this.logger.warn(line);

View File

@@ -3967,6 +3967,11 @@ export class BookingBatchService implements OnModuleInit {
schedulingStatus: "SCHEDULED", schedulingStatus: "SCHEDULED",
scheduledAt: new Date(), scheduledAt: new Date(),
wagonsRequired, 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, paymentDeadline: null,
selectedForBatchAt: null, selectedForBatchAt: null,
} as never); } as never);

View File

@@ -39,15 +39,30 @@ export class BookingNotifierService {
try { try {
const s = await this.trainSchedules.findByIdWithStations(scheduleId); const s = await this.trainSchedules.findByIdWithStations(scheduleId);
if (!s) return fallback; if (!s) return fallback;
const ref = s.reference ?? s.trainNumber ?? null; // Customers know the train by its operating number (8001), not the
const route = // 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})` ? `${s.originStation.label}${s.destinationStation.label}`
: ''; : null,
].filter(Boolean);
const detail = parts.length ? ` (${parts.join(', ')})` : '';
const departure = s.scheduledDepartureDate 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) { } catch (err) {
this.logger.warn( this.logger.warn(
`scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`, `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. // batch fill, which unlinks it and frees its wagons on the next window cycle.
const scheduledAt = new Date(); const scheduledAt = new Date();
for (const booking of bookings) { for (const booking of bookings) {
const wagonsRequired = sumWagonsRequired(booking, wagonPlan);
await this.bookingsRepository.updateSchedulingFields( await this.bookingsRepository.updateSchedulingFields(
booking.id, booking.id,
{ {
schedulingStatus: SchedulingStatus.Scheduled, schedulingStatus: SchedulingStatus.Scheduled,
scheduledAt, 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, manager,
); );

View File

@@ -1,25 +1,34 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from 'class-transformer'; import { Transform } from "class-transformer";
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator'; import {
IsBoolean,
IsDateString,
IsEmail,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
const toBoolean = ({ value }: { value: unknown }) => { const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value; if (typeof value === "boolean") return value;
if (value === 'true') return true; if (value === "true") return true;
if (value === 'false') return false; if (value === "false") return false;
return value; return value;
}; };
export class CreateTransitAgentDto { export class CreateTransitAgentDto {
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' }) @ApiProperty({ maxLength: 150, example: "Ahmed Bourhan" })
@IsString() @IsString()
@MaxLength(150) @MaxLength(150)
name!: string; name!: string;
@ApiProperty({ example: '2026-01-01' }) @ApiProperty({ example: "2026-01-01" })
@IsDateString() @IsDateString()
validFrom!: string; validFrom!: string;
@ApiProperty({ example: '2026-12-31' }) @ApiProperty({ example: "2026-12-31" })
@IsDateString() @IsDateString()
validTo!: string; validTo!: string;
@@ -28,4 +37,33 @@ export class CreateTransitAgentDto {
@Transform(toBoolean) @Transform(toBoolean)
@IsBoolean() @IsBoolean()
isActive?: boolean; 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) {} export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}

View File

@@ -1,5 +1,5 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from 'typeorm'; import { Column, Entity, Index } from "typeorm";
/** /**
* Djibouti transit officer GL Djibouti may assign against a shipment's * 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 * validity window arrive without a code change; `isActive` is the manual
* suspend/reactivate switch, independent of the validity window. * suspend/reactivate switch, independent of the validity window.
*/ */
@Entity({ schema: 'freight', name: 'transit_agents' }) @Entity({ schema: "freight", name: "transit_agents" })
@Index(['isActive']) @Index(["isActive"])
export class TransitAgent extends BaseEntity { export class TransitAgent extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 150 }) @Column({ name: "name", type: "varchar", length: 150 })
name!: string; name!: string;
@Column({ name: 'valid_from', type: 'date' }) @Column({ name: "valid_from", type: "date" })
validFrom!: string; validFrom!: string;
@Column({ name: 'valid_to', type: 'date' }) @Column({ name: "valid_to", type: "date" })
validTo!: string; validTo!: string;
@Column({ name: 'is_active', type: 'boolean', default: true }) @Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean; 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, Patch,
Post, Post,
Query, Query,
} from '@nestjs/common'; } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { import {
RuleEngineCreate, RuleEngineCreate,
RuleEngineDelete, RuleEngineDelete,
RuleEngineUpdate, RuleEngineUpdate,
RuleEngineView, RuleEngineView,
} from '../../common/rule-engine-guards'; } from "../../common/rule-engine-guards";
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto";
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
import { TransitAgentsService } from './transit-agents.service'; 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') @ApiTags("transit-agents")
@Controller('transit-agents') @Controller("transit-agents")
@ApiBearerAuth() @ApiBearerAuth()
export class TransitAgentsController { export class TransitAgentsController {
constructor(private readonly transitAgentsService: TransitAgentsService) {} constructor(private readonly transitAgentsService: TransitAgentsService) {}
@Get() @Get()
@RuleEngineView('transit-agents') @RuleEngineView("transit-agents")
@ApiOperation({ summary: 'List transit agents' }) @ApiOperation({ summary: "List transit agents" })
findAll(@Query() query: Record<string, string | undefined>) { findAll(@Query() query: Record<string, string | undefined>) {
return this.transitAgentsService.findAll({ return this.transitAgentsService.findAll({
isActive: isActive:
query.isActive === 'all' query.isActive === "all"
? undefined ? undefined
: query.isActive !== undefined : query.isActive !== undefined
? query.isActive === 'true' ? query.isActive === "true"
: undefined, : undefined,
page: query.page ? parseInt(query.page, 10) : undefined, page: query.page ? parseInt(query.page, 10) : undefined,
pageSize: query.pageSize ? parseInt(query.pageSize, 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. */ /** Active + currently valid officers — the transit-assignee assignment dropdown. */
@Get('assignable') @Get("assignable")
@RuleEngineView('transit-agents') @RuleEngineView("transit-agents")
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' }) @ApiOperation({
summary: "List transit agents assignable right now (active and in-window)",
})
findAssignable() { findAssignable() {
return this.transitAgentsService.findAssignable(); return this.transitAgentsService.findAssignable();
} }
@Get(':id') @Get(":id")
@RuleEngineView('transit-agents') @RuleEngineView("transit-agents")
@ApiOperation({ summary: 'Get a transit agent by ID' }) @ApiOperation({ summary: "Get a transit agent by ID" })
findOne(@Param('id', ParseUUIDPipe) id: string) { findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.transitAgentsService.findById(id); return this.transitAgentsService.findById(id);
} }
@Post() @Post()
@RuleEngineCreate('transit-agents') @RuleEngineCreate("transit-agents")
@ApiOperation({ summary: 'Create a transit agent' }) @ApiOperation({
summary:
"Create a transit agent; with an email, also creates its portal account and sends the activation link",
})
create(@Body() dto: CreateTransitAgentDto) { create(@Body() dto: CreateTransitAgentDto) {
return this.transitAgentsService.create(dto); return this.transitAgentsService.createWithInvite(dto);
} }
@Patch(':id') /**
@RuleEngineUpdate('transit-agents') * The path for the roster entries already in production: they were created
@ApiOperation({ summary: 'Update a transit agent' }) * before transit agents had logins, so they get their account here rather
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) { * 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); return this.transitAgentsService.update(id, dto);
} }
@Delete(':id') @Delete(":id")
@RuleEngineDelete('transit-agents') @RuleEngineDelete("transit-agents")
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a transit agent' }) @ApiOperation({ summary: "Soft-delete a transit agent" })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param("id", ParseUUIDPipe) id: string) {
return this.transitAgentsService.remove(id); return this.transitAgentsService.remove(id);
} }
} }

View File

@@ -1,13 +1,24 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from "@nestjs/typeorm";
import { TransitAgent } from './entities/transit-agent.entity'; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { TransitAgentsController } from './transit-agents.controller';
import { TransitAgentsRepository } from './transit-agents.repository'; import { FreightAuthModule } from "../auth/freight-auth.module";
import { TransitAgentsService } from './transit-agents.service'; 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({ @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], controllers: [TransitAgentsController],
providers: [TransitAgentsRepository, TransitAgentsService], providers: [TransitAgentsRepository, TransitAgentsService],
exports: [TransitAgentsRepository, TransitAgentsService], exports: [TransitAgentsRepository, TransitAgentsService],

View File

@@ -1,9 +1,14 @@
import { BaseRepository } from '@edr/api-common'; import { BaseRepository } from "@edr/api-common";
import { Injectable } from '@nestjs/common'; import { Injectable } from "@nestjs/common";
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from "@nestjs/typeorm";
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import {
EntityManager,
LessThanOrEqual,
MoreThanOrEqual,
Repository,
} from "typeorm";
import { TransitAgent } from './entities/transit-agent.entity'; import { TransitAgent } from "./entities/transit-agent.entity";
@Injectable() @Injectable()
export class TransitAgentsRepository extends BaseRepository<TransitAgent> { export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
@@ -22,7 +27,48 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
validFrom: LessThanOrEqual(today), validFrom: LessThanOrEqual(today),
validTo: MoreThanOrEqual(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 {
import { FindOptionsOrder } from 'typeorm'; 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 { CustomerResetService } from "../auth/customer-reset.service";
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; import { ResetChannel } from "../auth/dto/forgot-password.dto";
import { TransitAgent } from './entities/transit-agent.entity'; import { isDomesticPhone } from "../otp/otp.service";
import { TransitAgentsRepository } from './transit-agents.repository'; 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 & { export type TransitAgentView = TransitAgent & {
validityStatus: TransitAgentValidityStatus; 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 = { type TransitAgentListFilter = {
isActive?: boolean; isActive?: boolean;
page?: number; page?: number;
@@ -25,20 +57,34 @@ function todayISODate(): string {
return new Date().toISOString().slice(0, 10); 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(); const today = todayISODate();
if (today < agent.validFrom) return 'NOT_STARTED'; if (today < agent.validFrom) return "NOT_STARTED";
if (today > agent.validTo) return 'EXPIRED'; if (today > agent.validTo) return "EXPIRED";
return 'VALID'; return "VALID";
} }
function withValidityStatus(agent: TransitAgent): TransitAgentView { function withValidityStatus(agent: TransitAgent): TransitAgentView {
return { ...agent, validityStatus: validityStatus(agent) }; return {
...agent,
validityStatus: validityStatus(agent),
hasAccount: Boolean(agent.userId),
};
} }
@Injectable() @Injectable()
export class TransitAgentsService { 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<{ async findAll(filter: TransitAgentListFilter = {}): Promise<{
data: TransitAgentView[]; data: TransitAgentView[];
@@ -46,10 +92,13 @@ export class TransitAgentsService {
}> { }> {
const page = filter.page ?? 1; const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 500; 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) ? (filter.sortBy as keyof TransitAgent)
: 'name'; : "name";
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; const sortOrder =
filter.sortOrder?.toUpperCase() === "DESC" ? "DESC" : "ASC";
const [data, total] = await this.transitAgentsRepository.findAndCount({ const [data, total] = await this.transitAgentsRepository.findAndCount({
where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
@@ -86,12 +135,14 @@ export class TransitAgentsService {
async getAssignable(id: string): Promise<TransitAgent> { async getAssignable(id: string): Promise<TransitAgent> {
const agent = await this.transitAgentsRepository.findById(id); const agent = await this.transitAgentsRepository.findById(id);
if (!agent) { if (!agent) {
throw new BadRequestException('Selected transit officer was not found.'); throw new BadRequestException("Selected transit officer was not found.");
} }
if (!agent.isActive) { 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( throw new BadRequestException(
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`, `${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
); );
@@ -99,38 +150,364 @@ export class TransitAgentsService {
return agent; return agent;
} }
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> { /**
if (dto.validTo < dto.validFrom) { * Create an IAM account for a transit agent, inside the caller's transaction.
throw new BadRequestException('Valid-to date must be on or after valid-from date.'); *
* 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(), name: dto.name.trim(),
validFrom: dto.validFrom, validFrom: dto.validFrom,
validTo: dto.validTo, validTo: dto.validTo,
isActive: dto.isActive ?? true, 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 current = await this.findById(id);
const nextValidFrom = dto.validFrom ?? current.validFrom; const nextValidFrom = dto.validFrom ?? current.validFrom;
const nextValidTo = dto.validTo ?? current.validTo; const nextValidTo = dto.validTo ?? current.validTo;
if (nextValidTo < nextValidFrom) { 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, { const updated = await this.transitAgentsRepository.update(id, {
...dto, ...rest,
...contact,
...(dto.name ? { name: dto.name.trim() } : {}), ...(dto.name ? { name: dto.name.trim() } : {}),
}); });
if (!updated) { if (!updated) {
throw new NotFoundException(`Transit agent ${id} not found`); 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); 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> { async remove(id: string): Promise<void> {
await this.findById(id); await this.findById(id);
await this.transitAgentsRepository.softDelete(id); await this.transitAgentsRepository.softDelete(id);

View File

@@ -97,6 +97,7 @@
"react-markdown": "^9.1.0", "react-markdown": "^9.1.0",
"react-pdf": "^10.4.1", "react-pdf": "^10.4.1",
"react-pdf-html": "^2.1.5", "react-pdf-html": "^2.1.5",
"react-phone-number-input": "^3.4.17",
"react-quill-new": "^3.8.3", "react-quill-new": "^3.8.3",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.27.0", "react-router-dom": "^6.27.0",

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { isSmsReachable, isValidPhone } from "./PhoneField";
/**
* `isSmsReachable` mirrors `isDomesticPhone` in the API's otp.service. The two
* must agree: this one greys out the SMS option, that one decides whether the
* message is actually sent, and a disagreement means the UI promises a text
* nobody sends (or hides one that would have worked). These cases are the same
* ones the API spec asserts.
*/
describe("isSmsReachable", () => {
it.each(["+251986680099", "0986680099", "251986680099"])(
"accepts Ethiopian mobile form %s",
(phone) => expect(isSmsReachable(phone)).toBe(true),
);
it.each(["+25377123456", "25377123456", "77123456"])(
"accepts Djibouti mobile form %s",
(phone) => expect(isSmsReachable(phone)).toBe(true),
);
it.each([
"+14155550123",
"+447911123456",
"0712345678",
"+2519866",
"12345",
// Djibouti fixed line — valid number, not a mobile the gateway serves.
"+25321350000",
"+25366123456",
])("rejects unreachable or malformed %s", (phone) =>
expect(isSmsReachable(phone)).toBe(false),
);
it.each([undefined, null, ""])("treats %s as unreachable", (phone) =>
expect(isSmsReachable(phone)).toBe(false),
);
});
/**
* The country-picker input emits a PARTIAL E.164 while the user is still
* typing — "+25377" is a non-empty string that will post happily and come back
* as a 400 from the API's own IsValidPhone. Forms must treat "non-empty" and
* "complete" as different questions, so this is the check they call.
*/
describe("isValidPhone", () => {
it.each(["+25377834567", "+251911223344"])(
"accepts the complete number %s",
(phone) => expect(isValidPhone(phone)).toBe(true),
);
it.each(["+253", "+25377", "+2537712", "+251", "+2519112"])(
"rejects the partial number %s the picker emits mid-typing",
(phone) => expect(isValidPhone(phone)).toBe(false),
);
it.each([undefined, null, ""])("treats %s as invalid", (phone) =>
expect(isValidPhone(phone)).toBe(false),
);
});

View File

@@ -0,0 +1,107 @@
import { Input, TextInput } from "@mantine/core";
import RPNInput, { isValidPhoneNumber } from "react-phone-number-input";
import "react-phone-number-input/style.css";
import "./phone-field.css";
/**
* The countries the railway operates between, and the only two the SMS gateway
* is contracted to reach (see `REACHABLE_MOBILE_PATTERNS` in the API's
* otp.service). Restricting the picker to them keeps staff from entering a
* number that would validate but could never receive an activation link.
*/
export const SUPPORTED_PHONE_COUNTRIES = ["DJ", "ET"] as const;
/**
* Djibouti — most accounts entered here (transit agents above all) are
* Djibouti-side, so it saves the picker interaction on the common case.
*/
export const DEFAULT_PHONE_COUNTRY = "DJ";
/**
* Re-exported so callers can validate before submitting.
*
* Needed because the input emits a PARTIAL E.164 while the user is still
* typing — "+25377" and "+2537712" are non-empty strings that reach a payload
* happily and then come back as a 400 from the API's own `IsValidPhone`. A
* caller must treat "non-empty" and "complete" as different questions.
*/
export const isValidPhone = (value?: string | null): boolean =>
!!value && isValidPhoneNumber(value);
/**
* Whether the SMS gateway can actually reach this number.
*
* Mirrors `isDomesticPhone` in the API's otp.service — Ethiopian `+2519…` and
* Djiboutian `+25377…` mobiles. Anything else (a landline, another country) is
* queued and silently lost, so the UI offers email instead of promising an SMS.
*/
export function isSmsReachable(rawPhone?: string | null): boolean {
if (!rawPhone) return false;
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
const bare = digits.replace(/^\+/, "").replace(/^0+/, "");
const normalized = digits.startsWith("+")
? digits
: /^251\d{9}$|^253\d{8}$/.test(digits)
? `+${digits}`
: /^9\d{8}$|^7\d{8}$/.test(bare)
? `+251${bare}`
: /^77\d{6}$/.test(bare)
? `+253${bare}`
: digits;
return /^\+2519\d{8}$/.test(normalized) || /^\+25377\d{6}$/.test(normalized);
}
export interface PhoneFieldProps {
label?: string;
value?: string;
onChange: (value: string | undefined) => void;
error?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
description?: string;
}
/**
* Phone input with a country selector, limited to Ethiopia and Djibouti.
* Emits a single E.164 value (e.g. +251912345678, +25377123456) so the API
* never has to guess a country from a bare local number.
*/
export function PhoneField({
label,
value,
onChange,
error,
required,
disabled,
placeholder = "77 83 45 67",
description,
}: PhoneFieldProps) {
return (
<Input.Wrapper
label={label}
required={required}
error={error}
description={description}
styles={{ label: { fontWeight: 600, fontSize: 14, color: "#10202F" } }}
>
<div
className={`edr-phone-wrapper${error ? " edr-phone-wrapper--error" : ""}`}
>
<RPNInput
international
defaultCountry={DEFAULT_PHONE_COUNTRY}
countries={[...SUPPORTED_PHONE_COUNTRIES]}
countryCallingCodeEditable={false}
value={value}
onChange={onChange}
inputComponent={TextInput}
disabled={disabled}
placeholder={placeholder}
/>
</div>
</Input.Wrapper>
);
}
export default PhoneField;

View File

@@ -30,6 +30,13 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
); );
const isBulk = booking.freightType === "BULK"; const isBulk = booking.freightType === "BULK";
// NUMBER_OF_WAGONS cargo is booked by a wagon COUNT, not by tonnage — the
// count the customer fixed is what allocation and per-wagon pricing use, so
// it belongs on the card next to the weight.
const requestedWagons =
isBulk && booking.cargoType?.unitOfMeasure === "NUMBER_OF_WAGONS"
? Number(booking.bulkRequestedWagons ?? 0) || null
: null;
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers: // Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
// the freight kind, with the shipper's own description alongside. // the freight kind, with the shipper's own description alongside.
const cargoHeadline = isBulk const cargoHeadline = isBulk
@@ -46,6 +53,11 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm"> <Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
{isBulk ? "Bulk" : "Container"} {isBulk ? "Bulk" : "Container"}
</Badge> </Badge>
{requestedWagons != null ? (
<Badge variant="light" color="grape" radius="sm">
{requestedWagons} wagon{requestedWagons === 1 ? "" : "s"}
</Badge>
) : null}
{cargoDescription ? ( {cargoDescription ? (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{cargoDescription} {cargoDescription}
@@ -62,6 +74,9 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
} }
/> />
<MetricTile label="Total VGM" value={`${tons} tons`} /> <MetricTile label="Total VGM" value={`${tons} tons`} />
{requestedWagons != null && (
<MetricTile label="Wagons booked" value={`${requestedWagons}`} />
)}
{items != null && <MetricTile label="Items" value={`${items}`} />} {items != null && <MetricTile label="Items" value={`${items}`} />}
<MetricTile <MetricTile
label="Hazardous" label="Hazardous"

View File

@@ -0,0 +1,82 @@
/* Align react-phone-number-input with the portal's Mantine field styling:
44px height, 10px radius, edr border, brand-green focus ring. */
.edr-phone-wrapper .PhoneInput {
display: flex;
align-items: stretch;
gap: 8px;
}
/* Country selector — a compact pill matching the input height/radius. */
.edr-phone-wrapper .PhoneInputCountry {
margin: 0;
padding: 0 10px;
height: 2.25rem;
border: 0.0625rem solid #b0bfce;
border-radius: 6px;
background: #fff;
display: flex;
align-items: center;
gap: 6px;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.edr-phone-wrapper .PhoneInputCountryIcon {
width: 22px;
height: 16px;
box-shadow: none;
}
.edr-phone-wrapper .PhoneInputCountrySelectArrow {
color: #6b7c8e;
opacity: 0.8;
}
/* The number input itself. */
.edr-phone-input {
flex: 1;
min-width: 0;
height: 44px;
padding: 0 12px;
border: 1px solid #e6ecf2;
border-radius: 10px;
font-size: 14px;
color: #10202f;
background: #fff;
outline: none;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.edr-phone-input::placeholder {
color: #9aa8b5;
}
.edr-phone-input:focus {
border-color: #0ea371;
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
}
.edr-phone-wrapper .PhoneInputCountry:focus-within {
border-color: #0ea371;
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
}
.edr-phone-input:disabled,
.edr-phone-wrapper .PhoneInputCountrySelect:disabled + .PhoneInputCountryIcon {
opacity: 0.6;
cursor: not-allowed;
}
/* Error state mirrors Mantine's invalid styling. */
.edr-phone-wrapper--error .edr-phone-input,
.edr-phone-wrapper--error .PhoneInputCountry {
border-color: #e03131;
}
.edr-phone-wrapper--error .edr-phone-input:focus {
box-shadow: 0 0 0 3px rgba(224, 49, 49, 0.12);
}

View File

@@ -22,6 +22,7 @@ import {
RULE_ENGINE_SELECT_NONE, RULE_ENGINE_SELECT_NONE,
type FormFieldDef, type FormFieldDef,
} from "@/pages/ruleEngine/config/resources"; } from "@/pages/ruleEngine/config/resources";
import PhoneField, { isValidPhone } from "@/components/PhoneField";
import type { RuleEngineRecord } from "@/types/rule-engine"; import type { RuleEngineRecord } from "@/types/rule-engine";
import { RULE_ENGINE_POSITION_END } from "./ruleEngineOrder.utils"; import { RULE_ENGINE_POSITION_END } from "./ruleEngineOrder.utils";
@@ -46,7 +47,11 @@ type FormRow =
/** One editable distance tier of a tierList field (raw input strings). */ /** One editable distance tier of a tierList field (raw input strings). */
type TierRow = { minKm: string; maxKm: string; rateValue: string }; type TierRow = { minKm: string; maxKm: string; rateValue: string };
const emptyTier = (fromKm = ""): TierRow => ({ minKm: fromKm, maxKm: "", rateValue: "" }); const emptyTier = (fromKm = ""): TierRow => ({
minKm: fromKm,
maxKm: "",
rateValue: "",
});
/** /**
* Validate a tier set before submit: every tier complete, ranges sane, no * Validate a tier set before submit: every tier complete, ranges sane, no
@@ -87,7 +92,11 @@ const buildFormRows = (fields: FormFieldDef[]): FormRow[] => {
while (index < fields.length) { while (index < fields.length) {
const field = fields[index]; const field = fields[index];
if (field.type === "textarea" || field.type === "boolean" || field.type === "tierList") { if (
field.type === "textarea" ||
field.type === "boolean" ||
field.type === "tierList"
) {
rows.push({ kind: "single", field }); rows.push({ kind: "single", field });
index += 1; index += 1;
continue; continue;
@@ -113,9 +122,10 @@ const buildInitialValues = (
): Record<string, unknown> => { ): Record<string, unknown> => {
const values: Record<string, unknown> = {}; const values: Record<string, unknown> = {};
for (const field of fields) { for (const field of fields) {
const raw = field.getInitialValue && record const raw =
? field.getInitialValue(record) field.getInitialValue && record
: record?.[field.name]; ? field.getInitialValue(record)
: record?.[field.name];
if (field.type === "multiselect") { if (field.type === "multiselect") {
values[field.name] = Array.isArray(raw) ? raw.map(String) : []; values[field.name] = Array.isArray(raw) ? raw.map(String) : [];
} else if (field.type === "tierList") { } else if (field.type === "tierList") {
@@ -160,10 +170,13 @@ const resolveSelectValue = (
}; };
const inputStyles = { const inputStyles = {
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" }, label: {
fontWeight: 600,
marginBottom: 6,
color: "var(--mantine-color-gray-8)",
},
} as const; } as const;
const RuleEngineFormDialog = ({ const RuleEngineFormDialog = ({
open, open,
onOpenChange, onOpenChange,
@@ -195,13 +208,17 @@ const RuleEngineFormDialog = ({
fields.filter((field) => { fields.filter((field) => {
if ( if (
field.hideWhen && field.hideWhen &&
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")) field.hideWhen.equals.includes(
String(values[field.hideWhen.field] ?? ""),
)
) { ) {
return false; return false;
} }
if ( if (
field.showWhen && field.showWhen &&
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? "")) !field.showWhen.equals.includes(
String(values[field.showWhen.field] ?? ""),
)
) { ) {
return false; return false;
} }
@@ -228,7 +245,10 @@ const RuleEngineFormDialog = ({
// Changing what a rate applies to (or its surcharge trigger) can invalidate // Changing what a rate applies to (or its surcharge trigger) can invalidate
// the previously-chosen unit — reset it so the admin re-picks from the new // the previously-chosen unit — reset it so the admin re-picks from the new
// allowed set instead of submitting a stale, rejected unit. // allowed set instead of submitting a stale, rejected unit.
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) { if (
(name === "appliesTo" || name === "trigger") &&
"rateUnit" in current
) {
next.rateUnit = ""; next.rateUnit = "";
} }
// The legal yards depend on what the rate is for and which way it runs, so // The legal yards depend on what the rate is for and which way it runs, so
@@ -342,6 +362,20 @@ const RuleEngineFormDialog = ({
[field.name]: `${field.label} is required.`, [field.name]: `${field.label} is required.`,
})); }));
blocked = true; blocked = true;
} else if (field.type === "phone" && raw !== "" && raw !== undefined) {
// The country-picker input emits a PARTIAL E.164 while the user is
// still typing ("+25377"), which is non-empty and would post straight
// through to a 400 from the API's own validator. Reject it here, on the
// field, instead of as a server error the admin has to decode.
if (!isValidPhone(String(raw))) {
setFieldErrors((current) => ({
...current,
[field.name]: `${field.label} is not a complete phone number.`,
}));
blocked = true;
} else {
payload[field.name] = raw;
}
} else if (raw === "" || raw === undefined) { } else if (raw === "" || raw === undefined) {
if (!field.required) continue; if (!field.required) continue;
payload[field.name] = raw; payload[field.name] = raw;
@@ -350,13 +384,19 @@ const RuleEngineFormDialog = ({
} }
} }
if (fields.some((f) => f.name === "code" && typeof payload.code === "string")) { if (
fields.some((f) => f.name === "code" && typeof payload.code === "string")
) {
payload.code = String(payload.code).toUpperCase(); payload.code = String(payload.code).toUpperCase();
} }
if (blocked) return; if (blocked) return;
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) { if (
!initialRecord &&
positionOptions &&
position !== RULE_ENGINE_POSITION_END
) {
payload.insertAfterId = position; payload.insertAfterId = position;
} }
@@ -421,7 +461,9 @@ const RuleEngineFormDialog = ({
const setRows = (next: TierRow[]) => setField(field.name, next); const setRows = (next: TierRow[]) => setField(field.name, next);
const setRow = (index: number, key: keyof TierRow, value: string) => { const setRow = (index: number, key: keyof TierRow, value: string) => {
if (value.trim().startsWith("-")) return; if (value.trim().startsWith("-")) return;
setRows(rows.map((row, i) => (i === index ? { ...row, [key]: value } : row))); setRows(
rows.map((row, i) => (i === index ? { ...row, [key]: value } : row)),
);
}; };
return ( return (
<Box key={field.name}> <Box key={field.name}>
@@ -443,7 +485,9 @@ const RuleEngineFormDialog = ({
step="any" step="any"
placeholder="0" placeholder="0"
value={row.minKm} value={row.minKm}
onChange={(e) => setRow(index, "minKm", e.currentTarget.value)} onChange={(e) =>
setRow(index, "minKm", e.currentTarget.value)
}
size="md" size="md"
radius="md" radius="md"
styles={inputStyles} styles={inputStyles}
@@ -456,7 +500,9 @@ const RuleEngineFormDialog = ({
step="any" step="any"
placeholder="No limit" placeholder="No limit"
value={row.maxKm} value={row.maxKm}
onChange={(e) => setRow(index, "maxKm", e.currentTarget.value)} onChange={(e) =>
setRow(index, "maxKm", e.currentTarget.value)
}
size="md" size="md"
radius="md" radius="md"
styles={inputStyles} styles={inputStyles}
@@ -469,7 +515,9 @@ const RuleEngineFormDialog = ({
step="any" step="any"
placeholder="Rate per km" placeholder="Rate per km"
value={row.rateValue} value={row.rateValue}
onChange={(e) => setRow(index, "rateValue", e.currentTarget.value)} onChange={(e) =>
setRow(index, "rateValue", e.currentTarget.value)
}
size="md" size="md"
radius="md" radius="md"
styles={inputStyles} styles={inputStyles}
@@ -494,7 +542,12 @@ const RuleEngineFormDialog = ({
size="xs" size="xs"
leftSection={<Plus size={14} />} leftSection={<Plus size={14} />}
// The next tier naturally starts where the previous one ends. // The next tier naturally starts where the previous one ends.
onClick={() => setRows([...rows, emptyTier(rows[rows.length - 1]?.maxKm ?? "")])} onClick={() =>
setRows([
...rows,
emptyTier(rows[rows.length - 1]?.maxKm ?? ""),
])
}
> >
Add tier Add tier
</Button> </Button>
@@ -531,7 +584,10 @@ const RuleEngineFormDialog = ({
onChange={(v) => setField(field.name, v)} onChange={(v) => setField(field.name, v)}
disabled={selectOptionsLoading} disabled={selectOptionsLoading}
data={options data={options
.filter((opt) => opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE) .filter(
(opt) =>
opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE,
)
.map((opt) => ({ label: opt.label, value: opt.value }))} .map((opt) => ({ label: opt.label, value: opt.value }))}
searchable searchable
clearable clearable
@@ -545,7 +601,9 @@ const RuleEngineFormDialog = ({
if (field.type === "select") { if (field.type === "select") {
// Dynamic options (e.g. rate unit) resolve from the live form values so // Dynamic options (e.g. rate unit) resolve from the live form values so
// the choices track the other fields the admin has picked. // the choices track the other fields the admin has picked.
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []); const options = field.optionsFromValues
? field.optionsFromValues(values)
: (field.options ?? []);
// A derived select shows (and submits) its computed value and is locked, // A derived select shows (and submits) its computed value and is locked,
// matching the text-input branch — used by fields the shape decides on the // matching the text-input branch — used by fields the shape decides on the
// admin's behalf, e.g. a shipping-line rate's import-only direction. // admin's behalf, e.g. a shipping-line rate's import-only direction.
@@ -558,14 +616,18 @@ const RuleEngineFormDialog = ({
label={label} label={label}
description={field.description} description={field.description}
placeholder={ placeholder={
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option") selectOptionsLoading
? "Loading options..."
: (field.placeholder ?? "Select an option")
} }
value={ value={
computedSelect !== undefined computedSelect !== undefined
? computedSelect ? computedSelect
: resolveSelectValue(field, values) : resolveSelectValue(field, values)
} }
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)} onChange={(v) =>
setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)
}
disabled={ disabled={
selectOptionsLoading || selectOptionsLoading ||
field.disabled || field.disabled ||
@@ -635,8 +697,29 @@ const RuleEngineFormDialog = ({
); );
} }
if (field.type === "phone") {
// Country-picker input restricted to Ethiopia and Djibouti — the two the
// SMS gateway reaches. Emits E.164, so the API never guesses a country
// from a bare local number.
return (
<PhoneField
key={field.name}
label={label}
description={field.description}
value={String(values[field.name] ?? "")}
onChange={(v) => setField(field.name, v ?? "")}
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
required={field.required}
error={fieldErrors[field.name] || undefined}
placeholder={field.placeholder}
/>
);
}
const isNumber = field.type === "number"; const isNumber = field.type === "number";
const computed = field.computeValue ? field.computeValue(values) : undefined; const computed = field.computeValue
? field.computeValue(values)
: undefined;
return ( return (
<TextInput <TextInput
@@ -654,8 +737,14 @@ const RuleEngineFormDialog = ({
// numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km), // numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km),
// so let the field carry decimals and let a 400 catch the rest. // so let the field carry decimals and let a 400 catch the rest.
step={isNumber ? "any" : undefined} step={isNumber ? "any" : undefined}
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord) || computed !== undefined} disabled={
value={String((computed !== undefined ? computed : values[field.name]) ?? "")} field.disabled ||
(field.disabledOnEdit && !!initialRecord) ||
computed !== undefined
}
value={String(
(computed !== undefined ? computed : values[field.name]) ?? "",
)}
onChange={(e) => { onChange={(e) => {
const next = e.currentTarget.value; const next = e.currentTarget.value;
if (isNumber && next.trim().startsWith("-")) return; if (isNumber && next.trim().startsWith("-")) return;
@@ -703,16 +792,27 @@ const RuleEngineFormDialog = ({
> >
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Stack gap="lg"> <Stack gap="lg">
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}> <Box
style={{
maxHeight: "calc(65vh - 120px)",
overflowY: "auto",
paddingRight: 4,
}}
>
<Stack gap="md"> <Stack gap="md">
{!initialRecord && positionOptions ? ( {!initialRecord && positionOptions ? (
<Select <Select
label="Position" label="Position"
description="New items are appended to the end by default." description="New items are appended to the end by default."
value={position} value={position}
onChange={(value) => setPosition(value ?? RULE_ENGINE_POSITION_END)} onChange={(value) =>
setPosition(value ?? RULE_ENGINE_POSITION_END)
}
data={[ data={[
{ label: "At end (default)", value: RULE_ENGINE_POSITION_END }, {
label: "At end (default)",
value: RULE_ENGINE_POSITION_END,
},
...positionOptions, ...positionOptions,
]} ]}
searchable searchable
@@ -724,9 +824,17 @@ const RuleEngineFormDialog = ({
) : null} ) : null}
{formRows.map((row) => {formRows.map((row) =>
row.kind === "pair" ? ( row.kind === "pair" ? (
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md"> <SimpleGrid
<Box style={{ minWidth: 0 }}>{renderField(row.fields[0])}</Box> key={`${row.fields[0].name}-${row.fields[1].name}`}
<Box style={{ minWidth: 0 }}>{renderField(row.fields[1])}</Box> cols={2}
spacing="md"
>
<Box style={{ minWidth: 0 }}>
{renderField(row.fields[0])}
</Box>
<Box style={{ minWidth: 0 }}>
{renderField(row.fields[1])}
</Box>
</SimpleGrid> </SimpleGrid>
) : ( ) : (
<Box key={row.field.name}>{renderField(row.field)}</Box> <Box key={row.field.name}>{renderField(row.field)}</Box>
@@ -752,7 +860,10 @@ const RuleEngineFormDialog = ({
disabled={isSubmitting} disabled={isSubmitting}
leftSection={ leftSection={
isSubmitting ? ( isSubmitting ? (
<Loader2 size={18} style={{ animation: "spin 1s linear infinite" }} /> <Loader2
size={18}
style={{ animation: "spin 1s linear infinite" }}
/>
) : undefined ) : undefined
} }
radius="md" radius="md"

View File

@@ -25,12 +25,20 @@ export const formatCell = (
row?: Record<string, unknown>, row?: Record<string, unknown>,
): ReactNode => { ): ReactNode => {
if (value === null || value === undefined || value === "") { if (value === null || value === undefined || value === "") {
return <Text size="sm" c="dimmed"></Text>; return (
<Text size="sm" c="dimmed">
</Text>
);
} }
// Handle stringified objects (e.g., "[object Object]") // Handle stringified objects (e.g., "[object Object]")
if (typeof value === "string" && value.trim() === "[object Object]") { if (typeof value === "string" && value.trim() === "[object Object]") {
return <Text size="sm" c="dimmed"></Text>; return (
<Text size="sm" c="dimmed">
</Text>
);
} }
if (format === "boolean") { if (format === "boolean") {
@@ -75,9 +83,17 @@ export const formatCell = (
if (format === "validityBadge") { if (format === "validityBadge") {
const status = String(value); const status = String(value);
const label = const label =
status === "VALID" ? "Valid" : status === "EXPIRED" ? "Expired" : "Not started"; status === "VALID"
? "Valid"
: status === "EXPIRED"
? "Expired"
: "Not started";
const color = const color =
status === "VALID" ? "edr-green" : status === "EXPIRED" ? "red" : "yellow"; status === "VALID"
? "edr-green"
: status === "EXPIRED"
? "red"
: "yellow";
return ( return (
<Badge color={color} variant="filled" size="sm" radius="md"> <Badge color={color} variant="filled" size="sm" radius="md">
{label} {label}
@@ -85,6 +101,20 @@ export const formatCell = (
); );
} }
if (format === "accountBadge") {
// `hasAccount` — whether a portal login backs this row. Roster-only rows
// predate accounts and stay legal, so "no" is a neutral dash, not a warning.
return value ? (
<Badge color="edr-green" variant="filled" size="sm" radius="md">
Invited
</Badge>
) : (
<Text size="sm" c="dimmed">
</Text>
);
}
if (format === "code") { if (format === "code") {
return ( return (
<Badge <Badge
@@ -110,7 +140,11 @@ export const formatCell = (
if (format === "number") { if (format === "number") {
const num = Number(value); const num = Number(value);
return <Text size="sm">{Number.isNaN(num) ? String(value) : num.toLocaleString()}</Text>; return (
<Text size="sm">
{Number.isNaN(num) ? String(value) : num.toLocaleString()}
</Text>
);
} }
if (format === "currency") { if (format === "currency") {
@@ -125,14 +159,13 @@ export const formatCell = (
if (format === "date") { if (format === "date") {
const d = new Date(String(value)); const d = new Date(String(value));
if (Number.isNaN(d.getTime())) return <Text size="sm">{String(value)}</Text>; if (Number.isNaN(d.getTime()))
return <Text size="sm">{String(value)}</Text>;
return <Text size="sm">{d.toLocaleDateString()}</Text>; return <Text size="sm">{d.toLocaleDateString()}</Text>;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
return ( return <Text size="sm">{value.length > 0 ? value.join(", ") : "—"}</Text>;
<Text size="sm">{value.length > 0 ? value.join(", ") : "—"}</Text>
);
} }
if (format === "entityLabel" && value && typeof value === "object") { if (format === "entityLabel" && value && typeof value === "object") {
@@ -140,7 +173,11 @@ export const formatCell = (
if (label) { if (label) {
return <Text size="sm">{label}</Text>; return <Text size="sm">{label}</Text>;
} }
return <Text size="sm" c="dimmed"></Text>; return (
<Text size="sm" c="dimmed">
</Text>
);
} }
if (format === "rateLabel") { if (format === "rateLabel") {
@@ -150,7 +187,9 @@ export const formatCell = (
{String(value)} {String(value)}
</Text> </Text>
) : ( ) : (
<Text size="sm" c="dimmed"></Text> <Text size="sm" c="dimmed">
</Text>
); );
} }
const rate = value as { const rate = value as {
@@ -168,14 +207,20 @@ export const formatCell = (
return parts.length > 0 ? ( return parts.length > 0 ? (
<Text size="sm">{parts.join(" · ")}</Text> <Text size="sm">{parts.join(" · ")}</Text>
) : ( ) : (
<Text size="sm" c="dimmed"></Text> <Text size="sm" c="dimmed">
</Text>
); );
} }
if (typeof value === "object") { if (typeof value === "object") {
const label = extractLabel(value); const label = extractLabel(value);
if (label) return <Text size="sm">{label}</Text>; if (label) return <Text size="sm">{label}</Text>;
return <Text size="sm" c="dimmed"></Text>; return (
<Text size="sm" c="dimmed">
</Text>
);
} }
return <Text size="sm">{String(value)}</Text>; return <Text size="sm">{String(value)}</Text>;

View File

@@ -10,9 +10,10 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Send } from "lucide-react"; import { Send } from "lucide-react";
import { useState } from "react"; import { useState, type ReactNode } from "react";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { isSmsReachable } from "@/components/PhoneField";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api"; import { api } from "@/services/api";
@@ -21,27 +22,19 @@ import type {
ShippingLineCompany, ShippingLineCompany,
} from "@/types/shippingLineCompany"; } from "@/types/shippingLineCompany";
/** /** The minimum an account needs for the link dialog to describe its channels. */
* Whether the SMS gateway can actually reach this number. export interface ActivationTarget {
* id: string;
* The carrier integration is domestic-only: anything else is queued and name: string;
* silently lost, so a foreign number counts as unavailable rather than as a email?: string | null;
* send that quietly fails. Mirrors `isDomesticPhone` in the API's otp.service. phoneNumber?: string | null;
*/
function isDomesticPhone(rawPhone: string): boolean {
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
const normalized = digits.startsWith("+")
? digits
: /^251\d{9}$/.test(digits)
? `+${digits}`
: /^9\d{8}$|^7\d{8}$/.test(digits.replace(/^0+/, ""))
? `+251${digits.replace(/^0+/, "")}`
: digits;
return /^\+2519\d{8}$/.test(normalized);
} }
export interface ResendActivationActionProps { export interface ResendActivationActionProps {
shippingLine: Pick<ShippingLineCompany, "id" | "name" | "email" | "phoneNumber">; shippingLine: Pick<
ShippingLineCompany,
"id" | "name" | "email" | "phoneNumber"
>;
} }
/** /**
@@ -56,15 +49,13 @@ export default function ResendActivationAction({
}: ResendActivationActionProps) { }: ResendActivationActionProps) {
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const [opened, setOpened] = useState(false); const allowed = hasPermission(
const [channel, setChannel] = useState<ResetChannel>("email"); user,
FREIGHT_PERMS.shippingLines.resetPassword,
const allowed = hasPermission(user, FREIGHT_PERMS.shippingLines.resetPassword); );
const { mutate, isPending } = useMutation( const { mutate, isPending } = useMutation(
api.shippingLineCompanies.resendActivation.mutationOptions({ api.shippingLineCompanies.resendActivation.mutationOptions({
onSuccess: (result) => { onSuccess: (result) => {
setOpened(false);
toast({ toast({
title: "Activation link sent", title: "Activation link sent",
description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`, description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
@@ -82,42 +73,100 @@ export default function ResendActivationAction({
if (!allowed) return null; if (!allowed) return null;
const phoneUsable = return (
!!shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber); <ActivationLinkDialog
target={shippingLine}
audienceLabel="shipping line"
isPending={isPending}
onSend={(channel, onDone) =>
mutate({ id: shippingLine.id, channel }, { onSuccess: onDone })
}
/>
);
}
export interface ActivationLinkDialogProps {
target: ActivationTarget;
/** How the account is described in the dialog copy ("shipping line", "transit agent"). */
audienceLabel: string;
isPending: boolean;
/**
* Perform the send. `onDone` closes the dialog — the caller owns the mutation
* (and its toast) because each audience posts to its own endpoint.
*/
onSend: (channel: ResetChannel, onDone: () => void) => void;
/** Overrides the icon-button trigger, e.g. a labelled "Invite" button. */
trigger?: (open: () => void) => ReactNode;
title?: string;
submitLabel?: string;
/** Extra fields above the channel picker — the invite flow collects the address here. */
children?: ReactNode;
/** Blocks the send button, e.g. while a required address is still empty. */
submitDisabled?: boolean;
}
/**
* The channel picker behind every activation-link send.
*
* Extracted from the shipping-line action so transit agents get the identical
* dialog — including the domestic-only SMS rule, which is the part most likely
* to be re-implemented subtly wrong.
*/
export function ActivationLinkDialog({
target,
audienceLabel,
isPending,
onSend,
trigger,
title = "Resend activation link",
submitLabel = "Send activation link",
children,
submitDisabled,
}: ActivationLinkDialogProps) {
const [opened, setOpened] = useState(false);
const [channel, setChannel] = useState<ResetChannel>("email");
const phoneUsable = isSmsReachable(target.phoneNumber);
const channelMissing = channel === "phone" && !phoneUsable; const channelMissing = channel === "phone" && !phoneUsable;
return ( return (
<> <>
<Tooltip label="Resend activation link" withArrow> {trigger ? (
<ActionIcon trigger(() => setOpened(true))
variant="subtle" ) : (
color="gray" <Tooltip label={title} withArrow>
aria-label={`Resend activation link to ${shippingLine.name}`} <ActionIcon
onClick={(event) => { variant="subtle"
// The row itself is not clickable today, but stop here anyway so color="gray"
// adding a detail-page navigation later cannot swallow this click. aria-label={`${title} to ${target.name}`}
event.stopPropagation(); onClick={(event) => {
setOpened(true); // The row itself is not clickable today, but stop here anyway so
}} // adding a detail-page navigation later cannot swallow this click.
> event.stopPropagation();
<Send size={16} /> setOpened(true);
</ActionIcon> }}
</Tooltip> >
<Send size={16} />
</ActionIcon>
</Tooltip>
)}
<Modal <Modal
opened={opened} opened={opened}
onClose={() => setOpened(false)} onClose={() => setOpened(false)}
title="Resend activation link" title={title}
centered centered
> >
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
We&apos;ll send a single-use link to {shippingLine.name}. They choose We&apos;ll send a single-use link to this {audienceLabel},{" "}
their own password you will not see it. The link expires in 24 {target.name}. They choose their own password you will not see it.
hours, and sending a new one invalidates nothing they haven&apos;t The link expires in 24 hours, and sending a new one invalidates
already used. nothing they haven&apos;t already used.
</Text> </Text>
{children}
<Radio.Group <Radio.Group
value={channel} value={channel}
onChange={(v) => setChannel(v as ResetChannel)} onChange={(v) => setChannel(v as ResetChannel)}
@@ -127,18 +176,19 @@ export default function ResendActivationAction({
<Radio <Radio
value="email" value="email"
label="Email" label="Email"
description={shippingLine.email} disabled={!target.email}
description={target.email ?? "No email on this account"}
/> />
<Radio <Radio
value="phone" value="phone"
label="SMS" label="SMS"
disabled={!phoneUsable} disabled={!phoneUsable}
description={ description={
!shippingLine.phoneNumber !target.phoneNumber
? "No phone number on this account" ? "No phone number on this account"
: !phoneUsable : !phoneUsable
? `${shippingLine.phoneNumber}foreign number, SMS unavailable; use email` ? `${target.phoneNumber}the SMS gateway does not reach this number; use email`
: shippingLine.phoneNumber : target.phoneNumber
} }
/> />
</Stack> </Stack>
@@ -156,10 +206,10 @@ export default function ResendActivationAction({
<Button <Button
color="edr-green" color="edr-green"
loading={isPending} loading={isPending}
disabled={channelMissing} disabled={channelMissing || submitDisabled}
onClick={() => mutate({ id: shippingLine.id, channel })} onClick={() => onSend(channel, () => setOpened(false))}
> >
Send activation link {submitLabel}
</Button> </Button>
</Stack> </Stack>
</Modal> </Modal>

View File

@@ -303,6 +303,7 @@ export function ScheduleWorkspacePanel({
const [moveBookingId, setMoveBookingId] = useState<string | null>(null); const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
// Per-wagon loading/unloading modal for one booking. // Per-wagon loading/unloading modal for one booking.
const [wagonModal, setWagonModal] = useState<{ const [wagonModal, setWagonModal] = useState<{
isExport: boolean;
bookingId: string; bookingId: string;
ref: string; ref: string;
phase: "load" | "unload"; phase: "load" | "unload";
@@ -908,7 +909,12 @@ export function ScheduleWorkspacePanel({
radius="md" radius="md"
disabled={!canLoad || !loadWindowStarted} disabled={!canLoad || !loadWindowStarted}
onClick={() => onClick={() =>
setWagonModal({ bookingId: b.id, ref, phase: "load" }) setWagonModal({
bookingId: b.id,
ref,
phase: "load",
isExport: b.tradeDirection === "EXPORT",
})
} }
> >
Wagons Wagons
@@ -987,7 +993,12 @@ export function ScheduleWorkspacePanel({
radius="md" radius="md"
disabled={!canUnload || !unloadWindowStarted} disabled={!canUnload || !unloadWindowStarted}
onClick={() => onClick={() =>
setWagonModal({ bookingId: b.id, ref, phase: "unload" }) setWagonModal({
bookingId: b.id,
ref,
phase: "unload",
isExport: b.tradeDirection === "EXPORT",
})
} }
> >
Wagons Wagons
@@ -1035,6 +1046,7 @@ export function ScheduleWorkspacePanel({
bookingId={wagonModal.bookingId} bookingId={wagonModal.bookingId}
reference={wagonModal.ref} reference={wagonModal.ref}
phase={wagonModal.phase} phase={wagonModal.phase}
isExport={wagonModal.isExport}
onClose={() => setWagonModal(null)} onClose={() => setWagonModal(null)}
onChanged={onChanged} onChanged={onChanged}
/> />
@@ -1423,6 +1435,7 @@ function PerWagonModal({
bookingId, bookingId,
reference, reference,
phase, phase,
isExport,
onClose, onClose,
onChanged, onChanged,
}: { }: {
@@ -1430,13 +1443,35 @@ function PerWagonModal({
bookingId: string; bookingId: string;
reference: string; reference: string;
phase: "load" | "unload"; phase: "load" | "unload";
/**
* EXPORT booking — only these may use the truck-to-train submit, which is
* what the server's handover-mode endpoint enforces too (it 400s on any
* other direction).
*/
isExport: boolean;
onClose: () => void; onClose: () => void;
onChanged: () => void; onChanged: () => void;
}) { }) {
const { toast } = useToast(); const { toast } = useToast();
const [cancelOpen, setCancelOpen] = useState(false);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [edrFault, setEdrFault] = useState(false); const [edrFault, setEdrFault] = useState(false);
// Wagons ticked for this submit. Load is a batch action now: pick the wagons
// that physically went on, then submit once.
const [picked, setPicked] = useState<Set<string>>(new Set());
// Which submit is in flight — also decides whether the handover mode is set
// first ("truck") or the GRN gate is left to reject ("load").
const [submitting, setSubmitting] = useState<null | "load" | "truck">(null);
// Submit awaiting confirmation. Loading is irreversible from this screen —
// there is no "unload back to the yard" here — and truck-to-train also drops
// the booking's GRN requirement for good, so both go through a confirm step.
// The one open panel, if any. A single slot rather than a flag per panel:
// separate booleans let the cancel form and a submit confirm show at the same
// time, each with its own buttons.
const [confirmSubmit, setConfirmSubmit] = useState<null | "load" | "truck" | "cancel">(
null,
);
const cancelOpen = confirmSubmit === "cancel";
const wagonsQuery = useQuery(api.trainScheduling.bookingWagons.queryOptions({ const wagonsQuery = useQuery(api.trainScheduling.bookingWagons.queryOptions({
input: { bookingId }, input: { bookingId },
@@ -1463,41 +1498,118 @@ function PerWagonModal({
? ((err.response?.data as { message?: string })?.message ?? err.message) ? ((err.response?.data as { message?: string })?.message ?? err.message)
: String(err); : String(err);
const onWagon = (allocationId: string) => { const toggle = (allocationId: string) =>
act setPicked((prev) => {
.mutateAsync({ scheduleId, bookingId, allocationId }) const next = new Set(prev);
.then((r) => { if (next.has(allocationId)) next.delete(allocationId);
void wagonsQuery.refetch(); else next.add(allocationId);
if (r.completed) { return next;
toast({ });
title: phase === "load" ? "Booking fully loaded" : "Booking fully unloaded",
description: `${reference}: every wagon is ${phase === "load" ? "loaded — the booking is in transit" : "unloaded — the booking arrived"}.`, const pickedPending = pending.filter((w) => picked.has(w.allocationId));
/**
* Submit the ticked wagons. There is no batch endpoint, so they go one at a
* time in order — the server flips the booking to IN_TRANSIT on whichever
* call clears the last unloaded wagon, so sequential is required, not just
* convenient. The first failure stops the run: the wagons already sent stay
* loaded (each call is its own transaction) and the toast names the survivor
* count, so a retry only resends what is left.
*
* `mode: "truck"` first sets DIRECT_TO_TRAIN, which is what makes the GRN
* gate let this booking through — see assertExportReceivedWithGrn. Plain
* "load" sends nothing extra and lets that gate reject unreceived cargo.
*/
const onSubmit = (mode: "load" | "truck") => {
const targets = pickedPending;
if (!targets.length) return;
setConfirmSubmit(null);
setSubmitting(mode);
const run = async () => {
if (mode === "truck") {
await bookingsService.setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN");
}
let done = 0;
let completed = false;
try {
for (const w of targets) {
const r = await act.mutateAsync({
scheduleId,
bookingId,
allocationId: w.allocationId,
}); });
onChanged(); done += 1;
onClose(); if (r.completed) completed = true;
} else { }
} catch (err) {
// Partial success is a real outcome here, not a rollback candidate:
// report what landed so the operator knows what to retry.
if (done > 0) {
void wagonsQuery.refetch();
onChanged(); onChanged();
} }
throw Object.assign(err as Error, { partial: done });
}
return { done, completed };
};
run()
.then(({ done, completed }) => {
void wagonsQuery.refetch();
onChanged();
setPicked(new Set());
if (completed) {
toast({
title: phase === "load" ? "Booking fully loaded" : "Booking fully unloaded",
description:
phase === "load"
? `${reference}: every wagon is loaded — the booking is in transit${mode === "truck" ? " (direct truck-to-train handover)" : ""}.`
: `${reference}: every wagon is unloaded — the booking arrived.`,
});
onClose();
} else {
toast({
title: phase === "load" ? "Wagons loaded" : "Wagons unloaded",
description: `${reference}: ${done} wagon${done === 1 ? "" : "s"} ${phase === "load" ? "loaded" : "unloaded"}.`,
});
}
}) })
.catch((err) => .catch((err) => {
const sent = (err as { partial?: number }).partial ?? 0;
toast({ toast({
title: phase === "load" ? "Wagon load failed" : "Wagon unload failed", title: phase === "load" ? "Wagon load failed" : "Wagon unload failed",
description: errText(err), description: sent
? `${sent} wagon(s) went through before this: ${errText(err)}`
: errText(err),
variant: "destructive", variant: "destructive",
}), });
); })
.finally(() => setSubmitting(null));
}; };
const onCancelRemaining = () => { const onCancelRemaining = () => {
// Cut exactly the ticked wagons. Sending the ids (rather than omitting them
// and letting the server cut the whole remainder) is what makes a partial
// cancel possible while other wagons are still waiting to load.
const ids = pickedPending.map((w) => w.allocationId);
const count = ids.length;
cancelRemaining cancelRemaining
.mutateAsync({ bookingId, scheduleId, reason: reason.trim(), edrFault }) .mutateAsync({
bookingId,
scheduleId,
reason: reason.trim(),
edrFault,
wagonAllocationIds: ids,
})
.then(() => { .then(() => {
toast({ toast({
title: "Remaining wagons cancelled", title: "Wagons cancelled",
description: edrFault description: edrFault
? `${reference}: ${pending.length} wagon(s) cancelled at EDR's fault — no fee charged; the credit is rebookable.` ? `${reference}: ${count} wagon(s) cancelled at EDR's fault — no fee charged; the credit is rebookable.`
: `${reference}: ${pending.length} wagon(s) cancelled — the cancellation fee was invoiced to the customer; the credit is rebookable.`, : `${reference}: ${count} wagon(s) cancelled — the cancellation fee was invoiced to the customer; the credit is rebookable.`,
}); });
setPicked(new Set());
onChanged(); onChanged();
onClose(); onClose();
}) })
@@ -1550,6 +1662,15 @@ function PerWagonModal({
<Paper key={w.allocationId} withBorder radius="md" p="xs"> <Paper key={w.allocationId} withBorder radius="md" p="xs">
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}> <Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
{!isDone(w) ? (
<Checkbox
checked={picked.has(w.allocationId)}
onChange={() => toggle(w.allocationId)}
disabled={submitting != null}
color={phase === "load" ? "edr-green" : "orange"}
aria-label={`Select wagon ${w.sequenceNo ?? ""} to ${phase}`}
/>
) : null}
<Badge size="sm" radius="sm" variant="outline" color="gray"> <Badge size="sm" radius="sm" variant="outline" color="gray">
{w.sequenceNo != null ? `#${w.sequenceNo}` : "—"} {w.sequenceNo != null ? `#${w.sequenceNo}` : "—"}
</Badge> </Badge>
@@ -1574,50 +1695,216 @@ function PerWagonModal({
> >
{phase === "load" ? "Loaded" : "Unloaded"} {phase === "load" ? "Loaded" : "Unloaded"}
</Badge> </Badge>
) : ( ) : null}
<Button
size="compact-sm"
variant="filled"
color={phase === "load" ? "edr-green" : "orange"}
radius="md"
leftSection={
phase === "load" ? <PackageCheck size={13} /> : <PackageOpen size={13} />
}
loading={
act.isPending && act.variables?.allocationId === w.allocationId
}
onClick={() => onWagon(w.allocationId)}
>
{phase === "load" ? "Load" : "Unload"}
</Button>
)}
</Group> </Group>
</Paper> </Paper>
)) ))
)} )}
{phase === "load" && doneCount > 0 && pending.length > 0 ? ( {pending.length > 0 && !confirmSubmit ? (
!cancelOpen ? ( <Group justify="space-between" wrap="wrap" gap="sm">
<Button <Group gap={8}>
variant="light" <Button
color="red" size="compact-sm"
radius="md" variant="subtle"
leftSection={<X size={14} />} radius="md"
onClick={() => setCancelOpen(true)} disabled={submitting != null}
> onClick={() =>
Cancel the {pending.length} remaining wagon{pending.length === 1 ? "" : "s"} setPicked(
</Button> picked.size === pending.length
) : ( ? new Set()
<Paper withBorder radius="md" p="sm"> : new Set(pending.map((w) => w.allocationId)),
)
}
>
{picked.size === pending.length ? "Clear all" : "Select all"}
</Button>
<Text size="xs" c="dimmed">
{pickedPending.length} of {pending.length} selected
</Text>
</Group>
<Group gap="sm">
{phase === "load" ? (
<Tooltip
label="Cancel the selected wagons — they will not ride. Customer fault invoices the cancellation fee; EDR fault charges nothing."
withArrow
>
<Button
variant="light"
color="red"
radius="md"
leftSection={<X size={14} />}
disabled={!pickedPending.length || submitting != null}
onClick={() => setConfirmSubmit("cancel")}
>
Cancel
</Button>
</Tooltip>
) : null}
{phase === "load" && isExport ? (
<Tooltip
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover, then loads the selected wagons."
withArrow
>
<Button
variant="light"
color="blue"
radius="md"
leftSection={<Truck size={14} />}
disabled={!pickedPending.length || submitting != null}
loading={submitting === "truck"}
onClick={() => setConfirmSubmit("truck")}
>
Truck to train
</Button>
</Tooltip>
) : null}
<Tooltip
label={
phase === "load"
? "Load the selected wagons — export cargo must already be received at the warehouse with a GRN."
: "Unload the selected wagons."
}
withArrow
>
<Button
color={phase === "load" ? "edr-green" : "orange"}
radius="md"
leftSection={
phase === "load" ? <PackageCheck size={14} /> : <PackageOpen size={14} />
}
disabled={!pickedPending.length || submitting != null}
loading={submitting === "load"}
onClick={() => setConfirmSubmit("load")}
>
{phase === "load" ? "Load" : "Unload"} {pickedPending.length || ""}
</Button>
</Tooltip>
</Group>
</Group>
) : null}
{confirmSubmit && confirmSubmit !== "cancel" ? (
<Paper withBorder radius="md" p="sm">
<Stack gap="xs">
<Group gap={10} wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="light"
color={
confirmSubmit === "truck"
? "blue"
: phase === "load"
? "edr-green"
: "orange"
}
>
{confirmSubmit === "truck" ? (
<Truck size={21} />
) : phase === "load" ? (
<PackageCheck size={21} />
) : (
<PackageOpen size={21} />
)}
</ThemeIcon>
<div>
<Text fw={800}>
{confirmSubmit === "truck"
? "Load as direct truck-to-train?"
: phase === "load"
? `Load ${pickedPending.length} wagon${pickedPending.length === 1 ? "" : "s"}?`
: `Unload ${pickedPending.length} wagon${pickedPending.length === 1 ? "" : "s"}?`}
</Text>
<Text size="xs" c="dimmed">
{reference}
</Text>
</div>
</Group>
<Text size="sm">
{confirmSubmit === "truck"
? `Sets direct truck-to-train handover for the whole booking (no warehouse receipt, no GRN — the carriage acceptance sheet becomes the handover document), then loads the ${pickedPending.length} selected wagon${pickedPending.length === 1 ? "" : "s"}.`
: phase === "load"
? "Stamps the selected wagons as loaded at this yard. Export cargo must already be received at the warehouse with a GRN."
: "Stamps the selected wagons as unloaded and frees them for reuse."}
</Text>
{confirmSubmit === "truck" ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
style={{
borderRadius: 10,
background: "var(--mantine-color-yellow-0)",
border: "1px solid var(--mantine-color-yellow-3)",
}}
>
<AlertTriangle size={16} color="#B54708" />
<Text size="xs" c="yellow.9" fw={500}>
The handover mode applies to the whole booking and stays set
even if a wagon then fails to load its GRN requirement is
dropped for good.
</Text>
</Group>
) : null}
{pickedPending.length < pending.length ? (
<Text size="xs" c="dimmed">
{pending.length - pickedPending.length} wagon
{pending.length - pickedPending.length === 1 ? "" : "s"} left
un{phase === "load" ? "loaded" : "unloaded"} the train cannot
dispatch until they are {phase === "load" ? "loaded" : "unloaded"} or
cancelled.
</Text>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setConfirmSubmit(null)}>
Back
</Button>
<Button
color={
confirmSubmit === "truck"
? "blue"
: phase === "load"
? "edr-green"
: "orange"
}
radius="md"
leftSection={
confirmSubmit === "truck" ? (
<Truck size={14} />
) : phase === "load" ? (
<PackageCheck size={14} />
) : (
<PackageOpen size={14} />
)
}
onClick={() => onSubmit(confirmSubmit === "truck" ? "truck" : "load")}
>
{confirmSubmit === "truck"
? "Load direct"
: phase === "load"
? "Load"
: "Unload"}
</Button>
</Group>
</Stack>
</Paper>
) : null}
{phase === "load" && cancelOpen && pickedPending.length > 0 ? (
<Paper withBorder radius="md" p="sm">
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
Cancel {pending.length} unloaded wagon Cancel {pickedPending.length} selected wagon
{pending.length === 1 ? "" : "s"} of {reference} {pickedPending.length === 1 ? "" : "s"} of {reference}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
The booking shrinks to its loaded wagons and the freed freight These wagons are cut from the booking and the freed freight
becomes a rebookable credit. Customer fault: the cancellation becomes a rebookable credit. Customer fault: the cancellation
fee is invoiced, payable afterwards. EDR fault: no fee. fee is invoiced, payable afterwards. EDR fault: no fee.
{pending.length > pickedPending.length
? ` The other ${pending.length - pickedPending.length} unloaded wagon(s) stay on the booking and still have to be loaded or cancelled before dispatch.`
: ""}
</Text> </Text>
<Textarea <Textarea
label="Reason" label="Reason"
@@ -1633,7 +1920,7 @@ function PerWagonModal({
onChange={(e) => setEdrFault(e.currentTarget.checked)} onChange={(e) => setEdrFault(e.currentTarget.checked)}
/> />
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setCancelOpen(false)}> <Button variant="default" radius="md" onClick={() => setConfirmSubmit(null)}>
Back Back
</Button> </Button>
<Button <Button
@@ -1647,8 +1934,7 @@ function PerWagonModal({
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>
)
) : null} ) : null}
</Stack> </Stack>
</Modal> </Modal>

View File

@@ -0,0 +1,156 @@
import { Button, Stack, TextInput, Tooltip } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import PhoneField, { isValidPhone } from "@/components/PhoneField";
import { ActivationLinkDialog } from "@/components/shipping-lines/ResendActivationAction";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
import { transitAgentsService } from "@/services/transit-agents.service";
import type { TransitAgent } from "@/services/transit-agents.service";
import type { ResetChannel } from "@/types/shippingLineCompany";
export interface TransitAgentAccountActionProps {
agent: TransitAgent;
/** Hidden entirely without the update permission, matching the other row controls. */
disabled?: boolean;
}
/**
* The account column's row control: **Invite** for an agent that has no portal
* login yet, **Resend** for one that has.
*
* Inviting is deliberately its own action rather than a side effect of editing
* the email: it mints an IAM user, and a field edit must never do that
* implicitly — the roster rows that predate portal logins would start growing
* accounts the first time anyone corrected a typo.
*/
export default function TransitAgentAccountAction({
agent,
disabled,
}: TransitAgentAccountActionProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [email, setEmail] = useState(agent.email ?? "");
const [phoneNumber, setPhoneNumber] = useState(agent.phoneNumber ?? "");
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.ROOT });
const invite = useMutation({
mutationFn: () =>
transitAgentsService.invite(agent.id, {
email: email.trim(),
phoneNumber: phoneNumber.trim() || undefined,
}),
onSuccess: async (result) => {
await invalidate();
toast({
title: "Portal account created",
description: result.activationSentTo
? `${agent.name} can set their password using the link sent to ${result.activationSentTo}. It expires in 24 hours.`
: `${agent.name} now has a portal account, but the activation link could not be sent — resend it.`,
});
},
onError: (error: Error) =>
toast({
title: "Could not create the portal account",
description: error.message,
variant: "destructive",
}),
});
const resend = useMutation({
mutationFn: (channel: ResetChannel) =>
transitAgentsService.resendActivation(agent.id, channel),
onSuccess: (result) =>
toast({
title: "Activation link sent",
description: `${agent.name} can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
}),
onError: (error: Error) =>
toast({
title: "Could not send activation link",
description: error.message,
variant: "destructive",
}),
});
if (disabled) return null;
// Already has a login — the only thing left is another copy of the link, so
// this is exactly the shipping-line dialog with a different endpoint behind it.
if (agent.hasAccount) {
return (
<ActivationLinkDialog
target={agent}
audienceLabel="transit agent"
isPending={resend.isPending}
onSend={(channel, onDone) =>
resend.mutate(channel, { onSuccess: onDone })
}
trigger={(open) => (
<Tooltip label="Resend activation link" withArrow>
<Button size="compact-xs" variant="light" onClick={open}>
Resend
</Button>
</Tooltip>
)}
/>
);
}
return (
<ActivationLinkDialog
// Show the addresses being typed, not the (empty) stored ones, so the
// channel picker enables SMS as soon as a domestic number is entered.
target={{
...agent,
email: email.trim() || null,
phoneNumber: phoneNumber.trim() || null,
}}
audienceLabel="transit agent"
isPending={invite.isPending}
title="Create portal account"
submitLabel="Create account and send link"
// The channel choice is the dialog's, but invite always emails (and texts
// a domestic number) — the API picks both. Sending is what matters here.
// A half-typed number is a non-empty partial E.164 the API rejects with a
// 400 — block it here rather than posting it.
submitDisabled={
!email.trim() || (!!phoneNumber.trim() && !isValidPhone(phoneNumber))
}
onSend={(_channel, onDone) =>
invite.mutate(undefined, { onSuccess: onDone })
}
trigger={(open) => (
<Tooltip label="Give this agent a portal login" withArrow>
<Button size="compact-xs" variant="light" onClick={open}>
Invite
</Button>
</Tooltip>
)}
>
<Stack gap="sm">
<TextInput
label="Email"
placeholder="a.bourhan@transit.dj"
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<PhoneField
label="Phone number"
value={phoneNumber}
onChange={(v) => setPhoneNumber(v ?? "")}
error={
phoneNumber.trim() && !isValidPhone(phoneNumber)
? "Enter the complete number"
: undefined
}
description="Ethiopian and Djiboutian mobiles also receive the link by SMS"
/>
</Stack>
</ActivationLinkDialog>
);
}

View File

@@ -609,6 +609,10 @@ export const URL_CONSTANTS = {
TRANSIT_AGENTS: "/transit-agents", TRANSIT_AGENTS: "/transit-agents",
TRANSIT_AGENT_BY_ID: (id: string) => `/transit-agents/${id}`, TRANSIT_AGENT_BY_ID: (id: string) => `/transit-agents/${id}`,
TRANSIT_AGENTS_ASSIGNABLE: "/transit-agents/assignable", TRANSIT_AGENTS_ASSIGNABLE: "/transit-agents/assignable",
/** Give an existing roster-only agent a portal account and send the link. */
TRANSIT_AGENT_INVITE: (id: string) => `/transit-agents/${id}/invite`,
TRANSIT_AGENT_RESEND_ACTIVATION: (id: string) =>
`/transit-agents/${id}/resend-activation`,
}, },
RATE_MATRIX: { RATE_MATRIX: {
BASE: "/api/rate-matrices", BASE: "/api/rate-matrices",

View File

@@ -26,6 +26,8 @@ import { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog"; import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection"; import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection"; import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
import TransitAgentAccountAction from "@/components/transit-agents/TransitAgentAccountAction";
import type { TransitAgent } from "@/services/transit-agents.service";
import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal"; import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal";
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange"; import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
@@ -577,8 +579,9 @@ const RuleEngineResourcePage = () => {
base.push({ base.push({
id: "actions", id: "actions",
header: "Actions", header: "Actions",
size: config.orderConfig ? 200 : 140, // Transit agents carry an extra Invite/Resend button in this cell.
minSize: config.orderConfig ? 180 : 120, size: config.orderConfig ? 200 : config.slug === "transit-agents" ? 200 : 140,
minSize: config.orderConfig ? 180 : config.slug === "transit-agents" ? 180 : 120,
meta: { meta: {
headerClassName, headerClassName,
cellClassName: `${cellClassName} whitespace-nowrap`, cellClassName: `${cellClassName} whitespace-nowrap`,
@@ -597,6 +600,12 @@ const RuleEngineResourcePage = () => {
</Button> </Button>
</Tooltip> </Tooltip>
) : null} ) : null}
{config.slug === "transit-agents" ? (
<TransitAgentAccountAction
agent={row.original as unknown as TransitAgent}
disabled={!canUpdateControls}
/>
) : null}
{config.orderConfig && canUpdateControls ? ( {config.orderConfig && canUpdateControls ? (
<RuleEngineOrderControls <RuleEngineOrderControls
record={row.original} record={row.original}

View File

@@ -11,13 +11,14 @@ export type ColumnFormat =
| "activeBadge" | "activeBadge"
| "rateStatus" | "rateStatus"
| "validityBadge" | "validityBadge"
| "accountBadge"
| "date" | "date"
| "number" | "number"
| "currency" | "currency"
| "entityLabel" | "entityLabel"
| "rateLabel"; | "rateLabel";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio" | "tierList"; export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "phone" | "select" | "multiselect" | "textarea" | "radio" | "tierList";
export interface ResourceColumn { export interface ResourceColumn {
id: string; id: string;
@@ -669,6 +670,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
accessorKey: "validityStatus", accessorKey: "validityStatus",
format: "validityBadge", format: "validityBadge",
}, },
{
id: "hasAccount",
header: "Portal account",
accessorKey: "hasAccount",
format: "accountBadge",
},
activeColumn, activeColumn,
], ],
formFields: [ formFields: [
@@ -681,6 +688,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
required: true, required: true,
description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one", description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one",
}, },
{
name: "email",
label: "Email",
type: "email",
optional: true,
description:
"Filling this on create makes the portal account and emails the activation link. On an existing agent, use the Invite button instead — editing here only corrects the address.",
},
{
name: "phoneNumber",
label: "Phone number",
type: "phone",
optional: true,
description: "Ethiopian and Djiboutian mobiles also receive the link by SMS",
},
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" }, { name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" },
], ],
}, },

View File

@@ -975,13 +975,25 @@ export const api = {
), ),
cancelRemainingWagons: endpoint< cancelRemainingWagons: endpoint<
{ bookingId: string; scheduleId: string; reason: string; edrFault?: boolean }, {
bookingId: string;
scheduleId: string;
reason: string;
edrFault?: boolean;
/** Cut only these never-loaded wagons; omit for the whole remainder. */
wagonAllocationIds?: string[];
},
unknown unknown
>( >(
"train-scheduling", "train-scheduling",
"cancel-remaining-wagons", "cancel-remaining-wagons",
({ bookingId, scheduleId, reason, edrFault }) => ({ bookingId, scheduleId, reason, edrFault, wagonAllocationIds }) =>
trainSchedulingService.cancelRemainingWagons(bookingId, { scheduleId, reason, edrFault }), trainSchedulingService.cancelRemainingWagons(bookingId, {
scheduleId,
reason,
edrFault,
wagonAllocationIds,
}),
undefined, undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS, () => TRAIN_SCHEDULING_INVALIDATIONS,
), ),

View File

@@ -545,7 +545,12 @@ export const trainSchedulingService = {
cancelRemainingWagons: async ( cancelRemainingWagons: async (
bookingId: string, bookingId: string,
payload: { scheduleId: string; reason: string; edrFault?: boolean }, payload: {
scheduleId: string;
reason: string;
edrFault?: boolean;
wagonAllocationIds?: string[];
},
): Promise<unknown> => { ): Promise<unknown> => {
const response = await client.post( const response = await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_REMAINING_WAGONS(bookingId), URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_REMAINING_WAGONS(bookingId),

View File

@@ -1,5 +1,6 @@
import { api } from "../auth/http"; import { api as apiClient } from "../auth/http";
import { URL_CONSTANTS } from "../constants/URLS"; import { URL_CONSTANTS } from "../constants/URLS";
import type { ResetChannel } from "../types/shippingLineCompany";
export interface TransitAgent { export interface TransitAgent {
id: string; id: string;
@@ -7,14 +8,53 @@ export interface TransitAgent {
validFrom: string; validFrom: string;
validTo: string; validTo: string;
isActive: boolean; isActive: boolean;
/** Null on every agent that exists only as a GL-assignable roster entry. */
email?: string | null;
phoneNumber?: string | null;
/** True once an IAM account backs the agent — i.e. it can sign in. */
hasAccount?: boolean;
}
export interface InviteTransitAgentDto {
email: string;
phoneNumber?: string;
username?: string;
}
/** What the API reports back about an activation send. */
export interface ActivationSendResult {
maskedTarget: string;
channel: string;
expiresAt: string;
} }
export const transitAgentsService = { export const transitAgentsService = {
/** Active + currently inside its validity window — the assignment dropdown. */ /** Active + currently inside its validity window — the assignment dropdown. */
async listAssignable() { async listAssignable() {
const response = await api.get<TransitAgent[]>( const response = await apiClient.get<TransitAgent[]>(
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE, URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE,
); );
return response.data; return response.data;
}, },
/**
* Create the portal account for an agent that has none and send its
* activation link. Separate from the rule-engine CRUD update because it mints
* an IAM user, which a field edit must never do implicitly.
*/
async invite(id: string, dto: InviteTransitAgentDto) {
const response = await apiClient.post<{
agent: TransitAgent;
activationSentTo: string | null;
}>(URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENT_INVITE(id), dto);
return response.data;
},
async resendActivation(id: string, channel: ResetChannel) {
const response = await apiClient.post<ActivationSendResult>(
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENT_RESEND_ACTIVATION(id),
{ channel },
);
return response.data;
},
}; };

View File

@@ -201,6 +201,10 @@ export interface BookingDetail {
cargoTotalWeightVgm: number; cargoTotalWeightVgm: number;
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */ /** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
bulkTotalWeightTons?: number | null; bulkTotalWeightTons?: number | null;
/** NUMBER_OF_WAGONS bulk only: the wagon count the customer booked. */
bulkRequestedWagons?: number | null;
/** NUMBER_OF_WAGONS bulk only: informational item count entered with the weight. */
bulkItemCount?: number | null;
isHazardous: boolean; isHazardous: boolean;
isReefer?: boolean; isReefer?: boolean;
consolidationPartnerId?: string | null; consolidationPartnerId?: string | null;
@@ -264,7 +268,9 @@ export interface BookingDetail {
originYard?: BookingNamedRef; originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef; destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean }; serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
cargoType?: BookingNamedRef; cargoType?: BookingNamedRef & {
unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" | null;
};
shippingLine?: BookingNamedRef; shippingLine?: BookingNamedRef;
bookingContainers?: BookingContainerLine[]; bookingContainers?: BookingContainerLine[];
reviewNotes?: BookingReviewNote[]; reviewNotes?: BookingReviewNote[];

View File

@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
import { import {
Home, Home,
Layers, Layers,
LayoutDashboard,
LifeBuoy, LifeBuoy,
Loader2, Loader2,
// MapPin, // MapPin,
@@ -68,6 +69,10 @@ import {
ShippingLineInvoicesPage, ShippingLineInvoicesPage,
ShippingLineSettingsPage, ShippingLineSettingsPage,
} from "./pages/shipping-line"; } from "./pages/shipping-line";
import {
TransitAgentBookingsPage,
TransitAgentOverviewPage,
} from "./pages/transit-agent";
import FaqPage from "./pages/support/FaqPage"; import FaqPage from "./pages/support/FaqPage";
import HelpPage from "./pages/support/HelpPage"; import HelpPage from "./pages/support/HelpPage";
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage"; import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
@@ -143,15 +148,15 @@ function isOnboardingAllowedPath(pathname: string): boolean {
* captured, so there is nothing for them to onboard — they go straight to home. * captured, so there is nothing for them to onboard — they go straight to home.
*/ */
function OnboardingGate() { function OnboardingGate() {
const { company, onboardingCompleted, isShippingLine } = useAuth(); const { company, onboardingCompleted, isShippingLine, isTransitAgent } =
useAuth();
const location = useLocation(); const location = useLocation();
// Keyed off a positive shipping-line identification, never off "no company": // Keyed off a positive shipping-line / transit-agent identification, never
// that is also true mid-fetch and on error, which would let customers slip // off "no company": that is also true mid-fetch and on error, which would let
// past onboarding whenever the request failed. // customers slip past onboarding whenever the request failed.
const needsOnboarding = isShippingLine const needsOnboarding =
? false isShippingLine || isTransitAgent ? false : !company || !onboardingCompleted;
: !company || !onboardingCompleted;
const allowedHere = isOnboardingAllowedPath(location.pathname); const allowedHere = isOnboardingAllowedPath(location.pathname);
// Open by default while onboarding is pending (covers the login case). // Open by default while onboarding is pending (covers the login case).
@@ -199,13 +204,14 @@ function OnboardingGate() {
* contract/company-shaped page that has no meaning for it. * contract/company-shaped page that has no meaning for it.
*/ */
function RequireCustomer() { function RequireCustomer() {
const { isShippingLine, customerQuery } = useAuth(); const { isShippingLine, isTransitAgent, customerQuery } = useAuth();
// RequireCompany already awaits this query, but guard anyway: a refetch can // RequireCompany already awaits this query, but guard anyway: a refetch can
// flip `isPending` back on, and redirecting on a half-loaded account would // flip `isPending` back on, and redirecting on a half-loaded account would
// throw the user into the wrong app. // throw the user into the wrong app.
if (customerQuery.isPending) return <FullScreenSpinner />; if (customerQuery.isPending) return <FullScreenSpinner />;
if (isShippingLine) return <Navigate to="/shipping-line" replace />; if (isShippingLine) return <Navigate to="/shipping-line" replace />;
if (isTransitAgent) return <Navigate to="/transit-agent" replace />;
return <Outlet />; return <Outlet />;
} }
@@ -218,6 +224,15 @@ function RequireShippingLine() {
return <Outlet />; return <Outlet />;
} }
/** Transit-agent routes, closed to every other account kind. */
function RequireTransitAgent() {
const { isTransitAgent, customerQuery } = useAuth();
if (customerQuery.isPending) return <FullScreenSpinner />;
if (!isTransitAgent) return <Navigate to="/portal" replace />;
return <Outlet />;
}
/** /**
* Where a signed-in account belongs. Shipping lines and customers have separate * Where a signed-in account belongs. Shipping lines and customers have separate
* apps, so every "you're already logged in" redirect has to pick between them. * apps, so every "you're already logged in" redirect has to pick between them.
@@ -225,11 +240,15 @@ function RequireShippingLine() {
* still in flight, which would land a shipping line on the customer home first. * still in flight, which would land a shipping line on the customer home first.
*/ */
function useHomeRoute(): { ready: boolean; href: string } { function useHomeRoute(): { ready: boolean; href: string } {
const { isShippingLine, customerQuery } = useAuth(); const { isShippingLine, isTransitAgent, customerQuery } = useAuth();
return { return {
ready: !customerQuery.isPending, ready: !customerQuery.isPending,
href: isShippingLine ? "/shipping-line" : "/portal", href: isShippingLine
? "/shipping-line"
: isTransitAgent
? "/transit-agent"
: "/portal",
}; };
} }
@@ -326,6 +345,24 @@ const shippingLineSidebarItems: SidebarItem[] = [
}, },
]; ];
/**
* Sidebar for transit agents. Two entries only — the rest of the portal
* (contracts, invoices, settings, support) is company-scoped and has no meaning
* for an agent, so nothing is filtered in from the other lists.
*/
const transitAgentSidebarItems: SidebarItem[] = [
{
label: "Overview",
href: "/transit-agent",
icon: <LayoutDashboard size={18} />,
},
{
label: "Bookings",
href: "/transit-agent/bookings",
icon: <Package size={18} />,
},
];
const App = () => { const App = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -337,6 +374,7 @@ const App = () => {
reapplyProfile, reapplyProfile,
isAuthenticated, isAuthenticated,
isShippingLine, isShippingLine,
isTransitAgent,
} = useAuth(); } = useAuth();
// Attribute replays and exceptions to the signed-in user (id/org only). // Attribute replays and exceptions to the signed-in user (id/org only).
@@ -478,6 +516,39 @@ const App = () => {
</Route> </Route>
)} )}
{/* Transit-agent app. Two pages, both empty for now, behind their
own layout — there is no support widget because the chat is
company-scoped and an agent has no company, exactly as for a
shipping line. */}
{isTransitAgent && (
<Route element={<RequireTransitAgent />}>
<Route
element={
<AppLayout
title="EDR Freight"
sidebarItems={transitAgentSidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
userName={displayName}
userEmail={userEmail}
showSupportWidget={false}
>
<Outlet />
</AppLayout>
}
>
<Route
path="/transit-agent"
element={<TransitAgentOverviewPage />}
/>
<Route
path="/transit-agent/bookings"
element={<TransitAgentBookingsPage />}
/>
</Route>
</Route>
)}
{/* Customer app — unchanged. */} {/* Customer app — unchanged. */}
<Route element={<RequireCustomer />}> <Route element={<RequireCustomer />}>
<Route <Route

View File

@@ -6,6 +6,7 @@ import type {
import { import {
companiesService, companiesService,
isShippingLineAccount, isShippingLineAccount,
isTransitAgentAccount,
} from "@/services/companies.service"; } from "@/services/companies.service";
import type { import type {
LoginPayload, LoginPayload,
@@ -184,10 +185,21 @@ const useAuth = () => {
const isShippingLine = isShippingLineAccount(accountInfo); const isShippingLine = isShippingLineAccount(accountInfo);
const shippingLine = isShippingLine ? accountInfo : null; const shippingLine = isShippingLine ? accountInfo : null;
// Every customer-shaped field below is null/empty for a shipping line. /**
const companyInfo = isShippingLine * Transit agents share the portal with customers and shipping lines but have
? null * no company, no external profile and no onboarding. Identified positively
: (accountInfo as CompanyInfoResponse | null); * from the backend's discriminator, for the same reason as the shipping line
* above — never from "company is missing".
*/
const isTransitAgent = isTransitAgentAccount(accountInfo);
const transitAgent = isTransitAgent ? accountInfo : null;
// Every customer-shaped field below is null/empty for a shipping line and for
// a transit agent alike.
const companyInfo =
isShippingLine || isTransitAgent
? null
: (accountInfo as CompanyInfoResponse | null);
const companyType = companyInfo?.company?.type ?? null; const companyType = companyInfo?.company?.type ?? null;
const companyStatus = companyInfo?.company?.status ?? null; const companyStatus = companyInfo?.company?.status ?? null;
// A company can create bookings only once an admin has approved it (active). // A company can create bookings only once an admin has approved it (active).
@@ -306,6 +318,8 @@ const useAuth = () => {
onboardingStep, onboardingStep,
isShippingLine, isShippingLine,
shippingLine, shippingLine,
isTransitAgent,
transitAgent,
createProfile, createProfile,
reapplyProfile, reapplyProfile,
login, login,

View File

@@ -235,9 +235,9 @@ function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
LOCO LOCO
</Text> </Text>
</Box> </Box>
{wagons.map((w) => ( {wagons.map((w, i) => (
<Tooltip <Tooltip
key={w.sequenceNo} key={w.allocationId ?? w.sequenceNo}
label={`${w.wagonNumber ?? "Unassigned"} · ${w.wagonType ?? "—"} · ${ label={`${w.wagonNumber ?? "Unassigned"} · ${w.wagonType ?? "—"} · ${
STATUS_TONES[w.status]?.label ?? w.status STATUS_TONES[w.status]?.label ?? w.status
}`} }`}
@@ -257,7 +257,7 @@ function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
}} }}
> >
<Text fz={10} fw={700} c="#6B7C8E"> <Text fz={10} fw={700} c="#6B7C8E">
W{w.sequenceNo} W{i + 1}
</Text> </Text>
<Text fz={11.5} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}> <Text fz={11.5} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
{w.wagonNumber ?? "—"} {w.wagonNumber ?? "—"}
@@ -302,12 +302,21 @@ const th = { color: "#9AA8B5", fontSize: 11 } as const;
function WagonCard({ function WagonCard({
wagon, wagon,
displayNo,
selectable, selectable,
selected, selected,
shared, shared,
onToggle, onToggle,
}: { }: {
wagon: BookingWagonAllocation; wagon: BookingWagonAllocation;
/**
* 1-based position in THIS booking's wagon list — what the customer sees.
* Deliberately not `sequenceNo`, which is the wagon's slot in the shared
* train set and so starts wherever the previous booking left off (and
* leaves holes when wagons are cancelled). Cancellation keys off
* `allocationId`, so this number is display-only.
*/
displayNo: number;
selectable?: boolean; selectable?: boolean;
selected?: boolean; selected?: boolean;
/** Shared consolidation wagon — not selectable for cancellation. */ /** Shared consolidation wagon — not selectable for cancellation. */
@@ -333,7 +342,7 @@ function WagonCard({
checked={!!selected} checked={!!selected}
onChange={onToggle} onChange={onToggle}
color="orange" color="orange"
aria-label={`Select wagon ${wagon.sequenceNo} for cancellation`} aria-label={`Select wagon ${displayNo} for cancellation`}
/> />
)} )}
<Box <Box
@@ -354,7 +363,7 @@ function WagonCard({
WAGON WAGON
</Text> </Text>
<Text fz={15} fw={800} lh={1.2}> <Text fz={15} fw={800} lh={1.2}>
{wagon.sequenceNo} {displayNo}
</Text> </Text>
</Box> </Box>
<Box> <Box>
@@ -727,7 +736,7 @@ export function WagonsTab({
<CancelledWagonsSection rows={ownCancellations} /> <CancelledWagonsSection rows={ownCancellations} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}> <SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
{wagons.map((w) => { {wagons.map((w, i) => {
// The shared consolidation wagon carries this booking's lone 20ft — // The shared consolidation wagon carries this booking's lone 20ft —
// its other half belongs to the partner booking, so it can never be // its other half belongs to the partner booking, so it can never be
// cancelled on its own (the server rejects it too). // cancelled on its own (the server rejects it too).
@@ -740,6 +749,7 @@ export function WagonsTab({
<WagonCard <WagonCard
key={w.allocationId ?? w.sequenceNo} key={w.allocationId ?? w.sequenceNo}
wagon={w} wagon={w}
displayNo={i + 1}
shared={isSharedWagon} shared={isSharedWagon}
selectable={ selectable={
canSelect && canSelect &&

View File

@@ -0,0 +1,14 @@
import { Package } from "lucide-react";
import ShippingLinePlaceholder from "@/pages/shipping-line/ShippingLinePlaceholder";
/** Empty by design for now — see {@link TransitAgentOverviewPage}. */
export default function TransitAgentBookingsPage() {
return (
<ShippingLinePlaceholder
title="Bookings"
description="Shipments assigned to you for transit."
icon={<Package size={28} opacity={0.4} />}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { LayoutDashboard } from "lucide-react";
import ShippingLinePlaceholder from "@/pages/shipping-line/ShippingLinePlaceholder";
/**
* Empty by design for now. Reuses the shipping-line placeholder rather than a
* transit-agent copy of it: it is generic empty-state chrome, and duplicating it
* would mean two files to delete once either page gains real content.
*/
export default function TransitAgentOverviewPage() {
return (
<ShippingLinePlaceholder
title="Overview"
description="Your transit activity at a glance."
icon={<LayoutDashboard size={28} opacity={0.4} />}
/>
);
}

View File

@@ -0,0 +1,2 @@
export { default as TransitAgentOverviewPage } from "./TransitAgentOverviewPage";
export { default as TransitAgentBookingsPage } from "./TransitAgentBookingsPage";

View File

@@ -97,7 +97,7 @@ export interface CompanyProfileResponse {
* "shipping line" would skip onboarding for customers whenever the request * "shipping line" would skip onboarding for customers whenever the request
* failed. Absent (older responses) means `customer`. * failed. Absent (older responses) means `customer`.
*/ */
export type AccountKind = "customer" | "shipping_line"; export type AccountKind = "customer" | "shipping_line" | "transit_agent";
export interface CompanyInfoResponse { export interface CompanyInfoResponse {
accountKind?: AccountKind; accountKind?: AccountKind;
@@ -132,13 +132,38 @@ export interface ShippingLineInfoResponse {
review: null; review: null;
} }
/** `GET /companies/getInfo` serves both portal audiences. */ /**
export type AccountInfoResponse = CompanyInfoResponse | ShippingLineInfoResponse; * A signed-in transit agent. Like a shipping line it has no company, no
* external profile and no onboarding — the agent record itself is the account.
*/
export interface TransitAgentInfoResponse {
accountKind: "transit_agent";
id: string;
name: string;
email: string | null;
phoneNumber: string | null;
isActive: boolean;
validFrom: string;
validTo: string;
company: null;
profile: null;
review: null;
}
/** `GET /companies/getInfo` serves every portal audience. */
export type AccountInfoResponse =
| CompanyInfoResponse
| ShippingLineInfoResponse
| TransitAgentInfoResponse;
export const isShippingLineAccount = ( export const isShippingLineAccount = (
info: AccountInfoResponse | null | undefined, info: AccountInfoResponse | null | undefined,
): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line"; ): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line";
export const isTransitAgentAccount = (
info: AccountInfoResponse | null | undefined,
): info is TransitAgentInfoResponse => info?.accountKind === "transit_agent";
/** A staged profile-edit review request (portal view). */ /** A staged profile-edit review request (portal view). */
export interface ChangeRequestResponse { export interface ChangeRequestResponse {
id: string; id: string;

3
pnpm-lock.yaml generated
View File

@@ -481,6 +481,9 @@ importers:
react-pdf-html: react-pdf-html:
specifier: ^2.1.5 specifier: ^2.1.5
version: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) version: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
react-phone-number-input:
specifier: ^3.4.17
version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-quill-new: react-quill-new:
specifier: ^3.8.3 specifier: ^3.8.3
version: 3.8.3(quill-delta@5.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 3.8.3(quill-delta@5.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)