From 15c791c63ac915ec00435663c11251e6cfe50040 Mon Sep 17 00:00:00 2001 From: marshal Date: Tue, 8 Sep 2026 04:16:40 +0000 Subject: [PATCH] feat: implement notification system for transit agents on assignment changes --- .../booking-lifecycle-notifier.service.ts | 107 ++++++------ .../contracts/booking-clearance.service.ts | 2 +- .../contracts/contract-clearance.service.ts | 7 +- .../contracts/contract-notifier.service.ts | 37 +++- .../contracts/transit-assignee.spec.ts | 6 +- .../notify-transit-agent.util.ts | 165 ++++++++++++++++++ 6 files changed, 262 insertions(+), 62 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/notifications/notify-transit-agent.util.ts diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 7c210ceb9..efe1260b4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -10,6 +10,7 @@ import { import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { notifyTransitAgent } from '../notifications/notify-transit-agent.util'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; @@ -407,8 +408,17 @@ export class BookingLifecycleNotifierService { }); } - /** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */ - transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void { + /** + * Djibouti named (or changed) the transit officer — Ethiopia can proceed, + * and the officer is told the job is theirs (bell, SMS and email), so the + * shipment does not just appear silently in their work list. + */ + transitAssigneeAssigned( + b: Booking, + agent: { id: string; name: string }, + previous: string | null, + ): void { + const assignee = agent.name; const msg = previous ? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` + `"${previous}" to "${assignee}".` @@ -420,6 +430,24 @@ export class BookingLifecycleNotifierService { type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }); + void notifyTransitAgent( + { + dataSource: this.dataSource, + notifications: this.notifications, + inbox: this.inbox, + logger: this.logger, + }, + agent.id, + { + title: 'New shipment assigned to you', + body: + `GL Djibouti assigned shipment ${b.reference} to ${assignee} for transit. ` + + `Open it in the portal to see what is needed.`, + officerLink: `/transit-agent/bookings/${b.id}`, + forwarderLink: '/forwarder/assigned-bookings', + data: { bookingId: b.id, reference: b.reference, transitAgentId: agent.id }, + context: this.ref(b), + }); } // ── Clearance milestones needing customer action ────────────────────────── @@ -659,64 +687,31 @@ export class BookingLifecycleNotifierService { /** * The customer picked a registered transit agent (a freight forwarder on the - * platform) to clear this booking. Tell the forwarder company — every one of - * its portal users in the bell, plus SMS and email to its contact — so the - * job shows up in its Assigned Bookings tab and it can start preparing - * documents. The company is found through its transit-agent link; an agent - * nobody has registered against gets no message, since there is nobody to - * send it to. Never throws: a failed notice must not undo a completed booking. + * platform) to clear this booking. Tell everyone acting as that agent — the + * forwarder company's portal users and the agent's own account, in the bell, + * plus SMS and email to every contact on record — so the job shows up in + * Assigned Bookings and they can start on the documents. Never throws: a + * failed notice must not undo the booking it reports. */ async transitAgentAssigned( b: Booking, agent: { id: string; name: string }, ): Promise { - try { - const [forwarder]: Array<{ id: string }> = await this.dataSource.query( - `SELECT id FROM freight.companies - WHERE transit_agent_id = $1 AND deleted_at IS NULL - LIMIT 1`, - [agent.id], - ); - if (!forwarder) { - this.logger.warn( - `Transit agent ${agent.name} has no forwarder company — assignment notice for ${this.ref(b)} not sent`, - ); - return; - } - const title = 'New booking assigned to you'; - const body = `Booking ${b.reference} has been assigned to ${agent.name} for customs clearance. Open Assigned Bookings in the portal to see it.`; - void this.inbox.notify({ - recipients: { companyId: forwarder.id }, - audience: NotificationAudience.PORTAL, - type: NotificationType.BOOKING_STATUS, - title, - body, - link: '/forwarder/assigned-bookings', - data: { bookingId: b.id, reference: b.reference, transitAgentId: agent.id }, - }); - const { phone, email } = await resolveCompanyNotifyContact( - this.dataSource, - forwarder.id, - ); - const message = `EDR Freight: ${body}`; - if (phone) { - try { - await this.notifications.directSend('sms', phone, message); - } catch (err) { - this.logger.warn(`Forwarder 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(`Forwarder email failed for ${this.ref(b)}: ${(err as Error).message}`); - } - } - } catch (err) { - this.logger.warn( - `transitAgentAssigned failed for ${this.ref(b)}: ${(err as Error).message}`, - ); - } + await notifyTransitAgent( + { + dataSource: this.dataSource, + notifications: this.notifications, + inbox: this.inbox, + logger: this.logger, + }, + agent.id, + { + title: 'New booking assigned to you', + body: `Booking ${b.reference} has been assigned to ${agent.name} for customs clearance. Open Assigned Bookings in the portal to see it.`, + officerLink: `/transit-agent/bookings/${b.id}`, + forwarderLink: '/forwarder/assigned-bookings', + data: { bookingId: b.id, reference: b.reference, transitAgentId: agent.id }, + context: this.ref(b), + }); } } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 5d0943fe6..2fd232c7e 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -619,7 +619,7 @@ export class BookingClearanceService { metadata: { transitAgentId, agentName: agent.name, previous }, }); - this.notifier.transitAssigneeAssigned(booking, agent.name, previous); + this.notifier.transitAssigneeAssigned(booking, agent, previous); return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index c15d20800..e23b09a14 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -1246,7 +1246,12 @@ export class ContractClearanceService { } const updated = await this.contractsService.findById(contractId); - this.notifier.transitAssigneeAssigned(updated, agent.name, previous); + this.notifier.transitAssigneeAssigned( + updated, + agent, + previous, + cycle.bookingId ?? null, + ); return updated; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 8395d28c5..7de1717c7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -8,6 +8,7 @@ import { } from '@edr/types'; import { Contract } from './entities/contract.entity'; +import { notifyTransitAgent } from '../notifications/notify-transit-agent.util'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; @@ -283,12 +284,19 @@ export class ContractNotifierService { }); } - /** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */ + /** + * Djibouti named (or changed) the transit officer — Ethiopia can proceed, + * and the officer is told (bell, SMS and email). `bookingId` is the shipment + * the officer works on when one exists; contract-level clearance can be + * assigned before any booking does. + */ transitAssigneeAssigned( c: Contract, - assignee: string, + agent: { id: string; name: string }, previous: string | null, + bookingId?: string | null, ): void { + const assignee = agent.name; const msg = previous ? `GL Djibouti changed the transit assignee for contract ${c.reference} from ` + `"${previous}" to "${assignee}".` @@ -300,6 +308,31 @@ export class ContractNotifierService { type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }); + void notifyTransitAgent( + { + dataSource: this.dataSource, + notifications: this.notifications, + inbox: this.inbox, + logger: this.logger, + }, + agent.id, + { + title: 'New shipment assigned to you', + body: + `GL Djibouti assigned contract ${c.reference} to ${assignee} for transit. ` + + `Open it in the portal to see what is needed.`, + officerLink: bookingId + ? `/transit-agent/bookings/${bookingId}` + : '/transit-agent/bookings', + forwarderLink: '/forwarder/assigned-bookings', + data: { + contractId: c.id, + reference: c.reference, + transitAgentId: agent.id, + ...(bookingId ? { bookingId } : {}), + }, + context: this.ref(c), + }); } /** diff --git a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts index 0b7de5be3..92207f981 100644 --- a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -94,8 +94,9 @@ describe('ContractClearanceService — transit assignee', () => { expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1'); expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith( expect.objectContaining({ id: 'ctr-1' }), - 'Ahmed Bourhan', + expect.objectContaining({ id: 'agent-1', name: 'Ahmed Bourhan' }), null, + null, // no booking yet on a contract-level assignment ); }); @@ -115,8 +116,9 @@ describe('ContractClearanceService — transit assignee', () => { expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith( expect.anything(), - 'Fatouma Ali', + expect.objectContaining({ name: 'Fatouma Ali' }), 'Ahmed Bourhan', + null, ); }); diff --git a/apps/edr-freight-api/src/modules/notifications/notify-transit-agent.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-transit-agent.util.ts new file mode 100644 index 000000000..3dd34c9be --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/notify-transit-agent.util.ts @@ -0,0 +1,165 @@ +import { Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { NotificationAudience, NotificationType } from "@edr/types"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +import { isDomesticPhone } from "../otp/otp.service"; +import { NotificationsService } from "./notifications.service"; +import { resolveCompanyNotifyContact } from "./resolve-company-phone.util"; + +/** + * Everyone who acts as one transit agent, and how to reach them. + * + * A roster entry is reached two ways, and an agent may have both: a Djibouti + * officer signs in AS the agent (`transit_agents.user_id`, with the row's own + * email and phone), and a freight forwarder is a customer company registered + * against the entry (`companies.transit_agent_id`), reached through the + * company's contact. Addresses are de-duplicated so nobody is texted twice. + */ +export interface TransitAgentNotifyTargets { + name: string; + /** The officer's own portal account, if the agent has one. */ + userId: string | null; + /** The forwarder company registered as this agent, if any. */ + companyId: string | null; + emails: string[]; + phones: string[]; +} + +export async function resolveTransitAgentNotifyTargets( + db: DataSource, + transitAgentId: string, +): Promise { + const [agent]: Array<{ + name: string; + user_id: string | null; + email: string | null; + phone_number: string | null; + company_id: string | null; + }> = await db.query( + `SELECT a.name, a.user_id, a.email, a.phone_number, + (SELECT c.id FROM freight.companies c + WHERE c.transit_agent_id = a.id AND c.deleted_at IS NULL + LIMIT 1) AS company_id + FROM freight.transit_agents a + WHERE a.id = $1 AND a.deleted_at IS NULL`, + [transitAgentId], + ); + if (!agent) return null; + + const emails = new Set(); + const phones = new Set(); + if (agent.email) emails.add(agent.email.trim().toLowerCase()); + if (agent.phone_number) phones.add(agent.phone_number.trim()); + if (agent.company_id) { + const contact = await resolveCompanyNotifyContact(db, agent.company_id); + if (contact.email) emails.add(contact.email.trim().toLowerCase()); + if (contact.phone) phones.add(contact.phone.trim()); + } + return { + name: agent.name, + userId: agent.user_id, + companyId: agent.company_id, + emails: [...emails], + phones: [...phones], + }; +} + +/** + * Tell a transit agent about work on a shipment: bell notification for every + * account acting as the agent, plus SMS and email to each reachable contact. + * + * Best-effort throughout — a failed notice must never undo the assignment it + * reports, so every failure is logged and swallowed. SMS skips non-domestic + * numbers, which the gateway drops silently. + */ +export async function notifyTransitAgent( + deps: { + dataSource: DataSource; + notifications: NotificationsService; + inbox: NotificationInboxService; + logger: Logger; + }, + transitAgentId: string, + notice: { + title: string; + body: string; + /** Portal link for the officer's own account. */ + officerLink: string; + /** Portal link for the forwarder company's users. */ + forwarderLink: string; + data?: Record; + /** For the log line. */ + context: string; + }, +): Promise { + const { dataSource, notifications, inbox, logger } = deps; + try { + const targets = await resolveTransitAgentNotifyTargets( + dataSource, + transitAgentId, + ); + if (!targets) { + logger.warn( + `Transit agent ${transitAgentId} not found — notice for ${notice.context} not sent`, + ); + return; + } + if (targets.userId) { + void inbox.notify({ + recipients: { userIds: [targets.userId] }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: notice.title, + body: notice.body, + link: notice.officerLink, + data: notice.data, + }); + } + if (targets.companyId) { + void inbox.notify({ + recipients: { companyId: targets.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: notice.title, + body: notice.body, + link: notice.forwarderLink, + data: notice.data, + }); + } + if (targets.emails.length === 0 && targets.phones.length === 0) { + logger.warn( + `Transit agent ${targets.name} has no email or phone — ${notice.context} notice reached the bell only`, + ); + } + const message = `EDR Freight: ${notice.body}`; + for (const phone of targets.phones) { + if (!isDomesticPhone(phone)) { + logger.warn( + `Transit agent ${targets.name}: SMS skipped for non-domestic number on ${notice.context}`, + ); + continue; + } + try { + await notifications.directSend("sms", phone, message); + } catch (err) { + logger.warn( + `Transit agent SMS failed for ${notice.context}: ${(err as Error).message}`, + ); + } + } + for (const email of targets.emails) { + try { + await notifications.directSend("email", email, message); + } catch (err) { + logger.warn( + `Transit agent email failed for ${notice.context}: ${(err as Error).message}`, + ); + } + } + } catch (err) { + logger.warn( + `notifyTransitAgent failed for ${notice.context}: ${(err as Error).message}`, + ); + } +}