mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add notification to intercity user
This commit is contained in:
@@ -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<
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user