feat(companies): add Transit Agent service linked to transit-agent roster; forwarder/agent onboarding step, assigned-bookings tab, booking assignment + notify, drop agent validity window

This commit is contained in:
marshal
2026-09-06 21:41:29 +00:00
parent 6d4a919f39
commit 1eb9f10354
67 changed files with 2528 additions and 302 deletions

View File

@@ -2,14 +2,15 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import {
IsBoolean,
IsDateString,
IsEmail,
IsEnum,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
import { TransitAgentCountry } from "../entities/transit-agent.entity";
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === "boolean") return value;
@@ -24,13 +25,18 @@ export class CreateTransitAgentDto {
@MaxLength(150)
name!: string;
@ApiProperty({ example: "2026-01-01" })
@IsDateString()
validFrom!: string;
@ApiProperty({ example: "2026-12-31" })
@IsDateString()
validTo!: string;
/**
* Defaults to Djibouti, which is what the whole roster was before Ethiopian
* agents were added. Only `ET` agents are offered to a freight forwarder
* picking itself during onboarding.
*/
@ApiPropertyOptional({
enum: TransitAgentCountry,
default: TransitAgentCountry.Djibouti,
})
@IsOptional()
@IsEnum(TransitAgentCountry)
country?: TransitAgentCountry;
@ApiPropertyOptional({ default: true })
@IsOptional()

View File

@@ -2,22 +2,38 @@ import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm";
/**
* Djibouti transit officer GL Djibouti may assign against a shipment's
* transit-assignee handshake. Admin-managed so the roster and each officer's
* validity window arrive without a code change; `isActive` is the manual
* suspend/reactivate switch, independent of the validity window.
* Where the agent is licensed. The roster started Djibouti-only (the officers
* GL Djibouti assigns), so that is the column default. An Ethiopian transit
* agent is the same business as a freight forwarder — a forwarder onboarding on
* the portal picks itself from the `ET` entries (`Company.transitAgentId`).
*/
export enum TransitAgentCountry {
Ethiopia = "ET",
Djibouti = "DJ",
}
/**
* Transit officer GL Djibouti may assign against a shipment's transit-assignee
* handshake, and — for the Ethiopian entries — the roster a freight forwarder
* registers itself against. Admin-managed so the roster arrives without a code
* change; `isActive` is the manual suspend/reactivate switch and the only
* thing that decides whether an agent may be assigned or picked.
*/
@Entity({ schema: "freight", name: "transit_agents" })
@Index(["isActive"])
@Index(["country"])
export class TransitAgent extends BaseEntity {
@Column({ name: "name", type: "varchar", length: 150 })
name!: string;
@Column({ name: "valid_from", type: "date" })
validFrom!: string;
@Column({ name: "valid_to", type: "date" })
validTo!: string;
@Column({
name: "country",
type: "varchar",
length: 2,
enum: TransitAgentCountry,
default: TransitAgentCountry.Djibouti,
})
country!: TransitAgentCountry;
@Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean;

View File

@@ -13,6 +13,7 @@ import {
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { PortalCustomer } from "../../common/booking-guards";
import {
RuleEngineCreate,
RuleEngineDelete,
@@ -50,16 +51,29 @@ export class TransitAgentsController {
});
}
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
/** Active officers — the transit-assignee assignment dropdown. */
@Get("assignable")
@RuleEngineView("transit-agents")
@ApiOperation({
summary: "List transit agents assignable right now (active and in-window)",
})
@ApiOperation({ summary: "List active transit agents (assignable)" })
findAssignable() {
return this.transitAgentsService.findAssignable();
}
/**
* The Ethiopian roster, id + name only, for a customer registering as a
* freight forwarder to pick itself from. Declared before `:id` so the
* literal path is not swallowed by the UUID route.
*/
@Get("forwarder-options")
@PortalCustomer()
@ApiOperation({
summary:
"List active Ethiopian transit agents (id + name) a freight forwarder can register as",
})
findForwarderOptions() {
return this.transitAgentsService.findForwarderOptions();
}
@Get(":id")
@RuleEngineView("transit-agents")
@ApiOperation({ summary: "Get a transit agent by ID" })

View File

@@ -1,14 +1,15 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import {
EntityManager,
LessThanOrEqual,
MoreThanOrEqual,
Repository,
} from "typeorm";
import { EntityManager, Repository } from "typeorm";
import { TransitAgent } from "./entities/transit-agent.entity";
import {
TransitAgent,
TransitAgentCountry,
} from "./entities/transit-agent.entity";
/** What a forwarder picking itself from the roster needs: the id and a label. */
export type ForwarderTransitAgentOption = Pick<TransitAgent, "id" | "name">;
@Injectable()
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
@@ -19,14 +20,23 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
super(repository);
}
/** Active AND currently inside its validity window (today's date, server-side). */
findAssignable(today: string): Promise<TransitAgent[]> {
/** Every active agent — the GL assignment dropdown. */
findAssignable(): Promise<TransitAgent[]> {
return this.repository.find({
where: {
isActive: true,
validFrom: LessThanOrEqual(today),
validTo: MoreThanOrEqual(today),
},
where: { isActive: true },
order: { name: "ASC" },
});
}
/**
* The Ethiopian roster a freight forwarder registers itself against, as
* `{ id, name }` only — this is served to customers, who have no business
* seeing another agent's email or phone. Suspended agents are left out.
*/
findForwarderOptions(): Promise<ForwarderTransitAgentOption[]> {
return this.repository.find({
select: { id: true, name: true },
where: { isActive: true, country: TransitAgentCountry.Ethiopia },
order: { name: "ASC" },
});
}

View File

@@ -5,11 +5,12 @@ import {
} from "@tria-plc/api-common/utils/enums/user.enum";
import { ResetChannel } from "../auth/dto/forgot-password.dto";
import { TransitAgentCountry } from "./entities/transit-agent.entity";
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
* The account half of a transit agent. The roster half (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.
*/
@@ -36,8 +37,6 @@ describe("TransitAgentsService accounts", () => {
const base = {
name: "Ahmed Bourhan",
validFrom: "2026-01-01",
validTo: "2026-12-31",
};
beforeEach(() => {
@@ -343,4 +342,102 @@ describe("TransitAgentsService accounts", () => {
expect(dataSource.transaction).not.toHaveBeenCalled();
});
});
/**
* An Ethiopian transit agent IS a freight forwarder, which signs up on the
* portal with its own email and phone. Nothing minted from this side may
* claim those first.
*/
describe("Ethiopian agents carry no contact details or account", () => {
const ethiopian = { ...base, country: TransitAgentCountry.Ethiopia };
it("refuses an email on create", async () => {
await expect(
service.createWithInvite({ ...ethiopian, email: "ff@example.et" }),
).rejects.toThrow(BadRequestException);
expect(repo.create).not.toHaveBeenCalled();
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("refuses a phone number on create", async () => {
await expect(
service.createWithInvite({ ...ethiopian, phoneNumber: "+251911223344" }),
).rejects.toThrow(BadRequestException);
expect(repo.create).not.toHaveBeenCalled();
});
it("creates the roster entry with neither", async () => {
const { agent } = await service.createWithInvite(ethiopian);
expect(agent.country).toBe(TransitAgentCountry.Ethiopia);
expect(agent.hasAccount).toBe(false);
});
it("refuses to invite one", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...ethiopian,
isActive: true,
userId: null,
});
await expect(
service.invite("ta-1", { email: "ff@example.et" }),
).rejects.toThrow(BadRequestException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("refuses an email on update", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...ethiopian,
isActive: true,
userId: null,
});
await expect(
service.update("ta-1", { email: "ff@example.et" }),
).rejects.toThrow(BadRequestException);
expect(repo.update).not.toHaveBeenCalled();
});
it("clears the contact details when a Djiboutian row is switched", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
country: TransitAgentCountry.Djibouti,
isActive: true,
userId: null,
email: "a@transit.dj",
phoneNumber: "+25377834567",
});
await service.update("ta-1", { country: TransitAgentCountry.Ethiopia });
expect(repo.update).toHaveBeenCalledWith(
"ta-1",
expect.objectContaining({
country: TransitAgentCountry.Ethiopia,
email: null,
phoneNumber: null,
}),
);
});
it("refuses the switch when the row already has a portal account", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
country: TransitAgentCountry.Djibouti,
isActive: true,
userId: "user-1",
email: "a@transit.dj",
});
await expect(
service.update("ta-1", { country: TransitAgentCountry.Ethiopia }),
).rejects.toThrow(BadRequestException);
expect(repo.update).not.toHaveBeenCalled();
});
});
});

View File

@@ -26,13 +26,16 @@ 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";
import {
TransitAgent,
TransitAgentCountry,
} from "./entities/transit-agent.entity";
import {
ForwarderTransitAgentOption,
TransitAgentsRepository,
} from "./transit-agents.repository";
export type TransitAgentView = TransitAgent & {
validityStatus: TransitAgentValidityStatus;
/** True once an IAM account backs this agent — i.e. it can sign in. */
hasAccount: boolean;
};
@@ -52,24 +55,9 @@ type TransitAgentListFilter = {
sortOrder?: string;
};
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */
function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
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";
}
function withValidityStatus(agent: TransitAgent): TransitAgentView {
function toView(agent: TransitAgent): TransitAgentView {
return {
...agent,
validityStatus: validityStatus(agent),
hasAccount: Boolean(agent.userId),
};
}
@@ -92,9 +80,7 @@ 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", "country", "isActive"].includes(filter.sortBy ?? "")
? (filter.sortBy as keyof TransitAgent)
: "name";
const sortOrder =
@@ -108,7 +94,7 @@ export class TransitAgentsService {
});
return {
data: data.map(withValidityStatus),
data: data.map(toView),
meta: {
total,
page,
@@ -118,9 +104,14 @@ export class TransitAgentsService {
};
}
/** Active and currently inside its validity window — the DJ assignment dropdown. */
/** Every active agent — the DJ assignment dropdown. */
async findAssignable(): Promise<TransitAgent[]> {
return this.transitAgentsRepository.findAssignable(todayISODate());
return this.transitAgentsRepository.findAssignable();
}
/** The Ethiopian roster a freight forwarder picks itself from at onboarding. */
findForwarderOptions(): Promise<ForwarderTransitAgentOption[]> {
return this.transitAgentsRepository.findForwarderOptions();
}
async findById(id: string): Promise<TransitAgentView> {
@@ -128,10 +119,10 @@ export class TransitAgentsService {
if (!agent) {
throw new NotFoundException(`Transit agent ${id} not found`);
}
return withValidityStatus(agent);
return toView(agent);
}
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */
/** Used by the assignment flow — rejects a suspended officer. */
async getAssignable(id: string): Promise<TransitAgent> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {
@@ -142,11 +133,6 @@ export class TransitAgentsService {
`${agent.name} is suspended — pick another transit officer.`,
);
}
if (validityStatus(agent) !== "VALID") {
throw new BadRequestException(
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
);
}
return agent;
}
@@ -221,6 +207,32 @@ export class TransitAgentsService {
return { email, username, phoneNumber };
}
/**
* An Ethiopian transit agent never gets a portal account of its own.
*
* It IS a freight forwarder, and the forwarder signs up on the portal as a
* customer with its own email and phone — the same ones staff would type
* here. An IAM account minted from this side would then claim that email
* first, and the forwarder's own registration would fail with "already
* registered". So for `ET` the contact fields are refused outright, and the
* invite path is closed.
*/
private assertNoAccountForEthiopian(
country: TransitAgentCountry,
dto: {
email?: string | null;
phoneNumber?: string | null;
username?: string;
},
): void {
if (country !== TransitAgentCountry.Ethiopia) return;
if (dto.email || dto.phoneNumber || dto.username) {
throw new BadRequestException(
"An Ethiopian transit agent has no email, phone or portal account here — it registers itself on the portal as a freight forwarder with its own contact details.",
);
}
}
/**
* Create a transit agent.
*
@@ -237,24 +249,18 @@ export class TransitAgentsService {
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,
country: dto.country ?? TransitAgentCountry.Djibouti,
isActive: dto.isActive ?? true,
};
this.assertNoAccountForEthiopian(base.country, dto);
if (!dto.email) {
// Roster-only agent — no account, nothing to send.
const agent = await this.transitAgentsRepository.create(base);
return {
agent: withValidityStatus(agent),
agent: toView(agent),
activationSentTo: null,
activationChannel: null,
};
@@ -286,7 +292,7 @@ export class TransitAgentsService {
// valid without it.
const activation = await this.sendActivationLink(agent);
return {
agent: withValidityStatus(agent),
agent: toView(agent),
activationSentTo: activation?.maskedTarget ?? null,
activationChannel: activation?.channel ?? null,
};
@@ -312,6 +318,7 @@ export class TransitAgentsService {
"This transit agent already has a portal account — resend the activation link instead.",
);
}
this.assertNoAccountForEthiopian(current.country, dto);
const { email, username, phoneNumber } = await this.prepareAccountFields(
dto,
@@ -338,7 +345,7 @@ export class TransitAgentsService {
const activation = await this.sendActivationLink(agent);
return {
agent: withValidityStatus(agent),
agent: toView(agent),
activationSentTo: activation?.maskedTarget ?? null,
activationChannel: activation?.channel ?? null,
};
@@ -436,13 +443,6 @@ export class TransitAgentsService {
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.",
);
}
// `username` only ever names an IAM account, and it is chosen once at
// account creation. Accepting it here (PartialType inherits it from the
@@ -450,6 +450,21 @@ export class TransitAgentsService {
const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto;
const contact: Partial<TransitAgent> = {};
const nextCountry = dto.country ?? current.country;
if (nextCountry === TransitAgentCountry.Ethiopia) {
this.assertNoAccountForEthiopian(nextCountry, { email, phoneNumber });
if (current.userId) {
// The account already holds the email the forwarder would sign up
// with; there is no way to hand it back, so the row stays Djiboutian.
throw new BadRequestException(
`${current.name} already has a portal account, so it cannot become an Ethiopian transit agent — create a new Ethiopian entry instead.`,
);
}
// Whatever contact details a Djiboutian row carried go with the switch,
// so the forwarder's own registration cannot collide with them.
contact.email = null;
contact.phoneNumber = null;
}
if (email !== undefined) {
const normalized = email.trim().toLowerCase();
if (await this.transitAgentsRepository.existsByEmail(normalized, id)) {
@@ -483,7 +498,7 @@ export class TransitAgentsService {
await this.syncIamContact(updated);
}
return withValidityStatus(updated);
return toView(updated);
}
/**