import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; 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'; import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; /** * 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, @InjectDataSource() private readonly dataSource: DataSource, ) {} 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 { this.logger.log(`${logLabel} — ${this.ref(c)}`); const phone = c.companyId ? await resolveCompanyNotifyPhone(this.dataSource, c.companyId) : 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 = {}, ): 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 = {}, ): 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); } /** * A later approver sent the contract back to an earlier stage of the chain. * Staff-only: the customer is not involved in an internal send-back — their * contract simply stays "under approval". */ sentBackToStep(c: Contract, targetRole: string, reason: string): void { this.inAppStaff( c, 'Contract returned in approval chain', `Contract ${c.reference} was sent back to the ${targetRole} step. Reason: ${reason}`, ); } /** 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); } /** GL rejected a shipment request filed under the contract. */ shipmentRequestRejected(c: Contract, requestRef: string, note?: string): void { const msg = `Your shipment request ${requestRef} under contract ${c.reference} was rejected.` + (note ? ` Reason: ${note}.` : '') + ` Please contact us for details.`; void this.notifyContact(c, msg, 'SHIPMENT REQUEST REJECTED'); this.inApp(c, 'Shipment request rejected', msg, { type: NotificationType.BOOKING_STATUS, data: { contractId: c.id, reference: requestRef }, }); } // ── 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`, }); } /** * GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and * deep-linked to the Djibouti clearance page where the name is entered — the * customs declaration is blocked until they answer. */ transitAssigneeRequested(c: Contract, note: string | null): void { const msg = `GL Ethiopia needs a transit assignee for contract ${c.reference} before ` + `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`); this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, { type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/gl-djibouti/clearance/${c.id}`, }); } /** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */ transitAssigneeAssigned( c: Contract, assignee: string, previous: string | null, ): void { const msg = previous ? `GL Djibouti changed the transit assignee for contract ${c.reference} from ` + `"${previous}" to "${assignee}".` : `GL Djibouti assigned ${assignee} to handle contract ${c.reference} in transit. ` + `The customs declaration can now be filed.`; this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`); this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, { type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }); } /** * The customer disputed the advised duty & tax. This goes to STAFF, not the * customer: GL Ethiopia is the one who has to re-advise, and the clearance * page is where they do it. */ dutyDisputed(c: Contract, note: string): void { const msg = `The customer disputed the duty & tax advised on contract ${c.reference}: ` + `"${note}". Review and re-advise the amount on the clearance page.`; this.logger.log(`DUTY DISPUTED — ${c.reference}`); this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, { type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }); } /** 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 }, }, ); } }