mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
@@ -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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] = [
|
||||
...CUSTOMER_PERMISSIONS,
|
||||
...FINANCE_PERMISSIONS,
|
||||
@@ -1355,6 +1403,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
...STAFF_IAM_PERMISSIONS,
|
||||
...AUDIENCE_GAP_PERMISSIONS,
|
||||
...GRANULAR_SPLIT_PERMISSIONS,
|
||||
...NOTIFICATION_PERMISSIONS,
|
||||
];
|
||||
|
||||
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
|
||||
@@ -1446,6 +1495,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",
|
||||
@@ -1482,6 +1535,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",
|
||||
@@ -1510,6 +1567,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",
|
||||
@@ -1521,6 +1582,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",
|
||||
@@ -1646,6 +1709,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",
|
||||
@@ -1715,6 +1780,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: {
|
||||
@@ -1819,6 +1886,40 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
} 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). */
|
||||
export const bothFreightTypes = (p: {
|
||||
bulk: string;
|
||||
@@ -1877,6 +1978,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
|
||||
@@ -1899,6 +2013,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).
|
||||
@@ -1931,6 +2047,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,
|
||||
@@ -1942,6 +2063,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.contracts.approveDirector,
|
||||
FREIGHT_PERMS.contracts.generateContract,
|
||||
...allRuleEngineViewKeys(),
|
||||
...BOOKING_DESK_NOTIFICATION_KEYS,
|
||||
],
|
||||
ceo: [
|
||||
...STAFF_DASHBOARD_KEYS,
|
||||
@@ -1951,6 +2073,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.approveCeo,
|
||||
...allRuleEngineViewKeys(),
|
||||
...BOOKING_DESK_NOTIFICATION_KEYS,
|
||||
],
|
||||
finance: [
|
||||
...STAFF_DASHBOARD_KEYS,
|
||||
@@ -1982,6 +2105,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.
|
||||
@@ -1993,6 +2117,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
|
||||
@@ -2022,6 +2147,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;
|
||||
@@ -2048,6 +2174,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,
|
||||
|
||||
Reference in New Issue
Block a user