import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable, Logger, NotFoundException, forwardRef, } from "@nestjs/common"; import { DataSource, In } from "typeorm"; import { Booking } from "./entities/booking.entity"; import { ConsolidationApproval, ConsolidationApprovalStatus, } from "./entities/consolidation-approval.entity"; import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repository"; import { BookingsRepository } from "./bookings.repository"; import { BookingsService } from "./bookings.service"; import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service"; import { YardScopeService } from "../rule-engine/services/yard-scope.service"; import { Contract } from "../contracts/entities/contract.entity"; /** Where a rejected pair goes back to, so GL can fix and resubmit. */ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED"; /** The gate's own holding status — neither half reaches Operations from here. */ export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING"; /** An approval row with the requester's and decider's names resolved. */ export type ConsolidationApprovalView = ConsolidationApproval & { requestedByName: string | null; decidedByName: string | null; /** Contract the booking half was created under — reviewers work by contract. */ contractReference: string | null; partnerContractReference: string | null; }; /** * The shared-wagon approval gate. * * A booking that fills its own wagons goes straight from GL completion to the * operations queue. A consolidated one does not: two customers' cargo rides one * physical wagon under two separate invoices, so a person reviews the pairing * before Operations sees either half. * * Both halves are held and released TOGETHER — the wagon is shared, so a * decision on one is meaningless without the other. Every request is kept, * decided or not: the table is the audit trail of who approved which pairing, * when, and why. * * No maker-checker separation: whoever holds the approve permission may decide a * pairing, including the GL user who created it. The record of who requested and * who decided is still kept either way. */ @Injectable() export class ConsolidationApprovalService { private readonly logger = new Logger(ConsolidationApprovalService.name); constructor( private readonly approvals: ConsolidationApprovalsRepository, private readonly bookingsRepository: BookingsRepository, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, private readonly notifier: BookingLifecycleNotifierService, private readonly dataSource: DataSource, private readonly yardScope: YardScopeService, ) {} /** * Park a newly consolidated pair for review instead of letting it continue to * Operations. Called from the completion path once the two halves are linked. * * Idempotent: a pair that already has an undecided request is left alone, so a * retried completion cannot open a second review of the same wagon. */ async requestApproval( bookingId: string, partnerBookingId: string, requestedBy: string | null, ): Promise { const existing = await this.approvals.findPendingForBooking(bookingId); if (existing) return existing; // Sequential reads: one connection per transaction context. const booking = await this.bookingsService.findById(bookingId); const partner = await this.bookingsService.findById(partnerBookingId); if (!booking || !partner) { throw new NotFoundException("Both bookings of the pair must exist."); } const approval = await this.approvals.create({ bookingId, partnerBookingId, requestedBy, scheduledDate: booking.scheduledDate ?? null, bookingReference: booking.reference ?? null, partnerBookingReference: partner.reference ?? null, }); // Hold BOTH halves: the wagon is shared, so neither may advance alone. await this.bookingsRepository.update(bookingId, { status: CONSOLIDATION_APPROVAL_PENDING, } as never); await this.bookingsRepository.update(partnerBookingId, { status: CONSOLIDATION_APPROVAL_PENDING, } as never); this.notifier.consolidationApprovalRequestedToStaff( booking, partner.reference ?? partnerBookingId, ); this.logger.log( `Consolidation ${booking.reference} + ${partner.reference} awaiting approval (${approval.id}).`, ); return approval; } /** * Approve the pairing: both halves leave the gate and continue to Operations, * which is exactly where a non-consolidated booking would already be. * * All-or-nothing — the two status writes and the decision record share one * transaction, so the audit trail can never claim an approval that did not * take effect. */ async approve( approvalId: string, decidedBy: string, note?: string, user?: unknown, ): Promise<{ booking: Booking; partner: Booking }> { // A pairing that was rejected can still be approved later — the reviewer // changed their mind, or GL argued the case. Only an already-approved one // is final, since both halves have moved on to Operations by then. const approval = await this.loadDecidable(approvalId, [ ConsolidationApprovalStatus.Pending, ConsolidationApprovalStatus.Rejected, ]); await this.assertInScope(approval, user); await this.dataSource.transaction(async () => { const claimed = await this.approvals.decide( approval.id, ConsolidationApprovalStatus.Approved, decidedBy, note, [ ConsolidationApprovalStatus.Pending, ConsolidationApprovalStatus.Rejected, ], ); // Lost the race to another approver deciding the same pairing. if (!claimed) { throw new ConflictException( "This consolidation was already decided by someone else.", ); } await this.bookingsRepository.update(approval.bookingId, { status: "OPERATION_REQUEST_PENDING", } as never); await this.bookingsRepository.update(approval.partnerBookingId, { status: "OPERATION_REQUEST_PENDING", } as never); }); const booking = await this.bookingsService.findById(approval.bookingId); const partner = await this.bookingsService.findById( approval.partnerBookingId, ); this.notifier.consolidationApprovedToStaff( booking, partner.reference ?? approval.partnerBookingId, ); // Operations only now learns about the pair — the gate is what kept it out. this.notifier.operationRequestedToStaff(booking); this.notifier.operationRequestedToStaff(partner); return { booking, partner }; } /** * Reject the pairing: both halves go back to GL as OPERATION_CHANGES_REQUESTED * with the reason, so the cargo or the partner can be changed and resubmitted. */ async reject( approvalId: string, decidedBy: string, reason: string, user?: unknown, ): Promise<{ booking: Booking; partner: Booking }> { if (!reason?.trim()) { throw new BadRequestException( "A reason is required to reject a consolidation.", ); } const approval = await this.loadDecidable(approvalId, [ ConsolidationApprovalStatus.Pending, ]); await this.assertInScope(approval, user); await this.dataSource.transaction(async () => { const claimed = await this.approvals.decide( approval.id, ConsolidationApprovalStatus.Rejected, decidedBy, reason.trim(), ); if (!claimed) { throw new ConflictException( "This consolidation was already decided by someone else.", ); } await this.bookingsRepository.createReviewNote( approval.bookingId, reason.trim(), "CHANGES_REQUESTED", ); await this.bookingsRepository.createReviewNote( approval.partnerBookingId, reason.trim(), "CHANGES_REQUESTED", ); await this.bookingsRepository.update(approval.bookingId, { status: REJECTED_STATUS, } as never); await this.bookingsRepository.update(approval.partnerBookingId, { status: REJECTED_STATUS, } as never); }); const booking = await this.bookingsService.findById(approval.bookingId); const partner = await this.bookingsService.findById( approval.partnerBookingId, ); this.notifier.consolidationRejectedToStaff( booking, partner.reference ?? approval.partnerBookingId, reason.trim(), ); return { booking, partner }; } /** * One page of the review queue, or of its history: pending pairings first, * then the decided ones, each carrying the display name of whoever requested * and whoever decided it — the stored ids tell a reviewer nothing. * * `user` narrows the whole thing to the caller's yards: a Mojo desk sees the * pairings that start or end at Mojo, a desk mapped to Mojo AND Adama sees * both yards' pairings. The counts behind the tabs are narrowed the same way, * so a badge never promises rows the caller cannot open. */ async queue(options?: { status?: ConsolidationApprovalStatus; page?: number; pageSize?: number; /** The `/auth/me` caller. Omit only for internal, unscoped reads. */ user?: unknown; }): Promise<{ items: ConsolidationApprovalView[]; total: number; /** Counts per status within the caller's scope — the tab badges. */ counts: Record; meta: { page: number; pageSize: number; total: number; totalPages: number; hasNextPage: boolean; hasPreviousPage: boolean; }; }> { const page = Math.max(1, options?.page ?? 1); const pageSize = Math.min(100, Math.max(1, options?.pageSize ?? 10)); const yardIds = await this.scopedYardIds(options?.user); const { items: rows, total } = await this.approvals.findQueuePage({ status: options?.status, yardIds, page, pageSize, }); const counts = await this.approvals.countByStatus(yardIds); const names = await this.bookingsRepository.resolveStaffNames( rows.flatMap((r) => [r.requestedBy, r.decidedBy]), ); const contractRefs = await this.contractReferences(rows); const refOf = (contractId?: string | null) => contractId ? (contractRefs.get(contractId) ?? null) : null; const items = rows.map((row) => ({ ...row, requestedByName: row.requestedBy ? (names.get(row.requestedBy) ?? null) : null, decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null, contractReference: refOf(row.booking?.contractId), partnerContractReference: refOf(row.partnerBooking?.contractId), })); const totalPages = Math.ceil(total / pageSize); return { items, total, counts, meta: { page, pageSize, total, totalPages, hasNextPage: page < totalPages, hasPreviousPage: page > 1, }, }; } /** * Contract id → reference for the bookings on this page. * * Booking has no contract relation (contract–booking split), so the * references are batch-loaded by id rather than joined — one query per page, * not one per row. */ private async contractReferences( rows: ConsolidationApproval[], ): Promise> { const ids = [ ...new Set( rows .flatMap((r) => [r.booking?.contractId, r.partnerBooking?.contractId]) .filter((id): id is string => !!id), ), ]; if (!ids.length) return new Map(); const contracts = await this.dataSource.getRepository(Contract).find({ where: { id: In(ids) }, select: { id: true, reference: true }, }); return new Map(contracts.map((c) => [c.id, c.reference])); } /** * Yard ids the caller may see, or undefined for unrestricted. * * Scope comes from the desk they are logged in as: `yard_positions` maps a * position to its yards, so a Mojo CEO resolves to [Mojo]. A super admin, a * `yards:view_all` holder, and a desk with NO yard mapping all resolve to * unrestricted — the mapping narrows access, it never grants it. * * Called with no user only from internal paths, which are unscoped. */ private async scopedYardIds(user: unknown): Promise { if (!user) return undefined; const scope = await this.yardScope.getScopedYardIds(user as never); return scope ?? undefined; } /** Full decision history for one booking — who decided what, and when. */ historyForBooking(bookingId: string): Promise { return this.approvals.findAllForBooking(bookingId); } /** The undecided request covering this booking, if any. */ pendingForBooking(bookingId: string): Promise { return this.approvals.findPendingForBooking(bookingId); } /** * Refuse a decision on a pairing outside the caller's yards. * * Hiding the row from the list is not enough on its own: the id is guessable * from a shared link, and deciding a pairing moves two other yards' bookings. * Same rule as the list — either half's origin or destination is enough. */ private async assertInScope( approval: ConsolidationApproval, user: unknown, ): Promise { const yardIds = await this.scopedYardIds(user); if (!yardIds) return; const booking = await this.bookingsService.findById(approval.bookingId); const partner = await this.bookingsService.findById( approval.partnerBookingId, ); const touches = (b: Booking | null | undefined) => !!b && (yardIds.includes(b.originYardId) || yardIds.includes(b.destinationYardId)); if (!touches(booking) && !touches(partner)) { throw new ForbiddenException( "This shared wagon is outside your assigned yards.", ); } } /** Load a row and refuse it unless it is in one of the decidable states. */ private async loadDecidable( approvalId: string, allowed: ConsolidationApprovalStatus[], ): Promise { const approval = await this.approvals.findById(approvalId); if (!approval) { throw new NotFoundException(`Approval ${approvalId} not found`); } if (!allowed.includes(approval.status)) { throw new ConflictException( `This consolidation was already ${approval.status.toLowerCase()}.`, ); } return approval; } }