Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts
Marshal 603537a20b add yard distances management to rule engine
- Introduced new yard distances resource with CRUD operations.
- Created migration for yard distances table with necessary constraints.
- Implemented service and repository for yard distances handling.
- Added controller for API endpoints to manage yard distances.
- Updated rule engine configuration to include yard distances.
- Enhanced rule engine resource page to support yard distance selection.
- Updated contracts and train builder pages to handle new yard distance logic.
- Added error handling utility for better error message extraction.
2026-07-21 08:50:50 +00:00

299 lines
11 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 { 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<void> {
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<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);
}
/**
* 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 service fee invoiced — customer must pay before document upload. */
clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
const msg =
`A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
`Please pay from the portal to unlock the clearance document upload.`;
void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
this.inApp(c, 'Clearance fee due', msg);
}
/** Clearance service fee settled — document upload is now open. */
clearanceFeePaid(c: Contract, shipmentRef?: string): void {
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
const msg =
`Your customs clearance service fee for ${scope} has been received. ` +
`You can now upload the clearance documents from the portal.`;
void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
this.inApp(c, 'Clearance fee paid', 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 },
},
);
}
}