mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
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:
@@ -0,0 +1,75 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Links a freight-forwarder company to the transit agent it is registered as.
|
||||
*
|
||||
* An Ethiopian transit agent and a freight forwarder are the same business seen
|
||||
* from two sides: the roster GL uses to assign an officer, and the customer
|
||||
* that signs contracts on other companies' behalf. Until now nothing tied the
|
||||
* two rows together, so a forwarder could onboard under any name and staff had
|
||||
* no way to tell which roster entry it was.
|
||||
*
|
||||
* Two changes:
|
||||
* - `transit_agents.country` — the roster was Djibouti-only, so every existing
|
||||
* row defaults to `DJ`. Only `ET` agents are offered to a forwarder onboarding.
|
||||
* - `companies.transit_agent_id` — nullable: importers and exporters have no
|
||||
* agent, and pre-existing forwarders stay unlinked until they are edited.
|
||||
*/
|
||||
export class CompanyTransitAgentLink3930000000000 implements MigrationInterface {
|
||||
name = 'CompanyTransitAgentLink3930000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.transit_agents
|
||||
ADD COLUMN IF NOT EXISTS country varchar(2) NOT NULL DEFAULT 'DJ'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_transit_agents_country"
|
||||
ON freight.transit_agents (country)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS transit_agent_id uuid
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_companies_transit_agent_id"
|
||||
ON freight.companies (transit_agent_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_companies_transit_agent'
|
||||
) THEN
|
||||
ALTER TABLE freight.companies
|
||||
ADD CONSTRAINT "FK_companies_transit_agent"
|
||||
FOREIGN KEY (transit_agent_id)
|
||||
REFERENCES freight.transit_agents (id)
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP CONSTRAINT IF EXISTS "FK_companies_transit_agent"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_companies_transit_agent_id"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS transit_agent_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_transit_agents_country"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.transit_agents
|
||||
DROP COLUMN IF EXISTS country
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Drops the transit-agent validity window.
|
||||
*
|
||||
* `valid_from` / `valid_to` gated which officers GL could assign, on top of the
|
||||
* `is_active` switch. In practice the dates were never maintained — an agent
|
||||
* whose window lapsed was simply suspended — and with Ethiopian agents now
|
||||
* doubling as the roster a freight forwarder registers itself against, a
|
||||
* date range that silently hides a live business is worse than no range.
|
||||
* `is_active` is the single switch from here on.
|
||||
*
|
||||
* `down()` re-adds the columns as nullable: the dates themselves are gone.
|
||||
*/
|
||||
export class DropTransitAgentValidity3940000000000 implements MigrationInterface {
|
||||
name = 'DropTransitAgentValidity3940000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.transit_agents
|
||||
DROP COLUMN IF EXISTS valid_from,
|
||||
DROP COLUMN IF EXISTS valid_to
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.transit_agents
|
||||
ADD COLUMN IF NOT EXISTS valid_from date,
|
||||
ADD COLUMN IF NOT EXISTS valid_to date
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Reference sequence for the new `transit_agent` operational profile.
|
||||
*
|
||||
* `company_profiles.type` is a plain varchar, so the role itself needs no DDL;
|
||||
* what it needs is its own reference series (`TA-A00001`, …), minted on
|
||||
* approval exactly like IM/EX/FF. Mirrors the baseline's sequences.
|
||||
*/
|
||||
export class TransitAgentProfileSequence3950000000000 implements MigrationInterface {
|
||||
name = "TransitAgentProfileSequence3950000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ta
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ta`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -656,4 +656,67 @@ export class BookingLifecycleNotifierService {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer picked a registered transit agent (a freight forwarder on the
|
||||
* platform) to clear this booking. Tell the forwarder company — every one of
|
||||
* its portal users in the bell, plus SMS and email to its contact — so the
|
||||
* job shows up in its Assigned Bookings tab and it can start preparing
|
||||
* documents. The company is found through its transit-agent link; an agent
|
||||
* nobody has registered against gets no message, since there is nobody to
|
||||
* send it to. Never throws: a failed notice must not undo a completed booking.
|
||||
*/
|
||||
async transitAgentAssigned(
|
||||
b: Booking,
|
||||
agent: { id: string; name: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [forwarder]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.companies
|
||||
WHERE transit_agent_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[agent.id],
|
||||
);
|
||||
if (!forwarder) {
|
||||
this.logger.warn(
|
||||
`Transit agent ${agent.name} has no forwarder company — assignment notice for ${this.ref(b)} not sent`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const title = 'New booking assigned to you';
|
||||
const body = `Booking ${b.reference} has been assigned to ${agent.name} for customs clearance. Open Assigned Bookings in the portal to see it.`;
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: forwarder.id },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title,
|
||||
body,
|
||||
link: '/forwarder/assigned-bookings',
|
||||
data: { bookingId: b.id, reference: b.reference, transitAgentId: agent.id },
|
||||
});
|
||||
const { phone, email } = await resolveCompanyNotifyContact(
|
||||
this.dataSource,
|
||||
forwarder.id,
|
||||
);
|
||||
const message = `EDR Freight: ${body}`;
|
||||
if (phone) {
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Forwarder SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications.directSend('email', email, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Forwarder email failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`transitAgentAssigned failed for ${this.ref(b)}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +330,7 @@ export class CompaniesController {
|
||||
dto.nationality,
|
||||
dto.cooperative,
|
||||
dto.investorLicence,
|
||||
dto.transitAgentId,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
@@ -363,6 +364,7 @@ export class CompaniesController {
|
||||
dto.type,
|
||||
dto.businessLicense,
|
||||
dto.licenceNumber,
|
||||
dto.transitAgentId,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
|
||||
const company = () => ({
|
||||
id: "company-1",
|
||||
// Already named its roster entry: taking the forwarder role needs one,
|
||||
// and these tests are about the PoA gate, not the transit-agent link.
|
||||
transitAgentId: "ta-et",
|
||||
status: ctx.status,
|
||||
nationality: ctx.nationality,
|
||||
attributes: ctx.attributes,
|
||||
@@ -175,6 +178,14 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
deps.verifayda as never,
|
||||
{
|
||||
findById: jest.fn(async () => ({
|
||||
id: "ta-et",
|
||||
name: "Abyssinia Transit",
|
||||
isActive: true,
|
||||
country: "ET",
|
||||
})),
|
||||
} as never, // transitAgentsRepo
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -66,6 +66,7 @@ function makeService(company: Record<string, unknown> | null) {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -101,6 +101,7 @@ function makeService(
|
||||
{ changeRequestSubmitted: jest.fn() } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -46,6 +46,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
|
||||
const company = () => ({
|
||||
id: "company-1",
|
||||
// Already named its roster entry: taking the forwarder role needs one,
|
||||
// and these tests are about the PoA gate, not the transit-agent link.
|
||||
transitAgentId: "ta-et",
|
||||
status: ctx.status,
|
||||
attributes: ctx.attributes,
|
||||
companyProfiles: ctx.profileTypes.map((type, i) => ({
|
||||
@@ -146,6 +149,14 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
findById: jest.fn(async () => ({
|
||||
id: "ta-et",
|
||||
name: "Abyssinia Transit",
|
||||
isActive: true,
|
||||
country: "ET",
|
||||
})),
|
||||
} as never, // transitAgentsRepo
|
||||
);
|
||||
|
||||
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for
|
||||
|
||||
@@ -75,6 +75,7 @@ function makeService(status: ProfileStatus) {
|
||||
companyNotifier as never,
|
||||
dataSource as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
return { service, profile, written, companyProfilesRepo };
|
||||
|
||||
@@ -70,6 +70,7 @@ function makeService(attributes: Record<string, unknown> = {}) {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -54,6 +54,7 @@ function makeService(existing: ExistingProfile[]) {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -31,6 +31,31 @@ import {
|
||||
POA_DELEGATION_PENDING_CODE,
|
||||
} from "../file-upload-settings/poa-delegation.constants";
|
||||
import { VerifaydaService } from "../verifayda/verifayda.service";
|
||||
import {
|
||||
TransitAgent,
|
||||
TransitAgentCountry,
|
||||
} from "../transit-agents/entities/transit-agent.entity";
|
||||
import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository";
|
||||
|
||||
/**
|
||||
* The roles that ARE an Ethiopian transit-agent roster entry. Both name the
|
||||
* company's `transitAgentId`; the difference is what else the company does —
|
||||
* a forwarder also trades on other companies' behalf, a plain transit agent
|
||||
* only clears customs for bookings customers assign to it.
|
||||
*/
|
||||
function isAgentRole(type: ProfileType): boolean {
|
||||
// A function rather than a module-level array: the entity module is still
|
||||
// initialising when this file loads (company-profile → company → …), so
|
||||
// reading the enum at load time throws.
|
||||
return (
|
||||
type === ProfileType.freightForwarder || type === ProfileType.transitAgent
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the company does nothing but act as a transit agent. */
|
||||
function isTransitAgentOnly(types: ProfileType[]): boolean {
|
||||
return types.length > 0 && types.every((t) => t === ProfileType.transitAgent);
|
||||
}
|
||||
import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
@@ -204,6 +229,7 @@ export class CompaniesService {
|
||||
private readonly companyNotifier: CompanyNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly verifaydaService: VerifaydaService,
|
||||
private readonly transitAgentsRepo: TransitAgentsRepository,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -387,21 +413,21 @@ export class CompaniesService {
|
||||
nationality?: CompanyNationality,
|
||||
cooperative?: boolean,
|
||||
investorLicence?: boolean,
|
||||
transitAgentId?: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
const needsAgent = roles.some(isAgentRole);
|
||||
// A company that is ONLY a transit agent has nothing else to tell us: its
|
||||
// registration IS the roster entry it picked, so onboarding ends here.
|
||||
const transitAgentOnly = isTransitAgentOnly(roles);
|
||||
// Already started — reuse the existing draft, just ensure roles exist and
|
||||
// keep the nationality up to date if it was (re)selected.
|
||||
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||
if (existing) {
|
||||
const companyId = existing.company?.id ?? existing.companyId;
|
||||
// Only load the row when the answer actually depends on it: to merge the
|
||||
// flag into `attributes`, or to read a stored one the caller didn't send.
|
||||
const needsCompany =
|
||||
cooperative !== undefined ||
|
||||
investorLicence !== undefined ||
|
||||
roles.includes(ProfileType.freightForwarder);
|
||||
const current = needsCompany
|
||||
? await this.companiesRepo.findById(companyId)
|
||||
: null;
|
||||
// The stored row decides more than one answer here: the flags the caller
|
||||
// didn't send, the transit agent a forwarder already linked, and whether
|
||||
// dropping the forwarder role has a link to clear.
|
||||
const current = await this.companiesRepo.findById(companyId);
|
||||
const isCoop = cooperative ?? isCooperative(current);
|
||||
const isInvestor = investorLicence ?? hasInvestorLicence(current);
|
||||
this.assertRolesAllowedForCooperative(isCoop, roles);
|
||||
@@ -411,8 +437,38 @@ export class CompaniesService {
|
||||
isCoop,
|
||||
nationality ?? current?.nationality ?? undefined,
|
||||
);
|
||||
// A re-run that keeps an agent role may omit the agent it already
|
||||
// picked; one that drops both agent roles drops the link with it, so a
|
||||
// company that later re-adds one is asked again rather than inheriting a
|
||||
// stale answer.
|
||||
const linkedAgent = needsAgent
|
||||
? await this.resolveLinkedTransitAgent(
|
||||
transitAgentId ?? current?.transitAgentId ?? undefined,
|
||||
)
|
||||
: null;
|
||||
const before = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
const wasTransitAgentOnly = isTransitAgentOnly(before.map((p) => p.type));
|
||||
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||
const updates: Partial<Company> = {};
|
||||
if ((linkedAgent?.id ?? null) !== (current?.transitAgentId ?? null)) {
|
||||
updates.transitAgentId = linkedAgent?.id ?? null;
|
||||
}
|
||||
const profilePatch: Partial<ExternalProfile> = {};
|
||||
if (transitAgentOnly && linkedAgent) {
|
||||
// The company is the agent — it gets the roster's name, and there is
|
||||
// no company/owner/documents step left to take.
|
||||
updates.name = linkedAgent.name;
|
||||
if (!existing.onboardingCompleted) {
|
||||
profilePatch.onboardingCompleted = true;
|
||||
profilePatch.onboardingStep = "transit-agent";
|
||||
}
|
||||
} else if (existing.onboardingCompleted && wasTransitAgentOnly) {
|
||||
// Adding a licensed role to a transit-agent-only company reopens the
|
||||
// wizard at the company step: importing or forwarding needs the TIN,
|
||||
// owner, contact and documents the transit agent never had to give.
|
||||
profilePatch.onboardingCompleted = false;
|
||||
profilePatch.onboardingStep = "company";
|
||||
}
|
||||
if (nationality) updates.nationality = nationality;
|
||||
// Ticking the box on a draft that was saved as foreign has to correct the
|
||||
// stored nationality too, or the company keeps resolving to the foreign
|
||||
@@ -446,10 +502,9 @@ export class CompaniesService {
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.companiesRepo.update(companyId, updates);
|
||||
}
|
||||
if (backToEtrade) {
|
||||
await this.profilesRepo.update(existing.id, {
|
||||
onboardingStep: "company",
|
||||
});
|
||||
if (backToEtrade) profilePatch.onboardingStep = "company";
|
||||
if (Object.keys(profilePatch).length > 0) {
|
||||
await this.profilesRepo.update(existing.id, profilePatch);
|
||||
}
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
@@ -463,16 +518,23 @@ export class CompaniesService {
|
||||
);
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||
const linkedAgent = needsAgent
|
||||
? await this.resolveLinkedTransitAgent(transitAgentId)
|
||||
: null;
|
||||
|
||||
const company = await this.companiesRepo.create({
|
||||
name: identity.firstName
|
||||
? `${identity.firstName}'s company`
|
||||
: "New company",
|
||||
name:
|
||||
transitAgentOnly && linkedAgent
|
||||
? linkedAgent.name
|
||||
: identity.firstName
|
||||
? `${identity.firstName}'s company`
|
||||
: "New company",
|
||||
type: companyType,
|
||||
tin: await this.generateDraftTin(),
|
||||
country: "Ethiopia",
|
||||
nationality: nationality ?? CompanyNationality.Ethiopian,
|
||||
status: CompanyStatus.Pending,
|
||||
transitAgentId: linkedAgent?.id ?? null,
|
||||
...(cooperative || investorLicence
|
||||
? {
|
||||
attributes: {
|
||||
@@ -489,8 +551,10 @@ export class CompaniesService {
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
isPrimaryContact: true,
|
||||
onboardingStep: "company",
|
||||
onboardingCompleted: false,
|
||||
// A transit-agent-only company is done the moment it picks its roster
|
||||
// entry — see `transitAgentOnly` above.
|
||||
onboardingStep: transitAgentOnly ? "transit-agent" : "company",
|
||||
onboardingCompleted: transitAgentOnly,
|
||||
});
|
||||
|
||||
await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
|
||||
@@ -498,6 +562,75 @@ export class CompaniesService {
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The transit agent a freight forwarder registers itself as.
|
||||
*
|
||||
* A forwarder and an Ethiopian transit agent are the same business, so the
|
||||
* role cannot be taken without naming which roster entry it is: a company
|
||||
* that is not on the roster asks support to be added first, which is what
|
||||
* the portal's "didn't find my company" note says. Foreign and suspended
|
||||
* entries are refused for the same reason a missing one is — none of them
|
||||
* is a forwarder EDR will assign work to.
|
||||
*/
|
||||
private async resolveLinkedTransitAgent(
|
||||
transitAgentId: string | undefined,
|
||||
): Promise<TransitAgent> {
|
||||
if (!transitAgentId) {
|
||||
throw new BadRequestException(
|
||||
"Select your company from the transit agent list to register as a transit agent or freight forwarder. If it is not listed, contact support to be added.",
|
||||
);
|
||||
}
|
||||
const agent = await this.transitAgentsRepo.findById(transitAgentId);
|
||||
if (
|
||||
!agent ||
|
||||
!agent.isActive ||
|
||||
agent.country !== TransitAgentCountry.Ethiopia
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"The selected transit agent is not an active Ethiopian transit agent. Pick another one or contact support.",
|
||||
);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make `agent` the company's transit agent if it is not already. Shared by
|
||||
* every add-role path: the link is per company, so a forwarder that already
|
||||
* picked its roster entry is not asked again when it adds the transit agent
|
||||
* role, and vice versa.
|
||||
*/
|
||||
private async linkTransitAgent(
|
||||
company: Company,
|
||||
transitAgentId: string | undefined,
|
||||
): Promise<void> {
|
||||
const agent = await this.resolveLinkedTransitAgent(
|
||||
transitAgentId ?? company.transitAgentId ?? undefined,
|
||||
);
|
||||
if (company.transitAgentId === agent.id) return;
|
||||
await this.companiesRepo.update(company.id, { transitAgentId: agent.id });
|
||||
company.transitAgentId = agent.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A transit-agent-only company that takes on a licensed role has to go
|
||||
* back through the wizard: importing, exporting or forwarding needs the TIN,
|
||||
* owner, contact and documents the transit agent never had to give. The
|
||||
* portal reopens the wizard at the company step the moment this flips.
|
||||
*/
|
||||
private async reopenOnboardingForLicensedRole(
|
||||
profile: ExternalProfile,
|
||||
profilesBefore: CompanyProfile[],
|
||||
addedTypes: ProfileType[],
|
||||
): Promise<void> {
|
||||
if (!profile.onboardingCompleted) return;
|
||||
if (!isTransitAgentOnly(profilesBefore.map((p) => p.type))) return;
|
||||
if (!addedTypes.some((t) => t !== ProfileType.transitAgent)) return;
|
||||
await this.profilesRepo.update(profile.id, {
|
||||
onboardingCompleted: false,
|
||||
onboardingStep: "company",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A co-operative union or farm cannot hold the freight-forwarder role.
|
||||
*
|
||||
@@ -517,6 +650,11 @@ export class CompaniesService {
|
||||
"A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.",
|
||||
);
|
||||
}
|
||||
if (roles.includes(ProfileType.transitAgent)) {
|
||||
throw new BadRequestException(
|
||||
"A co-operative union or farm cannot register as a transit agent — that is licensed customs work.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -675,6 +813,7 @@ export class CompaniesService {
|
||||
// External profiles carry the onboarding flag the backoffice gates
|
||||
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
||||
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
||||
await this.attachTransitAgent(company);
|
||||
return company;
|
||||
}
|
||||
|
||||
@@ -715,10 +854,22 @@ export class CompaniesService {
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
||||
company.id,
|
||||
);
|
||||
await this.attachTransitAgent(company);
|
||||
|
||||
return { profile, company };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang the linked transit agent off the company so responses can name it.
|
||||
* A separate read rather than a relation join: `findById` on the repository
|
||||
* loads no relations, and every other caller of it has no use for the agent.
|
||||
*/
|
||||
private async attachTransitAgent(company: Company): Promise<void> {
|
||||
company.transitAgent = company.transitAgentId
|
||||
? await this.transitAgentsRepo.findById(company.transitAgentId)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
|
||||
* current user's company bookings. All figures are scoped to that company.
|
||||
@@ -1799,6 +1950,7 @@ export class CompaniesService {
|
||||
ProfileType.importer,
|
||||
ProfileType.exporter,
|
||||
ProfileType.freightForwarder,
|
||||
ProfileType.transitAgent,
|
||||
];
|
||||
case "freight_forwarder":
|
||||
return [ProfileType.freightForwarder];
|
||||
@@ -2154,7 +2306,11 @@ export class CompaniesService {
|
||||
*/
|
||||
async addCompanyProfilesForUser(
|
||||
userId: string,
|
||||
inputs: Array<{ type: ProfileType; licenceNumber?: string }>,
|
||||
inputs: Array<{
|
||||
type: ProfileType;
|
||||
licenceNumber?: string;
|
||||
transitAgentId?: string;
|
||||
}>,
|
||||
): Promise<CompanyProfile[]> {
|
||||
const types = inputs.map((i) => i.type);
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
@@ -2164,6 +2320,8 @@ export class CompaniesService {
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
const before = company.companyProfiles ?? [];
|
||||
const wasTransitAgentOnly = isTransitAgentOnly(before.map((p) => p.type));
|
||||
|
||||
for (const type of types) {
|
||||
if (!allowedTypes.includes(type)) {
|
||||
@@ -2191,14 +2349,31 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// A transit agent or forwarder IS a roster entry — name it, once per
|
||||
// company, before the role exists.
|
||||
if (isAgentRole(type)) {
|
||||
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
||||
await this.linkTransitAgent(
|
||||
company,
|
||||
inputs.find((i) => i.type === type)?.transitAgentId,
|
||||
);
|
||||
}
|
||||
|
||||
// Which eTrade business this role operates as. Resolved (and rejected if
|
||||
// absent) BEFORE the row is created, so a role never lands unattached on
|
||||
// a company that has licences to pick from.
|
||||
const etradeBusiness = await this.resolveProfileBusiness(
|
||||
company,
|
||||
inputs.find((i) => i.type === type)?.licenceNumber,
|
||||
type,
|
||||
);
|
||||
// a company that has licences to pick from. A transit agent has none —
|
||||
// and a transit-agent-only company has no eTrade record to pick from
|
||||
// yet: its first licensed role attaches the business on the wizard's
|
||||
// licence step, once the TIN has been looked up, exactly like a role
|
||||
// picked at onboarding.
|
||||
const etradeBusiness =
|
||||
type === ProfileType.transitAgent || wasTransitAgentOnly
|
||||
? null
|
||||
: await this.resolveProfileBusiness(
|
||||
company,
|
||||
inputs.find((i) => i.type === type)?.licenceNumber,
|
||||
type,
|
||||
);
|
||||
|
||||
// Self-service role adds start Pending and carry no reference — a reference
|
||||
// is minted only when a backoffice reviewer approves the role.
|
||||
@@ -2210,6 +2385,7 @@ export class CompaniesService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.reopenOnboardingForLicensedRole(profile, before, types);
|
||||
return this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
@@ -2224,6 +2400,7 @@ export class CompaniesService {
|
||||
type: ProfileType,
|
||||
businessLicense?: string,
|
||||
licenceNumber?: string,
|
||||
transitAgentId?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
@@ -2248,12 +2425,18 @@ export class CompaniesService {
|
||||
await this.effectivePoaAttributes(company),
|
||||
);
|
||||
}
|
||||
if (!created && isAgentRole(type)) {
|
||||
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
||||
await this.linkTransitAgent(company, transitAgentId);
|
||||
}
|
||||
if (!created) {
|
||||
const etradeBusiness = await this.resolveProfileBusiness(
|
||||
company,
|
||||
licenceNumber,
|
||||
type,
|
||||
);
|
||||
// See addCompanyProfilesForUser: no business for a transit agent, nor
|
||||
// for a transit-agent-only company's first licensed role.
|
||||
const etradeBusiness =
|
||||
type === ProfileType.transitAgent ||
|
||||
isTransitAgentOnly((company.companyProfiles ?? []).map((p) => p.type))
|
||||
? null
|
||||
: await this.resolveProfileBusiness(company, licenceNumber, type);
|
||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||
// carry no reference until approved.
|
||||
created = await this.companyProfilesRepo.create({
|
||||
@@ -2263,6 +2446,11 @@ export class CompaniesService {
|
||||
etradeBusiness,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
await this.reopenOnboardingForLicensedRole(
|
||||
profile,
|
||||
company.companyProfiles ?? [],
|
||||
[type],
|
||||
);
|
||||
}
|
||||
|
||||
return created;
|
||||
@@ -2331,9 +2519,13 @@ export class CompaniesService {
|
||||
}));
|
||||
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
||||
|
||||
// 3. Per-operational-profile business licenses (FileRecord-backed).
|
||||
// 3. Per-operational-profile business licenses (FileRecord-backed). A
|
||||
// transit agent holds none here — its roster entry is its registration —
|
||||
// so it owes neither a licence nor an eTrade business.
|
||||
const licenseProfiles = await Promise.all(
|
||||
(company.companyProfiles ?? []).map(async (p) => {
|
||||
(company.companyProfiles ?? [])
|
||||
.filter((p) => p.type !== ProfileType.transitAgent)
|
||||
.map(async (p) => {
|
||||
const records = await this.filesService.findByResource(
|
||||
p.id,
|
||||
LICENSE_RESOURCE,
|
||||
@@ -3775,7 +3967,11 @@ export class CompaniesService {
|
||||
companyId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<string | null> {
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
// A transit agent profile never carries a booking — it is the roster
|
||||
// side of the company, not a trade role.
|
||||
const profiles = (
|
||||
await this.companyProfilesRepo.findByCompanyId(companyId)
|
||||
).filter((p) => p.type !== ProfileType.transitAgent);
|
||||
if (profiles.length === 0) return null;
|
||||
|
||||
const naturalType =
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { TransitAgentCountry } from "../transit-agents/entities/transit-agent.entity";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompanyType } from "./entities/company.entity";
|
||||
import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* A freight forwarder IS an Ethiopian transit agent, so taking the role means
|
||||
* naming which roster entry the company is. These lock the rule at the door:
|
||||
* no agent → refused; a foreign or suspended one → refused; and the link
|
||||
* follows the role, both on a fresh draft and when a draft is re-run.
|
||||
*/
|
||||
|
||||
interface ExistingProfile {
|
||||
id: string;
|
||||
type: ProfileType;
|
||||
status: ProfileStatus;
|
||||
}
|
||||
|
||||
const ETHIOPIAN = {
|
||||
id: "ta-et",
|
||||
name: "Abyssinia Transit",
|
||||
isActive: true,
|
||||
country: TransitAgentCountry.Ethiopia,
|
||||
};
|
||||
const DJIBOUTIAN = {
|
||||
id: "ta-dj",
|
||||
name: "Ahmed Bourhan",
|
||||
isActive: true,
|
||||
country: TransitAgentCountry.Djibouti,
|
||||
};
|
||||
const SUSPENDED = { ...ETHIOPIAN, id: "ta-off", isActive: false };
|
||||
const AGENTS = [ETHIOPIAN, DJIBOUTIAN, SUSPENDED];
|
||||
|
||||
function makeService(opts: {
|
||||
existing?: ExistingProfile[] | null;
|
||||
linkedAgentId?: string | null;
|
||||
/** The draft's owner already submitted onboarding (transit-agent-only). */
|
||||
onboardingCompleted?: boolean;
|
||||
}) {
|
||||
const companyProfilesRepo = {
|
||||
findByCompanyId: jest.fn(async () => opts.existing ?? []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "new",
|
||||
...row,
|
||||
})),
|
||||
softDelete: jest.fn(async () => undefined),
|
||||
};
|
||||
const companiesRepo = {
|
||||
update: jest.fn(async () => null),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "company-1",
|
||||
...row,
|
||||
})),
|
||||
findById: jest.fn(async () => ({
|
||||
id: "company-1",
|
||||
attributes: {},
|
||||
transitAgentId: opts.linkedAgentId ?? null,
|
||||
})),
|
||||
};
|
||||
const profilesRepo = {
|
||||
findByUserId: jest.fn(async () =>
|
||||
opts.existing === null
|
||||
? null
|
||||
: {
|
||||
id: "external-1",
|
||||
companyId: "company-1",
|
||||
company: { id: "company-1" },
|
||||
onboardingCompleted: opts.onboardingCompleted ?? false,
|
||||
},
|
||||
),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "external-1",
|
||||
...row,
|
||||
})),
|
||||
update: jest.fn(async () => null),
|
||||
};
|
||||
const transitAgentsRepo = {
|
||||
findById: jest.fn(async (id: string) => AGENTS.find((a) => a.id === id) ?? null),
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
companiesRepo as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
profilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
transitAgentsRepo as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
|
||||
);
|
||||
// The draft TIN is random and irrelevant here.
|
||||
jest
|
||||
.spyOn(service as never, "generateDraftTin" as never)
|
||||
.mockImplementation((async () => "D000000001") as never);
|
||||
|
||||
return { service, companiesRepo, companyProfilesRepo, profilesRepo };
|
||||
}
|
||||
|
||||
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
|
||||
|
||||
const start = (
|
||||
service: CompaniesService,
|
||||
roles: ProfileType[],
|
||||
transitAgentId?: string,
|
||||
) =>
|
||||
service.startOnboarding(
|
||||
identity as never,
|
||||
CompanyType.Customer,
|
||||
roles,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
transitAgentId,
|
||||
);
|
||||
|
||||
describe("a freight forwarder must name its transit agent", () => {
|
||||
it("refuses the forwarder role without an agent on a fresh draft", async () => {
|
||||
const { service, companiesRepo } = makeService({ existing: null });
|
||||
|
||||
await expect(
|
||||
start(service, [ProfileType.freightForwarder]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(companiesRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a Djiboutian agent", async () => {
|
||||
const { service } = makeService({ existing: null });
|
||||
|
||||
await expect(
|
||||
start(service, [ProfileType.freightForwarder], DJIBOUTIAN.id),
|
||||
).rejects.toThrow(/not an active Ethiopian transit agent/);
|
||||
});
|
||||
|
||||
it("refuses a suspended agent", async () => {
|
||||
const { service } = makeService({ existing: null });
|
||||
|
||||
await expect(
|
||||
start(service, [ProfileType.freightForwarder], SUSPENDED.id),
|
||||
).rejects.toThrow(/not an active Ethiopian transit agent/);
|
||||
});
|
||||
|
||||
it("stores the Ethiopian agent on the new draft company", async () => {
|
||||
const { service, companiesRepo } = makeService({ existing: null });
|
||||
|
||||
await start(
|
||||
service,
|
||||
[ProfileType.importer, ProfileType.freightForwarder],
|
||||
ETHIOPIAN.id,
|
||||
);
|
||||
|
||||
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not ask an importer for an agent, and stores none", async () => {
|
||||
const { service, companiesRepo } = makeService({ existing: null });
|
||||
|
||||
await start(service, [ProfileType.importer], ETHIOPIAN.id);
|
||||
|
||||
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transitAgentId: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("re-running role selection keeps the link in step with the role", () => {
|
||||
const importerOnly: ExistingProfile[] = [
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
];
|
||||
|
||||
it("links the agent when forwarding is added to an existing draft", async () => {
|
||||
const { service, companiesRepo } = makeService({ existing: importerOnly });
|
||||
|
||||
await start(
|
||||
service,
|
||||
[ProfileType.importer, ProfileType.freightForwarder],
|
||||
ETHIOPIAN.id,
|
||||
);
|
||||
|
||||
expect(companiesRepo.update).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the agent already linked when the re-run omits it", async () => {
|
||||
const { service, companiesRepo } = makeService({
|
||||
existing: importerOnly,
|
||||
linkedAgentId: ETHIOPIAN.id,
|
||||
});
|
||||
|
||||
await start(service, [ProfileType.importer, ProfileType.freightForwarder]);
|
||||
|
||||
expect(companiesRepo.update).not.toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ transitAgentId: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("still refuses a re-run that adds forwarding with nothing linked", async () => {
|
||||
const { service } = makeService({ existing: importerOnly });
|
||||
|
||||
await expect(
|
||||
start(service, [ProfileType.importer, ProfileType.freightForwarder]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("clears the link when the forwarder role is dropped", async () => {
|
||||
const { service, companiesRepo } = makeService({
|
||||
existing: importerOnly,
|
||||
linkedAgentId: ETHIOPIAN.id,
|
||||
});
|
||||
|
||||
await start(service, [ProfileType.importer]);
|
||||
|
||||
expect(companiesRepo.update).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ transitAgentId: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A company that is ONLY a transit agent has nothing else to tell us: its
|
||||
* registration is the roster entry it picked, so onboarding ends right there
|
||||
* — and reopens if it later takes on a licensed role.
|
||||
*/
|
||||
describe("a transit-agent-only company finishes onboarding on the roster pick", () => {
|
||||
it("names the draft after the agent and completes onboarding at once", async () => {
|
||||
const { service, companiesRepo, profilesRepo } = makeService({
|
||||
existing: null,
|
||||
});
|
||||
|
||||
await start(service, [ProfileType.transitAgent], ETHIOPIAN.id);
|
||||
|
||||
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: ETHIOPIAN.name,
|
||||
transitAgentId: ETHIOPIAN.id,
|
||||
}),
|
||||
);
|
||||
expect(profilesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onboardingCompleted: true,
|
||||
onboardingStep: "transit-agent",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still refuses the role without an agent", async () => {
|
||||
const { service, companiesRepo } = makeService({ existing: null });
|
||||
|
||||
await expect(
|
||||
start(service, [ProfileType.transitAgent]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(companiesRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the full wizard when a trade role is picked alongside", async () => {
|
||||
const { service, companiesRepo, profilesRepo } = makeService({
|
||||
existing: null,
|
||||
});
|
||||
|
||||
await start(
|
||||
service,
|
||||
[ProfileType.transitAgent, ProfileType.importer],
|
||||
ETHIOPIAN.id,
|
||||
);
|
||||
|
||||
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
|
||||
);
|
||||
expect(companiesRepo.create).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: ETHIOPIAN.name }),
|
||||
);
|
||||
expect(profilesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onboardingCompleted: false,
|
||||
onboardingStep: "company",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("completes an existing transit-agent-only draft on the pick", async () => {
|
||||
const { service, companiesRepo, profilesRepo } = makeService({
|
||||
existing: [
|
||||
{ id: "p-ta", type: ProfileType.transitAgent, status: ProfileStatus.Pending },
|
||||
],
|
||||
});
|
||||
|
||||
await start(service, [ProfileType.transitAgent], ETHIOPIAN.id);
|
||||
|
||||
expect(companiesRepo.update).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
transitAgentId: ETHIOPIAN.id,
|
||||
name: ETHIOPIAN.name,
|
||||
}),
|
||||
);
|
||||
expect(profilesRepo.update).toHaveBeenCalledWith(
|
||||
"external-1",
|
||||
expect.objectContaining({
|
||||
onboardingCompleted: true,
|
||||
onboardingStep: "transit-agent",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reopens onboarding at the company step when a licensed role is added", async () => {
|
||||
const { service, profilesRepo } = makeService({
|
||||
existing: [
|
||||
{ id: "p-ta", type: ProfileType.transitAgent, status: ProfileStatus.Active },
|
||||
],
|
||||
linkedAgentId: ETHIOPIAN.id,
|
||||
onboardingCompleted: true,
|
||||
});
|
||||
|
||||
await start(service, [ProfileType.transitAgent, ProfileType.importer]);
|
||||
|
||||
expect(profilesRepo.update).toHaveBeenCalledWith(
|
||||
"external-1",
|
||||
expect.objectContaining({
|
||||
onboardingCompleted: false,
|
||||
onboardingStep: "company",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const SEQUENCE_MAP: Record<ProfileType, string> = {
|
||||
[ProfileType.freightForwarder]: "seq_company_profile_ffe",
|
||||
[ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
|
||||
[ProfileType.transporter]: "seq_company_profile_tr",
|
||||
[ProfileType.transitAgent]: "seq_company_profile_ta",
|
||||
};
|
||||
|
||||
const PREFIX_MAP: Record<ProfileType, string> = {
|
||||
@@ -18,6 +19,7 @@ const PREFIX_MAP: Record<ProfileType, string> = {
|
||||
[ProfileType.freightForwarder]: "FF",
|
||||
[ProfileType.djFreightForwarder]: "FWJ",
|
||||
[ProfileType.transporter]: "TR",
|
||||
[ProfileType.transitAgent]: "TA",
|
||||
};
|
||||
|
||||
const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
@@ -86,16 +86,6 @@ export class TransitAgentInfoResponseDto {
|
||||
@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;
|
||||
@@ -112,8 +102,6 @@ export class TransitAgentInfoResponseDto {
|
||||
this.email = entity.email ?? null;
|
||||
this.phoneNumber = entity.phoneNumber ?? null;
|
||||
this.isActive = entity.isActive;
|
||||
this.validFrom = entity.validFrom;
|
||||
this.validTo = entity.validTo;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
@@ -26,6 +27,14 @@ export class AddCompanyProfileInputDto {
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
|
||||
/**
|
||||
* Which Ethiopian transit agent the company is. Required for the transit
|
||||
* agent and freight forwarder roles unless the company is already linked.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
transitAgentId?: string;
|
||||
}
|
||||
|
||||
export class AddCompanyProfilesDto {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class CreateCompanyProfileDto {
|
||||
@@ -19,4 +19,12 @@ export class CreateCompanyProfileDto {
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
|
||||
/**
|
||||
* Which Ethiopian transit agent the company is. Required for the transit
|
||||
* agent and freight forwarder roles unless the company is already linked.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
transitAgentId?: string;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,13 @@ export class ResponseCompanyDto {
|
||||
email?: string | null;
|
||||
website?: string | null;
|
||||
attributes?: Record<string, any> | null;
|
||||
/**
|
||||
* The transit-agent roster entry a freight forwarder registered itself as
|
||||
* (`Company.transitAgentId`). The name rides along when the relation was
|
||||
* loaded, so the portal and backoffice can show it without a second lookup.
|
||||
*/
|
||||
transitAgentId: string | null;
|
||||
transitAgent: { id: string; name: string } | null;
|
||||
profiles?: ResponseExternalProfileDto[];
|
||||
companyProfiles?: ResponseCompanyProfileDto[];
|
||||
/**
|
||||
@@ -144,6 +151,10 @@ export class ResponseCompanyDto {
|
||||
this.email = company.email;
|
||||
this.website = company.website;
|
||||
this.attributes = company.attributes;
|
||||
this.transitAgentId = company.transitAgentId ?? null;
|
||||
this.transitAgent = company.transitAgent
|
||||
? { id: company.transitAgent.id, name: company.transitAgent.name }
|
||||
: null;
|
||||
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||
this.companyProfiles = company.companyProfiles?.map(
|
||||
(p) => new ResponseCompanyProfileDto(p),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
} from "class-validator";
|
||||
import { CompanyNationality, CompanyType } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
@@ -42,4 +43,14 @@ export class StartOnboardingDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
investorLicence?: boolean;
|
||||
|
||||
/**
|
||||
* Which Ethiopian transit agent this company is. Required whenever `roles`
|
||||
* includes the freight forwarder — the two are the same business — and
|
||||
* refused for any other agent (foreign, suspended, or unknown). Ignored when
|
||||
* the forwarder role is not selected.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
transitAgentId?: string;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ export enum ProfileType {
|
||||
freightForwarder = "freight_forwarder",
|
||||
djFreightForwarder = "dj_freight_forwarder",
|
||||
transporter = "transporter",
|
||||
/**
|
||||
* An Ethiopian transit agent registering on the portal as itself — the
|
||||
* business customers pick to clear customs on a booking. Holds no trade
|
||||
* licence or eTrade business here: its identity is the roster entry
|
||||
* (`Company.transitAgentId`), and a company with ONLY this role finishes
|
||||
* onboarding right after picking it.
|
||||
*/
|
||||
transitAgent = "transit_agent",
|
||||
}
|
||||
|
||||
export enum ProfileStatus {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, OneToMany } from "typeorm";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from "typeorm";
|
||||
import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity";
|
||||
import { ExternalProfile } from "./external-profile.entity";
|
||||
import { CompanyProfile } from "./company-profile.entity";
|
||||
|
||||
@@ -235,6 +236,22 @@ export class Company extends BaseEntity {
|
||||
@Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true })
|
||||
etradePhone?: string | null;
|
||||
|
||||
/**
|
||||
* The transit-agent roster entry this company IS, when it holds the
|
||||
* freight-forwarder role. An Ethiopian transit agent and a freight forwarder
|
||||
* are one business seen from two sides — the roster GL assigns officers
|
||||
* from, and the customer signing contracts on other companies' behalf — and
|
||||
* this is what ties the two rows together. Required at onboarding for a
|
||||
* forwarder; null for every importer/exporter and for forwarders linked
|
||||
* before the column existed.
|
||||
*/
|
||||
@Column({ name: "transit_agent_id", type: "uuid", nullable: true })
|
||||
transitAgentId?: string | null;
|
||||
|
||||
@ManyToOne(() => TransitAgent, { nullable: true })
|
||||
@JoinColumn({ name: "transit_agent_id" })
|
||||
transitAgent?: TransitAgent | null;
|
||||
|
||||
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||
profiles?: ExternalProfile[];
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
@@ -158,7 +160,9 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
|
||||
@@ -27,6 +27,8 @@ describe('ContractBookingService — customs booking gate', () => {
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
// The pairing is parked for approval rather than going straight to
|
||||
// Operations; the gate itself is covered by its own spec.
|
||||
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
return { service, bookingsRepository, dataSource };
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ describe('ContractBookingService — changes-requested resubmit restating cargo'
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
return { service, bookingsRepository, invoiceService };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||
import { TransitAgentCountry } from '../transit-agents/entities/transit-agent.entity';
|
||||
import { TransitAgentsRepository } from '../transit-agents/transit-agents.repository';
|
||||
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
|
||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
|
||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
@@ -136,6 +139,8 @@ export class ContractBookingService {
|
||||
private readonly bookingTransitionService: BookingTransitionService,
|
||||
@Inject(forwardRef(() => ConsolidationApprovalService))
|
||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||
private readonly transitAgentsRepository: TransitAgentsRepository,
|
||||
private readonly transitAssignmentsService: TransitAssignmentsService,
|
||||
) {}
|
||||
|
||||
async createUnderContract(
|
||||
@@ -857,34 +862,66 @@ export class ContractBookingService {
|
||||
if (!dto.scheduledDate) {
|
||||
throw new BadRequestException('A binding shipment day is required');
|
||||
}
|
||||
// Without-customs import/export: the customer's own clearing agent (name,
|
||||
// email, phone) is captured per booking at completion. A resubmit may omit
|
||||
// the fields and keep what the booking already stored. Customs contracts
|
||||
// (GL clears) and intercity (no border) never collect an agent.
|
||||
// Without-customs import/export: the customer names who clears customs for
|
||||
// this booking, one of two ways. Either a registered Ethiopian transit
|
||||
// agent (a freight forwarder on the platform) — the booking is assigned to
|
||||
// it and the forwarder is told — or their own clearing agent typed in
|
||||
// (name, email, phone). A resubmit may omit the typed fields and keep what
|
||||
// the booking already stored. Customs contracts (GL clears) and intercity
|
||||
// (no border) never collect an agent.
|
||||
let assignedTransitAgent: { id: string; name: string } | null = null;
|
||||
if (
|
||||
!contract.customsClearingEnabled &&
|
||||
contract.tradeDirection !== 'DOMESTIC'
|
||||
) {
|
||||
const agentName =
|
||||
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
|
||||
const agentEmail =
|
||||
dto.customsClearingAgentEmail?.trim() ||
|
||||
booking.customsClearingAgentEmail ||
|
||||
null;
|
||||
const agentPhone =
|
||||
dto.customsClearingAgentPhone?.trim() ||
|
||||
booking.customsClearingAgentPhone ||
|
||||
null;
|
||||
if (!agentName || !agentEmail || !agentPhone) {
|
||||
throw new BadRequestException(
|
||||
'Customs clearing agent name, email and phone are required to complete this booking.',
|
||||
);
|
||||
if (dto.transitAgentId) {
|
||||
const agent = await this.transitAgentsRepository.findById(dto.transitAgentId);
|
||||
if (
|
||||
!agent ||
|
||||
!agent.isActive ||
|
||||
agent.country !== TransitAgentCountry.Ethiopia
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'The selected transit agent is not an active Ethiopian transit agent — pick another one or enter your clearing agent details.',
|
||||
);
|
||||
}
|
||||
// The forwarder company's own contact goes on the booking, so the
|
||||
// customer sees who to reach; an Ethiopian agent row carries none.
|
||||
const [forwarder]: Array<{ email: string | null; phone: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT email, phone FROM freight.companies
|
||||
WHERE transit_agent_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[agent.id],
|
||||
);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
customsClearingAgent: agent.name,
|
||||
customsClearingAgentEmail: forwarder?.email ?? null,
|
||||
customsClearingAgentPhone: forwarder?.phone ?? null,
|
||||
} as never);
|
||||
assignedTransitAgent = { id: agent.id, name: agent.name };
|
||||
} else {
|
||||
const agentName =
|
||||
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
|
||||
const agentEmail =
|
||||
dto.customsClearingAgentEmail?.trim() ||
|
||||
booking.customsClearingAgentEmail ||
|
||||
null;
|
||||
const agentPhone =
|
||||
dto.customsClearingAgentPhone?.trim() ||
|
||||
booking.customsClearingAgentPhone ||
|
||||
null;
|
||||
if (!agentName || !agentEmail || !agentPhone) {
|
||||
throw new BadRequestException(
|
||||
'Customs clearing agent name, email and phone are required to complete this booking — or pick a registered transit agent.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
customsClearingAgent: agentName,
|
||||
customsClearingAgentEmail: agentEmail,
|
||||
customsClearingAgentPhone: agentPhone,
|
||||
} as never);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
customsClearingAgent: agentName,
|
||||
customsClearingAgentEmail: agentEmail,
|
||||
customsClearingAgentPhone: agentPhone,
|
||||
} as never);
|
||||
}
|
||||
// No expiry gate here on purpose: this booking was already initiated
|
||||
// before the contract lapsed (createUnderContract/initiateUnderContract
|
||||
@@ -1092,6 +1129,18 @@ export class ContractBookingService {
|
||||
dto.scheduledDate,
|
||||
dto.trainScheduleId ?? null,
|
||||
);
|
||||
|
||||
// The forwarder's work list and its notice come AFTER the booking is
|
||||
// committed: a customer must never be told a forwarder has the job when
|
||||
// the completion itself was refused a line above.
|
||||
if (assignedTransitAgent) {
|
||||
await this.transitAssignmentsService.ensureAssignment(
|
||||
booking.id,
|
||||
assignedTransitAgent.id,
|
||||
(actorPermissions as { id?: string } | undefined)?.id,
|
||||
);
|
||||
void this.bookingNotifier.transitAgentAssigned(completed, assignedTransitAgent);
|
||||
}
|
||||
return { booking: completed, warnings };
|
||||
}
|
||||
|
||||
|
||||
@@ -256,6 +256,17 @@ export class CreateBookingUnderContractDto {
|
||||
@MaxLength(50)
|
||||
customsClearingAgentPhone?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Instead of typing a clearing agent: a registered Ethiopian transit agent (freight ' +
|
||||
'forwarder). The booking is assigned to it and the forwarder is notified; the typed ' +
|
||||
'agent fields are ignored when this is set.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
transitAgentId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -130,6 +130,7 @@ export const customersDataset: ExportDataset = {
|
||||
{ value: 'exporter', label: 'Exporter' },
|
||||
{ value: 'freight_forwarder', label: 'Freight forwarder' },
|
||||
{ value: 'dj_freight_forwarder', label: 'DJ freight forwarder' },
|
||||
{ value: 'transit_agent', label: 'Transit agent' },
|
||||
{ value: 'transporter', label: 'Transporter' },
|
||||
] },
|
||||
// The list's Status filter folds the review queues in, and sends these two
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
@@ -19,11 +20,14 @@ import { TransitAssignmentsService } from "./transit-assignments.service";
|
||||
// Milestones and train schedules are read for the agent's dashboard
|
||||
// timings (declaration stamps, departure/arrival fallbacks) — entities
|
||||
// only, for the same reason as Booking.
|
||||
// ExternalProfile: `/my` resolves a freight forwarder's portal user to the
|
||||
// transit agent its company registered as — entity only, same reason.
|
||||
TypeOrmModule.forFeature([
|
||||
TransitAssignment,
|
||||
Booking,
|
||||
ClearanceMilestone,
|
||||
TrainSchedule,
|
||||
ExternalProfile,
|
||||
]),
|
||||
FilesModule,
|
||||
TransitAgentsModule,
|
||||
|
||||
@@ -93,6 +93,7 @@ describe("TransitAssignmentsService", () => {
|
||||
files as never,
|
||||
milestones as never,
|
||||
trainSchedules as never,
|
||||
{ findOne: jest.fn().mockResolvedValue(null) } as never, // externalProfiles
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
} from "@edr/types";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
import {
|
||||
ProfileStatus,
|
||||
ProfileType,
|
||||
} from "../companies/entities/company-profile.entity";
|
||||
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
@@ -170,6 +175,11 @@ export class TransitAssignmentsService {
|
||||
private readonly milestonesRepository: Repository<ClearanceMilestone>,
|
||||
@InjectRepository(TrainSchedule)
|
||||
private readonly trainSchedulesRepository: Repository<TrainSchedule>,
|
||||
// The ExternalProfile ENTITY (not CompaniesModule) for the same reason as
|
||||
// Booking above: `/my` only has to walk portal user → company → the
|
||||
// transit agent that company registered itself as.
|
||||
@InjectRepository(ExternalProfile)
|
||||
private readonly externalProfilesRepository: Repository<ExternalProfile>,
|
||||
) {}
|
||||
|
||||
private static minutesBetween(
|
||||
@@ -252,9 +262,52 @@ export class TransitAssignmentsService {
|
||||
// client-supplied id: an agent must not be able to read or edit another
|
||||
// agent's assignments by guessing one.
|
||||
|
||||
/** The transit agent this portal user signs in as. */
|
||||
private async requireAgentForUser(userId: string) {
|
||||
const agent = await this.transitAgentsRepository.findByUserId(userId);
|
||||
/**
|
||||
* The transit agent this portal user acts as.
|
||||
*
|
||||
* Two kinds of account reach `/my`: a Djibouti transit officer, who signs in
|
||||
* AS the agent (`transit_agents.user_id`), and a customer company that
|
||||
* registered itself as an Ethiopian transit agent (`companies.
|
||||
* transit_agent_id`) — under the transit agent role, the forwarder role, or
|
||||
* both. It may look at its assigned bookings from the moment the role is
|
||||
* requested — that is how it learns work is waiting — but may only act on
|
||||
* them (`forWrite`) once a roster role has been approved.
|
||||
*/
|
||||
private async requireAgentForUser(
|
||||
userId: string,
|
||||
opts: { forWrite?: boolean } = {},
|
||||
) {
|
||||
const own = await this.transitAgentsRepository.findByUserId(userId);
|
||||
if (own) return own;
|
||||
|
||||
const profile = await this.externalProfilesRepository.findOne({
|
||||
where: { userId },
|
||||
relations: { company: { companyProfiles: true } },
|
||||
});
|
||||
const company = profile?.company;
|
||||
if (!company?.transitAgentId) {
|
||||
throw new ForbiddenException("This account is not a transit agent");
|
||||
}
|
||||
// Either roster role will do — a plain transit agent or a forwarder.
|
||||
const agentRoles = (company.companyProfiles ?? []).filter(
|
||||
(p) =>
|
||||
p.type === ProfileType.transitAgent ||
|
||||
p.type === ProfileType.freightForwarder,
|
||||
);
|
||||
if (agentRoles.length === 0) {
|
||||
throw new ForbiddenException("This account is not a transit agent");
|
||||
}
|
||||
if (
|
||||
opts.forWrite &&
|
||||
!agentRoles.some((p) => p.status === ProfileStatus.Active)
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
"Your transit agent role is not approved yet — you can view assigned bookings but not act on them until it is.",
|
||||
);
|
||||
}
|
||||
const agent = await this.transitAgentsRepository.findById(
|
||||
company.transitAgentId,
|
||||
);
|
||||
if (!agent) {
|
||||
throw new ForbiddenException("This account is not a transit agent");
|
||||
}
|
||||
@@ -640,8 +693,12 @@ export class TransitAssignmentsService {
|
||||
return { ...this.toView(assignment), files: await this.listFiles(id) };
|
||||
}
|
||||
|
||||
/** Assert the assignment is this user's before any write reaches it. */
|
||||
/**
|
||||
* Assert the assignment is this user's before any write reaches it — and
|
||||
* that the user may write at all (an unapproved forwarder may only look).
|
||||
*/
|
||||
private async assertMine(userId: string, id: string): Promise<void> {
|
||||
await this.requireAgentForUser(userId, { forWrite: true });
|
||||
await this.findMineById(userId, id);
|
||||
}
|
||||
|
||||
@@ -677,6 +734,7 @@ export class TransitAssignmentsService {
|
||||
id: string,
|
||||
input: { finish: boolean; note?: string },
|
||||
): Promise<TransitAssignmentView> {
|
||||
await this.requireAgentForUser(userId, { forWrite: true });
|
||||
const current = await this.findMineById(userId, id);
|
||||
if (current.status === TransitAssignmentStatus.Finished) {
|
||||
throw new ForbiddenException("This assignment is already finished.");
|
||||
|
||||
Reference in New Issue
Block a user