mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
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<void> {
|
|
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<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.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<NotifyInput> = {},
|
|
): 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,
|
|
});
|
|
}
|
|
|
|
/** Document approval finalized → customer can proceed to request operation. */
|
|
clearanceReady(b: Booking): void {
|
|
const msg =
|
|
`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, 'Document approval finalized', 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`,
|
|
},
|
|
);
|
|
}
|
|
}
|