mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 10:08:21 +00:00
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>
597 lines
21 KiB
TypeScript
597 lines
21 KiB
TypeScript
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);
|
||
}
|
||
}
|