Merge pull request #1164 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-07 15:58:02 +03:00
committed by GitHub
26 changed files with 786 additions and 52 deletions

View File

@@ -76,6 +76,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; // import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; // import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.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 { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; // import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
@@ -257,6 +258,7 @@ if (!process.env.APPLICATION_NAME) {
FileUploadSettingsSeeder, FileUploadSettingsSeeder,
// YardFacilitiesSeeder, // YardFacilitiesSeeder,
FreightPermissionKeyMigrationSeeder, FreightPermissionKeyMigrationSeeder,
FreightNotificationPermissionsSeeder,
// Disabled seeds — providers commented out (imports/injection/run too): // Disabled seeds — providers commented out (imports/injection/run too):
// DemoUsersSeeder, // DemoUsersSeeder,
// FreightStaffUsersSeeder, // FreightStaffUsersSeeder,
@@ -287,6 +289,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly freightNotificationPermissionsSeeder: FreightNotificationPermissionsSeeder,
// Disabled seeds — injections commented out (imports/provider/run too): // Disabled seeds — injections commented out (imports/provider/run too):
// private readonly demoUsersSeeder: DemoUsersSeeder, // private readonly demoUsersSeeder: DemoUsersSeeder,
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
@@ -328,6 +331,16 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run(); await this.edrOrgSeeder.run();
await this.iamBaselineSeeder.run(); await this.iamBaselineSeeder.run();
await this.freightPositionsSeeder.run(); await this.freightPositionsSeeder.run();
// freightNotificationPermissions → seeds the <module>: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. // File upload settings — keep enabled.
await this.fileUploadSettingsSeeder.run(); await this.fileUploadSettingsSeeder.run();

View File

@@ -46,7 +46,8 @@ export class BackofficeService {
/** /**
* IAM user ids of every current employee across all organizations — used by * 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<string[]> { async getAllCurrentEmployeeUserIds(): Promise<string[]> {
const employees = await this.employeeRepository.find({ 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 * IAM user ids of current employees (any org) holding ANY of the given
* permission keys — used by the notification recipients resolver's * permission keys — used by the notification recipients resolver's
* `permissionKeys` selector for department/role-scoped targeting. * `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( async getEmployeeUserIdsByPermission(
permissionKeys: string[], permissionKeys: string[],
): Promise<string[]> { ): Promise<string[]> {
if (!permissionKeys.length) return []; if (!permissionKeys.length) return [];
const rows: { userId: string | null }[] = await this.employeeRepository const rows: { userId: string }[] = await this.dataSource.query(
.createQueryBuilder("employee") `WITH target AS (SELECT id FROM iam.permissions WHERE key = ANY($1))
.innerJoin("employee.employeePositions", "employeePosition") -- 1. IAM role grants (user_roles -> role_permissions).
.innerJoin("employeePosition.position", "position") SELECT e.user_id AS "userId"
.innerJoin("position.positionPermission", "positionPermission") FROM iam.employees e
.innerJoin("positionPermission.permission", "permission") JOIN iam.user_roles ur ON ur.user_id = e.user_id
.where("employee.isCurrent = :isCurrent", { isCurrent: true }) JOIN iam.role_permissions rp ON rp.role_id = ur.role_id
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys }) WHERE e.is_current AND e.user_id IS NOT NULL
.select("DISTINCT employee.user_id", "userId") AND rp.permission_id IN (SELECT id FROM target)
.getRawMany(); UNION
return rows -- 2. Direct position grants. A delegate keeps their own position AND
.map((r) => r.userId) -- gains the one they stand in for, so both columns count.
.filter((id): id is string => Boolean(id)); 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( async createOrganizationUser(

View File

@@ -1,5 +1,6 @@
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import type { Booking } from './entities/booking.entity'; 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 * 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' }); 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],
});
});
});

View File

@@ -11,6 +11,16 @@ import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; 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 * 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( private inAppStaff(
b: Booking, b: Booking,
title: string, title: string,
@@ -97,7 +111,7 @@ export class BookingLifecycleNotifierService {
overrides: Partial<NotifyInput> = {}, overrides: Partial<NotifyInput> = {},
): void { ): void {
void this.inbox.notify({ void this.inbox.notify({
recipients: { allBackoffice: true }, recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED, type: NotificationType.REQUEST_SUBMITTED,
title, title,
@@ -274,6 +288,7 @@ export class BookingLifecycleNotifierService {
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`); this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, { this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${b.id}`, link: `/dashboard/gl-djibouti/clearance/${b.id}`,
}); });
@@ -288,6 +303,7 @@ export class BookingLifecycleNotifierService {
`The customs declaration can now be filed.`; `The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`, link: `/dashboard/bookings/${b.id}/clearance`,
}); });
@@ -395,6 +411,7 @@ export class BookingLifecycleNotifierService {
'Clearance documents uploaded', 'Clearance documents uploaded',
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`, `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`, 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)}: ` + `The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
`"${note}". Send a corrected draft from the clearance page.`; `"${note}". Send a corrected draft from the clearance page.`;
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, { this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`, link: `/dashboard/bookings/${b.id}/clearance`,
}); });
@@ -440,6 +458,7 @@ export class BookingLifecycleNotifierService {
'Payment slip uploaded', 'Payment slip uploaded',
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`, `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.PAYMENT_RECEIVED, type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/bookings/${b.id}/clearance`, link: `/dashboard/bookings/${b.id}/clearance`,
}, },

View File

@@ -1112,12 +1112,14 @@ export class BookingWagonCancellationService {
private notifyStaff(booking: Booking, title: string, body: string): void { private notifyStaff(booking: Booking, title: string, body: string): void {
void this.inbox.notify({ void this.inbox.notify({
recipients: { allBackoffice: true }, recipients: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] },
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS, type: NotificationType.BOOKING_STATUS,
title, title,
body, 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 }, data: { bookingId: booking.id, reference: booking.reference },
}); });
} }

View File

@@ -11,6 +11,7 @@ import { Company, CompanyStatus } from "./entities/company.entity";
import { NotificationsService } from "../notifications/notifications.service"; import { NotificationsService } from "../notifications/notifications.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util"; 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. */ /** Account statuses that lock the customer out and therefore must be told to them. */
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
@@ -175,12 +176,9 @@ export class CompanyNotifierService {
// ── Backoffice-facing: work has arrived back in the review queue ──────────── // ── Backoffice-facing: work has arrived back in the review queue ────────────
/** /**
* Persist + push an in-app item to every backoffice staff user, deep-linked to * Persist + push an in-app item to the customer desk — staff holding
* the customer's detail page. * `customers:get_notification` — deep-linked to the customer's detail page,
* * which is itself gated on `customers:view`.
* 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.
*/ */
private notifyStaff( private notifyStaff(
company: Company, company: Company,
@@ -189,7 +187,7 @@ export class CompanyNotifierService {
data: Record<string, unknown> = {}, data: Record<string, unknown> = {},
): void { ): void {
void this.inbox.notify({ void this.inbox.notify({
recipients: { allBackoffice: true }, recipients: { permissionKeys: [FREIGHT_PERMS.customers.getNotification] },
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED, type: NotificationType.REQUEST_SUBMITTED,
title, title,

View File

@@ -1,5 +1,6 @@
import { ContractExpiryService } from './contract-expiry.service'; import { ContractExpiryService } from './contract-expiry.service';
import type { Contract } from './entities/contract.entity'; 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 * 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')); inbox.notify.mockRejectedValue(new Error('inbox down'));
await expect(service.remindExpiringContracts()).resolves.toBeUndefined(); 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],
});
});
}); });

View File

@@ -4,6 +4,7 @@ import { NotificationAudience, NotificationType } from '@edr/types';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { ContractsRepository } from './contracts.repository'; 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 * How many days before a contract lapses the customer is reminded. Mirrored by
@@ -79,7 +80,11 @@ export class ContractExpiryService {
); );
try { try {
await this.inbox.notify({ 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, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC, type: NotificationType.GENERIC,
title: 'Contract expiry sweep failed', title: 'Contract expiry sweep failed',

View File

@@ -11,6 +11,16 @@ import { Contract } from './entities/contract.entity';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util'; 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 * 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( private inAppStaff(
c: Contract, c: Contract,
title: string, title: string,
@@ -94,7 +108,7 @@ export class ContractNotifierService {
overrides: Partial<NotifyInput> = {}, overrides: Partial<NotifyInput> = {},
): void { ): void {
void this.inbox.notify({ void this.inbox.notify({
recipients: { allBackoffice: true }, recipients: { permissionKeys: [FREIGHT_PERMS.contracts.getNotification] },
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED, type: NotificationType.REQUEST_SUBMITTED,
title, title,
@@ -230,6 +244,7 @@ export class ContractNotifierService {
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`); this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, { this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${c.id}`, link: `/dashboard/gl-djibouti/clearance/${c.id}`,
}); });
@@ -248,6 +263,7 @@ export class ContractNotifierService {
`The customs declaration can now be filed.`; `The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`); this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, { this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}); });
@@ -264,6 +280,7 @@ export class ContractNotifierService {
`"${note}". Review and re-advise the amount on the clearance page.`; `"${note}". Review and re-advise the amount on the clearance page.`;
this.logger.log(`DUTY DISPUTED — ${c.reference}`); this.logger.log(`DUTY DISPUTED — ${c.reference}`);
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, { this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}); });
@@ -321,6 +338,7 @@ export class ContractNotifierService {
'Clearance documents uploaded', 'Clearance documents uploaded',
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`, `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.CLEARANCE_REVIEW, type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}, },
@@ -334,6 +352,7 @@ export class ContractNotifierService {
'Duty slip uploaded', 'Duty slip uploaded',
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`, `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
{ {
recipients: CLEARANCE_DESK,
type: NotificationType.PAYMENT_RECEIVED, type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/contracts/clearance/${c.id}`, link: `/dashboard/contracts/clearance/${c.id}`,
}, },
@@ -347,6 +366,9 @@ export class ContractNotifierService {
'New shipment request', 'New shipment request',
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`, `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}`, link: `/dashboard/shipment-requests/${requestId}`,
data: { contractId: c.id, requestId, reference: requestRef }, data: { contractId: c.id, requestId, reference: requestRef },
}, },

View File

@@ -1,6 +1,7 @@
import { NotificationAudience } from '@edr/types'; import { NotificationAudience } from '@edr/types';
import { MaintenanceService } from './maintenance.service'; 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 * 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(notify).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
// The fleet desk, not every employee in the company.
recipients: {
permissionKeys: [FREIGHT_PERMS.maintenance.getNotification],
},
title: 'Maintenance due — ET-9875', title: 'Maintenance due — ET-9875',
body: expect.stringContaining('driven 50200 km (due at 50000 km)'), body: expect.stringContaining('driven 50200 km (due at 50000 km)'),
}), }),

View File

@@ -15,6 +15,7 @@ import {
UpsertMaintenanceIntervalDto, UpsertMaintenanceIntervalDto,
} from './dto/create-maintenance.dto'; } from './dto/create-maintenance.dto';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@Injectable() @Injectable()
export class MaintenanceService { export class MaintenanceService {
@@ -53,7 +54,9 @@ export class MaintenanceService {
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)` ? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`; : `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
await this.inbox.notify({ await this.inbox.notify({
recipients: { allBackoffice: true }, recipients: {
permissionKeys: [FREIGHT_PERMS.maintenance.getNotification],
},
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC, type: NotificationType.GENERIC,
title: `Maintenance due — ${item.plateNumber}`, title: `Maintenance due — ${item.plateNumber}`,

View File

@@ -14,7 +14,9 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit
* - `companyProfileId` → resolved to its company, then to that company's users. * - `companyProfileId` → resolved to its company, then to that company's users.
* - `organizationId` → all current employees of the org (backoffice staff). * - `organizationId` → all current employees of the org (backoffice staff).
* - `permissionKeys` → current employees (any org) holding any of these * - `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 `<module>:get_notification` keys name which one.
*/ */
@Injectable() @Injectable()
export class NotificationRecipientsService { 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) { if (recipients.permissionKeys?.length) {
try { try {
for (const uid of await this.backoffice.getEmployeeUserIdsByPermission( for (const uid of await this.backoffice.getEmployeeUserIdsByPermission(

View File

@@ -22,6 +22,7 @@ import {
PriorityRuleChangeStatus, PriorityRuleChangeStatus,
} from '../entities/priority-rule-change-request.entity'; } from '../entities/priority-rule-change-request.entity';
import { PriorityConfigsService } from './priority-configs.service'; import { PriorityConfigsService } from './priority-configs.service';
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
/** Backoffice rule-engine page — where both queue and rules live. */ /** Backoffice rule-engine page — where both queue and rules live. */
const RULES_LINK = '/dashboard/rules/priority-configs'; const RULES_LINK = '/dashboard/rules/priority-configs';
@@ -221,7 +222,9 @@ export class PriorityRuleChangeRequestsService {
): void { ): void {
void this.inbox void this.inbox
.notify({ .notify({
recipients: { allBackoffice: true }, recipients: {
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
},
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED, type: NotificationType.REQUEST_SUBMITTED,
title, title,

View File

@@ -19,6 +19,7 @@ import {
} from '../entities/rate-change-request.entity'; } from '../entities/rate-change-request.entity';
import { Rate } from '../entities/rate.entity'; import { Rate } from '../entities/rate.entity';
import { RatesService } from './rates.service'; import { RatesService } from './rates.service';
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
/** Backoffice page where both the queue and the rates live. */ /** Backoffice page where both the queue and the rates live. */
const RATES_LINK = '/dashboard/rules/rates'; const RATES_LINK = '/dashboard/rules/rates';
@@ -234,7 +235,9 @@ export class RateChangeRequestsService {
private notifyTeam(title: string, body: string, request: RateChangeRequest): void { private notifyTeam(title: string, body: string, request: RateChangeRequest): void {
void this.inbox void this.inbox
.notify({ .notify({
recipients: { allBackoffice: true }, recipients: {
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
},
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED, type: NotificationType.REQUEST_SUBMITTED,
title, title,

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 // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop
// (the `ticking` guard would otherwise delay every schedule's transition). // (the `ticking` guard would otherwise delay every schedule's transition).
void this.notifyWindowOpened(schedule); 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}`); this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
return true; 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. // 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. // 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( this.logger.log(
`[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` + `[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` +
`(cycle ${schedule.bookingCycleNo})`, `(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( private async setPhase(
schedule: TrainSchedule, schedule: TrainSchedule,
patch: Partial< 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');
});
});

View File

@@ -8,6 +8,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
interface ItemAttributes { interface ItemAttributes {
arrivedAt: Date | null; arrivedAt: Date | null;
@@ -178,7 +179,11 @@ export class WarehouseFeeService {
const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0); const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0);
try { try {
await this.inbox.notify({ await this.inbox.notify({
recipients: { allBackoffice: true }, recipients: {
permissionKeys: [
FREIGHT_PERMS.warehouseFeeInvoices.getNotification,
],
},
audience: NotificationAudience.BACKOFFICE, audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS, type: NotificationType.BOOKING_STATUS,
title: 'Warehouse fee accruals need attention', title: 'Warehouse fee accruals need attention',

View File

@@ -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 `<module>: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<void> {
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)`,
);
}
}

View File

@@ -1,5 +1,9 @@
import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; 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', () => { describe('EDR_FREIGHT_PERMISSIONS', () => {
// The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE // 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([]);
});
});

View File

@@ -1342,6 +1342,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[] = [ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...CUSTOMER_PERMISSIONS, ...CUSTOMER_PERMISSIONS,
...FINANCE_PERMISSIONS, ...FINANCE_PERMISSIONS,
@@ -1355,6 +1403,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...STAFF_IAM_PERMISSIONS, ...STAFF_IAM_PERMISSIONS,
...AUDIENCE_GAP_PERMISSIONS, ...AUDIENCE_GAP_PERMISSIONS,
...GRANULAR_SPLIT_PERMISSIONS, ...GRANULAR_SPLIT_PERMISSIONS,
...NOTIFICATION_PERMISSIONS,
]; ];
export const BOOKING_RULE_ENGINE_PERMISSIONS = [ export const BOOKING_RULE_ENGINE_PERMISSIONS = [
@@ -1446,6 +1495,10 @@ export const FREIGHT_PERMS = {
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook: wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook", "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: { contracts: {
view: "edr_freight_app:contracts:view", view: "edr_freight_app:contracts:view",
@@ -1482,6 +1535,10 @@ export const FREIGHT_PERMS = {
editDocument: "edr_freight_app:contracts:edit_document", editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", 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: { trainScheduling: {
view: "edr_freight_app:train_scheduling:view", view: "edr_freight_app:train_scheduling:view",
@@ -1510,6 +1567,10 @@ export const FREIGHT_PERMS = {
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:delete`, `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:delete`,
approve: (slug: RuleEngineApprovableSlug) => approve: (slug: RuleEngineApprovableSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`, `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: { allocation: {
manage: "edr_freight_app:allocation:manage", manage: "edr_freight_app:allocation:manage",
@@ -1521,6 +1582,8 @@ export const FREIGHT_PERMS = {
deactivate: "edr_freight_app:customers:deactivate", deactivate: "edr_freight_app:customers:deactivate",
verify: "edr_freight_app:customers:verify", verify: "edr_freight_app:customers:verify",
resetPassword: "edr_freight_app:customers:reset-password", resetPassword: "edr_freight_app:customers:reset-password",
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:customers:get_notification",
}, },
payments: { payments: {
view: "edr_freight_app:payments:view", view: "edr_freight_app:payments:view",
@@ -1646,6 +1709,8 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:maintenance:create", create: "edr_freight_app:maintenance:create",
update: "edr_freight_app:maintenance:update", update: "edr_freight_app:maintenance:update",
delete: "edr_freight_app:maintenance:delete", delete: "edr_freight_app:maintenance:delete",
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:maintenance:get_notification",
}, },
fleetReports: { fleetReports: {
view: "edr_freight_app:fleet_reports:view", view: "edr_freight_app:fleet_reports:view",
@@ -1715,6 +1780,8 @@ export const FREIGHT_PERMS = {
generate: "edr_freight_app:warehouse_fee_invoices:generate", generate: "edr_freight_app:warehouse_fee_invoices:generate",
cancel: "edr_freight_app:warehouse_fee_invoices:cancel", cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay", 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: { settings: {
fileUpload: { fileUpload: {
@@ -1819,6 +1886,40 @@ export const FREIGHT_PERMS = {
}, },
} as const; } as const;
/**
* Backfill sources for the `<module>: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<string, string[]> = {
[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). */ /** Both arms of a freight-type-split permission (for one-of route guards). */
export const bothFreightTypes = (p: { export const bothFreightTypes = (p: {
bulk: string; bulk: string;
@@ -1877,6 +1978,19 @@ const STAFF_DASHBOARD_KEYS: string[] = [
FREIGHT_PERMS.reports.view, 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 = { export const ROLE_PERMISSION_PRESETS = {
// Marketing / line staff: drives a booking from intake through line-staff // Marketing / line staff: drives a booking from intake through line-staff
// approval and contract generation/signing — i.e. until the contract is ready // approval and contract generation/signing — i.e. until the contract is ready
@@ -1899,6 +2013,8 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.editDocument, FREIGHT_PERMS.contracts.editDocument,
...allRuleEngineViewKeys(), ...allRuleEngineViewKeys(),
...BOOKING_DESK_NOTIFICATION_KEYS,
FREIGHT_PERMS.ruleEngine.getNotification,
], ],
// Operations Officer: train scheduling + wagon allocation + transit/complete // Operations Officer: train scheduling + wagon allocation + transit/complete
// + fleet management (wagons, trains, locomotives, routes, containers, cargo). // + fleet management (wagons, trains, locomotives, routes, containers, cargo).
@@ -1931,6 +2047,11 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.finalizeClearance, FREIGHT_PERMS.bookings.finalizeClearance,
...allRuleEngineViewKeys(), ...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: [ director: [
...STAFF_DASHBOARD_KEYS, ...STAFF_DASHBOARD_KEYS,
@@ -1942,6 +2063,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.generateContract, FREIGHT_PERMS.contracts.generateContract,
...allRuleEngineViewKeys(), ...allRuleEngineViewKeys(),
...BOOKING_DESK_NOTIFICATION_KEYS,
], ],
ceo: [ ceo: [
...STAFF_DASHBOARD_KEYS, ...STAFF_DASHBOARD_KEYS,
@@ -1951,6 +2073,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.approveCeo, FREIGHT_PERMS.contracts.approveCeo,
...allRuleEngineViewKeys(), ...allRuleEngineViewKeys(),
...BOOKING_DESK_NOTIFICATION_KEYS,
], ],
finance: [ finance: [
...STAFF_DASHBOARD_KEYS, ...STAFF_DASHBOARD_KEYS,
@@ -1982,6 +2105,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.uploadClearanceOutput,
FREIGHT_PERMS.bookings.finalizeClearance, FREIGHT_PERMS.bookings.finalizeClearance,
FREIGHT_PERMS.bookings.operations, FREIGHT_PERMS.bookings.operations,
...CLEARANCE_DESK_NOTIFICATION_KEYS,
], ],
// GL Djibouti (edr_gl_djibouti): DO/RO collection, gatepass, loading milestones, // GL Djibouti (edr_gl_djibouti): DO/RO collection, gatepass, loading milestones,
// damage reports. Read-only on the contract; no booking creation. // damage reports. Read-only on the contract; no booking creation.
@@ -1993,6 +2117,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.uploadClearanceOutput,
FREIGHT_PERMS.bookings.operations, FREIGHT_PERMS.bookings.operations,
...CLEARANCE_DESK_NOTIFICATION_KEYS,
], ],
// Marketing handles intake through contract (same as line staff here) and, // Marketing handles intake through contract (same as line staff here) and,
// for non-customs bookings, reviews/finalizes the customer's clearance // for non-customs bookings, reviews/finalizes the customer's clearance
@@ -2022,6 +2147,7 @@ export const ROLE_PERMISSION_PRESETS = {
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
FREIGHT_PERMS.contracts.suspend, FREIGHT_PERMS.contracts.suspend,
FREIGHT_PERMS.contracts.editDocument, FREIGHT_PERMS.contracts.editDocument,
...BOOKING_DESK_NOTIFICATION_KEYS,
], ],
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
} as const; } as const;
@@ -2048,6 +2174,8 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.customers.view, FREIGHT_PERMS.customers.view,
FREIGHT_PERMS.customers.verify, FREIGHT_PERMS.customers.verify,
FREIGHT_PERMS.customers.deactivate, 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. // The chief also owns the customer-facing support inbox.
FREIGHT_PERMS.support.agentView, FREIGHT_PERMS.support.agentView,
FREIGHT_PERMS.support.agentSend, FREIGHT_PERMS.support.agentSend,

View File

@@ -16,6 +16,7 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { import type {
CompanyNationality,
CompanyProfile, CompanyProfile,
CompanyStatus, CompanyStatus,
CompanyType, CompanyType,
@@ -88,6 +89,31 @@ export function CompanyTypeBadge({ type }: { type: CompanyType }) {
); );
} }
const NATIONALITY_COLOR: Record<CompanyNationality, string> = {
ethiopian: "edr-green",
foreign: "blue",
};
export function CompanyNationalityBadge({
nationality,
}: {
nationality?: CompanyNationality | null;
}) {
if (!nationality) return null;
return (
<Badge
color={NATIONALITY_COLOR[nationality] ?? "gray"}
variant="light"
size="sm"
radius="md"
fw={600}
style={badgeStyle}
>
{humanize(nationality)}
</Badge>
);
}
/** /**
* Profile chips for a company row: one chip per role (Importer / Exporter / …) * 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 * carrying its reference code. Caps at three (a company has at most three

View File

@@ -1,5 +1,6 @@
export { export {
BookingStatusBadge, BookingStatusBadge,
CompanyNationalityBadge,
CompanyStatusBadge, CompanyStatusBadge,
CompanyTypeBadge, CompanyTypeBadge,
InvoiceStatusBadge, InvoiceStatusBadge,

View File

@@ -812,6 +812,14 @@ export default function CustomerDetailPage() {
} }
/> />
<InfoField label="Country" value={company.country} /> <InfoField label="Country" value={company.country} />
<InfoField
label="Nationality"
value={
company.nationality
? humanize(company.nationality)
: undefined
}
/>
<InfoField label="Address" value={company.address} /> <InfoField label="Address" value={company.address} />
<InfoField label="Website" value={company.website} /> <InfoField label="Website" value={company.website} />
<InfoField label="Email" value={company.email} /> <InfoField label="Email" value={company.email} />

View File

@@ -31,6 +31,7 @@ import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
CompanyNationalityBadge,
CompanyStatusBadge, CompanyStatusBadge,
ProfileChips, ProfileChips,
formatDate, formatDate,
@@ -149,6 +150,7 @@ export default function CustomersPage() {
<Text fw={600} c="edr-text" truncate> <Text fw={600} c="edr-text" truncate>
{c.name} {c.name}
</Text> </Text>
<CompanyNationalityBadge nationality={c.nationality} />
</Group> </Group>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
TIN {c.tin} TIN {c.tin}

View File

@@ -21,6 +21,9 @@ export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted";
/** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */ /** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */
export type CompanyKind = "commercial" | "government"; export type CompanyKind = "commercial" | "government";
/** Mirrors backend `CompanyNationality`. */
export type CompanyNationality = "ethiopian" | "foreign";
/** Mirrors backend `ProfileType` (the role a company plays). */ /** Mirrors backend `ProfileType` (the role a company plays). */
export type ProfileType = export type ProfileType =
| "importer" | "importer"
@@ -207,6 +210,7 @@ export interface Company {
vatNumber?: string | null; vatNumber?: string | null;
fanNumber?: string | null; fanNumber?: string | null;
country: string; country: string;
nationality?: CompanyNationality | null;
address?: string | null; address?: string | null;
phone?: string | null; phone?: string | null;
email?: string | null; email?: string | null;

View File

@@ -90,13 +90,16 @@ export interface NotificationRecipients {
companyProfileId?: string; companyProfileId?: string;
/** Backoffice: all current employees of this organization. */ /** Backoffice: all current employees of this organization. */
organizationId?: string; organizationId?: string;
/** Backoffice: every current employee across all organizations. */
allBackoffice?: boolean;
/** /**
* Backoffice: current employees (any org) who hold ANY of these permission * Backoffice: current employees (any org) who hold ANY of these permission
* keys — e.g. notify only marketing, not every employee. Super/org admins * keys. Resolution mirrors the request-time guard `hasFreightPermission` —
* are not implicitly included; add `allBackoffice`/explicit userIds too if * IAM role grants, direct position grants, position TYPE grants, delegated
* admins should also see it. * 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 `<module>:get_notification` keys over reusing a domain
* key: they let ops tune who gets pinged without touching who has access.
*/ */
permissionKeys?: string[]; permissionKeys?: string[];
} }