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 { 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 { 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 { 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 { 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}`); } }