Files
edr-platform/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts
Hagernesh fd6f0d03b6 feat(loading): notify loaded and left-behind containers, surface the CAS
A booking is routinely loaded in parts, and nothing told the customer which
containers boarded and which stayed behind. The carriage acceptance sheet was
the only record, and it both totalled up cargo still sitting in the yard and
lived inside a Warehouse documents bundle that direct truck-to-train cargo
has no business in.

The sheet now marks each wagon Loaded or Not loaded and totals only the loaded
ones. On load, the customer gets an in-app, SMS and email notice carrying the
train number, route, departure time and both container lists — capped to a
summary on SMS and email, complete in the inbox. Anything left behind also
raises a warehouse-desk notice so somebody owns finding it space.

That desk is addressed by a new warehouse_inventory:get_notification
permission: a recipient selector, not a route guard, so ops can assign who
gets pinged without granting access to anything.

The GRN notice went out over SMS alone, to whatever phone number the gate
clerk typed. Where the receive carries a booking it now resolves the company
and delivers in-app, SMS and email, skipping the typed phone so the customer
is not texted twice; manual and backlog receives keep the old path.
2026-08-29 08:47:39 +00:00

267 lines
9.9 KiB
TypeScript

import { DataSource } from 'typeorm';
import { Logger } from '@nestjs/common';
import { NotificationAudience, NotificationType } from '@edr/types';
import { NotificationsService } from './notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import {
companyNotifyEmailExpr,
companyNotifyPhoneExpr,
primaryContactUserJoin,
} from './resolve-company-phone.util';
/**
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
* company's phone/email and sends the message over both channels, swallowing
* per-channel failures so a missing provider never breaks the caller's flow.
*/
export async function sendCompanyChannels(
dataSource: DataSource,
notifications: NotificationsService,
companyId: string,
message: string,
): Promise<void> {
const [contact]: Array<{ phone: string | null; email: string | null }> =
await dataSource.query(
`SELECT ${companyNotifyPhoneExpr('co')} AS phone,
${companyNotifyEmailExpr('co')} AS email
FROM freight.companies co
${primaryContactUserJoin('co')}
WHERE co.id = $1 AND co.deleted_at IS NULL`,
[companyId],
);
if (contact?.phone) {
try {
await notifications.directSend('sms', contact.phone, message);
} catch {
/* best-effort: SMS provider unavailable */
}
}
if (contact?.email) {
try {
await notifications.directSend('email', contact.email, message);
} catch {
/* best-effort: email provider unavailable */
}
}
}
/**
* Tell the customer their export carriage acceptance sheet is ready to
* download from the portal — the sheet itself is generated on demand by
* BookingsService.carriageAcceptanceSheet, never stored, so this is a
* "ready" notice + link, not an attachment (the email pipeline carries text
* only). Shared by every path that makes a booking's handover final: the
* warehouse gate on receive, and direct truck-to-train on load (that cargo
* never sees a warehouse, so its handover moment IS the load).
*/
export async function notifyCarriageAcceptanceReady(
dataSource: DataSource,
notifications: NotificationsService,
inbox: NotificationInboxService,
bookingId: string,
logger: Logger,
): Promise<void> {
try {
const [b]: Array<{ companyId: string | null; reference: string }> = await dataSource.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!b?.companyId) return;
const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`;
await inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Carriage acceptance sheet ready',
body,
link: `/bookings/${bookingId}`,
data: { bookingId, reference: b.reference },
});
await sendCompanyChannels(dataSource, notifications, b.companyId, body);
} catch (err) {
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
}
}
/** One container line on the load manifest notice. */
interface LoadManifestLists {
reference: string;
companyId: string | null;
trainNumber: string | null;
originStation: string | null;
destinationStation: string | null;
departureAt: Date | null;
loaded: string[];
leftBehind: string[];
}
/** At most `max` numbers, then "+N more" — an SMS must not carry 44 of them. */
function summarizeNumbers(numbers: string[], max = 5): string {
if (numbers.length === 0) return 'none';
const shown = numbers.slice(0, max).join(', ');
const rest = numbers.length - max;
return rest > 0 ? `${shown} +${rest} more` : shown;
}
/**
* Read what actually went on the train and what did not. Left behind = every
* container the customer declared minus the ones sitting on a LOADED/DEPARTED
* wagon, so a booking loaded in parts reports honestly on both halves.
*/
export async function loadManifestLists(
dataSource: DataSource,
bookingId: string,
trainScheduleId: string,
): Promise<LoadManifestLists | null> {
const [booking]: Array<{ reference: string; companyId: string | null }> =
await dataSource.query(
`SELECT reference, company_id AS "companyId"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking) return null;
const [train]: Array<{
trainNumber: string | null;
originStation: string | null;
destinationStation: string | null;
departureAt: Date | null;
}> = await dataSource.query(
`SELECT s.train_number AS "trainNumber",
so.label AS "originStation",
sd.label AS "destinationStation",
s.scheduled_departure_date AS "departureAt"
FROM freight.train_schedules s
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
WHERE s.id = $1 AND s.deleted_at IS NULL`,
[trainScheduleId],
);
const loadedRows: Array<{ containerNumber: string | null }> = await dataSource.query(
`SELECT DISTINCT ci.container_number AS "containerNumber"
FROM freight.wagon_allocation_container_items ci
JOIN freight.wagon_booking_allocations a
ON a.id = ci.wagon_booking_allocation_id AND a.deleted_at IS NULL
WHERE a.booking_id = $1
AND ci.deleted_at IS NULL
AND a.status IN ('LOADED', 'DEPARTED')
ORDER BY 1`,
[bookingId],
);
const declaredRows: Array<{ containerNumber: string | null }> = await dataSource.query(
`SELECT DISTINCT u.container_number AS "containerNumber"
FROM freight.booking_container_units u
JOIN freight.booking_container l
ON l.id = u.booking_container_id AND l.deleted_at IS NULL
WHERE l.booking_id = $1 AND u.deleted_at IS NULL
ORDER BY 1`,
[bookingId],
);
const loaded = loadedRows.map((r) => r.containerNumber).filter(Boolean) as string[];
const loadedSet = new Set(loaded);
const leftBehind = (declaredRows.map((r) => r.containerNumber).filter(Boolean) as string[]).filter(
(n) => !loadedSet.has(n),
);
return {
reference: booking.reference,
companyId: booking.companyId,
trainNumber: train?.trainNumber ?? null,
originStation: train?.originStation ?? null,
destinationStation: train?.destinationStation ?? null,
departureAt: train?.departureAt ?? null,
loaded,
leftBehind,
};
}
/**
* Tell the customer what boarded the train and what did not, over in-app + SMS
* + email, and raise a warehouse-desk notice for anything left behind so
* somebody owns finding it space. A booking is routinely loaded in parts, and
* before this the customer learnt about it only by reading the sheet.
*
* Best-effort throughout: loading must never roll back because a provider is
* down.
*/
export async function notifyLoadManifest(
dataSource: DataSource,
notifications: NotificationsService,
inbox: NotificationInboxService,
bookingId: string,
trainScheduleId: string,
warehouseNotificationPermission: string,
logger: Logger,
): Promise<void> {
try {
const m = await loadManifestLists(dataSource, bookingId, trainScheduleId);
if (!m) return;
const route =
m.originStation && m.destinationStation
? ` ${m.originStation}${m.destinationStation}`
: '';
const departs = m.departureAt
? `, departs ${new Date(m.departureAt).toLocaleString('en-GB')}`
: '';
const train = m.trainNumber ? `train ${m.trainNumber}` : 'the train';
const headline =
`Booking ${m.reference}: ${m.loaded.length} container(s) loaded on ${train}` +
`${route}${departs}.`;
const loadedLine = m.loaded.length > 0 ? ` Loaded: ${summarizeNumbers(m.loaded)}.` : '';
const leftLine =
m.leftBehind.length > 0
? ` Not loaded (${m.leftBehind.length}): ${summarizeNumbers(m.leftBehind)}.` +
' These stay with EDR — once a warehouse is assigned you will receive the GRN.'
: '';
const body = headline + loadedLine + leftLine;
if (m.companyId) {
await inbox.notify({
recipients: { companyId: m.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: m.leftBehind.length > 0 ? 'Cargo partly loaded' : 'Cargo loaded',
// The in-app copy carries every number; SMS and email get the summary.
body:
headline +
(m.loaded.length > 0 ? `\nLoaded: ${m.loaded.join(', ')}` : '') +
(m.leftBehind.length > 0
? `\nNot loaded: ${m.leftBehind.join(', ')}\nThese stay with EDR — once a warehouse is assigned you will receive the GRN.`
: ''),
link: `/bookings/${bookingId}`,
data: {
bookingId,
reference: m.reference,
trainNumber: m.trainNumber,
loaded: m.loaded,
leftBehind: m.leftBehind,
},
});
await sendCompanyChannels(dataSource, notifications, m.companyId, body);
}
// Nothing left behind is nothing for the warehouse desk to place.
if (m.leftBehind.length > 0) {
await inbox.notify({
recipients: { permissionKeys: [warehouseNotificationPermission] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title: `${m.leftBehind.length} container(s) left behind — ${m.reference}`,
body:
`${train} departed without ${m.leftBehind.length} container(s) of booking ${m.reference}: ` +
`${m.leftBehind.join(', ')}. Assign warehouse space and raise the GRN.`,
link: `/dashboard/booking-requests/${bookingId}`,
data: { bookingId, reference: m.reference, leftBehind: m.leftBehind },
});
}
} catch (err) {
logger.warn(`Load manifest notify failed for ${bookingId}: ${(err as Error).message}`);
}
}