Files
edr-platform/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts

938 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, Repository } from "typeorm";
import {
isDeliveryOrderFileCode,
isDjiboutiT1FileCode,
isGatePassFileCode,
isReleaseOrderFileCode,
isT1TransportFileCode,
} from "@edr/types";
import { Booking } from "../bookings/entities/booking.entity";
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
import { FilesService } from "../files/files.service";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
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[];
};
export type TransitTradeDirection = "IMPORT" | "EXPORT";
export type TransitDocumentKind = "ro" | "do" | "t1" | "gate_pass" | "djibouti_t1";
export interface TransitNextAction {
kind: "upload" | "wait" | "done";
label: string;
document?: TransitDocumentKind;
}
/** Minutes, or null when nothing has been measured yet — never zero. */
export interface TransitTimingSummary {
median: number | null;
fastest: number | null;
slowest: number | null;
measured: number;
}
export interface TransitStatItem {
id: string;
bookingId: string;
reference: string | null;
customerName: string | null;
tradeDirection: TransitTradeDirection;
status: TransitAssignmentStatus;
schedulingStatus: string | null;
trainLabel: string | null;
assignedAt: string;
startedAt: string | null;
finishedAt: string | null;
bookingCreatedAt: string | null;
departedAt: string | null;
arrivedAt: string | null;
declaredAt: string | null;
roAt: string | null;
doAt: string | null;
t1At: string | null;
t1Closed: boolean;
gatePassAt: string | null;
djiboutiT1At: string | null;
documents: {
ro: number;
do: number;
t1: number;
gatePass: number;
djiboutiT1: number;
own: number;
};
timings: {
transit: number | null;
declarationToRo: number | null;
bookingToDo: number | null;
departureToT1: number | null;
arrivalToT1: number | null;
arrivalToGatePass: number | null;
arrivalToDjiboutiT1: number | null;
arrivalToFinish: number | null;
};
nextAction: TransitNextAction;
}
export interface TransitStats {
totals: {
assignments: number;
open: number;
notStarted: number;
inProgress: number;
finished: number;
imports: number;
exports: number;
awaitingDeparture: number;
inTransit: number;
arrived: number;
actionNeeded: number;
};
timings: Record<keyof TransitStatItem["timings"], TransitTimingSummary>;
documents: TransitStatItem["documents"];
pending: { ro: number; do: number; t1: number; gatePass: number; djiboutiT1: number };
items: TransitStatItem[];
}
@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,
@InjectRepository(ClearanceMilestone)
private readonly milestonesRepository: Repository<ClearanceMilestone>,
@InjectRepository(TrainSchedule)
private readonly trainSchedulesRepository: Repository<TrainSchedule>,
) {}
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.
*/
/**
* The agent's dashboard, every figure derived from stamps that already exist:
* the train's departure and arrival, the booking's clearance milestones, and
* the upload time of each document on the booking (RO / DO / T1 / gate pass /
* Djibouti T1). Replaced batches carry a fresh stamp, so an "uploaded" time
* here is always the LAST update, matching the detail page.
*
* Nothing is stored: a corrected timestamp cannot leave a stale number behind.
*/
async myStats(userId: string): Promise<TransitStats> {
const agent = await this.requireAgentForUser(userId);
const rows = await this.assignmentsRepository.findByTransitAgent(agent.id);
const bookingIds = [...new Set(rows.map((r) => r.bookingId))];
const [ownDocs, bookingDocs, milestones, schedules] = await Promise.all([
rows.length
? this.filesService.findByResourceIdsGrouped(
rows.map((r) => r.id),
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
)
: new Map<string, FileRecord[]>(),
bookingIds.length
? this.filesService.findByResourceIdsGrouped(bookingIds, "bookings")
: new Map<string, FileRecord[]>(),
bookingIds.length
? this.milestonesRepository.find({
where: { bookingId: In(bookingIds) },
select: ["bookingId", "milestoneCode", "status", "triggeredAt"],
})
: [],
(() => {
const ids = [
...new Set(
rows
.map((r) => r.booking?.trainScheduleId)
.filter((id): id is string => Boolean(id)),
),
];
return ids.length
? this.trainSchedulesRepository.find({
where: { id: In(ids) },
select: [
"id",
"trainNumber",
"voyageNumber",
"actualDepartureAt",
"actualArrivalAt",
],
})
: [];
})(),
]);
const scheduleById = new Map(schedules.map((sch) => [sch.id, sch]));
const milestonesByBooking = new Map<string, ClearanceMilestone[]>();
for (const m of milestones) {
if (!m.bookingId) continue;
const bucket = milestonesByBooking.get(m.bookingId);
if (bucket) bucket.push(m);
else milestonesByBooking.set(m.bookingId, [m]);
}
const iso = (d?: Date | string | null): string | null =>
d ? new Date(d).toISOString() : null;
const minutes = (from?: string | null, to?: string | null): number | null =>
from && to
? Math.floor((new Date(to).getTime() - new Date(from).getTime()) / 60_000)
: null;
/** Latest upload stamp among files matching a code family. */
const latest = (
files: FileRecord[],
matches: (code: string | null | undefined) => boolean,
): { at: string | null; count: number } => {
const hits = files.filter((f) => matches(f.code));
return {
count: hits.length,
at: hits.reduce<string | null>((max, f) => {
const stamp = iso(f.createdAt);
return stamp && (!max || stamp > max) ? stamp : max;
}, null),
};
};
/** Earliest upload stamp — for append-only sets the FIRST document matters. */
const earliest = (
files: FileRecord[],
matches: (code: string | null | undefined) => boolean,
): { at: string | null; count: number } => {
const hits = files.filter((f) => matches(f.code));
return {
count: hits.length,
at: hits.reduce<string | null>((min, f) => {
const stamp = iso(f.createdAt);
return stamp && (!min || stamp < min) ? stamp : min;
}, null),
};
};
const items: TransitStatItem[] = rows.map((row) => {
const booking = row.booking;
const tradeDirection: TransitTradeDirection =
booking?.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT";
const schedule = booking?.trainScheduleId
? scheduleById.get(booking.trainScheduleId)
: undefined;
// Same rule as the clearance view's train state: the booking's own
// load/unload stamps first, the schedule's actuals only as a fallback for
// legacy bookings that predate per-booking loading.
const departedAt = iso(booking?.loadedAt ?? schedule?.actualDepartureAt);
const arrivedAt = iso(
booking?.arrivedAt ??
(booking?.loadedAt ? null : schedule?.actualArrivalAt),
);
const files = bookingDocs.get(row.bookingId) ?? [];
const ms = milestonesByBooking.get(row.bookingId) ?? [];
const milestone = (code: string) => ms.find((m) => m.milestoneCode === code);
const done = (code: string) => {
const m = milestone(code);
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
};
const declared = done("DECLARED");
const declaredAt = iso(milestone("DECLARED")?.triggeredAt);
const ro = latest(files, isReleaseOrderFileCode);
const deliveryOrder = latest(files, isDeliveryOrderFileCode);
const t1 = latest(files, isT1TransportFileCode);
const gatePass = earliest(files, isGatePassFileCode);
const djiboutiT1 = earliest(files, isDjiboutiT1FileCode);
const t1Closed = milestone("T1_CLOSED")?.status === "COMPLETED";
const bookingCreatedAt = iso(booking?.createdAt);
const finishedAt = iso(row.finishedAt);
const finished = row.status === TransitAssignmentStatus.Finished;
const timings: TransitStatItem["timings"] = {
transit: minutes(departedAt, arrivedAt),
declarationToRo: tradeDirection === "EXPORT" ? minutes(declaredAt, ro.at) : null,
bookingToDo:
tradeDirection === "IMPORT" ? minutes(bookingCreatedAt, deliveryOrder.at) : null,
departureToT1: tradeDirection === "IMPORT" ? minutes(departedAt, t1.at) : null,
arrivalToT1: tradeDirection === "IMPORT" ? minutes(arrivedAt, t1.at) : null,
arrivalToGatePass:
tradeDirection === "EXPORT" ? minutes(arrivedAt, gatePass.at) : null,
arrivalToDjiboutiT1:
tradeDirection === "EXPORT" ? minutes(arrivedAt, djiboutiT1.at) : null,
arrivalToFinish: minutes(arrivedAt, finishedAt),
};
// What the officer should do next on this shipment — the same gates the
// detail page enforces, so the dashboard never points at a locked button.
let nextAction: TransitNextAction;
if (finished) {
nextAction = { kind: "done", label: "Assignment finished" };
} else if (tradeDirection === "EXPORT") {
if (!declared) {
nextAction = { kind: "wait", label: "Awaiting customs declaration" };
} else if (ro.count === 0) {
nextAction = { kind: "upload", label: "Upload Release Order", document: "ro" };
} else if (!departedAt) {
nextAction = { kind: "wait", label: "Awaiting train departure" };
} else if (!arrivedAt) {
nextAction = { kind: "wait", label: "Train in transit" };
} else if (gatePass.count === 0) {
nextAction = { kind: "upload", label: "Upload gate pass", document: "gate_pass" };
} else if (djiboutiT1.count === 0) {
nextAction = {
kind: "upload",
label: "Upload Djibouti T1",
document: "djibouti_t1",
};
} else {
nextAction = { kind: "done", label: "Paperwork complete" };
}
} else if (deliveryOrder.count === 0) {
nextAction = { kind: "upload", label: "Upload Delivery Order", document: "do" };
} else if (!departedAt) {
nextAction = { kind: "wait", label: "Awaiting train departure" };
} else if (t1.count === 0 && !t1Closed) {
nextAction = { kind: "upload", label: "Upload T1 documents", document: "t1" };
} else if (!arrivedAt) {
nextAction = { kind: "wait", label: "Train in transit" };
} else {
nextAction = { kind: "done", label: t1Closed ? "T1 closed" : "Paperwork complete" };
}
return {
id: row.id,
bookingId: row.bookingId,
reference: booking?.reference ?? null,
customerName: booking?.company?.name ?? null,
tradeDirection,
status: row.status,
schedulingStatus: booking?.schedulingStatus ?? null,
trainLabel: schedule?.voyageNumber ?? schedule?.trainNumber ?? null,
assignedAt: iso(row.assignedAt) ?? new Date(0).toISOString(),
startedAt: iso(row.startedAt),
finishedAt,
bookingCreatedAt,
departedAt,
arrivedAt,
declaredAt,
roAt: ro.at,
doAt: deliveryOrder.at,
t1At: t1.at,
t1Closed,
gatePassAt: gatePass.at,
djiboutiT1At: djiboutiT1.at,
documents: {
ro: ro.count,
do: deliveryOrder.count,
t1: t1.count,
gatePass: gatePass.count,
djiboutiT1: djiboutiT1.count,
own: (ownDocs.get(row.id) ?? []).length,
},
timings,
nextAction,
};
});
// Most recently moving shipment first: arrival, else departure, else when
// it was handed to the agent.
const activity = (i: TransitStatItem) =>
i.arrivedAt ?? i.departedAt ?? i.assignedAt;
items.sort((a, b) => activity(b).localeCompare(activity(a)));
const summarize = (values: Array<number | null>): TransitTimingSummary => {
const measured = values.filter((v): v is number => v !== null && v >= 0);
if (!measured.length) {
return { median: null, fastest: null, slowest: null, measured: 0 };
}
const sorted = [...measured].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return {
median:
sorted.length % 2
? sorted[mid]
: Math.round((sorted[mid - 1] + sorted[mid]) / 2),
fastest: sorted[0],
slowest: sorted[sorted.length - 1],
measured: sorted.length,
};
};
const timing = (key: keyof TransitStatItem["timings"]) =>
summarize(items.map((i) => i.timings[key]));
const open = items.filter((i) => i.status !== TransitAssignmentStatus.Finished);
const pendingFor = (document: TransitDocumentKind) =>
items.filter(
(i) => i.nextAction.kind === "upload" && i.nextAction.document === document,
).length;
const sumDocs = (key: keyof TransitStatItem["documents"]) =>
items.reduce((sum, i) => sum + i.documents[key], 0);
return {
totals: {
assignments: items.length,
open: open.length,
notStarted: items.filter((i) => i.status === TransitAssignmentStatus.NotStarted)
.length,
inProgress: items.filter((i) => i.status === TransitAssignmentStatus.InProgress)
.length,
finished: items.length - open.length,
imports: items.filter((i) => i.tradeDirection === "IMPORT").length,
exports: items.filter((i) => i.tradeDirection === "EXPORT").length,
awaitingDeparture: open.filter((i) => !i.departedAt).length,
inTransit: open.filter((i) => i.departedAt && !i.arrivedAt).length,
arrived: open.filter((i) => Boolean(i.arrivedAt)).length,
actionNeeded: items.filter((i) => i.nextAction.kind === "upload").length,
},
timings: {
transit: timing("transit"),
declarationToRo: timing("declarationToRo"),
bookingToDo: timing("bookingToDo"),
departureToT1: timing("departureToT1"),
arrivalToT1: timing("arrivalToT1"),
arrivalToGatePass: timing("arrivalToGatePass"),
arrivalToDjiboutiT1: timing("arrivalToDjiboutiT1"),
arrivalToFinish: timing("arrivalToFinish"),
},
documents: {
ro: sumDocs("ro"),
do: sumDocs("do"),
t1: sumDocs("t1"),
gatePass: sumDocs("gatePass"),
djiboutiT1: sumDocs("djiboutiT1"),
own: sumDocs("own"),
},
pending: {
ro: pendingFor("ro"),
do: pendingFor("do"),
t1: pendingFor("t1"),
gatePass: pendingFor("gate_pass"),
djiboutiT1: pendingFor("djibouti_t1"),
},
items: items.slice(0, 20),
};
}
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);
}
/**
* Make `transitAgentId` the officer working `bookingId`, as the clearance
* desk's "assign transit assignee" step means it.
*
* The booking itself only records the officer's NAME, which is all the
* clearance UI needs; the officer's own portal reads `transit_assignments`.
* This keeps the two in step, and is deliberately forgiving where `create()`
* is strict:
* - assigning the same agent twice is a no-op, not a 409 — the desk may
* re-save the step without meaning to start over;
* - a REASSIGNMENT retires the previous officer's row, so a shipment does
* not sit in the work list of someone who no longer handles it. Finished
* rows stay, since they are that officer's record of work already done.
*/
async ensureAssignment(
bookingId: string,
transitAgentId: string,
assignedByUserId?: string,
): Promise<void> {
const existing =
await this.assignmentsRepository.findByBooking(bookingId);
for (const row of existing) {
if (
row.transitAgentId !== transitAgentId &&
row.status !== TransitAssignmentStatus.Finished
) {
await this.assignmentsRepository.softDelete(row.id);
}
}
if (existing.some((row) => row.transitAgentId === transitAgentId)) return;
await this.assignmentsRepository.create({
bookingId,
transitAgentId,
status: TransitAssignmentStatus.NotStarted,
startedAt: null,
finishedAt: null,
assignedByUserId: assignedByUserId ?? null,
note: null,
});
}
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);
}
}