mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 12:25:02 +00:00
fix issue, add transit flow, fix cancellation
This commit is contained in:
@@ -4,25 +4,29 @@ import {
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
|
||||
} from "class-validator";
|
||||
import {
|
||||
isValidPhoneNumber,
|
||||
parsePhoneNumberFromString,
|
||||
} from "libphonenumber-js";
|
||||
|
||||
/**
|
||||
* Country-aware phone validation. The value is expected as a full international
|
||||
* number (E.164, e.g. "+251911223344"), so the country is derived from the
|
||||
* value itself — no separate country field needed.
|
||||
* number (E.164, e.g. "+25377834567" for Djibouti or "+251911223344" for
|
||||
* Ethiopia), so the country is derived from the value itself — no separate
|
||||
* country field needed.
|
||||
*/
|
||||
@ValidatorConstraint({ name: 'IsValidPhone', async: false })
|
||||
@ValidatorConstraint({ name: "IsValidPhone", async: false })
|
||||
export class IsValidPhoneConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
|
||||
if (value === undefined || value === null || value === '') return true;
|
||||
if (typeof value !== 'string') return false;
|
||||
if (value === undefined || value === null || value === "") return true;
|
||||
if (typeof value !== "string") return false;
|
||||
return isValidPhoneNumber(value);
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`;
|
||||
return `${args.property} must be a complete international phone number (E.164, e.g. +25377834567 or +251911223344)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +57,7 @@ export function IsValidPhone(validationOptions?: ValidationOptions) {
|
||||
export function normalizeE164(
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null || value === '') return value;
|
||||
const parsed = parsePhoneNumberFromString(value, 'ET');
|
||||
if (value === undefined || value === null || value === "") return value;
|
||||
const parsed = parsePhoneNumberFromString(value, "ET");
|
||||
return parsed?.isValid() ? parsed.number : value.trim();
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
quantities: { bulkTons?: number };
|
||||
totalWagons: number;
|
||||
}>;
|
||||
};
|
||||
const booking = {
|
||||
@@ -29,7 +30,39 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
||||
|
||||
it('cancels every wagon with the exact total tonnage', async () => {
|
||||
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
|
||||
expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } });
|
||||
expect(cut).toEqual({
|
||||
wagons: 4,
|
||||
weightTons: 250.5,
|
||||
quantities: { bulkTons: 250.5 },
|
||||
totalWagons: 4,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unassigning a paid booking from a train clears `wagonsRequired` to NULL, so
|
||||
* cancellation used to reject it outright ("no wagon requirement to cancel
|
||||
* from"). The pinned `cancellationWagons`, stamped at first allocation, keeps
|
||||
* the footprint through the unassign.
|
||||
*/
|
||||
it('falls back to the pinned cancellation footprint when wagonsRequired is cleared', async () => {
|
||||
const unassigned = { ...booking, wagonsRequired: null, cancellationWagons: 4 };
|
||||
const cut = await svc.resolveRequestedCut(unassigned, { wagons: 4 });
|
||||
expect(cut.wagons).toBe(4);
|
||||
expect(cut.totalWagons).toBe(4);
|
||||
expect(cut.weightTons).toBe(250.5);
|
||||
});
|
||||
|
||||
/** NUMBER_OF_WAGONS bulk never allocated: the customer's pinned count sizes it. */
|
||||
it('sizes a never-allocated NUMBER_OF_WAGONS booking from bulkRequestedWagons', async () => {
|
||||
const fresh = {
|
||||
...booking,
|
||||
wagonsRequired: null,
|
||||
cancellationWagons: null,
|
||||
bulkRequestedWagons: 3,
|
||||
};
|
||||
const cut = await svc.resolveRequestedCut(fresh, { wagons: 3 });
|
||||
expect(cut.totalWagons).toBe(3);
|
||||
expect(cut.weightTons).toBe(250.5);
|
||||
});
|
||||
|
||||
it('rejects more wagons than the booking has', async () => {
|
||||
|
||||
@@ -23,6 +23,8 @@ import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { requestedBulkWagons } from '../train-scheduling/train-capacity.util';
|
||||
import { wagonsRequiredForBooking } from '../train-scheduling/utils/fleet-plan.util';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -74,6 +76,8 @@ interface RequestedCut {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
quantities: CancelledQuantities;
|
||||
/** The booking's whole wagon footprint the cut came out of — credit divides by it. */
|
||||
totalWagons: number;
|
||||
}
|
||||
|
||||
/** The priced fee for a cut: total, currency and the rate(s) it came from. */
|
||||
@@ -165,7 +169,7 @@ export class BookingWagonCancellationService {
|
||||
feePerWagon: fee.perWagon,
|
||||
feeAmount: fee.amount,
|
||||
feeCurrency: fee.currency,
|
||||
creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||
creditAmount: round2(Number(booking.totalAmount ?? 0)),
|
||||
};
|
||||
}
|
||||
this.assertCutSparesSharedWagon(cut);
|
||||
@@ -177,7 +181,7 @@ export class BookingWagonCancellationService {
|
||||
feePerWagon: fee.perWagon,
|
||||
feeAmount: fee.amount,
|
||||
feeCurrency: fee.currency,
|
||||
creditAmount: this.creditFor(booking, cut.wagons),
|
||||
creditAmount: this.creditFor(booking, cut.wagons, cut.totalWagons),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,7 +222,7 @@ export class BookingWagonCancellationService {
|
||||
: await this.resolveRequestedCut(booking, dto);
|
||||
const fee = await this.priceFee(booking, cut);
|
||||
const feeAmount = fee.amount;
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
|
||||
|
||||
const row = await this.repo.create({
|
||||
bookingId,
|
||||
@@ -318,7 +322,7 @@ export class BookingWagonCancellationService {
|
||||
const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({
|
||||
where: { bookingId: row.bookingId },
|
||||
});
|
||||
if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) {
|
||||
if (rows < Math.round(await this.wagonFootprint(booking))) {
|
||||
throw new ConflictException(
|
||||
'The train has no free wagon space left to restore the cancelled wagons — the request cannot be withdrawn. Pay the cancellation fee and rebook the credit on another day instead.',
|
||||
);
|
||||
@@ -363,7 +367,7 @@ export class BookingWagonCancellationService {
|
||||
const row = await this.openConsolidationBreak(
|
||||
booking,
|
||||
'ceil',
|
||||
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||
round2(Number(booking.totalAmount ?? 0)),
|
||||
reason ?? 'Consolidated pair cancelled',
|
||||
userId,
|
||||
);
|
||||
@@ -371,7 +375,7 @@ export class BookingWagonCancellationService {
|
||||
await this.openConsolidationBreak(
|
||||
partner,
|
||||
'floor',
|
||||
this.creditFor(partner, Number(partner.wagonsRequired ?? 0)),
|
||||
round2(Number(partner.totalAmount ?? 0)),
|
||||
`Cancelled with its consolidation partner ${booking.reference}`,
|
||||
userId,
|
||||
);
|
||||
@@ -540,7 +544,8 @@ export class BookingWagonCancellationService {
|
||||
} as RequestWagonCancellationDto);
|
||||
}
|
||||
return this.resolveRequestedCut(booking, {
|
||||
wagons: Number(booking.wagonsRequired ?? 0),
|
||||
// Footprint, not the live wagonsRequired: unassign clears that to NULL.
|
||||
wagons: await this.wagonFootprint(booking),
|
||||
} as RequestWagonCancellationDto);
|
||||
}
|
||||
|
||||
@@ -568,7 +573,7 @@ export class BookingWagonCancellationService {
|
||||
const row = await this.openConsolidationBreak(
|
||||
booking,
|
||||
'ceil',
|
||||
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||
round2(Number(booking.totalAmount ?? 0)),
|
||||
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
|
||||
);
|
||||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||||
@@ -729,9 +734,10 @@ export class BookingWagonCancellationService {
|
||||
// Whole-booking cut: nothing is left to ship, so the booking ends
|
||||
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
|
||||
// train. The credit row still points at it for T3.
|
||||
const wagonsLeft = round2(
|
||||
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
|
||||
);
|
||||
// Off the pinned footprint, not the live wagonsRequired — unassign
|
||||
// clears that to NULL, which read as a full cut on any partial cancel.
|
||||
const footprint = await this.wagonFootprint(booking);
|
||||
const wagonsLeft = round2(footprint - Number(row.wagonsCancelled));
|
||||
const isFull = wagonsLeft <= 0;
|
||||
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
|
||||
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
|
||||
@@ -745,6 +751,9 @@ export class BookingWagonCancellationService {
|
||||
: null;
|
||||
await manager.getRepository(Booking).update(booking.id, {
|
||||
wagonsRequired: Math.max(0, wagonsLeft),
|
||||
// Keep the cancellation footprint in step, so a second partial cancel
|
||||
// prices against what is actually left, not the original booking.
|
||||
cancellationWagons: Math.max(0, wagonsLeft),
|
||||
...(requestedWagonsLeft !== null
|
||||
? { bulkRequestedWagons: requestedWagonsLeft }
|
||||
: {}),
|
||||
@@ -853,14 +862,37 @@ export class BookingWagonCancellationService {
|
||||
);
|
||||
}
|
||||
|
||||
// Staff may cut a SUBSET of the never-loaded wagons (picked in the loading
|
||||
// modal) instead of the whole remainder. Anything already LOADED is
|
||||
// rejected rather than silently dropped: the operator believes they are
|
||||
// cancelling that wagon, and it is on the train.
|
||||
let target = remaining;
|
||||
if (dto.wagonAllocationIds?.length) {
|
||||
const wanted = new Set(dto.wagonAllocationIds);
|
||||
const known = new Set(allocations.map((a) => a.id));
|
||||
const unknown = dto.wagonAllocationIds.filter((id) => !known.has(id));
|
||||
if (unknown.length) {
|
||||
throw new BadRequestException(
|
||||
'Some selected wagons are not allocated to this booking on this schedule.',
|
||||
);
|
||||
}
|
||||
const loaded = allocations.filter((a) => wanted.has(a.id) && !remaining.includes(a));
|
||||
if (loaded.length) {
|
||||
throw new BadRequestException(
|
||||
`${loaded.length} selected wagon(s) are already loaded and cannot be cancelled.`,
|
||||
);
|
||||
}
|
||||
target = remaining.filter((a) => wanted.has(a.id));
|
||||
}
|
||||
|
||||
const cut = await this.resolveRequestedCut(booking, {
|
||||
wagonAllocationIds: remaining.map((r) => r.id),
|
||||
wagonAllocationIds: target.map((r) => r.id),
|
||||
} as RequestWagonCancellationDto);
|
||||
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
|
||||
|
||||
const edrFault = !!dto.edrFault;
|
||||
const fee = edrFault ? null : await this.priceFee(booking, cut);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
|
||||
|
||||
const row = await this.repo.create({
|
||||
bookingId,
|
||||
@@ -1253,7 +1285,7 @@ export class BookingWagonCancellationService {
|
||||
booking: Booking,
|
||||
dto: RequestWagonCancellationDto,
|
||||
): Promise<RequestedCut> {
|
||||
const totalWagons = Number(booking.wagonsRequired ?? 0);
|
||||
const totalWagons = await this.wagonFootprint(booking);
|
||||
if (totalWagons <= 0) {
|
||||
throw new BadRequestException('This booking has no wagon requirement to cancel from.');
|
||||
}
|
||||
@@ -1335,6 +1367,7 @@ export class BookingWagonCancellationService {
|
||||
weightTons: weightShare,
|
||||
// Bookings without unit records fall back to the T2 LIFO trim.
|
||||
quantities: { bySize, ...(units.length === requested ? { units } : {}) },
|
||||
totalWagons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1360,7 +1393,7 @@ export class BookingWagonCancellationService {
|
||||
if (tons <= 0) {
|
||||
throw new BadRequestException('The requested cut is too small to release cargo.');
|
||||
}
|
||||
return { wagons, weightTons: tons, quantities: { bulkTons: tons } };
|
||||
return { wagons, weightTons: tons, quantities: { bulkTons: tons }, totalWagons };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1416,6 +1449,7 @@ export class BookingWagonCancellationService {
|
||||
wagons,
|
||||
weightTons: tons,
|
||||
quantities: { bulkTons: tons, allocationIds },
|
||||
totalWagons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1460,12 +1494,54 @@ export class BookingWagonCancellationService {
|
||||
wagons,
|
||||
weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)),
|
||||
quantities: { bySize, units, allocationIds },
|
||||
totalWagons,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's wagon footprint for cancellation pricing.
|
||||
*
|
||||
* `wagonsRequired` is a LIVE scheduling field: unassign clears it to NULL, so
|
||||
* a paid booking pulled off a train read 0 wagons and could not be cancelled
|
||||
* at all. `cancellationWagons` is stamped once at first allocation and never
|
||||
* cleared — read it first. A booking never allocated has neither, so size it
|
||||
* from the cargo the same way the scheduler would: TEU geometry for
|
||||
* containers, the customer's pinned count for NUMBER_OF_WAGONS bulk, tonnage
|
||||
* ÷ wagon capacity for PER_TON bulk.
|
||||
*/
|
||||
private async wagonFootprint(booking: Booking): Promise<number> {
|
||||
const pinned = Number(booking.cancellationWagons ?? 0);
|
||||
if (pinned > 0) return round2(pinned);
|
||||
const stored = Number(booking.wagonsRequired ?? 0);
|
||||
if (stored > 0) return round2(stored);
|
||||
|
||||
const requested = requestedBulkWagons(booking);
|
||||
if (requested > 0) return requested;
|
||||
|
||||
// Cargo relations drive the sizing — reload when the caller passed a bare
|
||||
// booking (findById does not always hydrate them).
|
||||
const full =
|
||||
booking.bookingContainers || booking.cargoType
|
||||
? booking
|
||||
: ((await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: booking.id },
|
||||
relations: {
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
})) ?? booking);
|
||||
const capacities = (full.cargoType?.wagonTypes ?? [])
|
||||
.map((wt) => Number(wt.capacityTons))
|
||||
.filter((c) => c > 0);
|
||||
const bulkCapacity =
|
||||
full.freightType === 'BULK' && capacities.length
|
||||
? Math.max(...capacities)
|
||||
: undefined;
|
||||
return round2(wagonsRequiredForBooking(full, bulkCapacity));
|
||||
}
|
||||
|
||||
/** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */
|
||||
private creditFor(booking: Booking, wagons: number): number {
|
||||
const totalWagons = Number(booking.wagonsRequired ?? 0);
|
||||
private creditFor(booking: Booking, wagons: number, totalWagons: number): number {
|
||||
if (totalWagons <= 0) return 0;
|
||||
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
|
||||
}
|
||||
|
||||
@@ -1725,6 +1725,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
Booking,
|
||||
| 'schedulingStatus'
|
||||
| 'wagonsRequired'
|
||||
| 'cancellationWagons'
|
||||
| 'scheduledAt'
|
||||
| 'holdStartedAt'
|
||||
| 'holdExpiresAt'
|
||||
|
||||
@@ -183,6 +183,19 @@ export class CancelRemainingWagonsDto {
|
||||
@IsUUID('4')
|
||||
scheduleId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Cancel only THESE never-loaded wagons (wagon_booking_allocation ids from ' +
|
||||
'GET /bookings/:id/wagons). Omit to cancel the whole unloaded remainder. ' +
|
||||
'Already-loaded wagons are rejected — they are riding.',
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonAllocationIds?: string[];
|
||||
|
||||
@ApiProperty({ description: 'Why the remaining wagons are not riding' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -543,6 +543,13 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||
wagonsRequired?: number | null;
|
||||
|
||||
// Wagon footprint pinned for cancellation pricing. `wagonsRequired` above is
|
||||
// a LIVE scheduling field that unassign clears; this one is stamped once at
|
||||
// first allocation and never cleared, so a paid booking pulled off a train
|
||||
// can still price its cancellation fee and credit.
|
||||
@Column({ name: 'cancellation_wagons', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||
cancellationWagons?: number | null;
|
||||
|
||||
@Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' })
|
||||
schedulingStatus!: string;
|
||||
|
||||
|
||||
@@ -55,8 +55,10 @@ import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import {
|
||||
AccountInfoResponse,
|
||||
ShippingLineInfoResponseDto,
|
||||
TransitAgentInfoResponseDto,
|
||||
} from "./dto/account-info-response.dto";
|
||||
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service";
|
||||
import { TransitAgentsService } from "../transit-agents/transit-agents.service";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
@@ -102,6 +104,7 @@ export class CompaniesController {
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -131,10 +134,10 @@ export class CompaniesController {
|
||||
async getInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<AccountInfoResponse> {
|
||||
// A shipping line has no company and no external profile, so the customer
|
||||
// lookup below would 404. Checked first, and reported with an explicit
|
||||
// `accountKind` so the portal can skip onboarding for shipping lines
|
||||
// without inferring it from a missing company.
|
||||
// Neither a shipping line nor a transit agent has a company or an external
|
||||
// profile, so the customer lookup below would 404 for both. Checked first,
|
||||
// and reported with an explicit `accountKind` so the portal can skip
|
||||
// onboarding for them without inferring it from a missing company.
|
||||
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
|
||||
user.id,
|
||||
);
|
||||
@@ -142,6 +145,11 @@ export class CompaniesController {
|
||||
return new ShippingLineInfoResponseDto(shippingLine);
|
||||
}
|
||||
|
||||
const transitAgent = await this.transitAgentsService.findByUserId(user.id);
|
||||
if (transitAgent) {
|
||||
return new TransitAgentInfoResponseDto(transitAgent);
|
||||
}
|
||||
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
|
||||
import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
@@ -49,6 +50,10 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
// shipping-line session, which has no company row to look up. forwardRef
|
||||
// because that module imports BillingModule, which imports this one.
|
||||
forwardRef(() => ShippingLineCompaniesModule),
|
||||
// `GET /companies/getInfo` resolves a transit-agent session before falling
|
||||
// through to the customer lookup. TransitAgentsModule is a leaf here — it
|
||||
// does not import CompaniesModule — so no forwardRef is needed.
|
||||
TransitAgentsModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity";
|
||||
import { CompanyInfoResponseDto } from "./company-info-response.dto";
|
||||
|
||||
/**
|
||||
@@ -9,10 +10,10 @@ import { CompanyInfoResponseDto } from "./company-info-response.dto";
|
||||
* The portal keys its onboarding gate off this rather than off "is `company`
|
||||
* missing?": a failed or slow company fetch also leaves `company` empty, and
|
||||
* treating that as "no onboarding needed" would let customers skip onboarding
|
||||
* whenever the request failed. A shipping line is identified positively, and
|
||||
* anything else defaults to `customer`.
|
||||
* whenever the request failed. A shipping line and a transit agent are each
|
||||
* identified positively, and anything else defaults to `customer`.
|
||||
*/
|
||||
export type AccountKind = "customer" | "shipping_line";
|
||||
export type AccountKind = "customer" | "shipping_line" | "transit_agent";
|
||||
|
||||
/** The signed-in shipping line. No company, no profile, no onboarding. */
|
||||
export class ShippingLineInfoResponseDto {
|
||||
@@ -61,6 +62,62 @@ export class ShippingLineInfoResponseDto {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in transit agent. Like a shipping line: no company, no profile, no
|
||||
* onboarding — but a separate account kind because the two share nothing beyond
|
||||
* that, and the portal shows each a different (much smaller) set of tabs.
|
||||
*/
|
||||
export class TransitAgentInfoResponseDto {
|
||||
@ApiProperty({ enum: ["transit_agent"] })
|
||||
accountKind: "transit_agent" = "transit_agent";
|
||||
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
email?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Start of the agent's validity window (yyyy-MM-dd)",
|
||||
})
|
||||
validFrom: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "End of the agent's validity window (yyyy-MM-dd)",
|
||||
})
|
||||
validTo: string;
|
||||
|
||||
/** Always null — see {@link ShippingLineInfoResponseDto.company}. */
|
||||
@ApiProperty({ nullable: true })
|
||||
company: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
profile: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
review: null = null;
|
||||
|
||||
constructor(entity: TransitAgent) {
|
||||
this.id = entity.id;
|
||||
this.name = entity.name;
|
||||
this.email = entity.email ?? null;
|
||||
this.phoneNumber = entity.phoneNumber ?? null;
|
||||
this.isActive = entity.isActive;
|
||||
this.validFrom = entity.validFrom;
|
||||
this.validTo = entity.validTo;
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountInfoResponse =
|
||||
| (CompanyInfoResponseDto & { accountKind: "customer" })
|
||||
| ShippingLineInfoResponseDto;
|
||||
| ShippingLineInfoResponseDto
|
||||
| TransitAgentInfoResponseDto;
|
||||
|
||||
@@ -35,9 +35,24 @@ describe("isDomesticPhone", () => {
|
||||
(phone) => expect(isDomesticPhone(phone)).toBe(true),
|
||||
);
|
||||
|
||||
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
|
||||
"rejects non-domestic or malformed %s",
|
||||
(phone) => expect(isDomesticPhone(phone)).toBe(false),
|
||||
// Djibouti is the line's other end: the gateway reaches its 77x mobiles.
|
||||
it.each(["+25377123456", "25377123456", "77123456"])(
|
||||
"accepts Djibouti mobile form %s",
|
||||
(phone) => expect(isDomesticPhone(phone)).toBe(true),
|
||||
);
|
||||
|
||||
it.each([
|
||||
"+14155550123",
|
||||
"+447911123456",
|
||||
"0712345678",
|
||||
"+2519866",
|
||||
"12345",
|
||||
// Djibouti fixed line (2x) — valid number, not a mobile the gateway serves.
|
||||
"+25321350000",
|
||||
// Right length, wrong Djibouti prefix.
|
||||
"+25366123456",
|
||||
])("rejects unreachable or malformed %s", (phone) =>
|
||||
expect(isDomesticPhone(phone)).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,20 +42,42 @@ function normalizePhone(rawPhone: string): string {
|
||||
if (digits.startsWith("+")) return digits;
|
||||
const bare = digits.replace(/^0+/, "");
|
||||
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
||||
if (/^253\d{8}$/.test(digits)) return `+${digits}`;
|
||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
||||
// Djibouti mobiles are 8 digits starting 77 and have no trunk prefix, so a
|
||||
// bare "77…" is unambiguous — it cannot be an Ethiopian local number, which
|
||||
// is always 9 digits after the trunk zero.
|
||||
if (/^77\d{6}$/.test(bare)) return `+253${bare}`;
|
||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||
// it looks like a full international number, else leave as typed.
|
||||
return digits.length >= 11 ? `+${digits}` : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a phone is an Ethiopian mobile the SMS gateway can actually reach —
|
||||
* the carrier integration is domestic-only, so a send to anything else is
|
||||
* queued and silently lost. Callers use this to fall back to email instead of
|
||||
* pretending an SMS is on its way.
|
||||
* Mobile ranges the SMS gateway is contracted to reach, as E.164 patterns.
|
||||
*
|
||||
* The gateway itself is opaque from here — `SmsClientService` publishes to
|
||||
* RabbitMQ and the carrier sits several hops downstream — so this list is a
|
||||
* policy statement, not a capability probe: a number outside it is treated as
|
||||
* unreachable and callers fall back to email rather than promising an SMS that
|
||||
* would be queued and silently dropped.
|
||||
*
|
||||
* - Ethiopia: `+2519…` mobiles only. `+2517…` is deliberately absent; it parses
|
||||
* as a valid ET number but is not a range this gateway delivers to.
|
||||
* - Djibouti: `+25377…`, the country's only mobile range (2x is fixed-line).
|
||||
*/
|
||||
const REACHABLE_MOBILE_PATTERNS = [/^\+2519\d{8}$/, /^\+25377\d{6}$/];
|
||||
|
||||
/**
|
||||
* Whether a phone sits in a mobile range the SMS gateway can actually reach.
|
||||
*
|
||||
* Named "domestic" for the Ethiopian-only era this predates; it now covers both
|
||||
* countries the railway runs through. Callers use it to fall back to email
|
||||
* instead of pretending an SMS is on its way.
|
||||
*/
|
||||
export function isDomesticPhone(rawPhone: string): boolean {
|
||||
return /^\+2519\d{8}$/.test(normalizePhone(rawPhone));
|
||||
const normalized = normalizePhone(rawPhone);
|
||||
return REACHABLE_MOBILE_PATTERNS.some((p) => p.test(normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +121,7 @@ export class OtpService {
|
||||
private readonly otpRepository: OtpRepository,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly emailClient: EmailClientService,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate OTP
|
||||
@@ -197,8 +219,10 @@ export class OtpService {
|
||||
|
||||
for (const outcome of outcomes) {
|
||||
this.logger.log(
|
||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
|
||||
} latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : ""
|
||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
|
||||
outcome.queued
|
||||
} latencyMs=${Date.now() - startedAt}${
|
||||
outcome.error ? ` error=${outcome.error}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
@@ -222,7 +246,8 @@ export class OtpService {
|
||||
// user who never receives a code — indistinguishable from carrier loss,
|
||||
// and the misleading success response makes it look like our side worked.
|
||||
this.logger.error(
|
||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
|
||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
||||
} — no transport reported hand-off; no code will arrive for this send`,
|
||||
);
|
||||
}
|
||||
@@ -247,7 +272,8 @@ export class OtpService {
|
||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||
this.logger.error(
|
||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt
|
||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
|
||||
Date.now() - startedAt
|
||||
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
@@ -330,8 +356,9 @@ export class OtpService {
|
||||
) {
|
||||
const line = `otp.verify channels=${channelsOf(target).join(
|
||||
"+",
|
||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
||||
detail ? ` ${detail}` : ""
|
||||
}`;
|
||||
if (result === "ok") this.logger.log(line);
|
||||
else this.logger.warn(line);
|
||||
|
||||
|
||||
@@ -3967,6 +3967,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
schedulingStatus: "SCHEDULED",
|
||||
scheduledAt: new Date(),
|
||||
wagonsRequired,
|
||||
// Pinned for cancellation pricing: unassign clears wagonsRequired, this
|
||||
// stays. Written once — a later re-allocation keeps the first stamp.
|
||||
...(Number(booking.cancellationWagons ?? 0) > 0
|
||||
? {}
|
||||
: { cancellationWagons: wagonsRequired }),
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
|
||||
@@ -39,15 +39,30 @@ export class BookingNotifierService {
|
||||
try {
|
||||
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
|
||||
if (!s) return fallback;
|
||||
const ref = s.reference ?? s.trainNumber ?? null;
|
||||
const route =
|
||||
// Customers know the train by its operating number (8001), not the
|
||||
// schedule reference — lead with it and keep S-… as the secondary id.
|
||||
const parts = [
|
||||
s.reference,
|
||||
s.originStation?.label && s.destinationStation?.label
|
||||
? ` (${s.originStation.label} → ${s.destinationStation.label})`
|
||||
: '';
|
||||
? `${s.originStation.label} → ${s.destinationStation.label}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const detail = parts.length ? ` (${parts.join(', ')})` : '';
|
||||
const departure = s.scheduledDepartureDate
|
||||
? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}`
|
||||
? `, departing ${new Date(s.scheduledDepartureDate).toLocaleString('en-GB', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})} EAT`
|
||||
: '';
|
||||
return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`;
|
||||
const number = s.trainNumber ?? s.reference ?? null;
|
||||
return number
|
||||
? `train ${number}${number === s.reference ? '' : detail}${departure}`
|
||||
: `${fallback}${detail}${departure}`;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`,
|
||||
|
||||
@@ -2320,12 +2320,18 @@ export class TrainSchedulingService {
|
||||
// batch fill, which unlinks it and frees its wagons on the next window cycle.
|
||||
const scheduledAt = new Date();
|
||||
for (const booking of bookings) {
|
||||
const wagonsRequired = sumWagonsRequired(booking, wagonPlan);
|
||||
await this.bookingsRepository.updateSchedulingFields(
|
||||
booking.id,
|
||||
{
|
||||
schedulingStatus: SchedulingStatus.Scheduled,
|
||||
scheduledAt,
|
||||
wagonsRequired: sumWagonsRequired(booking, wagonPlan),
|
||||
wagonsRequired,
|
||||
// Pinned for cancellation pricing: unassign clears wagonsRequired,
|
||||
// this stays. Written once — re-allocation keeps the first stamp.
|
||||
...(Number(booking.cancellationWagons ?? 0) > 0
|
||||
? {}
|
||||
: { cancellationWagons: wagonsRequired }),
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return value;
|
||||
};
|
||||
|
||||
export class CreateTransitAgentDto {
|
||||
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' })
|
||||
@ApiProperty({ maxLength: 150, example: "Ahmed Bourhan" })
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-01-01' })
|
||||
@ApiProperty({ example: "2026-01-01" })
|
||||
@IsDateString()
|
||||
validFrom!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-12-31' })
|
||||
@ApiProperty({ example: "2026-12-31" })
|
||||
@IsDateString()
|
||||
validTo!: string;
|
||||
|
||||
@@ -28,4 +37,33 @@ export class CreateTransitAgentDto {
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
/**
|
||||
* Becomes the IAM account's email and is where the activation link is sent.
|
||||
* Optional: an agent may be created as a GL-assignable roster entry only, and
|
||||
* invited later. Supplying it creates the portal account right away.
|
||||
*/
|
||||
@ApiPropertyOptional({ example: "a.bourhan@transit.dj" })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "+25377834567",
|
||||
description:
|
||||
"E.164. Djiboutian (+253 77…) and Ethiopian (+251 9…) mobiles also receive the activation link by SMS.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
@IsValidPhone()
|
||||
phoneNumber?: string;
|
||||
|
||||
/** Login name. Defaults to the email, which is what the agent tries first. */
|
||||
@ApiPropertyOptional({ example: "a-bourhan" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
username?: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/**
|
||||
* Djibouti transit officer GL Djibouti may assign against a shipment's
|
||||
@@ -7,18 +7,43 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
* validity window arrive without a code change; `isActive` is the manual
|
||||
* suspend/reactivate switch, independent of the validity window.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'transit_agents' })
|
||||
@Index(['isActive'])
|
||||
@Entity({ schema: "freight", name: "transit_agents" })
|
||||
@Index(["isActive"])
|
||||
export class TransitAgent extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||
@Column({ name: "name", type: "varchar", length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'valid_from', type: 'date' })
|
||||
@Column({ name: "valid_from", type: "date" })
|
||||
validFrom!: string;
|
||||
|
||||
@Column({ name: 'valid_to', type: 'date' })
|
||||
@Column({ name: "valid_to", type: "date" })
|
||||
validTo!: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
@Column({ name: "is_active", type: "boolean", default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
/**
|
||||
* The IAM account (`iam.users`, userType `individual`) that signs in to the
|
||||
* portal as this agent. No FK: `iam` is a separate schema owned by the IAM
|
||||
* service, and the rest of the codebase reaches it by query rather than by
|
||||
* relation.
|
||||
*
|
||||
* NULL for every agent that exists only as a GL-assignable roster entry —
|
||||
* which is all of them before this feature, and stays legal afterwards. An
|
||||
* agent gains an account when staff invite it, so `userId !== null` IS the
|
||||
* "has a portal login" predicate; nothing else needs to track it.
|
||||
*/
|
||||
@Column({ name: "user_id", type: "uuid", nullable: true })
|
||||
userId?: string | null;
|
||||
|
||||
/**
|
||||
* Mirrors the IAM account's email; the activation link is sent here. Nullable
|
||||
* because a roster-only agent has never needed one — but an invite cannot be
|
||||
* sent without it, so {@link TransitAgentsService.invite} requires it.
|
||||
*/
|
||||
@Column({ name: "email", type: "varchar", length: 150, nullable: true })
|
||||
email?: string | null;
|
||||
|
||||
@Column({ name: "phone_number", type: "varchar", length: 30, nullable: true })
|
||||
phoneNumber?: string | null;
|
||||
}
|
||||
|
||||
@@ -10,36 +10,38 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
RuleEngineCreate,
|
||||
RuleEngineDelete,
|
||||
RuleEngineUpdate,
|
||||
RuleEngineView,
|
||||
} from '../../common/rule-engine-guards';
|
||||
} from "../../common/rule-engine-guards";
|
||||
|
||||
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
|
||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
||||
import { TransitAgentsService } from './transit-agents.service';
|
||||
import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto";
|
||||
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
|
||||
import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto";
|
||||
import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto";
|
||||
import { TransitAgentsService } from "./transit-agents.service";
|
||||
|
||||
@ApiTags('transit-agents')
|
||||
@Controller('transit-agents')
|
||||
@ApiTags("transit-agents")
|
||||
@Controller("transit-agents")
|
||||
@ApiBearerAuth()
|
||||
export class TransitAgentsController {
|
||||
constructor(private readonly transitAgentsService: TransitAgentsService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'List transit agents' })
|
||||
@RuleEngineView("transit-agents")
|
||||
@ApiOperation({ summary: "List transit agents" })
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
return this.transitAgentsService.findAll({
|
||||
isActive:
|
||||
query.isActive === 'all'
|
||||
query.isActive === "all"
|
||||
? undefined
|
||||
: query.isActive !== undefined
|
||||
? query.isActive === 'true'
|
||||
? query.isActive === "true"
|
||||
: undefined,
|
||||
page: query.page ? parseInt(query.page, 10) : undefined,
|
||||
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
|
||||
@@ -49,39 +51,77 @@ export class TransitAgentsController {
|
||||
}
|
||||
|
||||
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
|
||||
@Get('assignable')
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' })
|
||||
@Get("assignable")
|
||||
@RuleEngineView("transit-agents")
|
||||
@ApiOperation({
|
||||
summary: "List transit agents assignable right now (active and in-window)",
|
||||
})
|
||||
findAssignable() {
|
||||
return this.transitAgentsService.findAssignable();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'Get a transit agent by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Get(":id")
|
||||
@RuleEngineView("transit-agents")
|
||||
@ApiOperation({ summary: "Get a transit agent by ID" })
|
||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitAgentsService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineCreate('transit-agents')
|
||||
@ApiOperation({ summary: 'Create a transit agent' })
|
||||
@RuleEngineCreate("transit-agents")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a transit agent; with an email, also creates its portal account and sends the activation link",
|
||||
})
|
||||
create(@Body() dto: CreateTransitAgentDto) {
|
||||
return this.transitAgentsService.create(dto);
|
||||
return this.transitAgentsService.createWithInvite(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineUpdate('transit-agents')
|
||||
@ApiOperation({ summary: 'Update a transit agent' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) {
|
||||
/**
|
||||
* The path for the roster entries already in production: they were created
|
||||
* before transit agents had logins, so they get their account here rather
|
||||
* than at create time.
|
||||
*/
|
||||
@Post(":id/invite")
|
||||
@RuleEngineUpdate("transit-agents")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a portal account for an existing transit agent and send the activation link",
|
||||
})
|
||||
invite(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: InviteTransitAgentDto,
|
||||
) {
|
||||
return this.transitAgentsService.invite(id, dto);
|
||||
}
|
||||
|
||||
@Post(":id/resend-activation")
|
||||
@RuleEngineUpdate("transit-agents")
|
||||
@ApiOperation({
|
||||
summary: "Resend a transit agent's activation / password-reset link",
|
||||
})
|
||||
resendActivation(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: BackofficeResetPasswordDto,
|
||||
) {
|
||||
return this.transitAgentsService.resendActivation(id, dto.channel);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RuleEngineUpdate("transit-agents")
|
||||
@ApiOperation({ summary: "Update a transit agent" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTransitAgentDto,
|
||||
) {
|
||||
return this.transitAgentsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineDelete('transit-agents')
|
||||
@Delete(":id")
|
||||
@RuleEngineDelete("transit-agents")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a transit agent' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Soft-delete a transit agent" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitAgentsService.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgentsController } from './transit-agents.controller';
|
||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
||||
import { TransitAgentsService } from './transit-agents.service';
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { FreightAuthModule } from "../auth/freight-auth.module";
|
||||
import { OtpModule } from "../otp/otp.module";
|
||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
||||
import { TransitAgentsController } from "./transit-agents.controller";
|
||||
import { TransitAgentsRepository } from "./transit-agents.repository";
|
||||
import { TransitAgentsService } from "./transit-agents.service";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TransitAgent])],
|
||||
imports: [
|
||||
// `User` is registered here so this module can create the IAM account that
|
||||
// backs an invited transit agent, in the same transaction as the agent row.
|
||||
TypeOrmModule.forFeature([TransitAgent, User]),
|
||||
// CustomerResetService — activation links reuse the staff-triggered reset path.
|
||||
FreightAuthModule,
|
||||
OtpModule,
|
||||
],
|
||||
controllers: [TransitAgentsController],
|
||||
providers: [TransitAgentsRepository, TransitAgentsService],
|
||||
exports: [TransitAgentsRepository, TransitAgentsService],
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import {
|
||||
EntityManager,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
Repository,
|
||||
} from "typeorm";
|
||||
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||
@@ -22,7 +27,48 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||
validFrom: LessThanOrEqual(today),
|
||||
validTo: MoreThanOrEqual(today),
|
||||
},
|
||||
order: { name: 'ASC' },
|
||||
order: { name: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** The transit agent signed in as `userId`, or null for any other account. */
|
||||
findByUserId(userId: string): Promise<TransitAgent | null> {
|
||||
return this.repository.findOne({ where: { userId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive, matching the `lower(email)` unique index. `exceptId` lets
|
||||
* an update re-save its own address without colliding with itself.
|
||||
*/
|
||||
async existsByEmail(email: string, exceptId?: string): Promise<boolean> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("ta")
|
||||
.where("lower(ta.email) = lower(:email)", { email });
|
||||
if (exceptId) qb.andWhere("ta.id != :exceptId", { exceptId });
|
||||
return (await qb.getCount()) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert inside a caller-supplied transaction, so the agent row and the IAM
|
||||
* user it points at commit together — a row referencing a user that was
|
||||
* rolled back (or vice versa) is an account nobody can sign in to.
|
||||
*/
|
||||
createInTransaction(
|
||||
manager: EntityManager,
|
||||
data: Partial<TransitAgent>,
|
||||
): Promise<TransitAgent> {
|
||||
const repo = manager.getRepository(TransitAgent);
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
|
||||
/** Attach an IAM account to an existing agent, inside the caller's transaction. */
|
||||
async linkAccountInTransaction(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
data: Pick<TransitAgent, "userId" | "email" | "phoneNumber">,
|
||||
): Promise<TransitAgent> {
|
||||
const repo = manager.getRepository(TransitAgent);
|
||||
await repo.update(id, data);
|
||||
return repo.findOneOrFail({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,49 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsOrder } from 'typeorm';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import {
|
||||
EUserStatus,
|
||||
EUserType,
|
||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
// Subpath import (not the package root) so ts-jest can resolve it when this
|
||||
// file lands in a spec's compile graph — same reason as backoffice.service.ts.
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
FindOptionsOrder,
|
||||
Repository,
|
||||
} from "typeorm";
|
||||
|
||||
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
|
||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
||||
import { CustomerResetService } from "../auth/customer-reset.service";
|
||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||
import { isDomesticPhone } from "../otp/otp.service";
|
||||
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
|
||||
import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto";
|
||||
import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto";
|
||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
||||
import { TransitAgentsRepository } from "./transit-agents.repository";
|
||||
|
||||
export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED';
|
||||
export type TransitAgentValidityStatus = "VALID" | "NOT_STARTED" | "EXPIRED";
|
||||
|
||||
export type TransitAgentView = TransitAgent & {
|
||||
validityStatus: TransitAgentValidityStatus;
|
||||
/** True once an IAM account backs this agent — i.e. it can sign in. */
|
||||
hasAccount: boolean;
|
||||
};
|
||||
|
||||
export interface InvitedTransitAgent {
|
||||
agent: TransitAgentView;
|
||||
/** Masked destination of the activation link, or null if none was sent. */
|
||||
activationSentTo: string | null;
|
||||
activationChannel: ResetChannel | null;
|
||||
}
|
||||
|
||||
type TransitAgentListFilter = {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
@@ -25,20 +57,34 @@ function todayISODate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function validityStatus(agent: Pick<TransitAgent, 'validFrom' | 'validTo'>): TransitAgentValidityStatus {
|
||||
function validityStatus(
|
||||
agent: Pick<TransitAgent, "validFrom" | "validTo">,
|
||||
): TransitAgentValidityStatus {
|
||||
const today = todayISODate();
|
||||
if (today < agent.validFrom) return 'NOT_STARTED';
|
||||
if (today > agent.validTo) return 'EXPIRED';
|
||||
return 'VALID';
|
||||
if (today < agent.validFrom) return "NOT_STARTED";
|
||||
if (today > agent.validTo) return "EXPIRED";
|
||||
return "VALID";
|
||||
}
|
||||
|
||||
function withValidityStatus(agent: TransitAgent): TransitAgentView {
|
||||
return { ...agent, validityStatus: validityStatus(agent) };
|
||||
return {
|
||||
...agent,
|
||||
validityStatus: validityStatus(agent),
|
||||
hasAccount: Boolean(agent.userId),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsService {
|
||||
constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {}
|
||||
private readonly logger = new Logger(TransitAgentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly transitAgentsRepository: TransitAgentsRepository,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly customerResetService: CustomerResetService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async findAll(filter: TransitAgentListFilter = {}): Promise<{
|
||||
data: TransitAgentView[];
|
||||
@@ -46,10 +92,13 @@ export class TransitAgentsService {
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 500;
|
||||
const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '')
|
||||
const sortBy = ["name", "validFrom", "validTo", "isActive"].includes(
|
||||
filter.sortBy ?? "",
|
||||
)
|
||||
? (filter.sortBy as keyof TransitAgent)
|
||||
: 'name';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
: "name";
|
||||
const sortOrder =
|
||||
filter.sortOrder?.toUpperCase() === "DESC" ? "DESC" : "ASC";
|
||||
|
||||
const [data, total] = await this.transitAgentsRepository.findAndCount({
|
||||
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
||||
@@ -86,12 +135,14 @@ export class TransitAgentsService {
|
||||
async getAssignable(id: string): Promise<TransitAgent> {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
throw new BadRequestException('Selected transit officer was not found.');
|
||||
throw new BadRequestException("Selected transit officer was not found.");
|
||||
}
|
||||
if (!agent.isActive) {
|
||||
throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`);
|
||||
throw new BadRequestException(
|
||||
`${agent.name} is suspended — pick another transit officer.`,
|
||||
);
|
||||
}
|
||||
if (validityStatus(agent) !== 'VALID') {
|
||||
if (validityStatus(agent) !== "VALID") {
|
||||
throw new BadRequestException(
|
||||
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
|
||||
);
|
||||
@@ -99,38 +150,364 @@ export class TransitAgentsService {
|
||||
return agent;
|
||||
}
|
||||
|
||||
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
|
||||
if (dto.validTo < dto.validFrom) {
|
||||
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
|
||||
/**
|
||||
* Create an IAM account for a transit agent, inside the caller's transaction.
|
||||
*
|
||||
* Follows `ShippingLineCompaniesService.register` — same entities, same shape
|
||||
* — including its one deliberate difference from employee creation: no
|
||||
* `UserCredential` row is written and `hasSetPassword` stays false, so the
|
||||
* agent must come through the activation link. Staff never handle a password.
|
||||
*/
|
||||
private async createIamAccount(
|
||||
manager: EntityManager,
|
||||
args: {
|
||||
name: string;
|
||||
email: string;
|
||||
username: string;
|
||||
phoneNumber?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const userRepo = manager.getRepository(User);
|
||||
const user = await userRepo.save(
|
||||
userRepo.create({
|
||||
email: args.email,
|
||||
username: args.username,
|
||||
phoneNumber: args.phoneNumber,
|
||||
name: { en: args.name },
|
||||
userType: EUserType.INDIVIDUAL,
|
||||
isActive: true,
|
||||
// No credential row: the account has no password until the activation
|
||||
// link is used. `hasSetPassword` must stay false or the portal treats
|
||||
// the account as ready to sign in with a password that does not exist.
|
||||
hasSetPassword: false,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
}),
|
||||
);
|
||||
return user.id as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and validate the account fields shared by create and invite, and
|
||||
* refuse credentials that already belong to somebody.
|
||||
*/
|
||||
private async prepareAccountFields(
|
||||
dto: { email: string; phoneNumber?: string; username?: string },
|
||||
exceptAgentId?: string,
|
||||
) {
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const username = (dto.username?.trim() || email).toLowerCase();
|
||||
const phoneNumber = dto.phoneNumber?.trim() || undefined;
|
||||
|
||||
if (
|
||||
await this.transitAgentsRepository.existsByEmail(email, exceptAgentId)
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`A transit agent with email ${email} already exists`,
|
||||
);
|
||||
}
|
||||
const agent = await this.transitAgentsRepository.create({
|
||||
|
||||
// An existing IAM account means these credentials already belong to a
|
||||
// customer, a shipping line or an employee. Reusing it would let one login
|
||||
// resolve to two different account kinds, so this is refused rather than
|
||||
// merged.
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: [{ email }, { username }],
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new ConflictException("email_or_username_already_in_use");
|
||||
}
|
||||
|
||||
return { email, username, phoneNumber };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transit agent.
|
||||
*
|
||||
* With no `email` this is the pre-existing behaviour: a GL-assignable roster
|
||||
* entry with no login, which is what production is full of. With an `email`
|
||||
* the IAM account and the agent row are created in one transaction and the
|
||||
* activation link goes out.
|
||||
*/
|
||||
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
|
||||
return (await this.createWithInvite(dto)).agent;
|
||||
}
|
||||
|
||||
/** {@link create}, also reporting where the activation link went. */
|
||||
async createWithInvite(
|
||||
dto: CreateTransitAgentDto,
|
||||
): Promise<InvitedTransitAgent> {
|
||||
if (dto.validTo < dto.validFrom) {
|
||||
throw new BadRequestException(
|
||||
"Valid-to date must be on or after valid-from date.",
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
name: dto.name.trim(),
|
||||
validFrom: dto.validFrom,
|
||||
validTo: dto.validTo,
|
||||
isActive: dto.isActive ?? true,
|
||||
};
|
||||
|
||||
if (!dto.email) {
|
||||
// Roster-only agent — no account, nothing to send.
|
||||
const agent = await this.transitAgentsRepository.create(base);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
activationSentTo: null,
|
||||
activationChannel: null,
|
||||
};
|
||||
}
|
||||
|
||||
const { email, username, phoneNumber } = await this.prepareAccountFields({
|
||||
email: dto.email,
|
||||
phoneNumber: dto.phoneNumber,
|
||||
username: dto.username,
|
||||
});
|
||||
return withValidityStatus(agent);
|
||||
|
||||
const agent = await this.dataSource.transaction(async (manager) => {
|
||||
const userId = await this.createIamAccount(manager, {
|
||||
name: base.name,
|
||||
email,
|
||||
username,
|
||||
phoneNumber,
|
||||
});
|
||||
return this.transitAgentsRepository.createInTransaction(manager, {
|
||||
...base,
|
||||
userId,
|
||||
email,
|
||||
phoneNumber: phoneNumber ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// Outside the transaction on purpose: a delivery failure must not roll back
|
||||
// a registered agent. The link is resendable, and the account is already
|
||||
// valid without it.
|
||||
const activation = await this.sendActivationLink(agent);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
activationSentTo: activation?.maskedTarget ?? null,
|
||||
activationChannel: activation?.channel ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateTransitAgentDto): Promise<TransitAgentView> {
|
||||
/**
|
||||
* Give an EXISTING agent a portal login — the path for the roster entries
|
||||
* already in production. Creates the IAM account, attaches it, and sends the
|
||||
* activation link.
|
||||
*/
|
||||
async invite(
|
||||
id: string,
|
||||
dto: InviteTransitAgentDto,
|
||||
): Promise<InvitedTransitAgent> {
|
||||
const current = await this.transitAgentsRepository.findById(id);
|
||||
if (!current) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
if (current.userId) {
|
||||
// Already has an account — resending is `resendActivation`, which reuses
|
||||
// the existing user instead of minting a second one for the same person.
|
||||
throw new ConflictException(
|
||||
"This transit agent already has a portal account — resend the activation link instead.",
|
||||
);
|
||||
}
|
||||
|
||||
const { email, username, phoneNumber } = await this.prepareAccountFields(
|
||||
dto,
|
||||
id,
|
||||
);
|
||||
|
||||
const agent = await this.dataSource.transaction(async (manager) => {
|
||||
const userId = await this.createIamAccount(manager, {
|
||||
name: current.name,
|
||||
email,
|
||||
username,
|
||||
phoneNumber,
|
||||
});
|
||||
return this.transitAgentsRepository.linkAccountInTransaction(
|
||||
manager,
|
||||
id,
|
||||
{
|
||||
userId,
|
||||
email,
|
||||
phoneNumber: phoneNumber ?? null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const activation = await this.sendActivationLink(agent);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
activationSentTo: activation?.maskedTarget ?? null,
|
||||
activationChannel: activation?.channel ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the activation link.
|
||||
*
|
||||
* Email always goes out — it is the only channel guaranteed to reach a
|
||||
* foreign-registered officer. SMS is sent in addition when the number is
|
||||
* domestic, since the gateway silently drops anything else. Both carry the
|
||||
* SAME single-use ticket: minting retires earlier tickets, so two mints would
|
||||
* kill the email link the moment the SMS went out.
|
||||
*
|
||||
* Reports the email send, as that is the one that is always attempted.
|
||||
*/
|
||||
async sendActivationLink(agent: TransitAgent) {
|
||||
if (!agent.userId) return null;
|
||||
|
||||
const scope = `transit agent ${agent.id}`;
|
||||
const channels = [ResetChannel.Email];
|
||||
if (agent.phoneNumber && isDomesticPhone(agent.phoneNumber)) {
|
||||
channels.push(ResetChannel.Phone);
|
||||
}
|
||||
|
||||
const sent = await this.customerResetService.sendResetLinkToUserOnChannels(
|
||||
agent.userId,
|
||||
channels,
|
||||
{ scope, allowWithoutCredential: true },
|
||||
);
|
||||
const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null;
|
||||
|
||||
if (!emailed) {
|
||||
this.logger.error(
|
||||
`Activation email not sent for transit agent ${agent.id} — no reachable address`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
channels.includes(ResetChannel.Phone) &&
|
||||
!sent.some((s) => s.channel === ResetChannel.Phone)
|
||||
) {
|
||||
this.logger.warn(`Activation SMS not sent for transit agent ${agent.id}`);
|
||||
}
|
||||
|
||||
return emailed;
|
||||
}
|
||||
|
||||
async resendActivation(id: string, channel: ResetChannel) {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
throw new NotFoundException("Transit agent not found");
|
||||
}
|
||||
if (!agent.userId) {
|
||||
throw new BadRequestException(
|
||||
"This transit agent has no portal account yet — invite them first.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
channel === ResetChannel.Phone &&
|
||||
(!agent.phoneNumber || !isDomesticPhone(agent.phoneNumber))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"This transit agent has no domestic phone number — the SMS gateway cannot reach it",
|
||||
);
|
||||
}
|
||||
|
||||
const sent = await this.customerResetService.sendResetLinkToUser(
|
||||
agent.userId,
|
||||
channel,
|
||||
{
|
||||
scope: `transit agent ${agent.id}`,
|
||||
allowWithoutCredential: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (!sent) {
|
||||
throw new NotFoundException(
|
||||
`No active account with ${
|
||||
channel === ResetChannel.Email ? "an email address" : "a phone number"
|
||||
} for this transit agent`,
|
||||
);
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** The transit agent signed in as `userId`, or null for any other account. */
|
||||
findByUserId(userId: string): Promise<TransitAgent | null> {
|
||||
return this.transitAgentsRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateTransitAgentDto,
|
||||
): Promise<TransitAgentView> {
|
||||
const current = await this.findById(id);
|
||||
const nextValidFrom = dto.validFrom ?? current.validFrom;
|
||||
const nextValidTo = dto.validTo ?? current.validTo;
|
||||
if (nextValidTo < nextValidFrom) {
|
||||
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
|
||||
throw new BadRequestException(
|
||||
"Valid-to date must be on or after valid-from date.",
|
||||
);
|
||||
}
|
||||
|
||||
// `username` only ever names an IAM account, and it is chosen once at
|
||||
// account creation. Accepting it here (PartialType inherits it from the
|
||||
// create DTO) would write a column that does not exist on this table.
|
||||
const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto;
|
||||
|
||||
const contact: Partial<TransitAgent> = {};
|
||||
if (email !== undefined) {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
if (await this.transitAgentsRepository.existsByEmail(normalized, id)) {
|
||||
throw new ConflictException(
|
||||
`A transit agent with email ${normalized} already exists`,
|
||||
);
|
||||
}
|
||||
contact.email = normalized;
|
||||
}
|
||||
if (phoneNumber !== undefined) {
|
||||
contact.phoneNumber = phoneNumber.trim() || null;
|
||||
}
|
||||
|
||||
const updated = await this.transitAgentsRepository.update(id, {
|
||||
...dto,
|
||||
...rest,
|
||||
...contact,
|
||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
|
||||
// Keep the IAM account in step. Without this, an agent whose address was
|
||||
// corrected here would still receive its activation link at the old one —
|
||||
// the reset service reads the address off `iam.users`, not off this row.
|
||||
if (
|
||||
updated.userId &&
|
||||
(contact.email !== undefined || contact.phoneNumber !== undefined)
|
||||
) {
|
||||
await this.syncIamContact(updated);
|
||||
}
|
||||
|
||||
return withValidityStatus(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror an edited email/phone onto the linked IAM account.
|
||||
*
|
||||
* Best-effort: a failure here must not fail the agent edit that already
|
||||
* committed, but it does mean the two are out of step, so it is logged loudly
|
||||
* rather than swallowed. Re-running the edit retries it.
|
||||
*/
|
||||
private async syncIamContact(agent: TransitAgent): Promise<void> {
|
||||
if (!agent.userId) return;
|
||||
try {
|
||||
await this.userRepository.update(agent.userId, {
|
||||
...(agent.email ? { email: agent.email } : {}),
|
||||
phoneNumber: agent.phoneNumber ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Transit agent ${agent.id} contact updated but IAM user ${agent.userId} was not — ` +
|
||||
`activation links will still go to the old address: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.transitAgentsRepository.softDelete(id);
|
||||
|
||||
Reference in New Issue
Block a user