feat: implement notification system for transit agents on assignment changes

This commit is contained in:
marshal
2026-09-08 04:16:40 +00:00
parent 8314b3bd45
commit 15c791c63a
6 changed files with 262 additions and 62 deletions

View File

@@ -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<void> {
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),
});
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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),
});
}
/**

View File

@@ -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,
);
});

View File

@@ -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<TransitAgentNotifyTargets | null> {
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<string>();
const phones = new Set<string>();
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<string, unknown>;
/** For the log line. */
context: string;
},
): Promise<void> {
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}`,
);
}
}