mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 23:35:42 +00:00
feat(transit-agents): give transit agents a portal login
Optional IAM account per agent — nullable, nothing backfilled, so roster-only rows keep working. Staff invite existing ones; new ones get an account when an email is supplied. Activation reuses the shipping-line link path. SMS reachability widens to Djibouti. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
|
||||
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
|
||||
import { TransitAssignmentsModule } from "./modules/transit-assignments/transit-assignments.module";
|
||||
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||
@@ -205,6 +206,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
LocomotivesModule,
|
||||
TruckTypesModule,
|
||||
TransitAgentsModule,
|
||||
TransitAssignmentsModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
TrainSchedulesModule,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Transit assignments — one row per (booking × transit agent), so an agent
|
||||
* handles many bookings.
|
||||
*
|
||||
* Deliberately NOT the existing transit-assignee handshake on bookings
|
||||
* (`/bookings/:id/clearance/transit-assignee/...`, which stores its answer on
|
||||
* the booking itself): that is a pre-declaration agreement between GL Ethiopia
|
||||
* and GL Djibouti about WHO will handle customs. This is the work record —
|
||||
* status, timings and documents — and nothing here reads or writes that flow.
|
||||
*
|
||||
* There is no duration column on purpose. The time taken after the train
|
||||
* arrives is `finished_at − bookings.arrived_at`, and both halves already
|
||||
* exist; storing the difference would be a third source of truth that goes
|
||||
* stale the moment either timestamp is corrected. It is computed on read.
|
||||
*
|
||||
* Documents hang off `freight.files` with `resource = 'transit_assignments'`
|
||||
* and `resource_id = transit_assignments.id`. That table already carries the
|
||||
* MinIO object, the upload time (`created_at`), the uploader, the edit time
|
||||
* (`updated_at`) and the supersede history, so no file table is added here.
|
||||
*/
|
||||
export class TransitAssignments3810000000000 implements MigrationInterface {
|
||||
name = "TransitAssignments3810000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.transit_assignments (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL,
|
||||
transit_agent_id uuid NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'NOT_STARTED',
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
assigned_by_user_id uuid,
|
||||
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT pk_transit_assignments PRIMARY KEY (id),
|
||||
CONSTRAINT fk_transit_assignments_booking
|
||||
FOREIGN KEY (booking_id) REFERENCES freight.bookings (id),
|
||||
CONSTRAINT fk_transit_assignments_agent
|
||||
FOREIGN KEY (transit_agent_id) REFERENCES freight.transit_agents (id)
|
||||
)
|
||||
`);
|
||||
|
||||
// One live assignment per (booking, agent). Partial so a soft-deleted row
|
||||
// never blocks re-assigning the same agent to the same booking later.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_assignments_booking_agent
|
||||
ON freight.transit_assignments (booking_id, transit_agent_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// The two list directions: a booking's assignments, and an agent's workload.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_transit_assignments_booking
|
||||
ON freight.transit_assignments (booking_id) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_transit_assignments_agent_status
|
||||
ON freight.transit_assignments (transit_agent_id, status)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_assignments`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
|
||||
|
||||
export class CreateTransitAssignmentDto {
|
||||
@ApiProperty({ format: "uuid" })
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiProperty({ format: "uuid" })
|
||||
@IsUUID()
|
||||
transitAgentId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: TransitAssignmentStatus,
|
||||
default: TransitAssignmentStatus.NotStarted,
|
||||
description:
|
||||
"Assignments normally start NOT_STARTED; pass one only to record work already under way.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(TransitAssignmentStatus)
|
||||
status?: TransitAssignmentStatus;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 2000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||
|
||||
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
|
||||
|
||||
/** Filters for the transit agent's own booking list. */
|
||||
export class MyAssignmentsQueryDto {
|
||||
/** Free text over the booking reference and the customer's company name. */
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TransitAssignmentStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(TransitAssignmentStatus)
|
||||
status?: TransitAssignmentStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "DISPATCHED",
|
||||
description: "The booking's scheduling state.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
schedulingStatus?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
// Bounded so a hand-edited query string cannot ask for the whole table.
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
/** The portal's Save / Finish action on the agent's own assignment. */
|
||||
export class SubmitTransitAssignmentDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"true finishes the assignment, which also locks its documents. false saves progress and leaves it open.",
|
||||
})
|
||||
// Arrives as a string when posted as multipart alongside files.
|
||||
@Transform(({ value }) =>
|
||||
value === "true" ? true : value === "false" ? false : value,
|
||||
)
|
||||
@IsBoolean()
|
||||
finish!: boolean;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 2000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator";
|
||||
|
||||
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
|
||||
|
||||
export class TransitAssignmentQueryDto {
|
||||
@ApiPropertyOptional({ format: "uuid" })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
bookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: "uuid" })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
transitAgentId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TransitAssignmentStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(TransitAssignmentStatus)
|
||||
status?: TransitAssignmentStatus;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEnum, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
|
||||
|
||||
/**
|
||||
* `bookingId` and `transitAgentId` are absent on purpose: repointing an
|
||||
* assignment at a different booking or agent would silently reattribute the
|
||||
* work and the documents already filed under it. Delete and re-create instead.
|
||||
*/
|
||||
export class UpdateTransitAssignmentDto {
|
||||
@ApiPropertyOptional({ enum: TransitAssignmentStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(TransitAssignmentStatus)
|
||||
status?: TransitAssignmentStatus;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 2000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Booking } from "../../bookings/entities/booking.entity";
|
||||
import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity";
|
||||
|
||||
/** Where the agent's work on this booking currently stands. */
|
||||
export enum TransitAssignmentStatus {
|
||||
NotStarted = "NOT_STARTED",
|
||||
InProgress = "IN_PROGRESS",
|
||||
Finished = "FINISHED",
|
||||
}
|
||||
|
||||
/**
|
||||
* One transit agent's work on one booking. An agent handles many bookings, so
|
||||
* this is the join between the two, carrying the work's own state: when it
|
||||
* started, when it finished, and the documents produced along the way.
|
||||
*
|
||||
* Deliberately separate from the transit-assignee handshake on the booking
|
||||
* (`/bookings/:id/clearance/transit-assignee/...`), which is a pre-declaration
|
||||
* agreement between GL Ethiopia and GL Djibouti about WHO will handle customs.
|
||||
* Nothing here reads or writes that flow.
|
||||
*
|
||||
* There is no stored duration. "Time after the train arrives" is
|
||||
* `finishedAt − booking.arrivedAt`; both halves already exist, and storing the
|
||||
* difference would be a third source of truth that goes stale the moment either
|
||||
* timestamp is corrected. It is computed on read — see
|
||||
* `TransitAssignmentsService.toView`.
|
||||
*
|
||||
* Documents live in `freight.files` under
|
||||
* {@link TRANSIT_ASSIGNMENT_FILE_RESOURCE}, which already carries the MinIO
|
||||
* object, the upload time, the uploader and the supersede history.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "transit_assignments" })
|
||||
@Index(["bookingId"])
|
||||
@Index(["transitAgentId", "status"])
|
||||
export class TransitAssignment extends BaseEntity {
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "booking_id" })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: "transit_agent_id", type: "uuid" })
|
||||
transitAgentId!: string;
|
||||
|
||||
@ManyToOne(() => TransitAgent)
|
||||
@JoinColumn({ name: "transit_agent_id" })
|
||||
transitAgent?: TransitAgent;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
default: TransitAssignmentStatus.NotStarted,
|
||||
})
|
||||
status!: TransitAssignmentStatus;
|
||||
|
||||
/** Stamped on the first move to IN_PROGRESS; never overwritten afterwards. */
|
||||
@Column({ name: "started_at", type: "timestamptz", nullable: true })
|
||||
startedAt?: Date | null;
|
||||
|
||||
/** Stamped on the move to FINISHED. Cleared if the work is reopened. */
|
||||
@Column({ name: "finished_at", type: "timestamptz", nullable: true })
|
||||
finishedAt?: Date | null;
|
||||
|
||||
@Column({ name: "assigned_by_user_id", type: "uuid", nullable: true })
|
||||
assignedByUserId?: string | null;
|
||||
|
||||
@Column({ name: "assigned_at", type: "timestamptz", default: () => "now()" })
|
||||
assignedAt!: Date;
|
||||
|
||||
@Column({ name: "note", type: "text", nullable: true })
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/** `files.resource` value for documents attached to a transit assignment. */
|
||||
export const TRANSIT_ASSIGNMENT_FILE_RESOURCE = "transit_assignments";
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff, PortalCustomer } from "../../common/booking-guards";
|
||||
import { documentUploadMulterOptions } from "../../common/document-upload.options";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto";
|
||||
import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto";
|
||||
import { SubmitTransitAssignmentDto } from "./dto/submit-transit-assignment.dto";
|
||||
import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto";
|
||||
import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto";
|
||||
import { TransitAssignmentsService } from "./transit-assignments.service";
|
||||
|
||||
/**
|
||||
* Transit assignments — one transit agent's work on one booking.
|
||||
*
|
||||
* Distinct from the transit-assignee handshake under
|
||||
* `/bookings/:id/clearance/transit-assignee/...`, which decides WHO will handle
|
||||
* a shipment's customs. This is the work record that follows: status, timings
|
||||
* and documents.
|
||||
*/
|
||||
@ApiTags("transit-assignments")
|
||||
@Controller("transit-assignments")
|
||||
@ApiBearerAuth()
|
||||
export class TransitAssignmentsController {
|
||||
constructor(
|
||||
private readonly transitAssignmentsService: TransitAssignmentsService,
|
||||
) {}
|
||||
|
||||
// ── Portal — the signed-in transit agent's own work ───────────────────────
|
||||
// Declared first so the literal `my` segment is matched before `:id`.
|
||||
// Every route resolves the agent from the session; none accepts an agent id.
|
||||
|
||||
@Get("my/stats")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary: "Dashboard figures for the signed-in transit agent's own work",
|
||||
})
|
||||
myStats(@CurrentUser() user: TCurrentUser) {
|
||||
return this.transitAssignmentsService.myStats(user.id);
|
||||
}
|
||||
|
||||
@Get("my")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The signed-in transit agent's assigned bookings (paginated, filterable)",
|
||||
})
|
||||
findMine(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Query() query: MyAssignmentsQueryDto,
|
||||
) {
|
||||
return this.transitAssignmentsService.findMine(user.id, query);
|
||||
}
|
||||
|
||||
@Get("my/:id")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({ summary: "One of my assignments, with its documents" })
|
||||
findMineById(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.transitAssignmentsService.findMineById(user.id, id);
|
||||
}
|
||||
|
||||
@Post("my/:id/files")
|
||||
@PortalCustomer()
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upload documents to my assignment. Allowed only while the booking is DISPATCHED and the assignment is not finished.",
|
||||
})
|
||||
uploadMyFiles(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
// One `titles` part per file, in the same order. A single-file upload posts
|
||||
// one part, which multipart parsing hands back as a bare string rather than
|
||||
// an array — normalised here so the service always sees a positional list.
|
||||
@Body("titles") titles?: string | string[],
|
||||
) {
|
||||
return this.transitAssignmentsService.uploadMyFiles(
|
||||
user.id,
|
||||
id,
|
||||
files,
|
||||
{ userId: user.id, name: user.name?.en ?? undefined },
|
||||
titles === undefined ? undefined : ([] as string[]).concat(titles),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete("my/:id/files/:fileId")
|
||||
@PortalCustomer()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove a document from my assignment" })
|
||||
removeMyFile(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
) {
|
||||
return this.transitAssignmentsService.removeMyFile(user.id, id, fileId);
|
||||
}
|
||||
|
||||
@Post("my/:id/submit")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Save progress, or finish the assignment (which locks its documents)",
|
||||
})
|
||||
submitMine(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SubmitTransitAssignmentDto,
|
||||
) {
|
||||
return this.transitAssignmentsService.submitMine(user.id, id, dto);
|
||||
}
|
||||
|
||||
// ── Backoffice ────────────────────────────────────────────────────────────
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"List transit assignments (paginated, filterable by booking / agent / status)",
|
||||
})
|
||||
findAll(@Query() query: TransitAssignmentQueryDto) {
|
||||
return this.transitAssignmentsService.findAll(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared before `:id` — Nest matches routes in order, so a literal segment
|
||||
* registered after a parameter would be swallowed by it.
|
||||
*/
|
||||
@Get("by-agent/:transitAgentId")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
|
||||
@ApiOperation({ summary: "Every assignment handed to one transit agent" })
|
||||
findByTransitAgent(
|
||||
@Param("transitAgentId", ParseUUIDPipe) transitAgentId: string,
|
||||
) {
|
||||
return this.transitAssignmentsService.findByTransitAgent(transitAgentId);
|
||||
}
|
||||
|
||||
@Get("by-booking/:bookingId")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
|
||||
@ApiOperation({ summary: "Every transit agent assigned to one booking" })
|
||||
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
||||
return this.transitAssignmentsService.findByBooking(bookingId);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"One assignment, with its attached documents and computed duration",
|
||||
})
|
||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitAssignmentsService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.create)
|
||||
@ApiOperation({ summary: "Assign a transit agent to a booking" })
|
||||
create(
|
||||
@Body() dto: CreateTransitAssignmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitAssignmentsService.create(dto, user?.id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.update)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Update status or note — status changes stamp the start/finish clocks",
|
||||
})
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTransitAssignmentDto,
|
||||
) {
|
||||
return this.transitAssignmentsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.delete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Soft-delete an assignment" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitAssignmentsService.remove(id);
|
||||
}
|
||||
|
||||
// ── Documents ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(":id/files")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
|
||||
@ApiOperation({ summary: "An assignment's uploaded documents" })
|
||||
listFiles(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitAssignmentsService.listFiles(id);
|
||||
}
|
||||
|
||||
@Post(":id/files")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.update)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upload one or more documents; re-uploading adds a version, it does not overwrite",
|
||||
})
|
||||
uploadFiles(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Body("titles") titles?: string | string[],
|
||||
) {
|
||||
return this.transitAssignmentsService.uploadFiles(
|
||||
id,
|
||||
files,
|
||||
{ userId: user?.id, name: user?.name?.en ?? undefined },
|
||||
titles === undefined ? undefined : ([] as string[]).concat(titles),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id/files/:fileId")
|
||||
@BookingStaff(FREIGHT_PERMS.transitAssignments.update)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: "Remove one document from an assignment" })
|
||||
removeFile(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
) {
|
||||
return this.transitAssignmentsService.removeFile(id, fileId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
|
||||
import { TransitAssignment } from "./entities/transit-assignment.entity";
|
||||
import { TransitAssignmentsController } from "./transit-assignments.controller";
|
||||
import { TransitAssignmentsRepository } from "./transit-assignments.repository";
|
||||
import { TransitAssignmentsService } from "./transit-assignments.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// `Booking` is registered as an ENTITY rather than importing BookingsModule:
|
||||
// this module only confirms a booking id exists, and that module would drag
|
||||
// its whole graph (billing, contracts, scheduling, first/last mile) along.
|
||||
TypeOrmModule.forFeature([TransitAssignment, Booking]),
|
||||
FilesModule,
|
||||
TransitAgentsModule,
|
||||
],
|
||||
controllers: [TransitAssignmentsController],
|
||||
providers: [TransitAssignmentsService, TransitAssignmentsRepository],
|
||||
exports: [TransitAssignmentsService, TransitAssignmentsRepository],
|
||||
})
|
||||
export class TransitAssignmentsModule {}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
TransitAssignment,
|
||||
TransitAssignmentStatus,
|
||||
} from "./entities/transit-assignment.entity";
|
||||
|
||||
export interface TransitAssignmentFilter {
|
||||
bookingId?: string;
|
||||
transitAgentId?: string;
|
||||
status?: TransitAssignmentStatus;
|
||||
/** The booking's scheduling state (DISPATCHED / SCHEDULED / …). */
|
||||
schedulingStatus?: string;
|
||||
/** Free text over the booking reference and the customer's company name. */
|
||||
search?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransitAssignmentsRepository extends BaseRepository<TransitAssignment> {
|
||||
constructor(
|
||||
@InjectRepository(TransitAssignment)
|
||||
private readonly assignmentsRepo: Repository<TransitAssignment>,
|
||||
) {
|
||||
super(assignmentsRepo);
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking is joined rather than lazily loaded because every read needs
|
||||
* its `arrivedAt` — that is the other half of the computed
|
||||
* "time after the train arrives", so a list without it would be N+1 queries
|
||||
* or a column of nulls. The customer's company rides along for the same
|
||||
* reason: the agent's list is read by reference AND by whose cargo it is.
|
||||
*/
|
||||
private baseQuery() {
|
||||
return this.assignmentsRepo
|
||||
.createQueryBuilder("ta")
|
||||
.leftJoinAndSelect("ta.booking", "booking")
|
||||
.leftJoinAndSelect("booking.company", "company")
|
||||
.leftJoinAndSelect("ta.transitAgent", "agent")
|
||||
.where("ta.deletedAt IS NULL");
|
||||
}
|
||||
|
||||
/** Shared filter application, so a list and its count can never diverge. */
|
||||
private applyFilters(
|
||||
qb: ReturnType<TransitAssignmentsRepository["baseQuery"]>,
|
||||
filter: TransitAssignmentFilter,
|
||||
) {
|
||||
if (filter.bookingId) {
|
||||
qb.andWhere("ta.bookingId = :bookingId", { bookingId: filter.bookingId });
|
||||
}
|
||||
if (filter.transitAgentId) {
|
||||
qb.andWhere("ta.transitAgentId = :transitAgentId", {
|
||||
transitAgentId: filter.transitAgentId,
|
||||
});
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere("ta.status = :status", { status: filter.status });
|
||||
}
|
||||
if (filter.schedulingStatus) {
|
||||
qb.andWhere("booking.schedulingStatus = :schedulingStatus", {
|
||||
schedulingStatus: filter.schedulingStatus,
|
||||
});
|
||||
}
|
||||
if (filter.search?.trim()) {
|
||||
qb.andWhere(
|
||||
"(booking.reference ILIKE :search OR company.name ILIKE :search)",
|
||||
{ search: `%${filter.search.trim()}%` },
|
||||
);
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findPaginated(
|
||||
filter: TransitAssignmentFilter,
|
||||
skip: number,
|
||||
take: number,
|
||||
): Promise<[TransitAssignment[], number]> {
|
||||
return this.applyFilters(this.baseQuery(), filter)
|
||||
.orderBy("ta.assignedAt", "DESC")
|
||||
.skip(skip)
|
||||
.take(take)
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* One agent's own list, filtered and paginated. Differs from
|
||||
* {@link findPaginated} only in that the agent is pinned by the caller from
|
||||
* the session, so it can never be widened by a query parameter.
|
||||
*/
|
||||
async findByTransitAgentPaginated(
|
||||
transitAgentId: string,
|
||||
filter: Omit<TransitAssignmentFilter, "transitAgentId">,
|
||||
skip: number,
|
||||
take: number,
|
||||
): Promise<[TransitAssignment[], number]> {
|
||||
return this.applyFilters(this.baseQuery(), { ...filter, transitAgentId })
|
||||
.orderBy("ta.assignedAt", "DESC")
|
||||
.skip(skip)
|
||||
.take(take)
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
findOneWithRelations(id: string): Promise<TransitAssignment | null> {
|
||||
return this.baseQuery().andWhere("ta.id = :id", { id }).getOne();
|
||||
}
|
||||
|
||||
/** Every live assignment for one agent — the agent's own workload list. */
|
||||
findByTransitAgent(transitAgentId: string): Promise<TransitAssignment[]> {
|
||||
return this.baseQuery()
|
||||
.andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId })
|
||||
.orderBy("ta.assignedAt", "DESC")
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Every live assignment on one booking. */
|
||||
findByBooking(bookingId: string): Promise<TransitAssignment[]> {
|
||||
return this.baseQuery()
|
||||
.andWhere("ta.bookingId = :bookingId", { bookingId })
|
||||
.orderBy("ta.assignedAt", "DESC")
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Guards the unique (booking, agent) pair before an insert 23505s. */
|
||||
async existsForPair(
|
||||
bookingId: string,
|
||||
transitAgentId: string,
|
||||
): Promise<boolean> {
|
||||
const count = await this.assignmentsRepo
|
||||
.createQueryBuilder("ta")
|
||||
.where("ta.bookingId = :bookingId", { bookingId })
|
||||
.andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId })
|
||||
.andWhere("ta.deletedAt IS NULL")
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import {
|
||||
TransitAssignment,
|
||||
TransitAssignmentStatus,
|
||||
} from "./entities/transit-assignment.entity";
|
||||
import { TransitAssignmentsService } from "./transit-assignments.service";
|
||||
|
||||
/**
|
||||
* The two things this module gets wrong quietly: the status transitions that
|
||||
* stamp the clocks, and the duration computed from them. Both are invisible
|
||||
* until a report reads a null or a negative number months later.
|
||||
*/
|
||||
describe("TransitAssignmentsService", () => {
|
||||
const ARRIVED = new Date("2026-08-28T09:00:00Z");
|
||||
|
||||
let assignments: {
|
||||
findPaginated: jest.Mock;
|
||||
findOneWithRelations: jest.Mock;
|
||||
findByTransitAgent: jest.Mock;
|
||||
findByTransitAgentPaginated: jest.Mock;
|
||||
findByBooking: jest.Mock;
|
||||
existsForPair: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
softDelete: jest.Mock;
|
||||
};
|
||||
let agents: { findById: jest.Mock; findByUserId: jest.Mock };
|
||||
let bookings: { findOne: jest.Mock };
|
||||
let files: {
|
||||
findByResource: jest.Mock;
|
||||
findByResourceIdsGrouped: jest.Mock;
|
||||
upload: jest.Mock;
|
||||
remove: jest.Mock;
|
||||
};
|
||||
let service: TransitAssignmentsService;
|
||||
|
||||
const row = (over: Partial<TransitAssignment> = {}) =>
|
||||
({
|
||||
id: "ta-1",
|
||||
bookingId: "bk-1",
|
||||
transitAgentId: "ag-1",
|
||||
status: TransitAssignmentStatus.NotStarted,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
// DISPATCHED by default: uploads are gated on it, so a fixture without it
|
||||
// would fail every document test for the wrong reason.
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: ARRIVED,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
},
|
||||
...over,
|
||||
}) as TransitAssignment;
|
||||
|
||||
beforeEach(() => {
|
||||
assignments = {
|
||||
findPaginated: jest.fn(),
|
||||
findOneWithRelations: jest.fn().mockResolvedValue(row()),
|
||||
findByTransitAgent: jest.fn().mockResolvedValue([]),
|
||||
findByTransitAgentPaginated: jest.fn().mockResolvedValue([[], 0]),
|
||||
findByBooking: jest.fn().mockResolvedValue([]),
|
||||
existsForPair: jest.fn().mockResolvedValue(false),
|
||||
create: jest.fn(async (data) => ({ id: "ta-1", ...data })),
|
||||
update: jest.fn(async (id, data) => ({ id, ...data })),
|
||||
softDelete: jest.fn(),
|
||||
};
|
||||
agents = {
|
||||
findById: jest.fn().mockResolvedValue({ id: "ag-1", name: "Ahmed" }),
|
||||
findByUserId: jest.fn().mockResolvedValue({ id: "ag-1", name: "Ahmed" }),
|
||||
};
|
||||
bookings = { findOne: jest.fn().mockResolvedValue({ id: "bk-1" }) };
|
||||
files = {
|
||||
findByResource: jest.fn().mockResolvedValue([]),
|
||||
findByResourceIdsGrouped: jest.fn().mockResolvedValue(new Map()),
|
||||
upload: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
service = new TransitAssignmentsService(
|
||||
assignments as never,
|
||||
agents as never,
|
||||
bookings as never,
|
||||
files as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("timeAfterTrainArrives", () => {
|
||||
it("reports whole minutes between arrival and finish", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date("2026-08-28T14:30:00Z"),
|
||||
}),
|
||||
);
|
||||
|
||||
const view = await service.findById("ta-1");
|
||||
|
||||
expect(view.timeAfterTrainArrives).toBe(330);
|
||||
});
|
||||
|
||||
it("is null while the work is unfinished", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({ status: TransitAssignmentStatus.InProgress, startedAt: ARRIVED }),
|
||||
);
|
||||
|
||||
expect((await service.findById("ta-1")).timeAfterTrainArrives).toBeNull();
|
||||
});
|
||||
|
||||
it("is null when the booking never recorded an arrival", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date("2026-08-28T14:30:00Z"),
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: null,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await service.findById("ta-1")).timeAfterTrainArrives).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("status transitions", () => {
|
||||
it("stamps startedAt on the move to IN_PROGRESS", async () => {
|
||||
await service.update("ta-1", {
|
||||
status: TransitAssignmentStatus.InProgress,
|
||||
});
|
||||
|
||||
const patch = assignments.update.mock.calls[0][1];
|
||||
expect(patch.startedAt).toBeInstanceOf(Date);
|
||||
expect(patch.finishedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the ORIGINAL startedAt when finished work is reopened", async () => {
|
||||
const original = new Date("2026-08-28T10:00:00Z");
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
startedAt: original,
|
||||
finishedAt: new Date("2026-08-28T12:00:00Z"),
|
||||
}),
|
||||
);
|
||||
|
||||
await service.update("ta-1", {
|
||||
status: TransitAssignmentStatus.InProgress,
|
||||
});
|
||||
|
||||
const patch = assignments.update.mock.calls[0][1];
|
||||
// Reopening must not restart the clock, or the elapsed time would only
|
||||
// cover the second attempt rather than the whole job.
|
||||
expect(patch.startedAt).toBe(original);
|
||||
expect(patch.finishedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("stamps both clocks when finishing work that was never started", async () => {
|
||||
await service.update("ta-1", {
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
});
|
||||
|
||||
const patch = assignments.update.mock.calls[0][1];
|
||||
expect(patch.startedAt).toBeInstanceOf(Date);
|
||||
expect(patch.finishedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("clears both clocks on a reset to NOT_STARTED", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
startedAt: ARRIVED,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await service.update("ta-1", {
|
||||
status: TransitAssignmentStatus.NotStarted,
|
||||
});
|
||||
|
||||
const patch = assignments.update.mock.calls[0][1];
|
||||
expect(patch.startedAt).toBeNull();
|
||||
expect(patch.finishedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("refuses to assign the same agent to one booking twice", async () => {
|
||||
assignments.existsForPair.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: "bk-1", transitAgentId: "ag-1" }),
|
||||
).rejects.toThrow(ConflictException);
|
||||
expect(assignments.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unknown booking", async () => {
|
||||
bookings.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: "nope", transitAgentId: "ag-1" }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("files", () => {
|
||||
it("refuses to delete a file belonging to another assignment", async () => {
|
||||
files.findByResource.mockResolvedValue([{ id: "file-1" }]);
|
||||
|
||||
await expect(service.removeFile("ta-1", "file-2")).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(files.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names each uploaded file from its positional title", async () => {
|
||||
await service.uploadFiles(
|
||||
"ta-1",
|
||||
[
|
||||
{ originalname: "a.pdf" } as Express.Multer.File,
|
||||
{ originalname: "b.pdf" } as Express.Multer.File,
|
||||
{ originalname: "c.pdf" } as Express.Multer.File,
|
||||
],
|
||||
{},
|
||||
["Bill of lading", " ", "Packing list"],
|
||||
);
|
||||
|
||||
const titles = files.upload.mock.calls.map((call) => call[0].title);
|
||||
// Index N names file N; a blank entry falls back to null so the record
|
||||
// shows its original filename rather than an empty label.
|
||||
expect(titles).toEqual(["Bill of lading", null, "Packing list"]);
|
||||
});
|
||||
|
||||
it("stores no title when none were sent", async () => {
|
||||
await service.uploadFiles(
|
||||
"ta-1",
|
||||
[{ originalname: "a.pdf" } as Express.Multer.File],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(files.upload.mock.calls[0][0].title).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an upload before the booking is dispatched", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: null,
|
||||
schedulingStatus: "SCHEDULED",
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.uploadFiles("ta-1", [{} as Express.Multer.File], {}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
expect(files.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an upload once the assignment is finished", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.uploadFiles("ta-1", [{} as Express.Multer.File], {}),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("refuses to remove a document once the assignment is finished", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
files.findByResource.mockResolvedValue([{ id: "file-1" }]);
|
||||
|
||||
await expect(service.removeFile("ta-1", "file-1")).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(files.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("myStats", () => {
|
||||
const at = (iso: string) => new Date(iso);
|
||||
|
||||
const withRows = (rows: Record<string, unknown>[]) => {
|
||||
assignments.findByTransitAgent.mockResolvedValue(
|
||||
rows.map((r, i) => row({ id: `ta-${i}`, ...r } as never)),
|
||||
);
|
||||
files.findByResourceIdsGrouped.mockResolvedValue(new Map());
|
||||
};
|
||||
|
||||
it("uses the median, so one reopened assignment cannot skew the headline", async () => {
|
||||
withRows([
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T10:35:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T12:10:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T13:45:00Z"),
|
||||
},
|
||||
// 47h outlier: a mean would report ~12h, which describes nobody.
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-30T08:00:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
|
||||
// 95/190/285/2820 -> even count, so the median averages the middle two.
|
||||
// A mean would be 848 minutes, describing none of the four.
|
||||
expect(stats.performance.medianClearanceMinutes).toBe(238);
|
||||
expect(stats.performance.slowestClearanceMinutes).toBe(2820);
|
||||
});
|
||||
|
||||
it("bands clearance times into the SLA buckets", async () => {
|
||||
withRows([
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T10:30:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T13:00:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-29T09:00:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
|
||||
expect(stats.sla).toEqual({ under2h: 1, under6h: 1, over6h: 1 });
|
||||
expect(stats.performance.onTimeRate).toBe(67);
|
||||
});
|
||||
|
||||
it("counts coverage only over dispatched bookings", async () => {
|
||||
withRows([
|
||||
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } },
|
||||
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } },
|
||||
// Scheduled bookings cannot receive documents yet, so counting them
|
||||
// would report a failure the agent could not have avoided.
|
||||
{ booking: { arrivedAt: null, schedulingStatus: "SCHEDULED" } },
|
||||
]);
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
|
||||
expect(stats.coverage.dispatched).toBe(2);
|
||||
expect(stats.coverage.withDocuments).toBe(0);
|
||||
});
|
||||
|
||||
it("reports nulls rather than zero when nothing has been measured", async () => {
|
||||
withRows([{ status: TransitAssignmentStatus.NotStarted }]);
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
|
||||
expect(stats.performance.medianClearanceMinutes).toBeNull();
|
||||
expect(stats.performance.onTimeRate).toBeNull();
|
||||
expect(stats.totals.open).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customerName", () => {
|
||||
it("flattens the booking's company name", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: ARRIVED,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
company: { name: "SHAFICI PHARMACEUTICAL" },
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await service.findById("ta-1")).customerName).toBe(
|
||||
"SHAFICI PHARMACEUTICAL",
|
||||
);
|
||||
});
|
||||
|
||||
it("is null when the booking has no company", async () => {
|
||||
expect((await service.findById("ta-1")).customerName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canUploadDocuments", () => {
|
||||
it("is true for an open assignment on a dispatched booking", async () => {
|
||||
expect((await service.findById("ta-1")).canUploadDocuments).toBe(true);
|
||||
});
|
||||
|
||||
it("is false before dispatch", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: null,
|
||||
schedulingStatus: "SCHEDULED",
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
expect((await service.findById("ta-1")).canUploadDocuments).toBe(false);
|
||||
});
|
||||
|
||||
it("is false once finished", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
expect((await service.findById("ta-1")).canUploadDocuments).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("portal scoping", () => {
|
||||
it("hides another agent's assignment behind a NotFound", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({ transitAgentId: "someone-else" }),
|
||||
);
|
||||
|
||||
await expect(service.findMineById("user-1", "ta-1")).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an account that is not a transit agent", async () => {
|
||||
agents.findByUserId.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findMine("user-1")).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it("pins the query to the session's agent and passes the filters through", async () => {
|
||||
await service.findMine("user-1", {
|
||||
search: "BK-2026",
|
||||
status: TransitAssignmentStatus.InProgress,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const [agentId, filter, skip, take] =
|
||||
assignments.findByTransitAgentPaginated.mock.calls[0];
|
||||
// The agent id comes from the session, never from the query — otherwise
|
||||
// one agent could page through another agent's work.
|
||||
expect(agentId).toBe("ag-1");
|
||||
expect(filter).toMatchObject({
|
||||
search: "BK-2026",
|
||||
status: TransitAssignmentStatus.InProgress,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
});
|
||||
expect(skip).toBe(10);
|
||||
expect(take).toBe(10);
|
||||
});
|
||||
|
||||
it("reports pagination meta", async () => {
|
||||
assignments.findByTransitAgentPaginated.mockResolvedValue([[], 45]);
|
||||
|
||||
const result = await service.findMine("user-1", { pageSize: 20 });
|
||||
|
||||
expect(result.meta).toEqual({
|
||||
total: 45,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("save moves the assignment to IN_PROGRESS, finish closes it", async () => {
|
||||
await service.submitMine("user-1", "ta-1", { finish: false });
|
||||
expect(assignments.update.mock.calls[0][1].status).toBe(
|
||||
TransitAssignmentStatus.InProgress,
|
||||
);
|
||||
|
||||
assignments.update.mockClear();
|
||||
await service.submitMine("user-1", "ta-1", { finish: true });
|
||||
expect(assignments.update.mock.calls[0][1].status).toBe(
|
||||
TransitAssignmentStatus.Finished,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to re-submit an already finished assignment", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.submitMine("user-1", "ta-1", { finish: true }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,596 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto";
|
||||
import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto";
|
||||
import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto";
|
||||
import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto";
|
||||
import {
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
TransitAssignment,
|
||||
TransitAssignmentStatus,
|
||||
} from "./entities/transit-assignment.entity";
|
||||
import { TransitAssignmentsRepository } from "./transit-assignments.repository";
|
||||
|
||||
/** One attached document, flattened for the API. */
|
||||
export interface TransitAssignmentFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
/** When the file was first uploaded. */
|
||||
uploadedAt: string;
|
||||
/** When its metadata was last edited — equal to `uploadedAt` if never. */
|
||||
updatedAt: string;
|
||||
uploadedByUserId: string | null;
|
||||
uploadedByName: string | null;
|
||||
}
|
||||
|
||||
export type TransitAssignmentView = TransitAssignment & {
|
||||
/**
|
||||
* Minutes between the train arriving and the transit work finishing —
|
||||
* `finishedAt − booking.arrivedAt`, floored to whole minutes.
|
||||
*
|
||||
* Null until BOTH exist: an unfinished assignment has no end, and a booking
|
||||
* whose arrival was never stamped has no start. Computed rather than stored
|
||||
* so a corrected timestamp cannot leave a stale number behind.
|
||||
*/
|
||||
timeAfterTrainArrives: number | null;
|
||||
/**
|
||||
* Whether documents may still be added or removed right now. Mirrors
|
||||
* `assertUploadAllowed` so the portal can disable its controls instead of
|
||||
* letting the agent discover the rule through a 403.
|
||||
*/
|
||||
canUploadDocuments: boolean;
|
||||
/**
|
||||
* Whose cargo this is. Flattened off the joined company so the portal grid
|
||||
* does not have to reach through `booking.company` — and so a booking with no
|
||||
* company (shipping-line bookings carry none) renders as a blank rather than
|
||||
* throwing.
|
||||
*/
|
||||
customerName: string | null;
|
||||
files?: TransitAssignmentFileView[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TransitAssignmentsService {
|
||||
constructor(
|
||||
private readonly assignmentsRepository: TransitAssignmentsRepository,
|
||||
private readonly transitAgentsRepository: TransitAgentsRepository,
|
||||
// The Booking ENTITY, not BookingsModule: this only needs to confirm a
|
||||
// booking id exists, and importing that module would pull its whole graph
|
||||
// (billing, contracts, scheduling, first/last mile) in behind it.
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookingsRepository: Repository<Booking>,
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
|
||||
private static minutesBetween(
|
||||
from?: Date | null,
|
||||
to?: Date | null,
|
||||
): number | null {
|
||||
if (!from || !to) return null;
|
||||
return Math.floor((to.getTime() - from.getTime()) / 60_000);
|
||||
}
|
||||
|
||||
private toView(assignment: TransitAssignment): TransitAssignmentView {
|
||||
return {
|
||||
...assignment,
|
||||
timeAfterTrainArrives: TransitAssignmentsService.minutesBetween(
|
||||
assignment.booking?.arrivedAt,
|
||||
assignment.finishedAt,
|
||||
),
|
||||
canUploadDocuments:
|
||||
assignment.status !== TransitAssignmentStatus.Finished &&
|
||||
assignment.booking?.schedulingStatus === "DISPATCHED",
|
||||
customerName: assignment.booking?.company?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(query: TransitAssignmentQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const [items, total] = await this.assignmentsRepository.findPaginated(
|
||||
{
|
||||
bookingId: query.bookingId,
|
||||
transitAgentId: query.transitAgentId,
|
||||
status: query.status,
|
||||
},
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
);
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.toView(item)),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Detail read — the only one that carries the attached documents. */
|
||||
async findById(id: string): Promise<TransitAssignmentView> {
|
||||
const assignment =
|
||||
await this.assignmentsRepository.findOneWithRelations(id);
|
||||
if (!assignment) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
return { ...this.toView(assignment), files: await this.listFiles(id) };
|
||||
}
|
||||
|
||||
/** Every assignment handed to one transit agent — their workload list. */
|
||||
async findByTransitAgent(
|
||||
transitAgentId: string,
|
||||
): Promise<TransitAssignmentView[]> {
|
||||
const agent = await this.transitAgentsRepository.findById(transitAgentId);
|
||||
if (!agent) {
|
||||
throw new NotFoundException(`Transit agent ${transitAgentId} not found`);
|
||||
}
|
||||
const rows =
|
||||
await this.assignmentsRepository.findByTransitAgent(transitAgentId);
|
||||
return rows.map((row) => this.toView(row));
|
||||
}
|
||||
|
||||
/** Every agent assigned to one booking. */
|
||||
async findByBooking(bookingId: string): Promise<TransitAssignmentView[]> {
|
||||
const rows = await this.assignmentsRepository.findByBooking(bookingId);
|
||||
return rows.map((row) => this.toView(row));
|
||||
}
|
||||
|
||||
// ── Portal (the signed-in transit agent's own work) ───────────────────────
|
||||
// Every one of these resolves the agent from the SESSION and never from a
|
||||
// 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);
|
||||
if (!agent) {
|
||||
throw new ForbiddenException("This account is not a transit agent");
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard figures for the signed-in agent's own work.
|
||||
*
|
||||
* Every interval is derived from timestamps that already exist — nothing is
|
||||
* stored, so a corrected arrival or finish time changes these on the next
|
||||
* read rather than leaving a stale metric behind.
|
||||
*
|
||||
* The median is used rather than the mean on purpose: one assignment
|
||||
* reopened days later drags an average far enough to make the whole panel
|
||||
* lie about typical performance.
|
||||
*/
|
||||
async myStats(userId: string) {
|
||||
const agent = await this.requireAgentForUser(userId);
|
||||
const rows = await this.assignmentsRepository.findByTransitAgent(agent.id);
|
||||
|
||||
const docCounts = rows.length
|
||||
? await this.filesService.findByResourceIdsGrouped(
|
||||
rows.map((r) => r.id),
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
)
|
||||
: new Map<string, unknown[]>();
|
||||
|
||||
const minutes = (from?: Date | null, to?: Date | null) =>
|
||||
from && to ? Math.floor((to.getTime() - from.getTime()) / 60_000) : null;
|
||||
|
||||
const items = rows.map((row) => {
|
||||
const arrivedAt = row.booking?.arrivedAt ?? null;
|
||||
return {
|
||||
id: row.id,
|
||||
reference: row.booking?.reference ?? null,
|
||||
customerName: row.booking?.company?.name ?? null,
|
||||
status: row.status,
|
||||
schedulingStatus: row.booking?.schedulingStatus ?? null,
|
||||
/** Dispatch (cargo loaded) to the train arriving. */
|
||||
transitMinutes: minutes(row.booking?.loadedAt, arrivedAt),
|
||||
/** Arrival to the agent picking the work up. */
|
||||
pickupMinutes: minutes(arrivedAt, row.startedAt),
|
||||
/** Arrival to the work being finished — the headline metric. */
|
||||
clearanceMinutes: minutes(arrivedAt, row.finishedAt),
|
||||
documentCount: (docCounts.get(row.id) ?? []).length,
|
||||
};
|
||||
});
|
||||
|
||||
const median = (values: number[]): number | null => {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2
|
||||
? sorted[mid]
|
||||
: Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
||||
};
|
||||
|
||||
const cleared = items
|
||||
.map((i) => i.clearanceMinutes)
|
||||
.filter((v): v is number => v !== null);
|
||||
const pickups = items
|
||||
.map((i) => i.pickupMinutes)
|
||||
.filter((v): v is number => v !== null);
|
||||
|
||||
// SLA bands, in minutes: inside 2h, inside 6h, beyond.
|
||||
const sla = {
|
||||
under2h: cleared.filter((v) => v <= 120).length,
|
||||
under6h: cleared.filter((v) => v > 120 && v <= 360).length,
|
||||
over6h: cleared.filter((v) => v > 360).length,
|
||||
};
|
||||
|
||||
// Coverage counts only bookings that COULD have documents — uploads are
|
||||
// gated on dispatch, so counting scheduled ones would invent a failure.
|
||||
const dispatched = items.filter((i) => i.schedulingStatus === "DISPATCHED");
|
||||
const withDocs = dispatched.filter((i) => i.documentCount > 0).length;
|
||||
|
||||
return {
|
||||
totals: {
|
||||
assignments: items.length,
|
||||
open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished)
|
||||
.length,
|
||||
finished: items.filter(
|
||||
(i) => i.status === TransitAssignmentStatus.Finished,
|
||||
).length,
|
||||
readyForDocuments: items.filter(
|
||||
(i) =>
|
||||
i.schedulingStatus === "DISPATCHED" &&
|
||||
i.status !== TransitAssignmentStatus.Finished,
|
||||
).length,
|
||||
documents: items.reduce((sum, i) => sum + i.documentCount, 0),
|
||||
},
|
||||
performance: {
|
||||
medianClearanceMinutes: median(cleared),
|
||||
medianPickupMinutes: median(pickups),
|
||||
fastestClearanceMinutes: cleared.length ? Math.min(...cleared) : null,
|
||||
slowestClearanceMinutes: cleared.length ? Math.max(...cleared) : null,
|
||||
onTimeRate: cleared.length
|
||||
? Math.round(((sla.under2h + sla.under6h) / cleared.length) * 100)
|
||||
: null,
|
||||
measured: cleared.length,
|
||||
},
|
||||
sla,
|
||||
coverage: {
|
||||
dispatched: dispatched.length,
|
||||
withDocuments: withDocs,
|
||||
},
|
||||
/** Newest first, for the timeline and the recent-activity list. */
|
||||
items: items.slice(0, 12),
|
||||
};
|
||||
}
|
||||
|
||||
async findMine(userId: string, query: MyAssignmentsQueryDto = {}) {
|
||||
const agent = await this.requireAgentForUser(userId);
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
|
||||
const [rows, total] =
|
||||
await this.assignmentsRepository.findByTransitAgentPaginated(
|
||||
agent.id,
|
||||
{
|
||||
status: query.status,
|
||||
schedulingStatus: query.schedulingStatus,
|
||||
search: query.search,
|
||||
},
|
||||
(page - 1) * pageSize,
|
||||
pageSize,
|
||||
);
|
||||
|
||||
// Documents come back with the list so the grid can show a per-row count.
|
||||
// Batched deliberately: one lookup for the page, not one per assignment.
|
||||
const grouped = rows.length
|
||||
? await this.filesService.findByResourceIdsGrouped(
|
||||
rows.map((row) => row.id),
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
)
|
||||
: new Map();
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
...this.toView(row),
|
||||
files: (grouped.get(row.id) ?? []).map((record: FileRecord) => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
title: record.title,
|
||||
url: record.url,
|
||||
size: record.size,
|
||||
mimeType: record.mimeType,
|
||||
uploadedAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
uploadedByUserId: record.uploadedByUserId,
|
||||
uploadedByName: record.uploadedByName,
|
||||
})),
|
||||
})),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One of the signed-in agent's own assignments, with its documents.
|
||||
* Ownership is asserted rather than filtered: a mismatch is hidden behind a
|
||||
* NotFound so assignment ids cannot be probed.
|
||||
*/
|
||||
async findMineById(
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<TransitAssignmentView> {
|
||||
const agent = await this.requireAgentForUser(userId);
|
||||
const assignment =
|
||||
await this.assignmentsRepository.findOneWithRelations(id);
|
||||
if (!assignment || assignment.transitAgentId !== agent.id) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
return { ...this.toView(assignment), files: await this.listFiles(id) };
|
||||
}
|
||||
|
||||
/** Assert the assignment is this user's before any write reaches it. */
|
||||
private async assertMine(userId: string, id: string): Promise<void> {
|
||||
await this.findMineById(userId, id);
|
||||
}
|
||||
|
||||
async uploadMyFiles(
|
||||
userId: string,
|
||||
id: string,
|
||||
files: Express.Multer.File[],
|
||||
uploader: { userId?: string; name?: string },
|
||||
titles?: string[],
|
||||
): Promise<TransitAssignmentFileView[]> {
|
||||
await this.assertMine(userId, id);
|
||||
return this.uploadFiles(id, files, uploader, titles);
|
||||
}
|
||||
|
||||
async removeMyFile(
|
||||
userId: string,
|
||||
id: string,
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
await this.assertMine(userId, id);
|
||||
return this.removeFile(id, fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The portal's Save / Finish action.
|
||||
*
|
||||
* Save keeps the assignment open (moving it to IN_PROGRESS so the work reads
|
||||
* as under way); Finish closes it, which also locks its documents — see
|
||||
* `assertUploadAllowed`.
|
||||
*/
|
||||
async submitMine(
|
||||
userId: string,
|
||||
id: string,
|
||||
input: { finish: boolean; note?: string },
|
||||
): Promise<TransitAssignmentView> {
|
||||
const current = await this.findMineById(userId, id);
|
||||
if (current.status === TransitAssignmentStatus.Finished) {
|
||||
throw new ForbiddenException("This assignment is already finished.");
|
||||
}
|
||||
await this.update(id, {
|
||||
status: input.finish
|
||||
? TransitAssignmentStatus.Finished
|
||||
: TransitAssignmentStatus.InProgress,
|
||||
note: input.note,
|
||||
});
|
||||
return this.findMineById(userId, id);
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateTransitAssignmentDto,
|
||||
assignedByUserId?: string,
|
||||
): Promise<TransitAssignmentView> {
|
||||
const booking = await this.bookingsRepository.findOne({
|
||||
where: { id: dto.bookingId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
|
||||
}
|
||||
const agent = await this.transitAgentsRepository.findById(
|
||||
dto.transitAgentId,
|
||||
);
|
||||
if (!agent) {
|
||||
throw new NotFoundException(
|
||||
`Transit agent ${dto.transitAgentId} not found`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
await this.assignmentsRepository.existsForPair(
|
||||
dto.bookingId,
|
||||
dto.transitAgentId,
|
||||
)
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`${agent.name} is already assigned to this booking`,
|
||||
);
|
||||
}
|
||||
|
||||
const status = dto.status ?? TransitAssignmentStatus.NotStarted;
|
||||
const created = await this.assignmentsRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
transitAgentId: dto.transitAgentId,
|
||||
status,
|
||||
// Creating straight into a working state still has to stamp its clock, or
|
||||
// the assignment would report no start.
|
||||
startedAt:
|
||||
status === TransitAssignmentStatus.NotStarted ? null : new Date(),
|
||||
finishedAt:
|
||||
status === TransitAssignmentStatus.Finished ? new Date() : null,
|
||||
assignedByUserId: assignedByUserId ?? null,
|
||||
note: dto.note?.trim() || null,
|
||||
});
|
||||
|
||||
return this.findById(created.id);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateTransitAssignmentDto,
|
||||
): Promise<TransitAssignmentView> {
|
||||
const current = await this.assignmentsRepository.findOneWithRelations(id);
|
||||
if (!current) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
|
||||
const patch: Partial<TransitAssignment> = {};
|
||||
if (dto.note !== undefined) patch.note = dto.note.trim() || null;
|
||||
|
||||
if (dto.status && dto.status !== current.status) {
|
||||
patch.status = dto.status;
|
||||
if (dto.status === TransitAssignmentStatus.InProgress) {
|
||||
// Only the FIRST start is recorded — reopening finished work keeps the
|
||||
// original start, so the elapsed time still spans the whole job.
|
||||
patch.startedAt = current.startedAt ?? new Date();
|
||||
patch.finishedAt = null;
|
||||
} else if (dto.status === TransitAssignmentStatus.Finished) {
|
||||
patch.startedAt = current.startedAt ?? new Date();
|
||||
patch.finishedAt = new Date();
|
||||
} else {
|
||||
// Back to NOT_STARTED — the work is being reset, so both clocks clear
|
||||
// rather than leaving a duration for work that no longer happened.
|
||||
patch.startedAt = null;
|
||||
patch.finishedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.assignmentsRepository.update(id, patch);
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.assignmentsRepository.softDelete(id);
|
||||
}
|
||||
|
||||
// ── Documents ─────────────────────────────────────────────────────────────
|
||||
// Stored in `freight.files` under TRANSIT_ASSIGNMENT_FILE_RESOURCE rather
|
||||
// than a table of their own: that one already carries the MinIO object, the
|
||||
// upload time, the uploader and the supersede history.
|
||||
|
||||
/**
|
||||
* Whether an assignment may still receive documents.
|
||||
*
|
||||
* Two gates, both business rules rather than UI conveniences:
|
||||
* - the booking must actually be on its way (`DISPATCHED`), since there is
|
||||
* nothing to clear before the train leaves;
|
||||
* - the assignment must not be FINISHED — filing closes with the work, so a
|
||||
* finished record cannot grow new paperwork afterwards.
|
||||
*/
|
||||
private assertUploadAllowed(assignment: TransitAssignment): void {
|
||||
if (assignment.status === TransitAssignmentStatus.Finished) {
|
||||
throw new ForbiddenException(
|
||||
"This assignment is finished — its documents can no longer be changed.",
|
||||
);
|
||||
}
|
||||
if (assignment.booking?.schedulingStatus !== "DISPATCHED") {
|
||||
throw new ForbiddenException(
|
||||
"Documents can only be uploaded once the booking has been dispatched.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(id: string): Promise<TransitAssignmentFileView[]> {
|
||||
const records = await this.filesService.findByResource(
|
||||
id,
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
);
|
||||
return records.map((record) => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
title: record.title,
|
||||
url: record.url,
|
||||
size: record.size,
|
||||
mimeType: record.mimeType,
|
||||
uploadedAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
uploadedByUserId: record.uploadedByUserId,
|
||||
uploadedByName: record.uploadedByName,
|
||||
}));
|
||||
}
|
||||
|
||||
async uploadFiles(
|
||||
id: string,
|
||||
files: Express.Multer.File[],
|
||||
uploader: { userId?: string; name?: string },
|
||||
/**
|
||||
* A display name per file, positionally matched to `files`. Multer preserves
|
||||
* the multipart part order, and the client appends one `titles` entry per
|
||||
* file in the same order, so index N names file N. A missing or blank entry
|
||||
* falls back to the original filename.
|
||||
*/
|
||||
titles?: string[],
|
||||
): Promise<TransitAssignmentFileView[]> {
|
||||
if (!files?.length) {
|
||||
throw new BadRequestException("No files were uploaded");
|
||||
}
|
||||
// Asserts the assignment exists before anything reaches MinIO — an upload
|
||||
// keyed to a missing row would be unreachable storage nobody ever lists.
|
||||
const assignment =
|
||||
await this.assignmentsRepository.findOneWithRelations(id);
|
||||
if (!assignment) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
this.assertUploadAllowed(assignment);
|
||||
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
this.filesService.upload({
|
||||
resourceId: id,
|
||||
resource: TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
code: file.fieldname || "document",
|
||||
file,
|
||||
title: titles?.[index]?.trim() || null,
|
||||
uploadedByUserId: uploader.userId ?? null,
|
||||
uploadedByName: uploader.name ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return this.listFiles(id);
|
||||
}
|
||||
|
||||
async removeFile(id: string, fileId: string): Promise<void> {
|
||||
const assignment =
|
||||
await this.assignmentsRepository.findOneWithRelations(id);
|
||||
if (!assignment) {
|
||||
throw new NotFoundException(`Transit assignment ${id} not found`);
|
||||
}
|
||||
// Same gate as upload: a finished assignment's paperwork is fixed, and
|
||||
// removal is as much a change as adding.
|
||||
this.assertUploadAllowed(assignment);
|
||||
|
||||
const files = await this.filesService.findByResource(
|
||||
id,
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
);
|
||||
// Scoped to this assignment's own documents: a bare file id would let one
|
||||
// assignment delete another's paperwork.
|
||||
if (!files.some((file) => file.id === fileId)) {
|
||||
throw new NotFoundException(
|
||||
`File ${fileId} not found on this assignment`,
|
||||
);
|
||||
}
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
}
|
||||
@@ -652,6 +652,32 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
),
|
||||
];
|
||||
|
||||
// C3. Transit assignments — a transit agent's work on one booking: status,
|
||||
// timings and documents. Separate from the booking's transit-assignee handshake,
|
||||
// which only decides who will handle customs.
|
||||
export const TRANSIT_ASSIGNMENT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
"d1a00003-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:transit_assignments:view",
|
||||
"View transit assignments",
|
||||
),
|
||||
perm(
|
||||
"d1a00003-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:transit_assignments:create",
|
||||
"Assign a transit agent to a booking",
|
||||
),
|
||||
perm(
|
||||
"d1a00003-0001-4000-8000-000000000003",
|
||||
"edr_freight_app:transit_assignments:update",
|
||||
"Update a transit assignment and its documents",
|
||||
),
|
||||
perm(
|
||||
"d1a00003-0001-4000-8000-000000000004",
|
||||
"edr_freight_app:transit_assignments:delete",
|
||||
"Remove a transit assignment",
|
||||
),
|
||||
];
|
||||
|
||||
// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger.
|
||||
export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
@@ -1885,6 +1911,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
...OVERVIEW_LAYOUT_PERMISSIONS,
|
||||
...CUSTOMER_PERMISSIONS,
|
||||
...SHIPPING_LINE_PERMISSIONS,
|
||||
...TRANSIT_ASSIGNMENT_PERMISSIONS,
|
||||
...CHAT_PERMISSIONS,
|
||||
...FINANCE_PERMISSIONS,
|
||||
...MILE_PERMISSIONS,
|
||||
@@ -2113,6 +2140,12 @@ export const FREIGHT_PERMS = {
|
||||
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
|
||||
getNotification: "edr_freight_app:customers:get_notification",
|
||||
},
|
||||
transitAssignments: {
|
||||
view: "edr_freight_app:transit_assignments:view",
|
||||
create: "edr_freight_app:transit_assignments:create",
|
||||
update: "edr_freight_app:transit_assignments:update",
|
||||
delete: "edr_freight_app:transit_assignments:delete",
|
||||
},
|
||||
shippingLines: {
|
||||
view: "edr_freight_app:shipping_lines:view",
|
||||
create: "edr_freight_app:shipping_lines:create",
|
||||
|
||||
Reference in New Issue
Block a user