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

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

View File

@@ -2,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,

View File

@@ -93,6 +93,7 @@ describe("TransitAssignmentsService", () => {
files as never,
milestones as never,
trainSchedules as never,
{ findOne: jest.fn().mockResolvedValue(null) } as never, // externalProfiles
);
});

View File

@@ -17,6 +17,11 @@ import {
} from "@edr/types";
import { Booking } from "../bookings/entities/booking.entity";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
import {
ProfileStatus,
ProfileType,
} from "../companies/entities/company-profile.entity";
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
import { FilesService } from "../files/files.service";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
@@ -170,6 +175,11 @@ export class TransitAssignmentsService {
private readonly milestonesRepository: Repository<ClearanceMilestone>,
@InjectRepository(TrainSchedule)
private readonly trainSchedulesRepository: Repository<TrainSchedule>,
// The ExternalProfile ENTITY (not CompaniesModule) for the same reason as
// Booking above: `/my` only has to walk portal user → company → the
// transit agent that company registered itself as.
@InjectRepository(ExternalProfile)
private readonly externalProfilesRepository: Repository<ExternalProfile>,
) {}
private static minutesBetween(
@@ -252,9 +262,52 @@ export class TransitAssignmentsService {
// client-supplied id: an agent must not be able to read or edit another
// agent's assignments by guessing one.
/** The transit agent this portal user signs in as. */
private async requireAgentForUser(userId: string) {
const agent = await this.transitAgentsRepository.findByUserId(userId);
/**
* The transit agent this portal user acts as.
*
* Two kinds of account reach `/my`: a Djibouti transit officer, who signs in
* AS the agent (`transit_agents.user_id`), and a customer company that
* registered itself as an Ethiopian transit agent (`companies.
* transit_agent_id`) — under the transit agent role, the forwarder role, or
* both. It may look at its assigned bookings from the moment the role is
* requested — that is how it learns work is waiting — but may only act on
* them (`forWrite`) once a roster role has been approved.
*/
private async requireAgentForUser(
userId: string,
opts: { forWrite?: boolean } = {},
) {
const own = await this.transitAgentsRepository.findByUserId(userId);
if (own) return own;
const profile = await this.externalProfilesRepository.findOne({
where: { userId },
relations: { company: { companyProfiles: true } },
});
const company = profile?.company;
if (!company?.transitAgentId) {
throw new ForbiddenException("This account is not a transit agent");
}
// Either roster role will do — a plain transit agent or a forwarder.
const agentRoles = (company.companyProfiles ?? []).filter(
(p) =>
p.type === ProfileType.transitAgent ||
p.type === ProfileType.freightForwarder,
);
if (agentRoles.length === 0) {
throw new ForbiddenException("This account is not a transit agent");
}
if (
opts.forWrite &&
!agentRoles.some((p) => p.status === ProfileStatus.Active)
) {
throw new ForbiddenException(
"Your transit agent role is not approved yet — you can view assigned bookings but not act on them until it is.",
);
}
const agent = await this.transitAgentsRepository.findById(
company.transitAgentId,
);
if (!agent) {
throw new ForbiddenException("This account is not a transit agent");
}
@@ -640,8 +693,12 @@ export class TransitAssignmentsService {
return { ...this.toView(assignment), files: await this.listFiles(id) };
}
/** Assert the assignment is this user's before any write reaches it. */
/**
* Assert the assignment is this user's before any write reaches it — and
* that the user may write at all (an unapproved forwarder may only look).
*/
private async assertMine(userId: string, id: string): Promise<void> {
await this.requireAgentForUser(userId, { forWrite: true });
await this.findMineById(userId, id);
}
@@ -677,6 +734,7 @@ export class TransitAssignmentsService {
id: string,
input: { finish: boolean; note?: string },
): Promise<TransitAssignmentView> {
await this.requireAgentForUser(userId, { forWrite: true });
const current = await this.findMineById(userId, id);
if (current.status === TransitAssignmentStatus.Finished) {
throw new ForbiddenException("This assignment is already finished.");