diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 1a3baa6b4..9eb6c0bf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -313,6 +313,8 @@ export class BookingWindowService implements OnModuleInit { // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop // (the `ticking` guard would otherwise delay every schedule's transition). void this.notifyWindowOpened(schedule); + // Intercity rides along whatever train passes, export included. + void this.notifyIntercityCorridorScheduled(schedule); this.logger.log(`Export booking window opened for schedule ${schedule.id}`); return true; } @@ -365,7 +367,10 @@ export class BookingWindowService implements OnModuleInit { } // Only announce the first opening of the day; reopen cycles don't re-notify. // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop. - if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule); + if (schedule.bookingCycleNo === 1) { + void this.notifyWindowOpened(schedule); + void this.notifyIntercityCorridorScheduled(schedule); + } this.logger.log( `[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` + `(cycle ${schedule.bookingCycleNo})`, @@ -710,6 +715,149 @@ export class BookingWindowService implements OnModuleInit { } } + /** + * SMS + email + inbox the owner of every waiting intercity booking whose + * corridor lies on this schedule's route. + * + * Intercity (DOMESTIC) bookings carry no date — the customer books a corridor + * and the cargo waits in a pool until staff ride it along a passing + * import/export train (see IntercityService). Until now that wait was silent: + * `notifyWindowOpened` only reaches companies holding an ACTIVE contract whose + * `contract_routes` match the train's exact origin→destination, and an + * intercity booking is neither contracted nor necessarily end-to-end. + * + * Corridor match mirrors `IntercityService.corridorOnRoute` exactly — both + * yards on the route with origin strictly before destination, falling back to + * the train's own origin/destination when the route has fewer than two + * milestones — so nobody is told about a train they can never be placed on. + */ + private async notifyIntercityCorridorScheduled( + schedule: TrainSchedule, + ): Promise { + try { + const rows: Array<{ + bookingId: string; + companyId: string; + phone: string | null; + email: string | null; + corridor: string; + }> = await this.dataSource.query( + // `stops` is the schedule's stop list, with the two-stop + // origin→destination pseudo-route as the legacy fallback — the same + // shape IntercityService.milestoneSequenceOf builds in TypeScript. + `WITH ms AS ( + SELECT yard_id, sequence_no + FROM freight.route_milestones + WHERE route_id = $1 AND deleted_at IS NULL + ), + stops AS ( + SELECT yard_id, sequence_no FROM ms WHERE (SELECT count(*) FROM ms) >= 2 + UNION ALL + SELECT v.yard_id, v.seq + FROM (VALUES ($2::uuid, 1), ($3::uuid, 2)) AS v(yard_id, seq) + WHERE (SELECT count(*) FROM ms) < 2 + ) + SELECT DISTINCT + b.id AS "bookingId", + b.company_id AS "companyId", + ${companyNotifyPhoneExpr('co')} AS phone, + COALESCE(co.email, co.general_manager_email) AS email, + COALESCE(oy.label, oy.code) || ' to ' || + COALESCE(dy.label, dy.code) AS corridor + FROM freight.bookings b + JOIN stops o ON o.yard_id = b.origin_yard_id + JOIN stops d ON d.yard_id = b.destination_yard_id + AND d.sequence_no > o.sequence_no + JOIN freight.companies co ON co.id = b.company_id AND co.deleted_at IS NULL + JOIN freight.yards oy ON oy.id = b.origin_yard_id + JOIN freight.yards dy ON dy.id = b.destination_yard_id + ${primaryContactUserJoin('co')} + WHERE b.deleted_at IS NULL + AND b.trade_direction = 'DOMESTIC' + AND b.train_schedule_id IS NULL + -- Same waiting pool IntercityService.findWaitingIntercityBookings + -- draws candidates from: commercial paid/executed, government approved. + AND ((b.is_government = false AND b.status IN ('FULLY_EXECUTED', 'PAID')) + OR (b.is_government = true AND b.status = 'APPROVED')) + -- Once per booking, not once per train. A booking can sit in the + -- pool for weeks while several trains open a window on its corridor, + -- and "trains run your corridor, you are queued" is the same message + -- every time. The inbox row written below is the marker. + -- ponytail: unindexed jsonb probe over freight.notifications; add a + -- partial index on (data->>'intercityCorridorBookingId') if the + -- table grows enough for this to show up in the tick loop. + AND NOT EXISTS ( + SELECT 1 FROM freight.notifications n + WHERE n.data->>'intercityCorridorBookingId' = b.id::text)`, + [schedule.routeId, schedule.originStationId, schedule.destinationStationId], + ); + if (!rows.length) return; + + const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { + timeZone: BATCH_TIMEZONE, + }); + const msgFor = (corridors: string[]) => + `A train is scheduled on your intercity corridor ${corridors.join(', ')}, ` + + `departing ${depart}. EDR will confirm once your cargo is placed on a train.`; + + // One inbox item per booking (its `data` is the once-per-booking marker + // the query above reads), but one SMS/email per company — a customer with + // three waiting bookings gets one message naming all three corridors. + const byCompany = new Map< + string, + { phone: string | null; email: string | null; corridors: string[] } + >(); + for (const row of rows) { + const entry = byCompany.get(row.companyId) ?? { + phone: row.phone, + email: row.email, + corridors: [], + }; + if (!entry.corridors.includes(row.corridor)) entry.corridors.push(row.corridor); + byCompany.set(row.companyId, entry); + + await this.inbox.notify({ + recipients: { companyId: row.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Train scheduled on your corridor', + body: msgFor([row.corridor]), + link: `/bookings/${row.bookingId}`, + data: { + intercityCorridorBookingId: row.bookingId, + trainScheduleId: schedule.id, + }, + }); + } + + for (const [companyId, entry] of byCompany) { + const msg = msgFor(entry.corridors); + if (entry.phone) { + await this.notifications + .directSend('sms', entry.phone, msg) + .catch((e) => + this.logger.warn(`Intercity corridor SMS failed: ${(e as Error).message}`), + ); + } + if (entry.email) { + await this.notifications + .directSend('email', entry.email, msg) + .catch((e) => + this.logger.warn(`Intercity corridor email failed: ${(e as Error).message}`), + ); + } + this.logger.log( + `Notified company ${companyId} of ${entry.corridors.length} intercity ` + + `corridor(s) served by schedule ${schedule.id}`, + ); + } + } catch (err) { + this.logger.warn( + `notifyIntercityCorridorScheduled failed for ${schedule.id}: ${(err as Error).message}`, + ); + } + } + private async setPhase( schedule: TrainSchedule, patch: Partial< diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts new file mode 100644 index 000000000..3b6648c19 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts @@ -0,0 +1,110 @@ +import { BookingWindowService } from './booking-window.service'; +import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * Intercity corridor announcement: when a train's booking window opens, every + * customer with a waiting intercity booking on that corridor is told over SMS, + * email and the portal inbox. + * + * The corridor SQL itself is EXPLAIN-validated against the dev database; what + * this covers is the fan-out shape around it — one inbox row per booking + * (that row's `data` is the once-per-booking marker the query dedupes on) but + * one SMS/email per company, naming every corridor at once. + */ +describe('BookingWindowService — intercity corridor announcement', () => { + const schedule = { + id: 'sched-1', + routeId: 'route-1', + originStationId: 'yard-o', + destinationStationId: 'yard-d', + scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), + } as unknown as TrainSchedule; + + const build = (rows: unknown[]) => { + const query = jest.fn().mockResolvedValue(rows); + const directSend = jest.fn().mockResolvedValue(undefined); + const notify = jest.fn().mockResolvedValue(undefined); + const service = new BookingWindowService( + { query, getRepository: () => ({ update: jest.fn() }) } as never, + { findById: jest.fn(), findAll: jest.fn() } as never, + {} as never, + {} as never, + { directSend } as never, + { notify } as never, + { emitPhase: jest.fn() } as never, + ); + const run = (): Promise => + ( + service as unknown as { + notifyIntercityCorridorScheduled: (s: TrainSchedule) => Promise; + } + ).notifyIntercityCorridorScheduled(schedule); + return { run, query, directSend, notify }; + }; + + it('sends one inbox item per booking and one SMS/email per company', async () => { + const { run, directSend, notify } = build([ + { + bookingId: 'bk-1', + companyId: 'co-1', + phone: '+251900000001', + email: 'ops@co1.example', + corridor: 'Dire Dawa to Adama', + }, + { + bookingId: 'bk-2', + companyId: 'co-1', + phone: '+251900000001', + email: 'ops@co1.example', + corridor: 'Adama to Mojo', + }, + ]); + + await run(); + + // Per booking: the marker keeps the next train on this corridor from + // re-announcing the same thing to the same booking. + expect(notify).toHaveBeenCalledTimes(2); + expect(notify.mock.calls.map((c) => c[0].data.intercityCorridorBookingId)).toEqual([ + 'bk-1', + 'bk-2', + ]); + expect(notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); + expect(notify.mock.calls[0][0].link).toBe('/bookings/bk-1'); + + // Per company: two bookings, one SMS and one email, both corridors named. + expect(directSend).toHaveBeenCalledTimes(2); + const [smsChannel, smsTo, smsBody] = directSend.mock.calls[0]; + expect([smsChannel, smsTo]).toEqual(['sms', '+251900000001']); + expect(smsBody).toContain('Dire Dawa to Adama, Adama to Mojo'); + expect(smsBody).toContain('01/08/2026'); + expect(directSend.mock.calls[1][0]).toBe('email'); + }); + + it('sends nothing when no waiting booking rides this corridor', async () => { + const { run, directSend, notify } = build([]); + + await run(); + + expect(notify).not.toHaveBeenCalled(); + expect(directSend).not.toHaveBeenCalled(); + }); + + it('skips the channels a company has no contact for', async () => { + const { run, directSend, notify } = build([ + { + bookingId: 'bk-3', + companyId: 'co-2', + phone: null, + email: 'ops@co2.example', + corridor: 'Dire Dawa to Adama', + }, + ]); + + await run(); + + expect(notify).toHaveBeenCalledTimes(1); + expect(directSend).toHaveBeenCalledTimes(1); + expect(directSend.mock.calls[0][0]).toBe('email'); + }); +});