mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
219 lines
8.7 KiB
TypeScript
219 lines
8.7 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import {
|
|
NotificationAudience,
|
|
NotificationPriority,
|
|
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<void> {
|
|
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<NotifyInput> = {},
|
|
): 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<void> {
|
|
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<void> {
|
|
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 remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` +
|
|
`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)');
|
|
// HIGH: a split is a change to what the customer ordered AND a live payment
|
|
// deadline — it must reach email/SMS, not just the portal inbox.
|
|
this.inApp(b, 'Partial allocation offer', msg, {
|
|
type: NotificationType.INVOICE_ISSUED,
|
|
priority: NotificationPriority.HIGH,
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Every train on the booking's chosen day filled up (or no further train runs)
|
|
* before the waiting list reached this booking — it expired unplaced. HIGH so
|
|
* the customer hears about it by email/SMS and rebooks another day.
|
|
*/
|
|
expiredNoCapacity(b: Booking): void {
|
|
const msg =
|
|
`Booking ${b.reference ?? b.id} could not be placed: every train for your selected day ` +
|
|
`is full and no other train is scheduled that day. The booking has expired — ` +
|
|
`please rebook for another day. No re-approval is needed.`;
|
|
void this.notifyContact(b, msg, 'EXPIRED (NO CAPACITY)');
|
|
this.inApp(b, 'No capacity — booking expired', msg, {
|
|
priority: NotificationPriority.HIGH,
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|