diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 457786cbe..636bb7e05 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -93,6 +93,7 @@ "@types/supertest": "^6.0.2", "@types/vorpal": "^1.12.8", "jest": "^29.7.0", + "socket.io-client": "^4.8.3", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index 7c7805b28..d49cc304e 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -8,7 +8,11 @@ import { hashPassword } from "@tria-plc/api-common/utils/argon"; import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm"; -import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; +// Subpath imports (not the package root) so ts-jest can resolve them when this +// file lands in a spec's compile graph via the notification recipients chain. +import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity"; +import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity"; +import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; @@ -40,6 +44,21 @@ export class BackofficeService { private readonly dataSource: DataSource, ) {} + /** + * IAM user ids of every current employee across all organizations — used by + * the notification recipients resolver's `allBackoffice` selector. + */ + async getAllCurrentEmployeeUserIds(): Promise { + const employees = await this.employeeRepository.find({ + where: { isCurrent: true }, + }); + return [ + ...new Set( + employees.map((e) => e.userId).filter((id): id is string => Boolean(id)), + ), + ]; + } + async createOrganizationUser( organizationId: string, dto: CreateOrganizationUserDto, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts new file mode 100644 index 000000000..a4546e301 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -0,0 +1,307 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; + +import { Booking } from './entities/booking.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; + +/** + * Customer + staff notifications for the booking lifecycle: review, clearance + * and operation flow. Every customer event fans out over SMS + email (direct) + * and a persisted in-app notification deep-linking to the booking detail page; + * staff events land in the backoffice inbox. All sends are fire-and-forget and + * never throw — a notification failure must not break a booking transition. + * + * NOTE: the batch/payment-window notifications (pay-now, allocated, expired, + * displaced) are handled separately by {@link BookingNotifierService} in + * train-scheduling. + */ +@Injectable() +export class BookingLifecycleNotifierService { + private readonly logger = new Logger(BookingLifecycleNotifierService.name); + + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private ref(b: Booking): string { + return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; + } + + /** Send SMS + email to the booking's company contact; log-only on failure. */ + private async notifyContact( + b: Booking, + message: string, + logLabel: string, + ): Promise { + this.logger.log(`${logLabel} — ${this.ref(b)}`); + const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; + + if (phone) { + try { + await this.notifications.directSend('sms', phone, message); + } catch (err) { + this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend('email', email, message); + } catch (err) { + this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + if (!phone && !email) { + this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`); + } + } + + /** Persist + push an in-app item to all portal users of the booking's company. */ + private inApp( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!b.companyId) return; // government/unlinked bookings have no portal users + void this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title, + body, + link: `/bookings/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + + /** Persist + push an in-app item to every backoffice staff user. */ + private inAppStaff( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/booking-requests/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + + // ── Customer-facing lifecycle events ─────────────────────────────────────── + + /** Line staff accepted intake → booking is under approval. */ + accepted(b: Booking): void { + const msg = + `Your booking ${b.reference} has been accepted and is now under approval. ` + + `We will notify you once it is approved.`; + void this.notifyContact(b, msg, 'ACCEPTED'); + this.inApp(b, 'Booking accepted', msg); + } + + /** All approval steps complete → contract generated, ready for customer to sign. */ + approved(b: Booking): void { + const msg = + `Your booking ${b.reference} has been approved. ` + + `Please review and sign your contract from the portal.`; + void this.notifyContact(b, msg, 'APPROVED'); + this.inApp(b, 'Booking approved', msg); + } + + /** Staff rejected the booking (intake or approval step). */ + rejected(b: Booking, reason: string): void { + const msg = + `Your booking ${b.reference} was rejected. Reason: ${reason}. ` + + `Please contact us for details.`; + void this.notifyContact(b, msg, 'REJECTED'); + this.inApp(b, 'Booking rejected', msg); + } + + /** Staff requested changes before approval. */ + changesRequested(b: Booking, note: string): void { + const msg = + `Changes were requested on your booking ${b.reference}: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(b, msg, 'CHANGES REQUESTED'); + this.inApp(b, 'Booking changes requested', msg); + } + + /** A clearance document was queried and needs the customer to re-upload. */ + documentQueried(b: Booking, fileKey: string, note: string): void { + const msg = + `A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` + + `${note}. Please re-upload from the portal.`; + void this.notifyContact(b, msg, 'DOCUMENT QUERIED'); + this.inApp(b, 'Document queried', msg, { + type: NotificationType.DOCUMENT_ACTION, + }); + } + + /** Clearance finalized → customer can proceed to request operation. */ + clearanceReady(b: Booking): void { + const msg = + `Clearance for booking ${b.reference} is complete. ` + + `You can now proceed to request operation from the portal.`; + void this.notifyContact(b, msg, 'CLEARANCE READY'); + this.inApp(b, 'Clearance complete', msg, { + type: NotificationType.CLEARANCE_DECISION, + }); + } + + /** Operations returned the operation request for changes. */ + operationChangesRequested(b: Booking, note: string): void { + const msg = + `Your operation request for booking ${b.reference} needs changes: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(b, msg, 'OPERATION CHANGES REQUESTED'); + this.inApp(b, 'Operation request needs changes', msg); + } + + /** Operation accepted → invoice ready; await payment / booking window. */ + operationAccepted(b: Booking): void { + const msg = + `Your operation request for booking ${b.reference} has been accepted. ` + + `An invoice has been prepared — watch for the payment window to secure your slot.`; + void this.notifyContact(b, msg, 'OPERATION ACCEPTED'); + this.inApp(b, 'Operation request accepted', msg); + } + + /** Shipment started → in transit. */ + inTransit(b: Booking): void { + const msg = `Your shipment for booking ${b.reference} is now in transit.`; + void this.notifyContact(b, msg, 'IN TRANSIT'); + this.inApp(b, 'Shipment in transit', msg); + } + + /** Shipment delivered → completed. */ + completed(b: Booking): void { + const msg = `Your shipment for booking ${b.reference} has been delivered. Thank you.`; + void this.notifyContact(b, msg, 'COMPLETED'); + this.inApp(b, 'Shipment delivered', msg); + } + + /** Booking cancelled. */ + cancelled(b: Booking, reason: string): void { + const msg = `Your booking ${b.reference} has been cancelled. Reason: ${reason}.`; + void this.notifyContact(b, msg, 'CANCELLED'); + this.inApp(b, 'Booking cancelled', msg); + } + + // ── Clearance milestones needing customer action ────────────────────────── + + /** GL advised duty & tax — the customer must pay and upload the slip. */ + dutyAdvised(b: Booking, amount: number, currency: string): void { + const msg = + `Duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'DUTY ADVISED'); + this.inApp(b, 'Duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL advised the post-arrival additional duty round (import). */ + secondDutyAdvised(b: Booking, amount: number, currency: string): void { + const msg = + `Additional duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'SECOND DUTY ADVISED'); + this.inApp(b, 'Additional duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL raised the final (post-offload) invoice — customer pays + uploads slip. */ + finalInvoiceCreated(b: Booking, amount: number, currency: string): void { + const msg = + `A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'FINAL INVOICE'); + this.inApp(b, 'Final invoice issued', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL confirmed the final-invoice payment slip. */ + finalInvoicePaid(b: Booking): void { + const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`; + void this.notifyContact(b, msg, 'FINAL INVOICE PAID'); + this.inApp(b, 'Final invoice paid', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + // ── Staff-facing (backoffice inbox) ──────────────────────────────────────── + + /** Customer submitted a booking for review. */ + submittedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'New booking submitted', + `Booking ${this.ref(b)} was submitted and is awaiting intake review.`, + ); + } + + /** Customer signed the booking contract. */ + customerSignedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Customer signed booking contract', + `The contract for booking ${this.ref(b)} was signed by the customer.`, + ); + } + + /** Customer requested operation (picked a shipment day). */ + operationRequestedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Operation requested', + `Booking ${this.ref(b)} requested operation — review capacity, documents and route.`, + ); + } + + /** Customer uploaded clearance documents — review is next. */ + clearanceDocsUploadedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Clearance documents uploaded', + `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`, + { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/bookings/${b.id}/clearance`, + }, + ); + } + + /** Customer uploaded a duty/tax payment slip — GL verifies it. */ + dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void { + const label = + round === 'final' + ? 'final invoice' + : round === 'second' + ? 'additional duty & tax' + : 'duty & tax'; + this.inAppStaff( + b, + 'Payment slip uploaded', + `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`, + { + type: NotificationType.PAYMENT_RECEIVED, + link: `/dashboard/bookings/${b.id}/clearance`, + }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 3c535f450..607a0d7a4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -30,14 +30,32 @@ describe('BookingTransitionService — acceptIntake validity window', () => { ruleEngineService as never, {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository, ruleEngineService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 9f9aa5713..72c83e136 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -41,14 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository }; } @@ -126,14 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( {} as never, {} as never, {} as never, - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, - {} as never, + {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository }; } @@ -197,14 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields {} as never, {} as never, {} as never, - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, - {} as never, + {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository, filesService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index ea3618a08..9201e4fa9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service'; /** * Operation-request review for general-contract drawdown orders: - * - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool. - * - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued. + * - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/ + * domestic bookings wait for their booking-day window cycle (no immediate + * batch enqueue at accept time). + * - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch. * - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED. */ describe('BookingTransitionService — operation review', () => { function makeService(serviceTypeCode: string) { const booking = { id: 'b-1', + reference: 'BKG-1', status: 'OPERATION_REQUEST_PENDING', originYardId: 'o-1', destinationYardId: 'd-1', @@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => { }; const bookingBatchService = { enqueueRouteDayProcessing: jest.fn(), + pickExportSchedule: jest.fn(), + acceptExportBooking: jest.fn(), + }; + const invoiceService = { + ensureInvoiceForBooking: jest + .fn() + .mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }), + updateStatus: jest.fn().mockResolvedValue(undefined), }; const service = new BookingTransitionService( @@ -33,37 +44,60 @@ describe('BookingTransitionService — operation review', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + invoiceService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); - return { service, bookingsRepository, bookingBatchService }; + return { service, bookingsRepository, bookingBatchService, invoiceService }; } - it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => { - const { service, bookingsRepository, bookingBatchService } = + it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => { + const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('RAIL_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'FULLY_EXECUTED' }), ); - expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1); + expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); + // Import/domestic train bookings are batched by the window cycle later — + // never enqueued directly at accept time. + expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); + expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled(); }); - it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => { - const { service, bookingsRepository, bookingBatchService } = + it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => { + const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('ROAD_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }), ); + expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 06edbf04e..b5c277073 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -4,6 +4,7 @@ import { Inject, Injectable, Logger, + Optional, } from "@nestjs/common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; @@ -15,6 +16,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingPricingService } from './booking-pricing.service'; import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; @@ -26,6 +28,7 @@ import { PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { BookingsService } from './bookings.service'; import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ContractDocPhase } from '@edr/types'; @@ -53,7 +56,8 @@ export class BookingTransitionService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly containerValidationService: ContainerValidationService, - + private readonly notifier: BookingLifecycleNotifierService, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} private isPhasedGeneralCustoms(booking: Booking): boolean { @@ -124,6 +128,9 @@ export class BookingTransitionService { const finalBooking = await this.bookingsService.runConsolidationOnSubmit( updated!.id, ); + if (finalBooking.status === "SUBMITTED") { + this.notifier.submittedToStaff(finalBooking); + } return { bookingId: finalBooking.id, status: finalBooking.status, @@ -204,6 +211,9 @@ export class BookingTransitionService { const finalBooking = await this.bookingsService.runConsolidationOnSubmit( updated!.id, ); + if (finalBooking.status === "SUBMITTED") { + this.notifier.submittedToStaff(finalBooking); + } return { bookingId: finalBooking.id, status: finalBooking.status, @@ -233,7 +243,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "CHANGES_REQUESTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.changesRequested(fresh, note); + return fresh; } /** Auto-create booking approval steps from system rules when none exist yet. */ @@ -284,7 +296,9 @@ export class BookingTransitionService { contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.accepted(fresh); + return fresh; } async staffReject( @@ -305,7 +319,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "REJECTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.rejected(fresh, reason); + return fresh; } async approveStep( @@ -394,7 +410,9 @@ export class BookingTransitionService { if (allDone) { const generated = await this.contractService.generateContract(bookingId); - return this.bookingsService.findById(generated.id); + const fresh = await this.bookingsService.findById(generated.id); + this.notifier.approved(fresh); + return fresh; } return this.bookingsService.findById(bookingId); @@ -435,7 +453,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "REJECTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.rejected(fresh, reason); + return fresh; } async customerSign(bookingId: string): Promise { @@ -446,7 +466,9 @@ export class BookingTransitionService { status: "SIGNED_CUSTOMER", customerSignedAt: new Date(), } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.customerSignedToStaff(fresh); + return fresh; } async startTransit(bookingId: string): Promise { @@ -456,7 +478,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "IN_TRANSIT", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.inTransit(fresh); + return fresh; } async complete(bookingId: string): Promise { @@ -467,7 +491,28 @@ export class BookingTransitionService { status: "COMPLETED", endDate: new Date(), } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.completed(fresh); + // Customer tracking: close out the tail milestones so a finished shipment + // never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are + // implied by delivery; a storage invoice that was never raised is skipped + // (storage billing does not apply to every shipment). All doc-trigger / + // best-effort — a booking without milestone rows is untouched. + if (this.milestoneService) { + for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) { + try { + await this.milestoneService.completeByDocTrigger({ bookingId }, code); + } catch { + /* tracking must never block completion */ + } + } + try { + await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED"); + } catch { + /* no such milestone row (export / non-customs) — fine */ + } + } + return fresh; } async cancel(bookingId: string, reason: string): Promise { @@ -491,7 +536,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "CANCELLED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.cancelled(fresh, reason); + return fresh; } /** @@ -723,7 +770,9 @@ export class BookingTransitionService { } as never); } - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.clearanceDocsUploadedToStaff(fresh); + return fresh; } /** @@ -824,6 +873,9 @@ export class BookingTransitionService { } const updated = await this.bookingsService.findById(bookingId); + if (status === "QUERIED") { + this.notifier.documentQueried(updated, fileKey, note ?? ''); + } if (this.isPhasedGeneralCustoms(updated)) { const allApproved = await this.isClearanceFullyApproved(updated); if (allApproved) { @@ -912,7 +964,9 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "CLEARANCE_READY", } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.clearanceReady(fresh); + return fresh; } /** @@ -957,7 +1011,9 @@ export class BookingTransitionService { status: "OPERATION_REQUEST_PENDING", scheduledDate: date, } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.operationRequestedToStaff(fresh); + return fresh; } /** @@ -992,7 +1048,9 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "OPERATION_CHANGES_REQUESTED", } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.operationChangesRequested(fresh, options.note); + return fresh; } // ACCEPT — enter the batch holding pool. @@ -1037,7 +1095,9 @@ export class BookingTransitionService { fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); - return this.bookingsService.findById(booking.id); + const roadFresh = await this.bookingsService.findById(booking.id); + this.notifier.operationAccepted(roadFresh); + return roadFresh; } await this.bookingsRepository.update(booking.id, { @@ -1072,7 +1132,9 @@ export class BookingTransitionService { // batch runs after the window closes + staff document review, never at accept // time. (Legacy pre-migration schedules with no window phase are still served // by the periodic legacy fill.) - return this.bookingsService.findById(booking.id); + const trainFresh = await this.bookingsService.findById(booking.id); + this.notifier.operationAccepted(trainFresh); + return trainFresh; } async enrichBookingResponse(booking: Booking): Promise< diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 2cb10ce8e..61dc78e13 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -18,7 +18,10 @@ import { BookingInvoiceService } from './booking-invoice.service'; // import { BookingPaymentService } from './booking-payment.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingTransitionService } from './booking-transition.service'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { BookingsController } from './bookings.controller'; // import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; @@ -64,6 +67,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckContainer, ]), BillingModule, + NotificationsModule, + NotificationInboxModule, forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), forwardRef(() => ContractsModule), @@ -90,6 +95,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContainerValidationService, BookingReferenceDataService, BookingPricingService, + BookingLifecycleNotifierService, BookingTransitionService, BookingContractService, BookingInvoiceService, @@ -107,6 +113,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingsRepository, BookingPricingService, BookingInvoiceService, + BookingLifecycleNotifierService, CustomerTruckService, ContainerReceiptService, ], diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d4791baf7..1fb37dc31 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1388,6 +1388,17 @@ export class BookingsService { ); } + // Surface the assigned train's operational status so the portal stepper + // can show the Arrival stage: the booking status stays IN_TRANSIT from + // dispatch until delivery, so arrival is only knowable from the schedule. + if (booking.trainScheduleId) { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: booking.trainScheduleId } }); + (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = + schedule?.status ?? null; + } + return booking; } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 2b0126003..7b5cb77d6 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -85,6 +85,13 @@ function makeService(overrides?: { milestoneService as never, dropdownSettingsService as never, glOperationsService as never, + { + dutyAdvised: jest.fn(), + clearanceReady: jest.fn(), + documentQueried: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + } as never, // notifier ); return { @@ -115,12 +122,18 @@ describe('BookingClearanceService', () => { it('records duty advice when duty applies', async () => { const { service, milestoneService } = makeService(); - await service.adviseDuty('b-general', { - dutyRequired: true, - amount: 1500, - currency: 'ETB', - declarationSerial: 'DS-1', - }); + await service.adviseDuty( + 'b-general', + { + dutyRequired: true, + amount: 1500, + currency: 'ETB', + declarationSerial: 'DS-1', + }, + undefined, + // The duty notice attachment is now mandatory when duty applies. + { fieldname: 'duty_tax_notice' } as Express.Multer.File, + ); expect(milestoneService.adviseDuty).toHaveBeenCalledWith( 'b-general', diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 61ac93925..eeea43a39 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -12,6 +12,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s import { FilesService } from '../files/files.service'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsService } from '../bookings/bookings.service'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; @@ -100,6 +101,7 @@ export class BookingClearanceService { private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private async assertPhasedGeneralCustoms(booking: Booking): Promise { @@ -414,6 +416,7 @@ export class BookingClearanceService { }, userId, ); + this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB'); } return this.bookingsService.findById(bookingId); @@ -441,6 +444,7 @@ export class BookingClearanceService { clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); + this.notifier.dutySlipUploadedToStaff(booking, 'first'); return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 88a4ec725..4752b004f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -10,6 +10,7 @@ import type { Freight } from '@edr/types'; import { BookingRequestRepository } from './booking-request.repository'; import { ContractsService } from './contracts.service'; import { ContractBookingService } from './contract-booking.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { BookingRequest } from './entities/booking-request.entity'; import { Contract } from './entities/contract.entity'; import { CreateBookingRequestDto } from './dto/create-booking-request.dto'; @@ -26,6 +27,7 @@ export class BookingRequestService { private readonly repo: BookingRequestRepository, private readonly contractsService: ContractsService, private readonly contractBookingService: ContractBookingService, + private readonly notifier: ContractNotifierService, ) {} /** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */ @@ -107,7 +109,7 @@ export class BookingRequestService { }; const reference = await this.generateReference(); - return this.repo.create({ + const request = await this.repo.create({ reference, contractId, requestedByUserId: userId ?? null, @@ -117,6 +119,8 @@ export class BookingRequestService { requestedLines, notes: dto.notes ?? null, } as never); + this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference); + return request; } listForContract(contractId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts index f30b64597..75178d640 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts @@ -48,6 +48,7 @@ function makeService(milestones: ClearanceMilestone[]) { contractsRepository as never, milestoneService as never, bookingsRepository as never, + { clearanceReady: jest.fn() } as never, // notifier ); return { service, milestoneService, contractsRepository, bookingsRepository }; } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts index 758da27ff..9b17a3e76 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -9,6 +9,7 @@ import { Contract } from './entities/contract.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { Booking } from '../bookings/entities/booking.entity'; import type { ClearanceMetaState } from './clearance-workflow.types'; import { metaFromBooking } from './clearance-workflow.types'; @@ -34,6 +35,7 @@ export class ClearanceWorkflowService { private readonly contractsRepository: ContractsRepository, private readonly milestoneService: ClearanceMilestoneService, private readonly bookingsRepository: BookingsRepository, + private readonly notifier: BookingLifecycleNotifierService, ) {} boundaryMilestone(tradeDirection: string): string { @@ -264,6 +266,14 @@ export class ClearanceWorkflowService { status: 'CLEARANCE_READY', clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); + // Tell the customer clearance is done and operation can be requested. Load + // failure only skips the notice — the status change above already committed. + try { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (booking) this.notifier.clearanceReady(booking); + } catch { + /* notification is best-effort */ + } } resolvePhase( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 2c79ba42f..3d41e68a5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -16,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; @@ -118,6 +119,7 @@ export class ContractClearanceService { private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, + private readonly notifier: ContractNotifierService, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -543,7 +545,9 @@ export class ContractClearanceService { await this.workflowService.onDocumentReviewReopened(contractId); } - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.clearanceDocsUploadedToStaff(updated); + return updated; } private async assertRequiredInputsPresent( @@ -674,6 +678,7 @@ export class ContractClearanceService { status: 'AWAITING_CLEARANCE_DOCUMENTS', clearanceStatus: 'AWAITING_DOCUMENTS', } as never); + this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? ''); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } @@ -1009,6 +1014,7 @@ export class ContractClearanceService { }, userId, ); + this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB'); } return this.contractsService.findById(contractId); @@ -1045,7 +1051,9 @@ export class ContractClearanceService { }); } - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.dutySlipUploadedToStaff(updated); + return updated; } async uploadTransitPermit( @@ -1118,6 +1126,7 @@ export class ContractClearanceService { await this.workflowService.markReadyForBooking(contractId); } + this.notifier.preClearanceFinalized(contract); return this.contractsService.findById(contractId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts new file mode 100644 index 000000000..d4f31e570 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -0,0 +1,245 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; + +import { Contract } from './entities/contract.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; + +/** + * Customer + staff notifications for the contract lifecycle. Every customer + * event fans out over three channels: SMS + email (direct, via + * {@link NotificationsService}) and a persisted in-app notification (via + * {@link NotificationInboxService}) that deep-links to the contract detail page. + * Staff events go to the backoffice inbox. All sends are fire-and-forget and + * never throw — a notification failure must not break a contract transition. + */ +@Injectable() +export class ContractNotifierService { + private readonly logger = new Logger(ContractNotifierService.name); + + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private ref(c: Contract): string { + return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`; + } + + /** Send SMS + email to the contract's company contact; log-only on failure. */ + private async notifyContact( + c: Contract, + message: string, + logLabel: string, + ): Promise { + this.logger.log(`${logLabel} — ${this.ref(c)}`); + const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null; + const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; + + if (phone) { + try { + await this.notifications.directSend('sms', phone, message); + } catch (err) { + this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend('email', email, message); + } catch (err) { + this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`); + } + } + if (!phone && !email) { + this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`); + } + } + + /** Persist + push an in-app item to all portal users of the contract's company. */ + private inApp( + c: Contract, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!c.companyId) return; // government/unlinked contracts have no portal users + void this.inbox.notify({ + recipients: { companyId: c.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.CONTRACT_STATUS, + title, + body, + link: `/contracts/${c.id}`, + data: { contractId: c.id, reference: c.reference }, + ...overrides, + }); + } + + /** Persist + push an in-app item to every backoffice staff user. */ + private inAppStaff( + c: Contract, + title: string, + body: string, + overrides: Partial = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/contract-requests/${c.id}`, + data: { contractId: c.id, reference: c.reference }, + ...overrides, + }); + } + + // ── Customer-facing lifecycle events ─────────────────────────────────────── + + /** Line staff accepted intake → contract is under approval. */ + accepted(c: Contract): void { + const msg = + `Your contract ${c.reference} has been accepted and is now under approval. ` + + `We will notify you once it is approved.`; + void this.notifyContact(c, msg, 'ACCEPTED'); + this.inApp(c, 'Contract accepted', msg); + } + + /** All approval steps complete → contract approved. */ + approved(c: Contract): void { + const msg = + `Your contract ${c.reference} has been approved. ` + + `The final document will be prepared for signing.`; + void this.notifyContact(c, msg, 'APPROVED'); + this.inApp(c, 'Contract approved', msg); + } + + /** Fully executed (all parties signed) → contract active, customer can book. */ + signedActive(c: Contract): void { + const msg = + `Your contract ${c.reference} has been signed and is now active. ` + + `You can start booking shipments from the portal.`; + void this.notifyContact(c, msg, 'SIGNED / ACTIVE'); + this.inApp(c, 'Contract active', msg); + } + + /** Staff rejected the contract. */ + rejected(c: Contract, reason: string): void { + const msg = + `Your contract ${c.reference} was rejected. Reason: ${reason}. ` + + `Please contact us for details.`; + void this.notifyContact(c, msg, 'REJECTED'); + this.inApp(c, 'Contract rejected', msg); + } + + /** Staff requested changes before approval. */ + changesRequested(c: Contract, note: string): void { + const msg = + `Changes were requested on your contract ${c.reference}: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(c, msg, 'CHANGES REQUESTED'); + this.inApp(c, 'Contract changes requested', msg); + } + + // ── Clearance milestones needing customer action ────────────────────────── + + /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */ + dutyAdvised(c: Contract, amount: number, currency: string): void { + const msg = + `Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(c, msg, 'DUTY ADVISED'); + this.inApp(c, 'Duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + link: `/contracts/${c.id}/clearance`, + }); + } + + /** A clearance document was queried — customer must re-upload it. */ + clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void { + const msg = + `A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` + + `${note}. Please re-upload from the portal.`; + void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED'); + this.inApp(c, 'Clearance document queried', msg, { + type: NotificationType.DOCUMENT_ACTION, + link: `/contracts/${c.id}/clearance`, + }); + } + + /** Import pre-clearance finalized — the process moves to GL Djibouti collection. */ + preClearanceFinalized(c: Contract): void { + const msg = + `Pre-clearance for contract ${c.reference} is complete. ` + + `Your shipment is proceeding to document collection in Djibouti.`; + void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED'); + this.inApp(c, 'Pre-clearance complete', msg, { + type: NotificationType.CLEARANCE_DECISION, + link: `/contracts/${c.id}/clearance`, + }); + } + + // ── Staff-facing (backoffice inbox) ──────────────────────────────────────── + + /** Customer submitted a contract for review. */ + submittedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'New contract submitted', + `Contract ${this.ref(c)} was submitted and is awaiting intake review.`, + ); + } + + /** Customer signed the contract — staff counter-sign is next. */ + customerSignedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Customer signed contract', + `Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`, + { link: `/dashboard/contract-requests/${c.id}/view` }, + ); + } + + /** Customer uploaded clearance documents — GL review is next. */ + clearanceDocsUploadedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Clearance documents uploaded', + `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`, + { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/contracts/clearance/${c.id}`, + }, + ); + } + + /** Customer uploaded the duty/tax payment slip — GL verifies it. */ + dutySlipUploadedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Duty slip uploaded', + `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`, + { + type: NotificationType.PAYMENT_RECEIVED, + link: `/dashboard/contracts/clearance/${c.id}`, + }, + ); + } + + /** Customer filed a shipment request under a GENERAL customs contract. */ + shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void { + this.inAppStaff( + c, + 'New shipment request', + `Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`, + { + link: `/dashboard/shipment-requests/${requestId}`, + data: { contractId: c.id, requestId, reference: requestRef }, + }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e31392666..9cf06c905 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -22,6 +22,7 @@ import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; import { OtpService } from '../otp/otp.service'; import { ContractPricingService } from './contract-pricing.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService } from './contracts.service'; @@ -66,6 +67,7 @@ export class ContractTransitionService { private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, private readonly otpService: OtpService, + private readonly notifier: ContractNotifierService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -79,7 +81,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.submittedToStaff(updated); + return updated; } /** Confirm a price change before submit (mirrors booking confirm-submit). */ @@ -93,7 +97,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.submittedToStaff(updated); + return updated; } /** @@ -130,7 +136,9 @@ export class ContractTransitionService { contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.accepted(updated); + return updated; } /** @@ -218,7 +226,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'CHANGES_REQUESTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.changesRequested(updated, note); + return updated; } async reject(contractId: string, reason: string, actorId: string): Promise { @@ -235,7 +245,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.rejected(updated, reason); + return updated; } /** Approve one approval step in sequence; → APPROVED when all complete. */ @@ -297,7 +309,11 @@ export class ContractTransitionService { if (Object.keys(updates).length > 0) { await this.contractsRepository.update(contractId, updates as never); } - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + if (allDone) { + this.notifier.approved(updated); + } + return updated; } /** @@ -535,7 +551,9 @@ export class ContractTransitionService { customerSignedAt: new Date(), } as never); await this.regenerateContractPdf(contractId, contract.reference); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.customerSignedToStaff(updated); + return updated; } return this.counterSign(contractId, dto, options); @@ -605,7 +623,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, updates as never); await this.regenerateContractPdf(contractId, contract.reference); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.signedActive(updated); + return updated; } /** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index a9f9dcf5d..33a547a9f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -12,6 +12,8 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { OtpModule } from '../otp/otp.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { BookingsModule } from '../bookings/bookings.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; @@ -19,6 +21,7 @@ import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; import { ContractsRepository } from './contracts.repository'; import { ContractPricingService } from './contract-pricing.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; import { BookingClearanceService } from './booking-clearance.service'; @@ -75,6 +78,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum MinioModule, SignaturesModule, OtpModule, + NotificationsModule, + NotificationInboxModule, CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). @@ -94,6 +99,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractsService, ContractsRepository, ContractPricingService, + ContractNotifierService, ContractTransitionService, ContractClearanceService, ClearanceWorkflowService, diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 72e2d14d9..181d8b688 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -11,6 +11,7 @@ import { BillingService } from '../billing/billing.service'; import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { @@ -53,6 +54,7 @@ export class GlOperationsService { private readonly filesService: FilesService, private readonly milestoneService: ClearanceMilestoneService, private readonly billingService: BillingService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private get bookings() { @@ -64,7 +66,11 @@ export class GlOperationsService { } private async getBooking(bookingId: string): Promise { - const booking = await this.bookings.findOne({ where: { id: bookingId } }); + // company is loaded so customer notifications have a phone/email to target. + const booking = await this.bookings.findOne({ + where: { id: bookingId }, + relations: { company: true }, + }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); return booking; } @@ -447,6 +453,7 @@ export class GlOperationsService { void userId; const summary = await this.finalInvoiceSummary(bookingId); if (!summary) throw new NotFoundException('Final invoice could not be created.'); + this.notifier.finalInvoiceCreated(booking, input.amount, input.currency); return summary; } @@ -455,7 +462,7 @@ export class GlOperationsService { bookingId: string, file: Express.Multer.File, ): Promise<{ uploaded: boolean }> { - await this.getBooking(bookingId); + const booking = await this.getBooking(bookingId); if (!file) throw new BadRequestException('No payment slip uploaded'); const invoice = await this.billingService.findInvoice( @@ -482,6 +489,7 @@ export class GlOperationsService { code: 'final_invoice_slip', file, }); + this.notifier.dutySlipUploadedToStaff(booking, 'final'); return { uploaded: true }; } @@ -490,7 +498,7 @@ export class GlOperationsService { bookingId: string, userId?: string, ): Promise { - await this.getBooking(bookingId); + const booking = await this.getBooking(bookingId); const invoice = await this.billingService.findInvoice( Freight.InvoiceSource.Booking, bookingId, @@ -507,6 +515,7 @@ export class GlOperationsService { ); } await this.billingService.markInvoiceAsPaid(invoice.id); + this.notifier.finalInvoicePaid(booking); } void userId; @@ -575,6 +584,7 @@ export class GlOperationsService { }, userId, ); + this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB'); return { advised: true, skipped: false }; } @@ -605,6 +615,7 @@ export class GlOperationsService { booking.tradeDirection ?? 'IMPORT', ); await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); + this.notifier.dutySlipUploadedToStaff(booking, 'second'); return { milestoneCompleted: true }; } diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index 265e19303..be7964a3d 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -70,6 +70,18 @@ export class NotificationRecipientsService { } } + if (recipients.allBackoffice) { + try { + for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) { + ids.add(uid); + } + } catch (err) { + this.logger.warn( + `Failed to resolve allBackoffice recipients: ${(err as Error).message}`, + ); + } + } + return [...ids]; } } diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts index 905e827e8..289e502f1 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => { bookingsRepository as never, trainSchedulingService as never, schedulingRescheduleRepository as never, + { + rescheduled: jest.fn(), + removedFromTrain: jest.fn(), + maintenanceMoved: jest.fn(), + } as never, // notifier ); }); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index dd20b100d..a9a3ae246 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { BookingNotifierService } from '../train-scheduling/booking-notifier.service'; import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; @@ -38,6 +39,7 @@ export class SchedulingRescheduleService { private readonly bookingsRepository: BookingsRepository, private readonly trainSchedulingService: TrainSchedulingService, private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository, + private readonly notifier: BookingNotifierService, ) {} /** Preview who is retained, displaced, and readmitted on a schedule. */ @@ -193,9 +195,64 @@ export class SchedulingRescheduleService { displacedBookingIds: dto.displacedBookingIds, }); + // Notify affected customers (SMS + email). Best-effort — a notification + // failure must never fail the reschedule, so each send is fire-and-forget + // inside the notifier. Government pre-empt already notifies via the batch + // displaced() path, so skip removed-from-train notices for that trigger. + // Use the new departure date when the reschedule moved it (the in-memory + // `schedule` still holds the pre-update date). + const effectiveDeparture = dto.newDepartureDate + ? new Date(dto.newDepartureDate) + : schedule.scheduledDepartureDate; + await this.notifyRescheduleOutcome(dto, effectiveDeparture); + return { plan, schedule: assignResult }; } + /** + * Fan out reschedule notifications: bookings that stayed on the train hear the + * new departure date; bookings dropped off the train (staff reschedule, not a + * government pre-empt) hear they were removed. Loads each booking with its + * company so the notifier has a phone/email to reach. + */ + private async notifyRescheduleOutcome( + dto: ExecuteRescheduleDto, + newDeparture: Date | null, + ): Promise { + const isMaintenance = dto.trigger === 'TRAIN_MAINTENANCE'; + const isGovPreempt = dto.trigger === 'GOVERNMENT_PREEMPT'; + + if (newDeparture) { + for (const bookingId of dto.finalBookingIds) { + const booking = await this.loadBookingForNotify(bookingId); + if (!booking) continue; + if (isMaintenance) { + this.notifier.maintenanceMoved(booking, newDeparture); + } else { + this.notifier.rescheduled(booking, newDeparture); + } + } + } + + // Government pre-empt displacements are already announced by the batch + // displaced() notice — don't double-notify. Staff reschedules are not. + if (!isGovPreempt) { + for (const bookingId of dto.displacedBookingIds) { + const booking = await this.loadBookingForNotify(bookingId); + if (!booking) continue; + this.notifier.removedFromTrain(booking); + } + } + } + + private async loadBookingForNotify(bookingId: string): Promise { + try { + return await this.bookingsRepository.findByIdWithFiles(bookingId); + } catch { + return null; + } + } + /** Maintenance shortcut: new departure + rebalance. */ async maintenanceReschedule( scheduleId: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index fd9b74d28..889af7cc5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1517,6 +1517,12 @@ export class BookingBatchService implements OnModuleInit { "PREPAID", ); await this.notifier.payNow(booking, deadline); + // Customer tracking: a wagon slot is reserved and the freight pay window is + // open. Doc-trigger path — silent no-op for bookings without milestone rows. + void this.completeTrackingMilestones(booking.id, [ + "WAGON_REQUESTED", + "FREIGHT_PAYMENT_PENDING", + ]); } /** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */ @@ -1548,6 +1554,15 @@ export class BookingBatchService implements OnModuleInit { this.notifier.secured(booking, reason); void this.triggerWagonAllocation(scheduleId); void this.markWagonAllocatedMilestone(booking.id); + // Customer tracking: freight payment settled (commercial pay-window path). + // Government allocations don't pay upfront — theirs stay pending. + if (reason === 'paid') { + void this.completeTrackingMilestones(booking.id, [ + 'WAGON_REQUESTED', + 'FREIGHT_PAYMENT_PENDING', + 'FREIGHT_PAYMENT_SETTLED', + ]); + } } private async markWagonAllocatedMilestone(bookingId: string): Promise { @@ -1559,6 +1574,27 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * Complete customer-tracking milestones on lifecycle events via the + * doc-trigger path — a silent no-op for bookings without milestone rows + * (non-customs bookings). Never blocks the batch action. + */ + private async completeTrackingMilestones( + bookingId: string, + codes: string[], + ): Promise { + if (!this.milestoneService) return; + for (const code of codes) { + try { + await this.milestoneService.completeByDocTrigger({ bookingId }, code); + } catch (err) { + this.logger.warn( + `Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`, + ); + } + } + } + /** * Expire an unpaid reservation and free its capacity. With day-level pooling we * also clear `trainScheduleId` so the booking is no longer pinned to the train diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 49df19758..f1f63802e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,13 +1,23 @@ import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { BATCH_TIMEZONE } from './booking-batch.constants'; @Injectable() export class BookingNotifierService { private readonly logger = new Logger(BookingNotifierService.name); - constructor(private readonly notifications: NotificationsService) {} + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} private ref(b: Booking): string { return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; @@ -41,11 +51,34 @@ export class BookingNotifierService { } } + /** Persist + push an in-app item to all portal users of the booking's company. */ + private inApp( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!b.companyId) return; // government/unlinked bookings have no portal users + void this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title, + body, + link: `/bookings/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + async payNow(b: Booking, deadline: Date): Promise { const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW'); + this.inApp(b, 'Payment window open', msg, { + type: NotificationType.INVOICE_ISSUED, + }); } /** @@ -66,6 +99,9 @@ export class BookingNotifierService { `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); + this.inApp(b, 'Partial allocation offer', msg, { + type: NotificationType.INVOICE_ISSUED, + }); } secured(b: Booking, reason: 'paid' | 'gov'): void { @@ -73,11 +109,13 @@ export class BookingNotifierService { reason === 'gov' ? ' (government)' : '' }.`; void this.notifyContact(b, msg, 'ALLOCATED'); + this.inApp(b, 'Wagon allocated', msg); } expired(b: Booking): void { const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`; void this.notifyContact(b, msg, 'EXPIRED'); + this.inApp(b, 'Payment window expired', msg); } scheduleFull(b: Booking): void { @@ -100,5 +138,42 @@ export class BookingNotifierService { displaced(b: Booking): void { const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; void this.notifyContact(b, msg, 'DISPLACED'); + this.inApp(b, 'Booking displaced', msg); + } + + /** + * Staff rescheduled the train carrying this booking to a new departure date. + * The booking stays on the train — only the date moved. + */ + rescheduled(b: Booking, newDeparture: Date): void { + const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); + const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`; + void this.notifyContact(b, msg, 'RESCHEDULED'); + this.inApp(b, 'Booking rescheduled', msg); + } + + /** + * Booking was removed from its train during a staff reschedule (not a government + * pre-empt). It returns to eligible — the customer must rebook or reschedule. + */ + removedFromTrain(b: Booking): void { + const msg = + `Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` + + `Please rebook or select a new schedule from the portal.`; + void this.notifyContact(b, msg, 'REMOVED FROM TRAIN'); + this.inApp(b, 'Removed from train', msg); + } + + /** + * The train carrying this booking was moved for maintenance to a new departure + * date. The booking stays on the train — only the date moved. + */ + maintenanceMoved(b: Booking, newDeparture: Date): void { + const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); + const msg = + `The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` + + `New departure date: ${when}.`; + void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE'); + this.inApp(b, 'Train maintenance reschedule', msg); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts new file mode 100644 index 000000000..a439e442d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts @@ -0,0 +1,109 @@ +import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE } from '@edr/types'; +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { io, type Socket } from 'socket.io-client'; + +import { WsAuthService } from '../notification-inbox/ws-auth.service'; +import { BookingWindowGateway } from './booking-window.gateway'; +import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * End-to-end proof the booking-window socket works: boots a real Nest app with + * the gateway, connects a real socket.io client to the namespace, emits a phase + * change, and asserts the client receives the exact payload. If this passes, + * any "no live update" report is environmental (stale server process, wrong + * checkout running, client not connecting) — not the gateway. + */ +describe('BookingWindowGateway (e2e)', () => { + let app: INestApplication; + let gateway: BookingWindowGateway; + let client: Socket; + let baseUrl: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + BookingWindowGateway, + // Accept any token — auth plumbing is covered by the real WsAuthService. + { provide: WsAuthService, useValue: { resolveUserId: async () => 'user-1' } }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.listen(0); + const address = app.getHttpServer().address() as { port: number }; + baseUrl = `http://127.0.0.1:${address.port}`; + gateway = app.get(BookingWindowGateway); + }); + + afterAll(async () => { + client?.disconnect(); + await app?.close(); + }); + + it('authenticated client receives the phase event with the schedule state', async () => { + client = io(`${baseUrl}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token: 'any' }, + transports: ['websocket'], + }); + await new Promise((resolve, reject) => { + client.on('connect', () => resolve()); + client.on('connect_error', (err) => reject(err)); + }); + + const received = new Promise>((resolve) => { + client.on(BOOKING_WINDOW_WS_EVENTS.PHASE, (payload) => resolve(payload)); + }); + + gateway.emitPhase({ + id: 'sched-1', + originStationId: 'yard-a', + destinationStationId: 'yard-b', + direction: 'IMPORT', + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + bookingCycleNo: 2, + windowOpensAt: new Date('2026-07-06T16:15:00Z'), + windowClosesAt: new Date('2026-07-06T16:18:00Z'), + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + scheduledDepartureDate: new Date('2026-07-09T05:53:00Z'), + } as unknown as TrainSchedule); + + const payload = await received; + expect(payload).toMatchObject({ + scheduleId: 'sched-1', + phase: 'OPEN', + bookingWindowStatus: 'OPEN', + bookingCycleNo: 2, + windowOpensAt: '2026-07-06T16:15:00.000Z', + }); + }); + + it('rejects a client whose token does not resolve to a user', async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + BookingWindowGateway, + { provide: WsAuthService, useValue: { resolveUserId: async () => null } }, + ], + }).compile(); + const rejectingApp = moduleRef.createNestApplication(); + await rejectingApp.listen(0); + const addr = rejectingApp.getHttpServer().address() as { port: number }; + + const rejected = io(`http://127.0.0.1:${addr.port}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token: 'bad' }, + transports: ['websocket'], + reconnection: false, + }); + const outcome = await new Promise((resolve) => { + rejected.on('disconnect', () => resolve('disconnected')); + rejected.on('connect_error', () => resolve('rejected')); + // The server accepts the transport then drops it in handleConnection. + setTimeout(() => resolve(rejected.connected ? 'still-connected' : 'disconnected'), 500); + }); + rejected.disconnect(); + await rejectingApp.close(); + expect(outcome).not.toBe('still-connected'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts index d1c1d34a1..699471d90 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts @@ -41,6 +41,9 @@ export class BookingWindowGateway implements OnGatewayConnection { return; } socket.data.userId = userId; + // Log at info so "is anyone actually connected?" is answerable from the + // API log when diagnosing missing live updates. + this.logger.log(`Booking-window client connected (user ${userId})`); } /** Push a schedule's current window state to every connected client. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 12a78047c..429a03a05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -2,12 +2,17 @@ import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/com import { Cron } from '@nestjs/schedule'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; -import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; +import { + NotificationAudience, + NotificationType, + TrainScheduleStatus as TrainScheduleStatusEnum, +} from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; @@ -41,6 +46,7 @@ export class BookingWindowService implements OnModuleInit { private readonly bookingBatchService: BookingBatchService, private readonly trainSchedulingService: TrainSchedulingService, private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, private readonly gateway: BookingWindowGateway, ) {} @@ -380,22 +386,26 @@ export class BookingWindowService implements OnModuleInit { */ private async notifyWindowOpened(schedule: TrainSchedule): Promise { try { - const rows: Array<{ phone: string | null; email: string | null }> = - await this.dataSource.query( - `SELECT DISTINCT - COALESCE(co.contact_person_phone, co.phone) AS phone, - COALESCE(co.email, co.general_manager_email) AS email - FROM freight.contract_routes cr - JOIN freight.contracts c - ON c.id = cr.contract_id - AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') - AND c.deleted_at IS NULL - JOIN freight.companies co ON co.id = c.company_id - WHERE cr.origin_yard_id = $1 - AND cr.destination_yard_id = $2 - AND cr.deleted_at IS NULL`, - [schedule.originStationId, schedule.destinationStationId], - ); + const rows: Array<{ + company_id: string; + phone: string | null; + email: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT + c.company_id, + COALESCE(co.contact_person_phone, co.phone) AS phone, + COALESCE(co.email, co.general_manager_email) AS email + FROM freight.contract_routes cr + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.deleted_at IS NULL + JOIN freight.companies co ON co.id = c.company_id + WHERE cr.origin_yard_id = $1 + AND cr.destination_yard_id = $2 + AND cr.deleted_at IS NULL`, + [schedule.originStationId, schedule.destinationStationId], + ); if (!rows.length) return; const closes = schedule.windowClosesAt @@ -410,6 +420,7 @@ export class BookingWindowService implements OnModuleInit { const seenPhone = new Set(); const seenEmail = new Set(); + const seenCompany = new Set(); for (const r of rows) { if (r.phone && !seenPhone.has(r.phone)) { seenPhone.add(r.phone); @@ -423,9 +434,23 @@ export class BookingWindowService implements OnModuleInit { .directSend('email', r.email, msg) .catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`)); } + // In-app inbox item for every portal user of each eligible company, + // deep-linking to the new-booking page. + if (r.company_id && !seenCompany.has(r.company_id)) { + seenCompany.add(r.company_id); + void this.inbox.notify({ + recipients: { companyId: r.company_id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Booking window open', + body: msg, + link: '/bookings/new', + data: { trainScheduleId: schedule.id }, + }); + } } this.logger.log( - `Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`, + `Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`, ); } catch (err) { this.logger.warn( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index e01e0d629..792fb7c64 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -33,6 +33,7 @@ import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { BookingSplitService } from './booking-split.service'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { ContractsModule } from '../contracts/contracts.module'; @Module({ @@ -56,6 +57,7 @@ import { ContractsModule } from '../contracts/contracts.module'; forwardRef(() => BookingsModule), BillingModule, NotificationsModule, + NotificationInboxModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, @@ -76,6 +78,11 @@ import { ContractsModule } from '../contracts/contracts.module'; BookingSplitService, IntercityService, ], - exports: [TrainSchedulingService, BookingBatchService, BookingWindowService], + exports: [ + TrainSchedulingService, + BookingBatchService, + BookingWindowService, + BookingNotifierService, + ], }) export class TrainSchedulingModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 16d5052bf..7211d63fd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -12,6 +12,7 @@ import { Injectable, Logger, NotFoundException, + Optional, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; @@ -21,6 +22,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -274,9 +276,45 @@ export class TrainSchedulingService { private readonly warehouseInventoryService: WarehouseInventoryService, private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly bookingWindowGateway: BookingWindowGateway, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, ) {} + /** + * Complete customer-tracking clearance milestones for every booking on a + * schedule when a physical lifecycle event fires (dispatch, arrive, load, + * unload, gatepass). Uses the doc-trigger path, which is a silent no-op for + * bookings without milestone rows (non-customs bookings), so this is safe to + * call for every direction and flow. Never blocks the operational action. + */ + private async completeMilestonesForScheduleBookings( + scheduleId: string, + codes: string[], + ): Promise { + if (!this.milestoneService || codes.length === 0) return; + try { + const rows: Array<{ booking_id: string }> = await this.dataSource.query( + `SELECT tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL`, + [scheduleId], + ); + for (const { booking_id } of rows) { + for (const code of codes) { + await this.milestoneService.completeByDocTrigger( + { bookingId: booking_id }, + code, + ); + } + } + } catch (err) { + this.logger.warn( + `Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`, + ); + } + } + /** * Push a schedule's current booking-window state over the socket so the * portal home card and backoffice GL/batch views update in real time — @@ -1121,26 +1159,32 @@ export class TrainSchedulingService { { country: schedule.destinationCountry }, ); if (direction === 'IMPORT') { + const result = await this.warehouseInventoryService.autoUnloadArrivedBookings( + scheduleId, + 'SYSTEM_TRAIN_ARRIVAL', + ); + // Customer tracking: cargo is off the train at the destination yard. + void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']); return { direction, action: 'IMPORT_AUTO_UNLOAD', status: 'COMPLETED', - result: await this.warehouseInventoryService.autoUnloadArrivedBookings( - scheduleId, - 'SYSTEM_TRAIN_ARRIVAL', - ), + result, }; } if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) { + const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti( + scheduleId, + 'SYSTEM_TRAIN_ARRIVAL', + ); + // Customer tracking: cargo is off the train at the Djibouti port. + void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']); return { direction, action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD', status: 'COMPLETED', - result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti( - scheduleId, - 'SYSTEM_TRAIN_ARRIVAL', - ), + result, }; } @@ -1443,6 +1487,20 @@ export class TrainSchedulingService { // Dispatch closed the window — drop it from portal/GL cards right away. void this.emitWindowState(scheduleId); + // Customer tracking: cargo is on the departing train — loading milestones + // plus the direction's "departed" handoff milestone. + if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') { + void this.completeMilestonesForScheduleBookings(scheduleId, [ + // CARGO_ARRIVED is export-only (cargo reached the origin yard) — the + // doc-trigger path no-ops it for import bookings. + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + schedule.direction === 'IMPORT' + ? 'DEPARTED_FROM_DJIBOUTI' + : 'DEPARTED_TO_DJIBOUTI', + ]); + } return this.getTrainScheduleById(scheduleId); } @@ -1599,6 +1657,13 @@ export class TrainSchedulingService { LoadingStatus.Loaded, ); } + // Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED + // is the export-side "cargo reached origin yard" step that precedes it). + void this.completeMilestonesForScheduleBookings(scheduleId, [ + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + ]); return this.getTrainScheduleById(scheduleId); } @@ -2417,6 +2482,13 @@ export class TrainSchedulingService { } }); + // Customer tracking: the train reached the corridor's far end. + if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') { + void this.completeMilestonesForScheduleBookings(scheduleId, [ + schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI', + ]); + } + const detail = await this.getTrainScheduleById(scheduleId); const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); return Object.assign(detail, { warehouseAutomation }); diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts index 375c12f19..3941c487c 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -35,6 +35,18 @@ export function useBookingWindowSocket(enabled: boolean = true) { withCredentials: true, }); + // Deliberate console breadcrumbs: "live updates not arriving" is only + // diagnosable from the browser when connect/reject outcomes are visible. + socket.on("connect", () => + console.debug("[booking-windows] socket connected", socket.id), + ); + socket.on("connect_error", (err) => + console.warn("[booking-windows] socket connect failed:", err.message), + ); + socket.on("disconnect", (reason) => + console.debug("[booking-windows] socket disconnected:", reason), + ); + socket.on( BOOKING_WINDOW_WS_EVENTS.PHASE, (_event: BookingWindowPhaseEvent) => { diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx b/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx index 9baf4ebf2..1ed61f72b 100644 --- a/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx +++ b/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx @@ -1,6 +1,6 @@ import { NotificationType } from "@edr/types"; import type { NotificationItemData, NotificationVisual } from "@edr/ui-common"; -import { Bell, ClipboardCheck, Inbox, Wallet } from "lucide-react"; +import { Bell, ClipboardCheck, FileSignature, Inbox, Wallet } from "lucide-react"; const ICON_SIZE = 17; @@ -19,6 +19,8 @@ export function resolveNotificationVisual( return { icon: , color: "teal" }; case NotificationType.CLEARANCE_REVIEW: return { icon: , color: "orange" }; + case NotificationType.CONTRACT_STATUS: + return { icon: , color: "indigo" }; default: return { icon: , color: "edr-green" }; } @@ -39,14 +41,32 @@ export function resolveNotificationHref( if (item.link) return item.link; const data = item.data ?? {}; switch (item.type) { - case NotificationType.REQUEST_SUBMITTED: + case NotificationType.REQUEST_SUBMITTED: { + const bookingId = asId(data.bookingId); + if (bookingId) return `/dashboard/booking-requests/${bookingId}`; + const contractId = asId(data.contractId); + if (contractId) return `/dashboard/contract-requests/${contractId}`; return "/dashboard/booking-requests"; + } case NotificationType.PAYMENT_RECEIVED: { + const bookingId = asId(data.bookingId); + if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`; const id = asId(data.customerId); return id ? `/dashboard/customers/${id}` : "/dashboard/customers"; } - case NotificationType.CLEARANCE_REVIEW: + case NotificationType.CLEARANCE_REVIEW: { + const bookingId = asId(data.bookingId); + if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`; + const contractId = asId(data.contractId); + if (contractId) return `/dashboard/contracts/clearance/${contractId}`; return "/dashboard/arrival-queue"; + } + case NotificationType.CONTRACT_STATUS: { + const contractId = asId(data.contractId); + return contractId + ? `/dashboard/contract-requests/${contractId}` + : "/dashboard/contract-requests"; + } default: return null; } diff --git a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts index 17f6174b3..5b91af82e 100644 --- a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts @@ -40,6 +40,18 @@ export function useBookingWindowSocket(enabled: boolean) { withCredentials: true, }); + // Deliberate console breadcrumbs: "live updates not arriving" is only + // diagnosable from the browser when connect/reject outcomes are visible. + socket.on("connect", () => + console.debug("[booking-windows] socket connected", socket.id), + ); + socket.on("connect_error", (err) => + console.warn("[booking-windows] socket connect failed:", err.message), + ); + socket.on("disconnect", (reason) => + console.debug("[booking-windows] socket disconnected:", reason), + ); + socket.on( BOOKING_WINDOW_WS_EVENTS.PHASE, (_event: BookingWindowPhaseEvent) => { diff --git a/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx b/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx index 42f3294f7..9cde29f77 100644 --- a/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx +++ b/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx @@ -3,6 +3,8 @@ import type { NotificationItemData, NotificationVisual } from "@edr/ui-common"; import { BadgeCheck, Bell, + CalendarClock, + FileSignature, FileWarning, Package, Receipt, @@ -25,6 +27,10 @@ export function resolveNotificationVisual( return { icon: , color: "orange" }; case NotificationType.BOOKING_STATUS: return { icon: , color: "blue" }; + case NotificationType.CONTRACT_STATUS: + return { icon: , color: "indigo" }; + case NotificationType.SCHEDULE_UPDATE: + return { icon: , color: "cyan" }; case NotificationType.INVOICE_ISSUED: return { icon: , color: "violet" }; default: @@ -55,8 +61,18 @@ export function resolveNotificationHref( const id = asId(data.bookingId); return id ? `/bookings/${id}` : null; } + case NotificationType.CONTRACT_STATUS: { + const id = asId(data.contractId); + return id ? `/contracts/${id}` : "/contracts"; + } + case NotificationType.SCHEDULE_UPDATE: { + const id = asId(data.bookingId); + return id ? `/bookings/${id}` : "/bookings/new"; + } case NotificationType.CLEARANCE_DECISION: case NotificationType.DOCUMENT_ACTION: { + const bookingId = asId(data.bookingId); + if (bookingId) return `/bookings/${bookingId}`; const id = asId(data.contractId); return id ? `/contracts/${id}` : "/contracts"; } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index ac9352653..127901186 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -3,7 +3,7 @@ import { Check, MoveRight } from "lucide-react"; import type { Freight } from "@edr/types"; -import { PROGRESS_STAGES, STATUS_MAP } from "../constants"; +import { ARRIVAL_STAGE, PROGRESS_STAGES, STATUS_MAP, resolveStage } from "../constants"; import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils"; import { SectionCard } from "./layout"; @@ -65,7 +65,18 @@ export function StatusHero({ children?: React.ReactNode; }) { const status = booking.status; - const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT; + const stage = resolveStage(booking); + // The Arrival stage has no booking status of its own — it lights up from the + // train's ARRIVED state, so the headline is overridden here. + const cfg = + stage === ARRIVAL_STAGE + ? { + title: "Train arrived at destination", + description: + "Your shipment reached its destination yard and is being unloaded and prepared for release.", + stage, + } + : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); const negative = isNegative(status); const draft = isDraftLike(status); @@ -107,7 +118,7 @@ export function StatusHero({ {children ?? ( @@ -139,7 +150,7 @@ function ProgressTracker({ } as React.CSSProperties } > -
+
{PROGRESS_STAGES.map((stage, idx) => { const state = idx < current ? "done" : idx === current ? "active" : "idle"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 67be567db..2a41a6fa3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -1,6 +1,7 @@ import { ClipboardCheck, FileText, + MapPin, PackageCheck, ShieldCheck, Ship, @@ -52,6 +53,14 @@ export const PROGRESS_STAGES = [ icon: Train, statuses: ["EXPIRED", "IN_TRANSIT"], }, + { + // No booking status maps here: the booking stays IN_TRANSIT until + // delivery, so this stage lights up from the assigned train's own status + // (trainScheduleStatus === "ARRIVED") — see resolveStage. + label: "Arrival", + icon: MapPin, + statuses: [], + }, { label: "Complete", icon: PackageCheck, @@ -59,6 +68,30 @@ export const PROGRESS_STAGES = [ }, ]; +/** Stage index of the Arrival step (train ARRIVED, cargo not yet delivered). */ +export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex( + (s) => s.label === "Arrival", +); + +/** + * Stage for a booking, factoring in the assigned train's operational status: + * a booking is stuck at IN_TRANSIT between dispatch and delivery, so once its + * train has ARRIVED the tracker advances to the Arrival stage. + */ +export function resolveStage(booking: { + status: string; + trainScheduleStatus?: string | null; +}): number { + const base = STATUS_MAP[booking.status]?.stage ?? 0; + if ( + booking.status === "IN_TRANSIT" && + booking.trainScheduleStatus === "ARRIVED" + ) { + return ARRIVAL_STAGE; + } + return base; +} + export const STATUS_MAP: Record< string, { title: string; description: string; stage: number } @@ -209,7 +242,7 @@ export const STATUS_MAP: Record< title: "Contract closed", description: "This general contract is closed — its reserved quantity has been used or its window has elapsed.", - stage: 7, + stage: 8, }, PRICE_CHANGED_PENDING_CONFIRM: { title: "Price changed — confirm to proceed", @@ -235,12 +268,12 @@ export const STATUS_MAP: Record< COMPLETED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", - stage: 7, + stage: 8, }, DELIVERED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", - stage: 7, + stage: 8, }, REJECTED: { title: "Booking rejected", diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 0b5752265..c51d838e1 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -438,6 +438,13 @@ export interface IBooking extends BaseEntity { contractReference?: string | null; trainId?: string | null; status: BookingStatus; + /** + * Operational status of the assigned train (DRAFT/SCHEDULED/DISPATCHED/ + * ARRIVED), joined on booking detail. The booking status stays IN_TRANSIT + * from dispatch until delivery, so the portal stepper reads arrival from + * here. + */ + trainScheduleStatus?: TrainScheduleStatus | string | null; /** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */ bookingType?: BookingType; /** General contracts only: when ordering closes (null until active / for one-time). */ diff --git a/packages/types/src/freight/notifications.ts b/packages/types/src/freight/notifications.ts index bdf83a719..75513dcb9 100644 --- a/packages/types/src/freight/notifications.ts +++ b/packages/types/src/freight/notifications.ts @@ -33,6 +33,8 @@ export enum NotificationType { CLEARANCE_DECISION = "CLEARANCE_DECISION", DOCUMENT_ACTION = "DOCUMENT_ACTION", BOOKING_STATUS = "BOOKING_STATUS", + CONTRACT_STATUS = "CONTRACT_STATUS", + SCHEDULE_UPDATE = "SCHEDULE_UPDATE", INVOICE_ISSUED = "INVOICE_ISSUED", // Backoffice-facing (staff) REQUEST_SUBMITTED = "REQUEST_SUBMITTED", @@ -87,6 +89,8 @@ export interface NotificationRecipients { companyProfileId?: string; /** Backoffice: all current employees of this organization. */ organizationId?: string; + /** Backoffice: every current employee across all organizations. */ + allBackoffice?: boolean; } /** Input any subsystem passes to `NotificationInboxService.notify(...)`. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fadeaca56..653fe803c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 supertest: specifier: ^7.0.0 version: 7.2.2