diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index f1f63802e..48985bdf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -71,6 +71,24 @@ export class BookingNotifierService { }); } + /** Train carrying the booking departed — dispatched origin → destination. */ + dispatched(b: Booking, origin: string | null, destination: string | null): void { + const msg = + `Your booking ${b.reference ?? b.id} has been dispatched` + + `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`; + void this.notifyContact(b, msg, 'DISPATCHED'); + this.inApp(b, 'Shipment dispatched', msg); + } + + /** Train carrying the booking arrived at destination. */ + arrived(b: Booking, origin: string | null, destination: string | null): void { + const msg = + `Your booking ${b.reference ?? b.id} has arrived` + + `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`; + void this.notifyContact(b, msg, 'ARRIVED'); + this.inApp(b, 'Shipment arrived', msg); + } + async payNow(b: Booking, deadline: Date): Promise { const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 0dba6b3de..04a972ed7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -158,6 +158,7 @@ describe('TrainSchedulingService', () => { { autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), } as never, // bookingJourneyService + { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier ); const defaultFleetWagons = [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 2c901b1ff..754a302d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -72,6 +72,7 @@ import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.d import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; +import { BookingNotifierService } from './booking-notifier.service'; import { buildCappedWagonPlan, computeFleetAvailability, @@ -281,10 +282,38 @@ export class TrainSchedulingService { private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly bookingWindowGateway: BookingWindowGateway, private readonly bookingJourneyService: BookingJourneyService, + private readonly bookingNotifier: BookingNotifierService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, ) {} + /** + * Notify each booking's customer that their shipment was dispatched / arrived, + * with a deep-link to the booking. Fire-and-forget — never blocks the action. + */ + private async notifyScheduleBookings( + schedule: TrainSchedule, + event: 'dispatched' | 'arrived', + ): Promise { + try { + const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean); + if (!ids.length) return; + const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null; + const destination = + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null; + const bookings = await this.dataSource.getRepository(Booking).find({ + where: { id: In(ids) }, + relations: { company: true }, + }); + for (const b of bookings) { + if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination); + else this.bookingNotifier.arrived(b, origin, destination); + } + } catch (err) { + this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`); + } + } + /** * Complete customer-tracking clearance milestones for every booking on a * schedule when a physical lifecycle event fires (dispatch, arrive, load, @@ -1545,6 +1574,7 @@ export class TrainSchedulingService { { originYardId: schedule.originStationId }, ); } + void this.notifyScheduleBookings(schedule, 'dispatched'); return this.getTrainScheduleById(scheduleId); } @@ -2550,6 +2580,7 @@ export class TrainSchedulingService { { destinationYardId: schedule.destinationStationId }, ); } + void this.notifyScheduleBookings(schedule, 'arrived'); const detail = await this.getTrainScheduleById(scheduleId); const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index 52bd318e1..d91ba4cbe 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -1,7 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, IsNull } from 'typeorm'; import { BookingHandover } from './entities/booking-handover.entity'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; /** * Import handover records. A booking has one handover per truck (single truck ⇒ @@ -13,7 +15,32 @@ import { BookingHandover } from './entities/booking-handover.entity'; export class HandoverService { private readonly logger = new Logger(HandoverService.name); - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly inbox: NotificationInboxService, + ) {} + + /** Tell the customer a handover is ready and needs their signature. */ + private async notifySignNeeded(bookingId: string, reference: string): Promise { + try { + const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( + `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!b?.companyId) return; + await this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Handover — signature needed', + body: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`, + link: `/bookings/${bookingId}`, + data: { bookingId, reference }, + }); + } catch (err) { + this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`); + } + } list(bookingId: string): Promise { return this.dataSource.getRepository(BookingHandover).find({ @@ -54,6 +81,7 @@ export class HandoverService { }), ); this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`); + void this.notifySignNeeded(bookingId, reference); return saved; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index c31fd471e..004704bea 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -39,6 +39,8 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { HandoverService } from './handover.service'; +import { NotificationAudience, NotificationType } from '@edr/types'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; /** Wagon states that may receive a load (besides being part of an existing schedule). */ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; @@ -383,8 +385,39 @@ export class WarehouseInventoryService { private readonly notifications: NotificationsService, private readonly signatures: SignaturesService, private readonly handover: HandoverService, + private readonly inbox: NotificationInboxService, ) {} + /** + * When a self-haul booking (no EDR first/last mile) is received to the warehouse + * but has no customer truck assigned yet, nudge the customer to assign one — with + * a deep-link to the booking's truck-assignment card. Fire-and-forget. + */ + private async notifyTruckAssignmentNeeded(booking: { + companyId?: string | null; + reference?: string | null; + hasFirstMile?: boolean; + hasLastMile?: boolean; + customerTruckAssignedAt?: string | null; + }, bookingId: string): Promise { + if (!booking.companyId) return; + if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck + if (booking.customerTruckAssignedAt) return; // already assigned + try { + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Assign a truck for pickup', + body: `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`, + link: `/bookings/${bookingId}`, + data: { bookingId, action: 'ASSIGN_TRUCK' }, + }); + } catch (err) { + this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`); + } + } + /** * Batch 6 — final terminal release / gate clearance. * Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch @@ -866,7 +899,10 @@ export class WarehouseInventoryService { b.customer_truck_driver_name AS "customerTruckDriverName", b.customer_truck_type AS "customerTruckType", b.customer_truck_container_number AS "customerTruckContainerNumber", - b.customer_truck_assigned_at AS "customerTruckAssignedAt" + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + b.company_id AS "companyId", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -1002,6 +1038,7 @@ export class WarehouseInventoryService { result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); + void this.notifyTruckAssignmentNeeded(booking, bookingId); } }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 9ee1dc398..2508e373a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -6,9 +6,11 @@ import { NotFoundException, } from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; -import { Freight } from "@edr/types"; +import { Freight, NotificationAudience, NotificationType } from "@edr/types"; import { DataSource } from "typeorm"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; + import { BillingService, InvoiceEventPayload, @@ -135,6 +137,7 @@ export class WarehouseInvoiceService { private readonly invoiceDocuments: InvoiceDocumentService, private readonly feeService: WarehouseFeeService, private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, ) { } // ── Generation ─────────────────────────────────────────────────────────── @@ -968,6 +971,25 @@ export class WarehouseInvoiceService { message, `warehouse fee invoice ${invoice.invoiceNumber}`, ); + + // In-app deep-link to pay the fee from the booking. + if (invoice.customerId && invoice.bookingId) { + try { + await this.inbox.notify({ + recipients: { companyId: invoice.customerId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: "Warehouse fee due", + body: + `Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` + + `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`, + link: `/bookings/${invoice.bookingId}`, + data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber }, + }); + } catch (err) { + this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`); + } + } } private async notifyWarehouseFeePayment( diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 5f0ce5749..4011bc14f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -9,6 +9,7 @@ import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; @@ -75,6 +76,7 @@ import { WarehousesService } from './warehouses.service'; InterchangeDocumentsModule, forwardRef(() => LastMileModule), NotificationsModule, + NotificationInboxModule, SignaturesModule, ExchangeModule.forRootAsync({ inject: [ConfigService],