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 { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * 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)}`); const phone = b.companyId ? await resolveCompanyNotifyPhone(this.dataSource, b.companyId) : 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`, }, ); } }