From 116b479bb02e74ce50e20a949a7f60ff8ab7c71a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:42:33 +0000 Subject: [PATCH 1/3] feat: scope notification to permission actions --- apps/edr-freight-api/src/app.module.ts | 13 ++ .../modules/backoffice/backoffice.service.ts | 72 ++++++++-- ...booking-lifecycle-notifier.service.spec.ts | 37 +++++ .../booking-lifecycle-notifier.service.ts | 23 +++- .../booking-wagon-cancellation.service.ts | 6 +- .../companies/company-notifier.service.ts | 12 +- .../contracts/contract-expiry.service.spec.ts | 15 ++ .../contracts/contract-expiry.service.ts | 7 +- .../contracts/contract-notifier.service.ts | 26 +++- .../maintenance/maintenance-due-alert.spec.ts | 5 + .../maintenance/maintenance.service.ts | 5 +- .../notification-recipients.service.ts | 16 +-- .../priority-rule-change-requests.service.ts | 5 +- .../services/rate-change-requests.service.ts | 5 +- .../warehouses/warehouse-fee.service.ts | 7 +- ...freight-notification-permissions.seeder.ts | 103 ++++++++++++++ .../seed/freight-permissions.registry.spec.ts | 39 +++++- .../src/seed/freight-permissions.registry.ts | 128 ++++++++++++++++++ packages/types/src/freight/notifications.ts | 13 +- 19 files changed, 486 insertions(+), 51 deletions(-) create mode 100644 apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c98990d25..c4c1514ec 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -76,6 +76,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; // import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; // import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; +import { FreightNotificationPermissionsSeeder } from "./seed/freight-notification-permissions.seeder"; // import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; // import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; @@ -257,6 +258,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsSeeder, // YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, + FreightNotificationPermissionsSeeder, // Disabled seeds — providers commented out (imports/injection/run too): // DemoUsersSeeder, // FreightStaffUsersSeeder, @@ -287,6 +289,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, + private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder, // Disabled seeds — injections commented out (imports/provider/run too): // private readonly demoUsersSeeder: DemoUsersSeeder, // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, @@ -328,6 +331,16 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); + // freightNotificationPermissions → seeds the :get_notification + // keys and backfills them onto whoever + // already holds each desk's anchor + // permission. Runs LAST in this block so + // it sees a freshly-seeded catalog and + // freshly-seeded positions. Unlike the + // seeders above it is NOT gated behind + // SEED_EDR_ORG — without it every staff + // notification resolves to no one. + await this.freightNotificationPermissionsSeeder.run(); // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index 49384922e..f47b5e8b1 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -46,7 +46,8 @@ export class BackofficeService { /** * IAM user ids of every current employee across all organizations — used by - * the notification recipients resolver's `allBackoffice` selector. + * support chat for staff room membership. Notifications deliberately do NOT + * use this: they target a desk via `getEmployeeUserIdsByPermission`. */ async getAllCurrentEmployeeUserIds(): Promise { const employees = await this.employeeRepository.find({ @@ -63,24 +64,67 @@ export class BackofficeService { * IAM user ids of current employees (any org) holding ANY of the given * permission keys — used by the notification recipients resolver's * `permissionKeys` selector for department/role-scoped targeting. + * + * This MUST agree with the request-time guard (`hasFreightPermission`, + * common/freight-permission.util.ts), which counts four grant carriers plus + * the super_admin bypass. Counting fewer silently drops legitimate + * recipients: an earlier version joined only direct position permissions, on + * which `bookings:view` resolved to 2 users — against 21 through position + * TYPES, which is where admin-created positions actually keep their grants. + * + * Raw SQL rather than QueryBuilder because `Position.positionTypePermissions` + * declares its inverse side against PositionType, so a relation join emits + * `ptp.position_type_id = position.id` and silently matches nothing. Same + * approach as FreightMeService's position-type lookups. */ async getEmployeeUserIdsByPermission( permissionKeys: string[], ): Promise { if (!permissionKeys.length) return []; - const rows: { userId: string | null }[] = await this.employeeRepository - .createQueryBuilder("employee") - .innerJoin("employee.employeePositions", "employeePosition") - .innerJoin("employeePosition.position", "position") - .innerJoin("position.positionPermission", "positionPermission") - .innerJoin("positionPermission.permission", "permission") - .where("employee.isCurrent = :isCurrent", { isCurrent: true }) - .andWhere("permission.key IN (:...permissionKeys)", { permissionKeys }) - .select("DISTINCT employee.user_id", "userId") - .getRawMany(); - return rows - .map((r) => r.userId) - .filter((id): id is string => Boolean(id)); + const rows: { userId: string }[] = await this.dataSource.query( + `WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1)) + -- 1. IAM role grants (user_roles -> role_permissions). + SELECT e.user_id AS "userId" + FROM iam.employees e + JOIN iam.user_roles ur ON ur.user_id = e.user_id + JOIN iam.role_permissions rp ON rp.role_id = ur.role_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND rp.permission_id IN (SELECT id FROM target) + UNION + -- 2. Direct position grants. A delegate keeps their own position AND + -- gains the one they stand in for, so both columns count. + SELECT e.user_id + FROM iam.employees e + JOIN iam.employee_positions ep + ON ep.employee_id = e.id AND ep.is_current + JOIN iam.position_permissions pp + ON pp.position_id IN (ep.position_id, ep.delegatee_position_id) + WHERE e.is_current AND e.user_id IS NOT NULL + AND pp.permission_id IN (SELECT id FROM target) + UNION + -- 3. Position TYPE grants — where admin-created positions keep theirs. + SELECT e.user_id + FROM iam.employees e + JOIN iam.employee_positions ep + ON ep.employee_id = e.id AND ep.is_current + JOIN iam.positions p + ON p.id IN (ep.position_id, ep.delegatee_position_id) + JOIN iam.position_type_permissions ptp + ON ptp.position_type_id = p.position_type_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND ptp.permission_id IN (SELECT id FROM target) + UNION + -- 4. super_admin passes every freight permission check, so mirror that + -- here or admins go blind on desks nobody else has been granted yet. + SELECT e.user_id + FROM iam.employees e + JOIN iam.user_roles ur ON ur.user_id = e.user_id + JOIN iam.roles r ON r.id = ur.role_id + WHERE e.is_current AND e.user_id IS NOT NULL + AND r.key = 'super_admin'`, + [permissionKeys], + ); + return rows.map((r) => r.userId); } async createOrganizationUser( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts index 2c21d804c..9a5960295 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts @@ -1,5 +1,6 @@ import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import type { Booking } from './entities/booking.entity'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * Who hears "Operations wants changes" depends on who owns the booking. A @@ -74,3 +75,39 @@ describe('BookingLifecycleNotifierService — operation changes requested', () = expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); }); }); + +/** + * Staff notifications used to go to every employee in every organization. They + * now target a desk — and the two desks are disjoint: the GL presets hold no + * bookings:view and no intake keys, so intake pings would be noise they cannot + * act on. Both branches run through the same `inAppStaff` helper, which is the + * easy place to lose the distinction again. + */ +describe('BookingLifecycleNotifierService — staff desk targeting', () => { + const booking = () => + ({ id: 'b-1', reference: 'BKG-0001', companyId: 'co-1' }) as Booking; + + let inbox: { notify: jest.Mock }; + let service: BookingLifecycleNotifierService; + + beforeEach(() => { + inbox = { notify: jest.fn().mockResolvedValue(undefined) }; + service = new BookingLifecycleNotifierService( + { directSend: jest.fn().mockResolvedValue(undefined) } as never, + inbox as never, + { query: jest.fn().mockResolvedValue([]) } as never, + ); + }); + + it('routes intake items to the booking desk and clearance items to the clearance desk', () => { + service.submittedToStaff(booking()); + service.clearanceDocsUploadedToStaff(booking()); + + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.getNotification], + }); + expect(inbox.notify.mock.calls[1][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 6245195eb..90b020472 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -11,6 +11,16 @@ import { Booking } from './entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; + +/** + * Clearance items are worked by the GL desks, which hold no bookings:view and + * no intake keys — so they take their own selector rather than the booking + * desk's. Every override using this deep-links to a clearance page. + */ +const CLEARANCE_DESK = { + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], +}; /** * Customer + staff notifications for the booking lifecycle: review, clearance @@ -89,7 +99,11 @@ export class BookingLifecycleNotifierService { }); } - /** Persist + push an in-app item to every backoffice staff user. */ + /** + * Persist + push an in-app item to the booking desk — staff holding + * `bookings:get_notification`. Callers whose item belongs to a different desk + * override `recipients` (see {@link CLEARANCE_DESK}). + */ private inAppStaff( b: Booking, title: string, @@ -97,7 +111,7 @@ export class BookingLifecycleNotifierService { overrides: Partial = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, @@ -274,6 +288,7 @@ export class BookingLifecycleNotifierService { `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`); this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/gl-djibouti/clearance/${b.id}`, }); @@ -288,6 +303,7 @@ export class BookingLifecycleNotifierService { `The customs declaration can now be filed.`; this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }); @@ -395,6 +411,7 @@ export class BookingLifecycleNotifierService { 'Clearance documents uploaded', `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }, @@ -422,6 +439,7 @@ export class BookingLifecycleNotifierService { `The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` + `"${note}". Send a corrected draft from the clearance page.`; this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/bookings/${b.id}/clearance`, }); @@ -440,6 +458,7 @@ export class BookingLifecycleNotifierService { 'Payment slip uploaded', `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`, { + recipients: CLEARANCE_DESK, type: NotificationType.PAYMENT_RECEIVED, link: `/dashboard/bookings/${b.id}/clearance`, }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index c576f5a43..198342cc8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -1112,12 +1112,14 @@ export class BookingWagonCancellationService { private notifyStaff(booking: Booking, title: string, body: string): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title, body, - link: `/bookings/${booking.id}`, + // The portal path `/bookings/:id` used to be sent here, which 404s in the + // dashboard. The staff view of these lives on the queue page. + link: '/dashboard/wagon-cancellations', data: { bookingId: booking.id, reference: booking.reference }, }); } diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index 66f88e9f8..178ace492 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -11,6 +11,7 @@ import { Company, CompanyStatus } from "./entities/company.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; /** Account statuses that lock the customer out and therefore must be told to them. */ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ @@ -175,12 +176,9 @@ export class CompanyNotifierService { // ── Backoffice-facing: work has arrived back in the review queue ──────────── /** - * Persist + push an in-app item to every backoffice staff user, deep-linked to - * the customer's detail page. - * - * The recipient resolver has no role/permission targeting (see - * `notification-recipients.service.ts`) — `allBackoffice` is the narrowest - * selector available, so marketing is reached by notifying all staff. + * Persist + push an in-app item to the customer desk — staff holding + * `customers:get_notification` — deep-linked to the customer's detail page, + * which is itself gated on `customers:view`. */ private notifyStaff( company: Company, @@ -189,7 +187,7 @@ export class CompanyNotifierService { data: Record = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts index 0551cfc7a..27cfa5fa1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts @@ -1,5 +1,6 @@ import { ContractExpiryService } from './contract-expiry.service'; import type { Contract } from './entities/contract.entity'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * The reminder must warn each customer once, ten days out, and must never let a @@ -60,4 +61,18 @@ describe('ContractExpiryService — expiry reminder', () => { inbox.notify.mockRejectedValue(new Error('inbox down')); await expect(service.remindExpiringContracts()).resolves.toBeUndefined(); }); + + // The sweep-failure alert is staff-facing. It used to go to every employee; + // it belongs to the people who would notice expired contracts still listed + // as active, i.e. the contract desk. + it('alerts the contract desk when the sweep itself fails', async () => { + repo.expireLapsedContracts.mockRejectedValue(new Error('deadlock')); + + await service.expireLapsedContracts(); + + expect(inbox.notify).toHaveBeenCalledTimes(1); + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.contracts.getNotification], + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts index 1ef84c141..c570fbc0a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts @@ -4,6 +4,7 @@ import { NotificationAudience, NotificationType } from '@edr/types'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { ContractsRepository } from './contracts.repository'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * How many days before a contract lapses the customer is reminded. Mirrored by @@ -79,7 +80,11 @@ export class ContractExpiryService { ); try { await this.inbox.notify({ - recipients: { allBackoffice: true }, + // The people who would notice expired contracts still listed as + // active are the ones working the contract desk. + recipients: { + permissionKeys: [FREIGHT_PERMS.contracts.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.GENERIC, title: 'Contract expiry sweep failed', diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 92a313569..e3c79a742 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -11,6 +11,16 @@ import { Contract } from './entities/contract.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; + +/** + * Clearance items are worked by the GL desks, which hold no intake keys — so + * they take their own selector rather than the contract desk's. Every override + * using this deep-links to a clearance or shipment-request page. + */ +const CLEARANCE_DESK = { + permissionKeys: [FREIGHT_PERMS.contracts.clearanceGetNotification], +}; /** * Customer + staff notifications for the contract lifecycle. Every customer @@ -86,7 +96,11 @@ export class ContractNotifierService { }); } - /** Persist + push an in-app item to every backoffice staff user. */ + /** + * Persist + push an in-app item to the contract desk — staff holding + * `contracts:get_notification`. Callers whose item belongs to a different + * desk override `recipients` (see {@link CLEARANCE_DESK}). + */ private inAppStaff( c: Contract, title: string, @@ -94,7 +108,7 @@ export class ContractNotifierService { overrides: Partial = {}, ): void { void this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { permissionKeys: [FREIGHT_PERMS.contracts.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, @@ -230,6 +244,7 @@ export class ContractNotifierService { `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`); this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/gl-djibouti/clearance/${c.id}`, }); @@ -248,6 +263,7 @@ export class ContractNotifierService { `The customs declaration can now be filed.`; this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`); this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }); @@ -264,6 +280,7 @@ export class ContractNotifierService { `"${note}". Review and re-advise the amount on the clearance page.`; this.logger.log(`DUTY DISPUTED — ${c.reference}`); this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }); @@ -321,6 +338,7 @@ export class ContractNotifierService { 'Clearance documents uploaded', `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`, { + recipients: CLEARANCE_DESK, type: NotificationType.CLEARANCE_REVIEW, link: `/dashboard/contracts/clearance/${c.id}`, }, @@ -334,6 +352,7 @@ export class ContractNotifierService { 'Duty slip uploaded', `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`, { + recipients: CLEARANCE_DESK, type: NotificationType.PAYMENT_RECEIVED, link: `/dashboard/contracts/clearance/${c.id}`, }, @@ -347,6 +366,9 @@ export class ContractNotifierService { 'New shipment request', `Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`, { + // GL reviews these, and the shipment-requests page is gated on + // contracts:create_booking — a key only the GL Ethiopia preset holds. + recipients: CLEARANCE_DESK, link: `/dashboard/shipment-requests/${requestId}`, data: { contractId: c.id, requestId, reference: requestRef }, }, diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts index 7aac5e863..5726123cf 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts @@ -1,6 +1,7 @@ import { NotificationAudience } from '@edr/types'; import { MaintenanceService } from './maintenance.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * The daily due-alert: a SCHEDULED item that crossed its km or date threshold @@ -37,6 +38,10 @@ describe('MaintenanceService.sendDueAlerts', () => { expect(notify).toHaveBeenCalledWith( expect.objectContaining({ audience: NotificationAudience.BACKOFFICE, + // The fleet desk, not every employee in the company. + recipients: { + permissionKeys: [FREIGHT_PERMS.maintenance.getNotification], + }, title: 'Maintenance due — ET-9875', body: expect.stringContaining('driven 50200 km (due at 50000 km)'), }), diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index dd9da55a8..54aab6e36 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -15,6 +15,7 @@ import { UpsertMaintenanceIntervalDto, } from './dto/create-maintenance.dto'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; @Injectable() export class MaintenanceService { @@ -53,7 +54,9 @@ export class MaintenanceService { ? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)` : `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`; await this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.maintenance.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.GENERIC, title: `Maintenance due — ${item.plateNumber}`, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index e3e711d19..d8e338dbf 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -14,7 +14,9 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit * - `companyProfileId` → resolved to its company, then to that company's users. * - `organizationId` → all current employees of the org (backoffice staff). * - `permissionKeys` → current employees (any org) holding any of these - * permission keys (e.g. department/role-scoped targeting). + * permission keys — how every staff-facing notification is targeted. There is + * deliberately no "all backoffice" selector: staff notifications belong to a + * desk, and the `:get_notification` keys name which one. */ @Injectable() export class NotificationRecipientsService { @@ -69,18 +71,6 @@ export class NotificationRecipientsService { } } - if (recipients.allBackoffice) { - try { - for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) { - ids.add(uid); - } - } catch (err) { - this.logger.warn( - `Failed to resolve allBackoffice recipients: ${(err as Error).message}`, - ); - } - } - if (recipients.permissionKeys?.length) { try { for (const uid of await this.backoffice.getEmployeeUserIdsByPermission( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts index a80221c68..1526769d3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts @@ -22,6 +22,7 @@ import { PriorityRuleChangeStatus, } from '../entities/priority-rule-change-request.entity'; import { PriorityConfigsService } from './priority-configs.service'; +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice rule-engine page — where both queue and rules live. */ const RULES_LINK = '/dashboard/rules/priority-configs'; @@ -221,7 +222,9 @@ export class PriorityRuleChangeRequestsService { ): void { void this.inbox .notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 6dde40d7d..8cc97f343 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -19,6 +19,7 @@ import { } from '../entities/rate-change-request.entity'; import { Rate } from '../entities/rate.entity'; import { RatesService } from './rates.service'; +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice page where both the queue and the rates live. */ const RATES_LINK = '/dashboard/rules/rates'; @@ -234,7 +235,9 @@ export class RateChangeRequestsService { private notifyTeam(title: string, body: string, request: RateChangeRequest): void { void this.inbox .notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index aa30b31e7..576933c01 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -8,6 +8,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; interface ItemAttributes { arrivedAt: Date | null; @@ -178,7 +179,11 @@ export class WarehouseFeeService { const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0); try { await this.inbox.notify({ - recipients: { allBackoffice: true }, + recipients: { + permissionKeys: [ + FREIGHT_PERMS.warehouseFeeInvoices.getNotification, + ], + }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title: 'Warehouse fee accruals need attention', diff --git a/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts b/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts new file mode 100644 index 000000000..0bea4ad95 --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-notification-permissions.seeder.ts @@ -0,0 +1,103 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Application, Permission } from '@tria-plc/iamapi-common'; +import { DataSource } from 'typeorm'; + +import { + NOTIFICATION_PERMISSIONS, + NOTIFICATION_PERMISSION_ANCHORS, +} from './freight-permissions.registry'; + +const EDR_FREIGHT_APP_KEY = 'edr_freight_app'; + +/** + * The three tables a permission grant can arrive on, with the column naming + * the grantee. These are compile-time constants — the only values interpolated + * into the SQL below. + */ +const CARRIERS = [ + ['iam.position_permissions', 'position_id'], + ['iam.position_type_permissions', 'position_type_id'], + ['iam.role_permissions', 'role_id'], +] as const; + +/** + * Backfills the `:get_notification` keys. + * + * Those keys are new, so on deploy nobody holds them and every staff + * notification would resolve to zero recipients — silently, because + * `NotificationInboxService.notify` logs an empty resolve at debug level. This + * grants each new key to whoever already holds that desk's anchor key (the + * permission gating the page the notification links to), across all three + * carriers. + * + * Deliberately NOT gated behind SEED_EDR_ORG: that flag is off everywhere + * except e2e/integration, and this has to run wherever the notifications do. + * For the same reason it inserts the permission rows itself rather than + * trusting EdrOrgSeeder. Idempotent — safe on every boot, and safe to leave in + * place permanently. + */ +@Injectable() +export class FreightNotificationPermissionsSeeder { + private readonly logger = new Logger(FreightNotificationPermissionsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const application = await this.dataSource + .getRepository(Application) + .findOne({ where: { key: EDR_FREIGHT_APP_KEY }, select: { id: true } }); + + if (!application?.id) { + this.logger.warn( + `Application '${EDR_FREIGHT_APP_KEY}' not found — notification permission backfill skipped`, + ); + return; + } + + // Ids are left to the column default and never sent: iam.permissions has + // two unique columns (PK id, UQ key) and ON CONFLICT can only target one, + // so a hand-minted id that some retired key already owns would slip past + // ON CONFLICT (key) and die on the PK. Same reasoning as EdrOrgSeeder. + await this.dataSource + .getRepository(Permission) + .createQueryBuilder() + .insert() + .values( + NOTIFICATION_PERMISSIONS.map((permission) => ({ + key: permission.key, + name: { ...permission.name }, + applicationId: application.id as string, + })), + ) + .orIgnore() + .execute(); + + let granted = 0; + for (const [key, anchors] of Object.entries( + NOTIFICATION_PERMISSION_ANCHORS, + )) { + for (const [table, granteeColumn] of CARRIERS) { + const result: unknown = await this.dataSource.query( + `INSERT INTO ${table} (${granteeColumn}, permission_id) + SELECT DISTINCT g.${granteeColumn}, target.id + FROM ${table} g + JOIN iam.permissions anchor + ON anchor.id = g.permission_id AND anchor.key = ANY($1) + JOIN iam.permissions target ON target.key = $2 + WHERE NOT EXISTS ( + SELECT 1 + FROM ${table} existing + WHERE existing.${granteeColumn} = g.${granteeColumn} + AND existing.permission_id = target.id)`, + [anchors, key], + ); + // node-postgres returns [rows, rowCount] for a bare INSERT. + granted += (Array.isArray(result) ? (result[1] as number) : 0) ?? 0; + } + } + + this.logger.log( + `Ensured ${NOTIFICATION_PERMISSIONS.length} notification permissions, backfilled ${granted} grant(s)`, + ); + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts index b69dcff71..d134a7986 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts @@ -1,5 +1,9 @@ import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; -import { readTwinOf } from './freight-permissions.registry'; +import { + NOTIFICATION_PERMISSIONS, + NOTIFICATION_PERMISSION_ANCHORS, + readTwinOf, +} from './freight-permissions.registry'; describe('EDR_FREIGHT_PERMISSIONS', () => { // The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE @@ -48,3 +52,36 @@ describe('EDR_FREIGHT_PERMISSIONS', () => { } }); }); + +describe('NOTIFICATION_PERMISSION_ANCHORS', () => { + const catalog = new Set(EDR_FREIGHT_PERMISSIONS.map((p) => p.key)); + + // FreightNotificationPermissionsSeeder backfills each get_notification key + // onto whoever holds its anchor. A typo'd anchor matches no permission row, + // so the key is granted to nobody and every notification on that desk + // silently resolves to zero recipients — notify() logs that at debug level. + it('anchors every notification key on keys that exist', () => { + const missing = Object.values(NOTIFICATION_PERMISSION_ANCHORS) + .flat() + .filter((key) => !catalog.has(key)); + + expect(missing).toEqual([]); + }); + + it('seeds every notification key into the catalog', () => { + const missing = NOTIFICATION_PERMISSIONS.map((p) => p.key).filter( + (key) => !catalog.has(key), + ); + + expect(missing).toEqual([]); + }); + + it('gives every notification key an anchor to backfill from', () => { + const anchored = new Set(Object.keys(NOTIFICATION_PERMISSION_ANCHORS)); + const unanchored = NOTIFICATION_PERMISSIONS.map((p) => p.key).filter( + (key) => !anchored.has(key), + ); + + expect(unanchored).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index cbe2fcf11..5d249f2be 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1335,6 +1335,54 @@ export const AUDIENCE_GAP_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// O. Notification recipient selectors. NOT route guards — these are never +// passed to FreightPermissionGuard/assertFreightPermission and never appear in +// the frontend's RequirePermission. They exist so ops can tune who gets pinged +// WITHOUT changing who can open the page. Before them every staff notification +// used the `allBackoffice` selector, i.e. every current employee in every org. +export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f3a00001-0001-4000-8000-000000000001", + "edr_freight_app:bookings:get_notification", + "Receive booking desk notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000002", + "edr_freight_app:bookings:clearance_get_notification", + "Receive booking clearance notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000003", + "edr_freight_app:contracts:get_notification", + "Receive contract desk notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000004", + "edr_freight_app:contracts:clearance_get_notification", + "Receive contract clearance notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000005", + "edr_freight_app:customers:get_notification", + "Receive customer desk notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000006", + "edr_freight_app:maintenance:get_notification", + "Receive maintenance due notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000007", + "edr_freight_app:rule_engine:get_notification", + "Receive rule-change approval notifications", + ), + perm( + "f3a00001-0001-4000-8000-000000000008", + "edr_freight_app:warehouse_fee_invoices:get_notification", + "Receive warehouse fee accrual notifications", + ), +]; + export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...CUSTOMER_PERMISSIONS, ...FINANCE_PERMISSIONS, @@ -1348,6 +1396,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...STAFF_IAM_PERMISSIONS, ...AUDIENCE_GAP_PERMISSIONS, ...GRANULAR_SPLIT_PERMISSIONS, + ...NOTIFICATION_PERMISSIONS, ]; export const BOOKING_RULE_ENGINE_PERMISSIONS = [ @@ -1439,6 +1488,10 @@ export const FREIGHT_PERMS = { wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", wagonCancellationRebook: "edr_freight_app:bookings:wagon_cancellation_rebook", + // Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:bookings:get_notification", + clearanceGetNotification: + "edr_freight_app:bookings:clearance_get_notification", }, contracts: { view: "edr_freight_app:contracts:view", @@ -1475,6 +1528,10 @@ export const FREIGHT_PERMS = { editDocument: "edr_freight_app:contracts:edit_document", finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", + // Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:contracts:get_notification", + clearanceGetNotification: + "edr_freight_app:contracts:clearance_get_notification", }, trainScheduling: { view: "edr_freight_app:train_scheduling:view", @@ -1503,6 +1560,10 @@ export const FREIGHT_PERMS = { `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:delete`, approve: (slug: RuleEngineApprovableSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`, + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + // One key for the whole rules desk: every preset grants the rule-engine + // view keys as a block, and both producers link to /dashboard/rules/*. + getNotification: "edr_freight_app:rule_engine:get_notification", }, allocation: { manage: "edr_freight_app:allocation:manage", @@ -1514,6 +1575,8 @@ export const FREIGHT_PERMS = { deactivate: "edr_freight_app:customers:deactivate", verify: "edr_freight_app:customers:verify", resetPassword: "edr_freight_app:customers:reset-password", + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:customers:get_notification", }, payments: { view: "edr_freight_app:payments:view", @@ -1637,6 +1700,8 @@ export const FREIGHT_PERMS = { create: "edr_freight_app:maintenance:create", update: "edr_freight_app:maintenance:update", delete: "edr_freight_app:maintenance:delete", + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:maintenance:get_notification", }, fleetReports: { view: "edr_freight_app:fleet_reports:view", @@ -1706,6 +1771,8 @@ export const FREIGHT_PERMS = { generate: "edr_freight_app:warehouse_fee_invoices:generate", cancel: "edr_freight_app:warehouse_fee_invoices:cancel", pay: "edr_freight_app:warehouse_fee_invoices:pay", + // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. + getNotification: "edr_freight_app:warehouse_fee_invoices:get_notification", }, settings: { fileUpload: { @@ -1810,6 +1877,40 @@ export const FREIGHT_PERMS = { }, } as const; +/** + * Backfill sources for the `:get_notification` keys: a new key is + * granted to whoever already holds ANY of its anchors. The anchor is the key + * that gates the page the notification deep-links to — if you cannot open the + * page, you were never the intended recipient. The rule-engine desk has no page + * of its own, so it anchors on the keys that let you ACT on a filed change. + * + * Consumed only by FreightNotificationPermissionsSeeder. Keeping it here means + * the registry spec can assert every anchor still exists in the catalog — a + * typo'd anchor backfills nobody, silently. + */ +export const NOTIFICATION_PERMISSION_ANCHORS: Record = { + [FREIGHT_PERMS.bookings.getNotification]: [FREIGHT_PERMS.bookings.view], + [FREIGHT_PERMS.bookings.clearanceGetNotification]: [ + FREIGHT_PERMS.bookings.clearanceView, + ], + [FREIGHT_PERMS.contracts.getNotification]: [FREIGHT_PERMS.contracts.view], + [FREIGHT_PERMS.contracts.clearanceGetNotification]: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, + FREIGHT_PERMS.contracts.opsClearanceReview, + ], + [FREIGHT_PERMS.customers.getNotification]: [FREIGHT_PERMS.customers.view], + [FREIGHT_PERMS.maintenance.getNotification]: [FREIGHT_PERMS.maintenance.view], + [FREIGHT_PERMS.ruleEngine.getNotification]: [ + FREIGHT_PERMS.ruleEngine.approve("rates"), + FREIGHT_PERMS.ruleEngine.update("priority-configs"), + ], + [FREIGHT_PERMS.warehouseFeeInvoices.getNotification]: [ + FREIGHT_PERMS.warehouseFeeInvoices.view, + ], +}; + /** Both arms of a freight-type-split permission (for one-of route guards). */ export const bothFreightTypes = (p: { bulk: string; @@ -1867,6 +1968,19 @@ const STAFF_DASHBOARD_KEYS: string[] = [ FREIGHT_PERMS.reports.view, ]; +// Notification desks — recipient selectors, not access. A preset gets a desk +// key only where it actually works that queue, which is why the GL presets take +// the clearance pair and nothing else: they hold no bookings:view and no intake +// keys, so intake pings would only be noise they cannot act on. +const BOOKING_DESK_NOTIFICATION_KEYS: string[] = [ + FREIGHT_PERMS.bookings.getNotification, + FREIGHT_PERMS.contracts.getNotification, +]; +const CLEARANCE_DESK_NOTIFICATION_KEYS: string[] = [ + FREIGHT_PERMS.bookings.clearanceGetNotification, + FREIGHT_PERMS.contracts.clearanceGetNotification, +]; + export const ROLE_PERMISSION_PRESETS = { // Marketing / line staff: drives a booking from intake through line-staff // approval and contract generation/signing — i.e. until the contract is ready @@ -1889,6 +2003,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.editDocument, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, + FREIGHT_PERMS.ruleEngine.getNotification, ], // Operations Officer: train scheduling + wagon allocation + transit/complete // + fleet management (wagons, trains, locomotives, routes, containers, cargo). @@ -1921,6 +2037,11 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.finalizeClearance, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, + // They run Path A clearance review from the booking detail, so the booking + // clearance desk is theirs too — but not the contract one, which is GL's. + FREIGHT_PERMS.bookings.clearanceGetNotification, + FREIGHT_PERMS.ruleEngine.getNotification, ], director: [ ...STAFF_DASHBOARD_KEYS, @@ -1932,6 +2053,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.generateContract, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, ], ceo: [ ...STAFF_DASHBOARD_KEYS, @@ -1941,6 +2063,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.approveCeo, ...allRuleEngineViewKeys(), + ...BOOKING_DESK_NOTIFICATION_KEYS, ], finance: [ ...STAFF_DASHBOARD_KEYS, @@ -1972,6 +2095,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.finalizeClearance, FREIGHT_PERMS.bookings.operations, + ...CLEARANCE_DESK_NOTIFICATION_KEYS, ], // GL Djibouti (edr_gl_djibouti): DO/RO collection, gatepass, loading milestones, // damage reports. Read-only on the contract; no booking creation. @@ -1983,6 +2107,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.operations, + ...CLEARANCE_DESK_NOTIFICATION_KEYS, ], // Marketing handles intake through contract (same as line staff here) and, // for non-customs bookings, reviews/finalizes the customer's clearance @@ -2012,6 +2137,7 @@ export const ROLE_PERMISSION_PRESETS = { ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), FREIGHT_PERMS.contracts.suspend, FREIGHT_PERMS.contracts.editDocument, + ...BOOKING_DESK_NOTIFICATION_KEYS, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; @@ -2038,6 +2164,8 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.customers.view, FREIGHT_PERMS.customers.verify, FREIGHT_PERMS.customers.deactivate, + // …and therefore the profile-review pings company-notifier emits. + FREIGHT_PERMS.customers.getNotification, // The chief also owns the customer-facing support inbox. FREIGHT_PERMS.support.agentView, FREIGHT_PERMS.support.agentSend, diff --git a/packages/types/src/freight/notifications.ts b/packages/types/src/freight/notifications.ts index e3ca5b219..d51006c7f 100644 --- a/packages/types/src/freight/notifications.ts +++ b/packages/types/src/freight/notifications.ts @@ -90,13 +90,16 @@ export interface NotificationRecipients { companyProfileId?: string; /** Backoffice: all current employees of this organization. */ organizationId?: string; - /** Backoffice: every current employee across all organizations. */ - allBackoffice?: boolean; /** * Backoffice: current employees (any org) who hold ANY of these permission - * keys — e.g. notify only marketing, not every employee. Super/org admins - * are not implicitly included; add `allBackoffice`/explicit userIds too if - * admins should also see it. + * keys. Resolution mirrors the request-time guard `hasFreightPermission` — + * IAM role grants, direct position grants, position TYPE grants, delegated + * positions, and the `super_admin` bypass. `organization_admin` is NOT + * implicitly included (it only bypasses approval steps, not permission + * checks); grant it a key explicitly if it should be notified. + * + * Prefer the dedicated `:get_notification` keys over reusing a domain + * key: they let ops tune who gets pinged without touching who has access. */ permissionKeys?: string[]; } From f330f486e53909f09dd1c7abe8ed28c3d94f7a40 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:53:19 +0000 Subject: [PATCH 2/3] feat: add notification to intercity user --- .../booking-window.service.ts | 150 +++++++++++++++++- .../intercity-corridor-notify.spec.ts | 110 +++++++++++++ 2 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/intercity-corridor-notify.spec.ts 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'); + }); +}); From c9bb105e9420ec68b2d2a86058646a13f55b631e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:54:00 +0000 Subject: [PATCH 3/3] feat: add nationality indicator to the customer --- .../src/components/customers/badges.tsx | 26 +++++++++++++++++++ .../src/components/customers/index.ts | 1 + .../pages/customers/CustomerDetailPage.tsx | 8 ++++++ .../src/pages/customers/CustomersPage.tsx | 2 ++ .../backoffice/src/types/customer.ts | 4 +++ 5 files changed, 41 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 046493b0f..edcc4a517 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -16,6 +16,7 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import type { + CompanyNationality, CompanyProfile, CompanyStatus, CompanyType, @@ -88,6 +89,31 @@ export function CompanyTypeBadge({ type }: { type: CompanyType }) { ); } +const NATIONALITY_COLOR: Record = { + ethiopian: "edr-green", + foreign: "blue", +}; + +export function CompanyNationalityBadge({ + nationality, +}: { + nationality?: CompanyNationality | null; +}) { + if (!nationality) return null; + return ( + + {humanize(nationality)} + + ); +} + /** * Profile chips for a company row: one chip per role (Importer / Exporter / …) * carrying its reference code. Caps at three (a company has at most three diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 1ccd29f41..81f25fcb2 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -1,5 +1,6 @@ export { BookingStatusBadge, + CompanyNationalityBadge, CompanyStatusBadge, CompanyTypeBadge, InvoiceStatusBadge, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index a9e56a6d8..f31ce02e9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -812,6 +812,14 @@ export default function CustomerDetailPage() { } /> + diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 34f90ffff..815615a1b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -31,6 +31,7 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { + CompanyNationalityBadge, CompanyStatusBadge, ProfileChips, formatDate, @@ -149,6 +150,7 @@ export default function CustomersPage() { {c.name} + TIN {c.tin} diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index ed77307f9..cd2d67842 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -21,6 +21,9 @@ export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted"; /** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */ export type CompanyKind = "commercial" | "government"; +/** Mirrors backend `CompanyNationality`. */ +export type CompanyNationality = "ethiopian" | "foreign"; + /** Mirrors backend `ProfileType` (the role a company plays). */ export type ProfileType = | "importer" @@ -207,6 +210,7 @@ export interface Company { vatNumber?: string | null; fanNumber?: string | null; country: string; + nationality?: CompanyNationality | null; address?: string | null; phone?: string | null; email?: string | null;