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, private readonly inbox: NotificationInboxService, ) {} private ref(b: Booking): string { return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; } 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.SCHEDULE_UPDATE, title, body, link: `/bookings/${b.id}`, data: { bookingId: b.id, reference: b.reference }, ...overrides, }); } /** Train carrying the booking departed — dispatched origin → destination. */ dispatched(b: Booking, origin: string | null, destination: string | null): void { const msg = `Your booking ${b.reference ?? b.id} has been dispatched` + `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`; void this.notifyContact(b, msg, 'DISPATCHED'); this.inApp(b, 'Shipment dispatched', msg); } /** Train carrying the booking arrived at destination. */ arrived(b: Booking, origin: string | null, destination: string | null): void { const msg = `Your booking ${b.reference ?? b.id} has arrived` + `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`; void this.notifyContact(b, msg, 'ARRIVED'); this.inApp(b, 'Shipment arrived', msg); } 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, }); } /** * Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit * this train. Paying accepts the split; letting the deadline pass keeps the * booking whole and expires it for this train. */ async payNowPartial( b: Booking, deadline: Date, offeredWagons: number, totalWagons: number, ): 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 = `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + `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 { const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${ 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 { this.logger.warn( `SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`, ); } /** * Staff-facing warning when a pooled booking fits no train on its chosen day. * It stays pending and is retried next batch; staff can add capacity or pin it * to a train manually. Mirrors {@link scheduleFull} — no customer notification. */ unplaced(b: Booking, day: string): void { this.logger.warn( `UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`, ); } 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); } }