mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 10:08:21 +00:00
feat: implement notification system for transit agents on assignment changes
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user