feat: add notification to intercity user

This commit is contained in:
Nathnael
2026-08-07 12:53:19 +00:00
parent 116b479bb0
commit f330f486e5
2 changed files with 259 additions and 1 deletions

View File

@@ -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<void> {
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<

View File

@@ -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<void> =>
(
service as unknown as {
notifyIntercityCorridorScheduled: (s: TrainSchedule) => Promise<void>;
}
).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');
});
});