import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; 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'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * Clearance items are worked by the GL desks, which hold no bookings:view and * no intake keys — so they take their own selector rather than the booking * desk's. Every override using this deep-links to a clearance page. */ const CLEARANCE_DESK = { permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], }; /** * 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, @InjectDataSource() private readonly dataSource: DataSource, ) {} 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)}`); // Both channels come from the same resolver: the company row's own columns // are only half the story (see companyNotifyEmailExpr), and reading them off // the loaded entity silently dropped every mail to a company whose address // lives in `attributes`. A shipping-line booking has NO company — its // contact lives on the shipping_line_companies row itself. const { phone, email } = b.shippingLineCompanyId ? await resolveShippingLineNotifyTarget( this.dataSource, b.shippingLineCompanyId, ) : b.companyId ? await resolveCompanyNotifyContact(this.dataSource, b.companyId) : { phone: null, email: 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 the booking's portal owner: every portal * user of the company, or — for a shipping-line booking — the line's own * account, deep-linked into the shipping-line app rather than the customer * one (its routes live under /shipping-line/*). */ private inApp( b: Booking, title: string, body: string, overrides: Partial = {}, ): void { if (b.shippingLineCompanyId) { void (async () => { const { userId } = await resolveShippingLineNotifyTarget( this.dataSource, b.shippingLineCompanyId!, ); if (!userId) return; void this.inbox.notify({ recipients: { userIds: [userId] }, audience: NotificationAudience.PORTAL, type: NotificationType.BOOKING_STATUS, title, body, data: { bookingId: b.id, reference: b.reference }, ...overrides, // After the spread: overrides carry customer links — the bell must // land a shipping line on ITS booking page. link: `/shipping-line/bookings/${b.id}`, }); })().catch((err) => this.logger.warn( `shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`, ), ); return; } 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 the booking desk — staff holding * `bookings:get_notification`. Callers whose item belongs to a different desk * override `recipients` (see {@link CLEARANCE_DESK}). */ private inAppStaff( b: Booking, title: string, body: string, overrides: Partial = {}, ): void { void this.inbox.notify({ recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] }, 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, }); } /** Document approval finalized → customer can proceed to request operation. */ clearanceReady(b: Booking): void { // A shipping line's next move is BOOKING (cargo + shipment day), not the // customer's operation-request step — say so, or the message points at a // flow their portal does not have. const msg = b.shippingLineCompanyId ? `Documents for booking ${b.reference} are approved. ` + `You can now book your shipment — enter the cargo and shipment day from the portal.` : `Document approval for booking ${b.reference} is finalized. ` + `You can now proceed to request operation from the portal.`; void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED'); this.inApp( b, b.shippingLineCompanyId ? 'Documents approved — book your shipment' : 'Document approval finalized', msg, { type: NotificationType.CLEARANCE_DECISION }, ); } /** Intercity documents approved → booking waits in the ride-along pool. */ intercityDocumentsApproved(b: Booking): void { const msg = `Documents for intercity booking ${b.reference} are approved. ` + `Operations will assign your shipment to a passing train; payment opens once it is accepted.`; void this.notifyContact(b, msg, 'DOCUMENTS APPROVED'); this.inApp(b, 'Documents approved', msg, { type: NotificationType.CLEARANCE_DECISION, }); } /** * Operations returned the operation request for changes. * * A customs (Path B) booking is completed by GL Ethiopia on the customer's * behalf regardless of who opened the shipment instance — the customer-opened * ONE_TIME case (see contract-booking.service assertGate) still stamps * createdByRole 'CUSTOMER', so gate on customsClearingEnabled, not on who * created it. The customer cannot edit or resubmit a customs booking, so * telling them to "update from the portal" is a dead end. Those go to the GL * who created it when known, else the clearance desk, linking the contract * clearance page they work from. Everything else (customer-made bookings) * keeps the portal message. */ operationChangesRequested(b: Booking, note: string): void { if (b.customsClearingEnabled) { const msg = `Operations returned booking ${b.reference} for changes: ${note}. ` + `Address it on the contract clearance page and resubmit to Operations.`; this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`); void this.inbox.notify({ recipients: b.createdByRole === 'GL_ET' && b.createdByUserId ? { userIds: [b.createdByUserId] } : CLEARANCE_DESK, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title: `Booking ${b.reference} needs changes`, body: msg, link: b.contractId ? `/dashboard/contracts/clearance/${b.contractId}` : `/dashboard/bookings/${b.id}/clearance`, data: { bookingId: b.id, reference: b.reference, note }, }); return; } 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 { // No invoice and no pay window for a shipping line — the charge sits on // its credit account and the booking boards its dedicated train directly. const msg = b.shippingLineCompanyId ? `Your booking ${b.reference} has been accepted. The charge has been ` + `recorded on your credit account and your shipment is being placed on its train.` : `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, b.shippingLineCompanyId ? 'Booking accepted' : 'Operation request accepted', msg, ); } /** * GL Ethiopia created this booking on the customer's behalf. On a customs * (Path B) contract the customer never books themselves, so without this they * would have no signal that their shipment now exists and is priced. */ createdByGlForCustomer(b: Booking): void { const total = Number(b.totalAmount ?? 0); const priced = total > 0 ? ` The total is ${total.toLocaleString()} ${b.paymentCurrency}.` : ''; const msg = `Global Logistics has created shipment ${b.reference} under your contract.${priced} ` + `You can review it in the portal.`; void this.notifyContact(b, msg, 'CREATED BY GL'); this.inApp(b, 'Shipment created for you', 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); } /** * GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and * deep-linked to the Djibouti clearance page where the name is entered — the * customs declaration is blocked until they answer. */ transitAssigneeRequested(b: Booking, note: string | null): void { const msg = `GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` + `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`); this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, { recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/gl-djibouti/clearance/${b.id}`, }); } /** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */ transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void { const msg = previous ? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` + `"${previous}" to "${assignee}".` : `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` + `The customs declaration can now be filed.`; this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }); } // ── 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 approves, pays, uploads slip. */ finalInvoiceCreated(b: Booking, amount: number, currency: string): void { const msg = `A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` + `Please review and approve it in the portal, then pay and upload the payment slip.`; 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) ──────────────────────────────────────── /** * A booking was created under a contract. Contract drawdowns never pass * through submit, so this is the only point at which staff learn the booking * exists — {@link submittedToStaff} covers the direct-booking flow instead. */ createdToStaff(b: Booking): void { this.inAppStaff( b, 'New booking created', `Booking ${this.ref(b)} was created under a contract and has entered the pipeline.`, ); } /** 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 approved the GL Djibouti final invoice — payment slip can follow. */ finalInvoiceApprovedToStaff(b: Booking): void { this.inAppStaff( b, 'Final invoice approved', `The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`, ); } /** 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.`, ); } /** * A shared-wagon pairing is waiting for a human decision. Two customers' cargo * on one wagon is a commercial call, so this never auto-advances. */ consolidationApprovalRequestedToStaff(b: Booking, partnerReference: string): void { this.inAppStaff( b, 'Shared wagon needs approval', `Booking ${this.ref(b)} shares a wagon with ${partnerReference} — approve the consolidation before it reaches Operations.`, ); } /** The pairing was approved; both halves move on to Operations together. */ consolidationApprovedToStaff(b: Booking, partnerReference: string): void { this.inAppStaff( b, 'Shared wagon approved', `The shared wagon for ${this.ref(b)} and ${partnerReference} was approved — both bookings are now with Operations.`, ); } /** The pairing was rejected; both halves go back to GL for changes. */ consolidationRejectedToStaff(b: Booking, partnerReference: string, reason: string): void { this.inAppStaff( b, 'Shared wagon rejected', `The shared wagon for ${this.ref(b)} and ${partnerReference} was rejected: ${reason}`, ); } /** 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.`, { recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }, ); } /** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */ draftDeclarationReady(b: Booking, price: number, currency: string): void { const msg = `A draft customs declaration for booking ${b.reference} is ready for your review — ` + `estimated price ${price} ${currency}. Please accept it or request a change from the portal.`; void this.notifyContact(b, msg, 'DRAFT DECLARATION READY'); this.inApp(b, 'Draft declaration ready for review', msg, { type: NotificationType.DOCUMENT_ACTION, }); } /** * The customer asked for a change on the draft declaration. This goes to * STAFF, not the customer: GL Ethiopia is the one who has to send a * corrected draft, and the clearance page is where they do it. */ draftDeclarationChangeRequested(b: Booking, note: string): void { const msg = `The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` + `"${note}". Send a corrected draft from the clearance page.`; this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, { recipients: CLEARANCE_DESK, 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)}.`, { recipients: CLEARANCE_DESK, type: NotificationType.PAYMENT_RECEIVED, link: `/dashboard/bookings/${b.id}/clearance`, }, ); } }