From 1eb9f1035419e35df89c2149e59cd13909b50939 Mon Sep 17 00:00:00 2001 From: marshal Date: Sun, 6 Sep 2026 21:41:29 +0000 Subject: [PATCH] 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 --- .../3930000000000-CompanyTransitAgentLink.ts | 75 +++ .../3940000000000-DropTransitAgentValidity.ts | 33 ++ ...50000000000-TransitAgentProfileSequence.ts | 29 ++ .../booking-lifecycle-notifier.service.ts | 63 +++ .../modules/companies/companies.controller.ts | 2 + .../companies.fayda-identity.spec.ts | 11 + .../companies.investor-licence.spec.ts | 1 + .../companies.license-supersede.spec.ts | 1 + .../companies.poa-delegation.spec.ts | 11 + .../companies.profile-approval.spec.ts | 1 + .../companies.profile-etrade-business.spec.ts | 1 + .../companies/companies.role-deselect.spec.ts | 1 + .../modules/companies/companies.service.ts | 262 +++++++++-- .../companies.transit-agent-link.spec.ts | 343 ++++++++++++++ .../companies/company-profile.repository.ts | 2 + .../dto/account-info-response.dto.ts | 12 - .../companies/dto/add-company-profiles.dto.ts | 9 + .../dto/create-company-profile.dto.ts | 10 +- .../companies/dto/response-company.dto.ts | 11 + .../companies/dto/start-onboarding.dto.ts | 11 + .../entities/company-profile.entity.ts | 8 + .../companies/entities/company.entity.ts | 19 +- .../contract-booking.completion.spec.ts | 6 +- .../contract-booking.consolidation.spec.ts | 2 + .../contract-booking.initiate-gate.spec.ts | 2 + ...tract-booking.manual-consolidation.spec.ts | 2 + .../contract-booking.resubmit-cargo.spec.ts | 2 + .../contracts/contract-booking.service.ts | 95 +++- .../dto/create-booking-under-contract.dto.ts | 11 + .../exports/datasets/customers.dataset.ts | 1 + .../dto/create-transit-agent.dto.ts | 22 +- .../entities/transit-agent.entity.ts | 34 +- .../transit-agents.controller.ts | 22 +- .../transit-agents.repository.ts | 38 +- .../transit-agents.service.spec.ts | 105 ++++- .../transit-agents/transit-agents.service.ts | 121 ++--- .../transit-assignments.module.ts | 4 + .../transit-assignments.service.spec.ts | 1 + .../transit-assignments.service.ts | 66 ++- .../contracts/TransitAssigneePanel.tsx | 4 +- .../src/components/customers/badges.tsx | 1 + .../ruleEngine/ruleEngineFormat.tsx | 22 +- .../pages/customers/CustomerDetailPage.tsx | 16 + .../src/pages/customers/CustomersPage.tsx | 1 + .../ruleEngine/RuleEngineResourcePage.tsx | 5 +- .../src/pages/ruleEngine/config/resources.ts | 36 +- .../src/services/transit-agents.service.ts | 5 +- .../backoffice/src/types/customer.ts | 10 +- apps/edr-freight-web/portal/src/App.tsx | 86 +++- .../portal/src/components/AppLayout.tsx | 89 +++- .../onboarding/OnboardingWizardDialog.tsx | 215 +++++++-- .../components/onboarding/RoleLicenseStep.tsx | 1 + .../onboarding/TransitAgentSelect.tsx | 158 +++++++ .../portal/src/constants/URLS.ts | 4 + .../portal/src/constants/profileMode.ts | 1 + .../portal/src/hooks/useAuth.ts | 30 +- .../portal/src/pages/SettingsPage.tsx | 1 + .../src/pages/contracts/NewShipmentPage.tsx | 83 +++- .../contracts/new-shipment-form/schema.ts | 16 +- .../pages/forwarder/AssignedBookingsPage.tsx | 445 ++++++++++++++++++ .../portal/src/pages/forwarder/index.ts | 1 + .../src/pages/settings/CompanyRolesCard.tsx | 47 +- .../src/pages/settings/TabDocuments.tsx | 1 + .../src/pages/settings/companyRoles.tsx | 32 +- .../portal/src/services/api.ts | 24 +- .../portal/src/services/companies.service.ts | 40 +- packages/types/src/freight/contracts.ts | 6 + 67 files changed, 2528 insertions(+), 302 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3930000000000-CompanyTransitAgentLink.ts create mode 100644 apps/edr-freight-api/src/migrations/3940000000000-DropTransitAgentValidity.ts create mode 100644 apps/edr-freight-api/src/migrations/3950000000000-TransitAgentProfileSequence.ts create mode 100644 apps/edr-freight-api/src/modules/companies/companies.transit-agent-link.spec.ts create mode 100644 apps/edr-freight-web/portal/src/components/onboarding/TransitAgentSelect.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/forwarder/index.ts diff --git a/apps/edr-freight-api/src/migrations/3930000000000-CompanyTransitAgentLink.ts b/apps/edr-freight-api/src/migrations/3930000000000-CompanyTransitAgentLink.ts new file mode 100644 index 000000000..104382e10 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3930000000000-CompanyTransitAgentLink.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3940000000000-DropTransitAgentValidity.ts b/apps/edr-freight-api/src/migrations/3940000000000-DropTransitAgentValidity.ts new file mode 100644 index 000000000..371bdf527 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3940000000000-DropTransitAgentValidity.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.transit_agents + ADD COLUMN IF NOT EXISTS valid_from date, + ADD COLUMN IF NOT EXISTS valid_to date + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3950000000000-TransitAgentProfileSequence.ts b/apps/edr-freight-api/src/migrations/3950000000000-TransitAgentProfileSequence.ts new file mode 100644 index 000000000..53d265d9f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3950000000000-TransitAgentProfileSequence.ts @@ -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 { + 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 { + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ta`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index caffee8c0..7c210ceb9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -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 { + 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}`, + ); + } + } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 733b6e441..ad8383082 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index da2497d90..397e4fd66 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -80,6 +80,9 @@ function makeService(overrides: Partial = {}) { 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 = {}) { 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 diff --git a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts index 9de99186f..a9042df46 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts @@ -66,6 +66,7 @@ function makeService(company: Record | null) { {} as never, {} as never, {} as never, + {} as never, ); jest diff --git a/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts index 5a73b6040..416d70bc5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts @@ -101,6 +101,7 @@ function makeService( { changeRequestSubmitted: jest.fn() } as never, {} as never, {} as never, + {} as never, ); jest diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index b801a793f..38440747c 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -46,6 +46,9 @@ function makeService(overrides: Partial = {}) { 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 = {}) { 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 diff --git a/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts index 79fbdd479..929607f5d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts @@ -75,6 +75,7 @@ function makeService(status: ProfileStatus) { companyNotifier as never, dataSource as never, {} as never, + {} as never, ); return { service, profile, written, companyProfilesRepo }; diff --git a/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts index c04ce40f9..59fc0fce3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts @@ -70,6 +70,7 @@ function makeService(attributes: Record = {}) { {} as never, {} as never, {} as never, + {} as never, ); jest diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts index ea81e4dbe..4065173cc 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -54,6 +54,7 @@ function makeService(existing: ExistingProfile[]) { {} as never, {} as never, {} as never, + {} as never, ); jest diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 1080ceb12..ca7d4c12b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 = {}; + if ((linkedAgent?.id ?? null) !== (current?.transitAgentId ?? null)) { + updates.transitAgentId = linkedAgent?.id ?? null; + } + const profilePatch: Partial = {}; + 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 { + 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 { + 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 { + 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 { + 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 { 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 { 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 { - 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 = diff --git a/apps/edr-freight-api/src/modules/companies/companies.transit-agent-link.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.transit-agent-link.spec.ts new file mode 100644 index 000000000..3ff645d17 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.transit-agent-link.spec.ts @@ -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) => ({ + id: "new", + ...row, + })), + softDelete: jest.fn(async () => undefined), + }; + const companiesRepo = { + update: jest.fn(async () => null), + create: jest.fn(async (row: Record) => ({ + 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) => ({ + 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", + }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index 9bf14cd61..1be1177db 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -10,6 +10,7 @@ const SEQUENCE_MAP: Record = { [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 = { @@ -18,6 +19,7 @@ const PREFIX_MAP: Record = { [ProfileType.freightForwarder]: "FF", [ProfileType.djFreightForwarder]: "FWJ", [ProfileType.transporter]: "TR", + [ProfileType.transitAgent]: "TA", }; const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; diff --git a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts index 9a43e65d4..5bf253ba9 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts @@ -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; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts index 8809310cc..67377dffd 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts index 9bb5453ee..8149c84e2 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 321d739e3..46b73867b 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -87,6 +87,13 @@ export class ResponseCompanyDto { email?: string | null; website?: string | null; attributes?: Record | 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), diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts index 0eac35a5f..855b06ae3 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 0a16343f4..d2411aa1b 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 68bf73cdf..3d0bd0f16 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -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[]; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 565164995..2b52ea1f2 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index fb68b3f5c..164153c80 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts index 6155eb583..1e1a86651 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts @@ -27,6 +27,8 @@ describe('ContractBookingService — customs booking gate', () => { {} as never, // bookingBatchService {} as never, // bookingTransitionService {} as never, // consolidationApprovalService + {} as never, // transitAgentsRepository + {} as never, // transitAssignmentsService ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts index 4fe111468..d40441363 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts index 0a892b108..dbb3c06f6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index b8205fd33..821e42e56 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index b1dabfa16..4dd8bb254 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts index d36bbecd6..3cfd7a579 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts index d035c53f8..80cabe9ff 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts index 433b6587f..b5059d106 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts index d254f7982..16b1fcc41 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts @@ -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" }) diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts index 5ae4191eb..d2cb4772c 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts @@ -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; @Injectable() export class TransitAgentsRepository extends BaseRepository { @@ -19,14 +20,23 @@ export class TransitAgentsRepository extends BaseRepository { super(repository); } - /** Active AND currently inside its validity window (today's date, server-side). */ - findAssignable(today: string): Promise { + /** Every active agent — the GL assignment dropdown. */ + findAssignable(): Promise { 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 { + return this.repository.find({ + select: { id: true, name: true }, + where: { isActive: true, country: TransitAgentCountry.Ethiopia }, order: { name: "ASC" }, }); } diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts index 3aa5e6e02..fed1fb141 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts @@ -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(); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts index b54f1fddb..c99296e85 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts @@ -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, -): 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 { - return this.transitAgentsRepository.findAssignable(todayISODate()); + return this.transitAgentsRepository.findAssignable(); + } + + /** The Ethiopian roster a freight forwarder picks itself from at onboarding. */ + findForwarderOptions(): Promise { + return this.transitAgentsRepository.findForwarderOptions(); } async findById(id: string): Promise { @@ -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 { 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 { - 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 { 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 = {}; + 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); } /** diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts index 44862e2ff..0c2b8d755 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts index 68e856eff..14018274b 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts @@ -93,6 +93,7 @@ describe("TransitAssignmentsService", () => { files as never, milestones as never, trainSchedules as never, + { findOne: jest.fn().mockResolvedValue(null) } as never, // externalProfiles ); }); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts index 80f8d6be9..e992c2b96 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts @@ -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, @InjectRepository(TrainSchedule) private readonly trainSchedulesRepository: Repository, + // 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, ) {} 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 { + 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 { + 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."); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/TransitAssigneePanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/TransitAssigneePanel.tsx index 1e23839d5..073b27d99 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/TransitAssigneePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/TransitAssigneePanel.tsx @@ -166,14 +166,14 @@ export function TransitAssigneePanel({ ) : null} { + onChange(v); + if (v) setNotFound(false); + }} + disabled={disabled || options.length === 0} + searchable + clearable + nothingFoundMessage="No transit agent matches that name" + maxDropdownHeight={280} + comboboxProps={{ withinPortal: true }} + /> + +
+ +
+ + {notFound && ( + } + title="Not on the list? Ask support to register you" + > + + Only transit agents registered by EDR appear here. Call{" "} + {supportPhone ? ( + + {supportPhone} + + ) : ( + "support" + )} + {supportEmail && ( + <> + {" "} + or email{" "} + + {supportEmail} + + + )}{" "} + with the company name and transit licence number. {notFoundHint} + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index b8cc9f13a..68ddab6b2 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -226,6 +226,10 @@ export const URL_CONSTANTS = { }, // Public — no session required; the sign-up screen links to these pages. + TRANSIT_AGENTS_API: { + /** Active Ethiopian transit agents (id + name) a forwarder can register as. */ + FORWARDER_OPTIONS: "/api/transit-agents/forwarder-options", + }, PORTAL_CONTENT: { PUBLIC: "/api/support-content", }, diff --git a/apps/edr-freight-web/portal/src/constants/profileMode.ts b/apps/edr-freight-web/portal/src/constants/profileMode.ts index 7629ad34b..5888d6caf 100644 --- a/apps/edr-freight-web/portal/src/constants/profileMode.ts +++ b/apps/edr-freight-web/portal/src/constants/profileMode.ts @@ -9,4 +9,5 @@ export const PROFILE_TYPE_LABELS: Record = { freight_forwarder: "Freight Forwarder", dj_freight_forwarder: "DJ Freight Forwarder", transporter: "Transporter", + transit_agent: "Transit Agent", }; diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index e9ad1753c..741af49a8 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -213,8 +213,30 @@ const useAuth = () => { // as long as they have at least one backoffice-approved operational role. const companyProfiles = companyInfo?.company?.companyProfiles ?? []; const hasActiveProfile = companyProfiles.some((p) => p.status === "active"); + // The roster roles: a transit agent, or a freight forwarder (which is one + // too). A company holding either, and linked to its roster entry, sees the + // bookings customers assign to it. The tab shows from the moment the role is + // requested — that is how it learns work is waiting — but acting on them + // waits for approval (the API enforces the same split). + const agentProfiles = companyProfiles.filter( + (p) => p.type === "transit_agent" || p.type === "freight_forwarder", + ); + const canSeeAssignedBookings = + agentProfiles.length > 0 && Boolean(companyInfo?.company?.transitAgentId); + const assignedBookingsUnlocked = agentProfiles.some( + (p) => p.status === "active", + ); + // A company that does nothing but act as a transit agent has no contracts, + // bookings or invoices of its own: its portal is the assigned-bookings list. + const isTransitAgentOnly = + companyProfiles.length > 0 && + companyProfiles.every((p) => p.type === "transit_agent"); const hasPendingProfile = companyProfiles.some((p) => p.status === "pending"); - const canBook = hasActiveProfile; + // Booking needs a TRADE role in service — the transit agent role carries no + // bookings of its own. + const canBook = companyProfiles.some( + (p) => p.status === "active" && p.type !== "transit_agent", + ); // Profile-edit review: while a change request is pending the customer is // locked out of editing and of creating new contracts/bookings; a rejected @@ -249,11 +271,14 @@ const useAuth = () => { * by the API for any company that has an eTrade record. */ licenceNumber?: string, + /** The roster entry, for the transit agent / forwarder roles. */ + transitAgentId?: string, ): Promise> => { try { const created = await api.companies.createCompanyProfile.call({ type, licenceNumber, + transitAgentId, }); if (licenseFiles.length > 0) { await companiesService.uploadProfileLicense(created.id, licenseFiles); @@ -316,6 +341,9 @@ const useAuth = () => { canBook, hasActiveProfile, hasPendingProfile, + canSeeAssignedBookings, + assignedBookingsUnlocked, + isTransitAgentOnly, companyType, companyStatus, isCompanyApproved, diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 53e0cf0a3..2e9edc9b1 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -426,6 +426,7 @@ const ROLE_LABELS: Record = { exporter: "Exporter", freight_forwarder: "Freight Forwarder", dj_freight_forwarder: "DJ Freight Forwarder", + transit_agent: "Transit Agent", transporter: "Transporter", }; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 3cc75e221..f064ea48b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -10,6 +10,7 @@ import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router-dom"; +import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect"; import { ActionIcon, Alert, @@ -23,6 +24,7 @@ import { Loader, Modal, Paper, + SegmentedControl, Stack, Switch, Text, @@ -630,15 +632,22 @@ function NewShipmentBookingForm({ ? { requestedWagons: Number(values.requestedWagons) } : {}), }), - // Customer's own clearing agent — collected at completion; the server - // requires all three for a without-customs import/export booking. - ...(values.customsClearingAgent?.trim() - ? { - customsClearingAgent: values.customsClearingAgent.trim(), - customsClearingAgentEmail: values.customsClearingAgentEmail.trim(), - customsClearingAgentPhone: values.customsClearingAgentPhone.trim(), - } - : {}), + // Who clears customs — collected at completion of a without-customs + // import/export booking. Either a registered transit agent (the booking + // is assigned to that forwarder) or the customer's own agent, for which + // the server requires all three fields. Never both. + ...(values.clearingAgentMode === "transit_agent" && + values.transitAgentId?.trim() + ? { transitAgentId: values.transitAgentId.trim() } + : values.customsClearingAgent?.trim() + ? { + customsClearingAgent: values.customsClearingAgent.trim(), + customsClearingAgentEmail: + values.customsClearingAgentEmail.trim(), + customsClearingAgentPhone: + values.customsClearingAgentPhone.trim(), + } + : {}), ...(values.notes ? { notes: values.notes } : {}), }; } @@ -2077,18 +2086,66 @@ function EquipmentReturnStep({ form }: { form: ShipmentForm }) { /** * Completion of a without-customs import/export booking: the customer names - * their own customs clearing agent per booking — name, email and phone are - * all required (the schema and the server both enforce it). + * who clears customs for it, one of two ways. Pick a registered Ethiopian + * transit agent — a freight forwarder on the platform, which then gets the + * booking in its own work list and a notice — or type their own agent's name, + * email and phone (all required; the schema and the server both enforce it). */ function ClearingAgentStep({ form }: { form: ShipmentForm }) { + const mode = form.watch("clearingAgentMode"); return ( } title="Customs Clearing Agent" - description="Your service does not include customs clearance — enter the agent handling customs for this booking." + description="Your service does not include customs clearance — tell us who handles customs for this booking." /> + ( + { + field.onChange(v); + // Switching clears the other option so only one is ever sent. + if (v === "transit_agent") { + form.setValue("customsClearingAgent", "", { shouldDirty: true }); + form.setValue("customsClearingAgentEmail", "", { shouldDirty: true }); + form.setValue("customsClearingAgentPhone", "", { shouldDirty: true }); + } else { + form.setValue("transitAgentId", "", { shouldDirty: true }); + } + }} + data={[ + { value: "transit_agent", label: "Registered transit agent" }, + { value: "manual", label: "Enter agent details" }, + ]} + /> + )} + /> + {mode === "transit_agent" ? ( + ( + field.onChange(v ?? "")} + label="Transit agent *" + description="Registered Ethiopian transit agents (freight forwarders). The booking is assigned to the one you pick and they are notified." + error={fieldState.error?.message} + notFoundHint="Only transit agents registered with EDR are listed. Ask your forwarder to register, or switch to entering their details instead." + /> + )} + /> + ) : null} + {mode === "manual" ? ( + <> + + ) : null} ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index be4b2ed42..c09f121e0 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -119,6 +119,10 @@ const shipmentFormBase = z.object({ customsClearingAgent: z.string().default(""), customsClearingAgentEmail: z.string().default(""), customsClearingAgentPhone: z.string().default(""), + // The other way to name who clears customs: a registered Ethiopian transit + // agent (a freight forwarder on the platform). One of the two, never both. + clearingAgentMode: z.enum(["manual", "transit_agent"]).default("manual"), + transitAgentId: z.string().default(""), notes: z.string().default(""), }); @@ -146,7 +150,15 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { }); } - if (ctx.requiresClearingAgent) { + if (ctx.requiresClearingAgent && data.clearingAgentMode === "transit_agent") { + if (!data.transitAgentId.trim()) { + refineCtx.addIssue({ + code: "custom", + path: ["transitAgentId"], + message: "Pick the transit agent handling customs for this booking.", + }); + } + } else if (ctx.requiresClearingAgent) { if (!data.customsClearingAgent.trim()) { refineCtx.addIssue({ code: "custom", @@ -407,6 +419,8 @@ export const initialShipmentFormValues: DeepPartial = { customsClearingAgent: "", customsClearingAgentEmail: "", customsClearingAgentPhone: "", + clearingAgentMode: "manual", + transitAgentId: "", notes: "", }; diff --git a/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingsPage.tsx b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingsPage.tsx new file mode 100644 index 000000000..a372b37b1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingsPage.tsx @@ -0,0 +1,445 @@ +import { + Alert, + Badge, + Box, + Button, + Card, + Group, + Pagination, + Select, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + DataTable, + usePagination, + type ColumnDef, + type DataTableFooterProps, +} from "@edr/ui-common"; +import { + Building2, + ClipboardList, + Clock3, + Inbox, + PackageCheck, + Paperclip, + RefreshCw, + Search, + ShipWheel, + Truck, + X, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import useAuth from "@/hooks/useAuth"; +import { + transitAssignmentsService, + type TransitAssignment, + type TransitAssignmentStatus, +} from "@/services/transit-assignments.service"; + +const headerCell = + "whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.08em] text-edr-muted"; + +const prettyStatus = (s?: string | null) => + (s ?? "") + .toLowerCase() + .replace(/_/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); + +const STATUS_META: Record< + TransitAssignmentStatus, + { label: string; color: string } +> = { + NOT_STARTED: { label: "Not started", color: "gray" }, + IN_PROGRESS: { label: "In progress", color: "blue" }, + FINISHED: { label: "Finished", color: "edr-green" }, +}; + +const STATUS_OPTIONS = [ + { value: "NOT_STARTED", label: "Not started" }, + { value: "IN_PROGRESS", label: "In progress" }, + { value: "FINISHED", label: "Finished" }, +]; + +function shipmentColor(status?: string | null): string { + switch (status) { + case "DISPATCHED": + return "teal"; + case "SCHEDULED": + return "blue"; + case "MANUAL_ONLY": + return "orange"; + default: + return "gray"; + } +} + +function formatDate(value?: string | null): string { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +/** DataTable footer: row range left, rows-per-page + pager right. */ +function TablePager({ table, pagination }: DataTableFooterProps) { + const pageIndex = pagination.pageIndex ?? 0; + const pageSize = pagination.pageSize ?? 10; + const total = pagination.totalCount ?? 0; + const pageCount = Math.max( + 1, + pagination.pageCount ?? Math.ceil(total / pageSize), + ); + const start = total === 0 ? 0 : pageIndex * pageSize + 1; + const end = Math.min((pageIndex + 1) * pageSize, total); + + return ( + + + Showing {start}–{end} of {total} bookings + + + + + Rows + + { + setStatus((v as TransitAssignmentStatus | null) ?? null); + setPagination((p) => ({ ...p, pageIndex: 0 })); + }} + data={STATUS_OPTIONS} + placeholder="All statuses" + clearable + radius="md" + size="sm" + w={{ base: "100%", sm: 180 }} + comboboxProps={{ withinPortal: true }} + /> + + + {!isPending && !isError && items.length === 0 ? ( + + + + {hasFilters ? "No bookings match" : "No bookings assigned yet"} + + + {hasFilters + ? "Try a different reference or clear the filters." + : "When a customer picks your company as the transit agent on a booking, it shows up here and you are notified."} + + {hasFilters ? ( + + ) : null} + + ) : ( + + + columns={columns} + data={items} + status={isPending ? "loading" : isError ? "error" : "success"} + error={ + isError + ? { + message: (error as Error).message, + onRetry: () => void refetch(), + } + : undefined + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none rounded-none bg-transparent" + footer={(p) => } + /> + + )} + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/forwarder/index.ts b/apps/edr-freight-web/portal/src/pages/forwarder/index.ts new file mode 100644 index 000000000..e606339e1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/forwarder/index.ts @@ -0,0 +1 @@ +export { default as AssignedBookingsPage } from "./AssignedBookingsPage"; diff --git a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx index ccd8adc35..c665bb3a1 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx @@ -15,6 +15,12 @@ import EtradeBusinessSelect, { businessLabel, useEtradeBusinesses, } from "@/components/onboarding/EtradeBusinessSelect"; +import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect"; +import useAuth from "@/hooks/useAuth"; +import { AGENT_ROLE_TYPES } from "./companyRoles"; + +const isAgentRole = (type: string) => + (AGENT_ROLE_TYPES as readonly string[]).includes(type); import type { CompanyProfileResponse } from "@/services/companies.service"; import type { ProfileResponse } from "@/types/profile"; import RoleCard from "./RoleCard"; @@ -52,6 +58,11 @@ function roleStatusView(p: CompanyProfileResponse): { export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { const queryClient = useQueryClient(); + // The roster entry the company already named, if any. A roster role added + // here asks for it only when there is none — the link is per company. + const { company } = useAuth(); + const linkedTransitAgentId = company?.company?.transitAgentId ?? null; + const [transitAgentId, setTransitAgentId] = useState(null); const options = useMemo( () => rolesForCompanyType(profile.companyType), @@ -115,11 +126,13 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { profiles: types.map((type) => ({ type, licenceNumber: licenceByType[type], + ...(isAgentRole(type) && transitAgentId ? { transitAgentId } : {}), })), }), onSuccess: () => { setSelected(new Set()); setLicenceByType({}); + setTransitAgentId(null); queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey(), }); @@ -145,14 +158,22 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { }, }); - // Every selected role needs its business named first — the API rejects a role - // added without one, so the button is what tells the user, not a 400. + // Every selected licensed role needs its business named first — the API + // rejects a role added without one, so the button is what tells the user, + // not a 400. A transit agent holds no business: its roster entry is its + // registration. const missingLicence = businessRequired && - Array.from(selected).some((type) => !licenceByType[type]); + Array.from(selected).some( + (type) => type !== "transit_agent" && !licenceByType[type], + ); + // A roster role needs the transit agent named, once per company. + const agentRequired = + !linkedTransitAgentId && Array.from(selected).some(isAgentRole); + const missingAgent = agentRequired && !transitAgentId; const handleSave = () => { - if (selected.size === 0 || missingLicence) return; + if (selected.size === 0 || missingLicence || missingAgent) return; mutation.mutate(Array.from(selected)); }; @@ -164,7 +185,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { {profile.companyType === "customer" - ? "Select the service(s) your company operates as — importer, exporter and/or freight forwarder." + ? "Select the service(s) your company operates as — importer, exporter, freight forwarder and/or transit agent." : "Your company's operational role."} @@ -252,7 +273,9 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { Say which of your eTrade business licences each new role operates as. {options - .filter((opt) => selected.has(opt.type)) + .filter( + (opt) => selected.has(opt.type) && opt.type !== "transit_agent", + ) .map((opt) => ( )} + {agentRequired && ( + + + + )} + {options.length > 0 && ( } loading={mutation.isPending} - disabled={selected.size === 0 || missingLicence} + disabled={selected.size === 0 || missingLicence || missingAgent} onClick={handleSave} > {selected.size > 1 ? "Add Roles" : "Add Role"} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx index 617b92826..1c6cbe8cc 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -49,6 +49,7 @@ const ROLE_LABELS: Record = { exporter: "Exporter", freight_forwarder: "Freight Forwarder", dj_freight_forwarder: "DJ Freight Forwarder", + transit_agent: "Transit Agent", transporter: "Transporter", }; diff --git a/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx index 58b9f18d1..2077d6de4 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx @@ -1,4 +1,9 @@ -import { ArrowDownToLine, ArrowUpFromLine, Building2 } from "lucide-react"; +import { + ArrowDownToLine, + ArrowUpFromLine, + Building2, + Truck, +} from "lucide-react"; export interface RoleMeta { type: string; @@ -28,12 +33,29 @@ export const FREIGHT_FORWARDER: RoleMeta = { icon: , }; +export const TRANSIT_AGENT: RoleMeta = { + type: "transit_agent", + label: "Transit Agent", + description: + "Clear customs for bookings that importers and exporters assign to you.", + icon: , +}; + +/** The roles that are an Ethiopian transit-agent roster entry. */ +export const AGENT_ROLE_TYPES = ["freight_forwarder", "transit_agent"] as const; + /** - * Importer / Exporter / Freight Forwarder — the services a "customer" company - * can hold. A single company may register for any combination, each getting its - * own business license. + * Importer / Exporter / Freight Forwarder / Transit Agent — the services a + * "customer" company can hold. A single company may register for any + * combination; the licensed ones each get their own business licence, the + * transit agent is identified by its roster entry instead. */ -export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER, FREIGHT_FORWARDER]; +export const CUSTOMER_ROLES: RoleMeta[] = [ + IMPORTER, + EXPORTER, + FREIGHT_FORWARDER, + TRANSIT_AGENT, +]; // dj_freight_forwarder and transporter are intentionally not exposed yet. export function rolesForCompanyType(companyType: string): RoleMeta[] { diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 1c77c2c80..229097296 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -60,6 +60,7 @@ import type { ChangeRequestResponse, CompanyDocument, CompanyInfoResponse, + ForwarderTransitAgentOption, CompanyNationality, CompanyProfileResponse, LicenseFile, @@ -220,12 +221,23 @@ export const api = { ), addCompanyProfiles: endpoint< - { profiles: { type: string; licenceNumber?: string }[] }, + { + profiles: { + type: string; + licenceNumber?: string; + transitAgentId?: string; + }[]; + }, CompanyProfileResponse[] >("companies", "addCompanyProfiles", companiesService.addCompanyProfiles), createCompanyProfile: endpoint< - { type: ProfileTypeValue; businessLicense?: string; licenceNumber?: string }, + { + type: ProfileTypeValue; + businessLicense?: string; + licenceNumber?: string; + transitAgentId?: string; + }, CompanyProfileResponse >( "companies", @@ -257,10 +269,18 @@ export const api = { cooperative?: boolean; /** Foreign investment licence: registration typed, no eTrade lookup. */ investorLicence?: boolean; + /** Which Ethiopian transit agent the company is — required with the forwarder role. */ + transitAgentId?: string; }, CompanyInfoResponse >("companies", "startOnboarding", companiesService.startOnboarding), + forwarderTransitAgents: endpoint( + "companies", + "forwarderTransitAgents", + companiesService.listForwarderTransitAgents, + ), + revertToRegularCompany: endpoint( "companies", "revertToRegularCompany", diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index bd3f7d5e9..604b696ea 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -12,7 +12,8 @@ export type ProfileTypeValue = | "exporter" | "freight_forwarder" | "dj_freight_forwarder" - | "transporter"; + | "transporter" + | "transit_agent"; export type CompanyNationality = "ethiopian" | "foreign"; @@ -69,11 +70,24 @@ export interface CompanyResponse { email: string | null; website: string | null; attributes: Record | null; + /** + * The transit-agent roster entry this company registered itself as when it + * took the freight-forwarder role — the two are the same business. Null for + * importers/exporters and for forwarders linked before the field existed. + */ + transitAgentId?: string | null; + transitAgent?: ForwarderTransitAgentOption | null; companyProfiles?: CompanyProfileResponse[]; createdAt: string; updatedAt: string; } +/** One entry of the Ethiopian transit-agent roster, as offered to a forwarder. */ +export interface ForwarderTransitAgentOption { + id: string; + name: string; +} + export interface CompanyProfileResponse { id: string; type: string; @@ -149,8 +163,6 @@ export interface TransitAgentInfoResponse { email: string | null; phoneNumber: string | null; isActive: boolean; - validFrom: string; - validTo: string; company: null; profile: null; review: null; @@ -369,7 +381,11 @@ export const companiesService = { }, addCompanyProfiles: async (payload: { - profiles: { type: string; licenceNumber?: string }[]; + profiles: { + type: string; + licenceNumber?: string; + transitAgentId?: string; + }[]; }): Promise => { const response = await client.post>( URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES, @@ -383,6 +399,7 @@ export const companiesService = { type: ProfileTypeValue; businessLicense?: string; licenceNumber?: string; + transitAgentId?: string; }): Promise => { const response = await client.post>( URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE, @@ -421,6 +438,7 @@ export const companiesService = { nationality?: CompanyNationality; cooperative?: boolean; investorLicence?: boolean; + transitAgentId?: string; }): Promise => { const response = await client.post>( URL_CONSTANTS.COMPANIES_API.ONBOARDING_START, @@ -429,6 +447,20 @@ export const companiesService = { return unwrap(response.data); }, + /** + * The Ethiopian transit agents a freight forwarder may register itself as. + * A company that is not listed has to ask support to be added — there is no + * self-service path, since the roster is what GL assigns work from. + */ + listForwarderTransitAgents: async (): Promise< + ForwarderTransitAgentOption[] + > => { + const response = await client.get< + ApiResponse + >(URL_CONSTANTS.TRANSIT_AGENTS_API.FORWARDER_OPTIONS); + return unwrap(response.data); + }, + /** * Give up the foreign investment-licence route and go back through eTrade. * The API clears the typed registration and reopens onboarding at the company diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 97a79234b..15f9d3be1 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -1053,6 +1053,12 @@ export interface CreateBookingUnderContractDto { customsClearingAgent?: string; customsClearingAgentEmail?: string; customsClearingAgentPhone?: string; + /** + * The alternative to typing a clearing agent: a registered Ethiopian transit + * agent (a freight forwarder on the platform). Assigns the booking to it and + * notifies the forwarder company; the typed agent fields are then ignored. + */ + transitAgentId?: string; notes?: string; }