Files
edr-platform/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts
Marshal 978f1889c6 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>
2026-08-29 07:40:07 +00:00

597 lines
21 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 { 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);
}
}