mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
enhance booking and contract notification systems
- Added detailed logging for socket connection events in useBookingWindowSocket. - Introduced new notification types for contract status and schedule updates. - Updated notification visuals to include new icons for contract status. - Enhanced notification href resolution for contract status and schedule updates. - Implemented booking lifecycle notifier service for customer and staff notifications. - Created contract notifier service for managing contract lifecycle notifications. - Added end-to-end tests for booking window socket functionality.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
NotifyInput,
|
||||
} from '@edr/types';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the contract lifecycle. Every customer
|
||||
* event fans out over three channels: SMS + email (direct, via
|
||||
* {@link NotificationsService}) and a persisted in-app notification (via
|
||||
* {@link NotificationInboxService}) that deep-links to the contract detail page.
|
||||
* Staff events go to the backoffice inbox. All sends are fire-and-forget and
|
||||
* never throw — a notification failure must not break a contract transition.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractNotifierService {
|
||||
private readonly logger = new Logger(ContractNotifierService.name);
|
||||
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
private ref(c: Contract): string {
|
||||
return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
|
||||
/** Send SMS + email to the contract's company contact; log-only on failure. */
|
||||
private async notifyContact(
|
||||
c: Contract,
|
||||
message: string,
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(c)}`);
|
||||
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
|
||||
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications.directSend('email', email, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (!phone && !email) {
|
||||
this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to all portal users of the contract's company. */
|
||||
private inApp(
|
||||
c: Contract,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
if (!c.companyId) return; // government/unlinked contracts have no portal users
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: c.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.CONTRACT_STATUS,
|
||||
title,
|
||||
body,
|
||||
link: `/contracts/${c.id}`,
|
||||
data: { contractId: c.id, reference: c.reference },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to every backoffice staff user. */
|
||||
private inAppStaff(
|
||||
c: Contract,
|
||||
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/contract-requests/${c.id}`,
|
||||
data: { contractId: c.id, reference: c.reference },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Customer-facing lifecycle events ───────────────────────────────────────
|
||||
|
||||
/** Line staff accepted intake → contract is under approval. */
|
||||
accepted(c: Contract): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been accepted and is now under approval. ` +
|
||||
`We will notify you once it is approved.`;
|
||||
void this.notifyContact(c, msg, 'ACCEPTED');
|
||||
this.inApp(c, 'Contract accepted', msg);
|
||||
}
|
||||
|
||||
/** All approval steps complete → contract approved. */
|
||||
approved(c: Contract): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been approved. ` +
|
||||
`The final document will be prepared for signing.`;
|
||||
void this.notifyContact(c, msg, 'APPROVED');
|
||||
this.inApp(c, 'Contract approved', msg);
|
||||
}
|
||||
|
||||
/** Fully executed (all parties signed) → contract active, customer can book. */
|
||||
signedActive(c: Contract): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been signed and is now active. ` +
|
||||
`You can start booking shipments from the portal.`;
|
||||
void this.notifyContact(c, msg, 'SIGNED / ACTIVE');
|
||||
this.inApp(c, 'Contract active', msg);
|
||||
}
|
||||
|
||||
/** Staff rejected the contract. */
|
||||
rejected(c: Contract, reason: string): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} was rejected. Reason: ${reason}. ` +
|
||||
`Please contact us for details.`;
|
||||
void this.notifyContact(c, msg, 'REJECTED');
|
||||
this.inApp(c, 'Contract rejected', msg);
|
||||
}
|
||||
|
||||
/** Staff requested changes before approval. */
|
||||
changesRequested(c: Contract, note: string): void {
|
||||
const msg =
|
||||
`Changes were requested on your contract ${c.reference}: ${note}. ` +
|
||||
`Please update and resubmit from the portal.`;
|
||||
void this.notifyContact(c, msg, 'CHANGES REQUESTED');
|
||||
this.inApp(c, 'Contract changes requested', msg);
|
||||
}
|
||||
|
||||
// ── Clearance milestones needing customer action ──────────────────────────
|
||||
|
||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||
dutyAdvised(c: Contract, amount: number, currency: string): void {
|
||||
const msg =
|
||||
`Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` +
|
||||
`Please pay and upload the payment slip from the portal.`;
|
||||
void this.notifyContact(c, msg, 'DUTY ADVISED');
|
||||
this.inApp(c, 'Duty & tax advised', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
link: `/contracts/${c.id}/clearance`,
|
||||
});
|
||||
}
|
||||
|
||||
/** A clearance document was queried — customer must re-upload it. */
|
||||
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
|
||||
const msg =
|
||||
`A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` +
|
||||
`${note}. Please re-upload from the portal.`;
|
||||
void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED');
|
||||
this.inApp(c, 'Clearance document queried', msg, {
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
link: `/contracts/${c.id}/clearance`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Import pre-clearance finalized — the process moves to GL Djibouti collection. */
|
||||
preClearanceFinalized(c: Contract): void {
|
||||
const msg =
|
||||
`Pre-clearance for contract ${c.reference} is complete. ` +
|
||||
`Your shipment is proceeding to document collection in Djibouti.`;
|
||||
void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED');
|
||||
this.inApp(c, 'Pre-clearance complete', msg, {
|
||||
type: NotificationType.CLEARANCE_DECISION,
|
||||
link: `/contracts/${c.id}/clearance`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
||||
|
||||
/** Customer submitted a contract for review. */
|
||||
submittedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'New contract submitted',
|
||||
`Contract ${this.ref(c)} was submitted and is awaiting intake review.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer signed the contract — staff counter-sign is next. */
|
||||
customerSignedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'Customer signed contract',
|
||||
`Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`,
|
||||
{ link: `/dashboard/contract-requests/${c.id}/view` },
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer uploaded clearance documents — GL review is next. */
|
||||
clearanceDocsUploadedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'Clearance documents uploaded',
|
||||
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
|
||||
{
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer uploaded the duty/tax payment slip — GL verifies it. */
|
||||
dutySlipUploadedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'Duty slip uploaded',
|
||||
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
|
||||
{
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer filed a shipment request under a GENERAL customs contract. */
|
||||
shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'New shipment request',
|
||||
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
|
||||
{
|
||||
link: `/dashboard/shipment-requests/${requestId}`,
|
||||
data: { contractId: c.id, requestId, reference: requestRef },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user