Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-12 08:03:10 +00:00
95 changed files with 7755 additions and 859 deletions

View File

@@ -191,6 +191,24 @@ const POSITION_TYPE_PERMISSIONS = [
},
] as const;
/**
* Audit trail access.
*
* Read-only by design: `audit_logs` rows are written solely by
* `AuditInterceptor` and the module exposes no create/update/delete route, so
* there is deliberately no `:create` / `:update` / `:delete` twin to grant.
* The matching `:read` key is derived automatically by
* `deriveReadPermissions` below.
*/
const AUDIT_LOG_PERMISSIONS = [
{
id: "52b2cdef-5313-4954-8c69-0f8c58ea2c1e",
key: "edr_freight_app:audit_log:view",
name: { am: "የኦዲት መዝገብ ይመልከቱ", en: "View audit logs" },
applicationKey: EDR_FREIGHT_APPLICATION.key,
},
] as const;
const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [
...EMPLOYEE_REGISTRATION_PERMISSIONS,
...ROLE_ASSIGNMENT_PERMISSIONS,
@@ -198,6 +216,7 @@ const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [
...HIERARCHY_POSITION_PERMISSIONS,
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS,
...POSITION_TYPE_PERMISSIONS,
...AUDIT_LOG_PERMISSIONS,
...BOOKING_RULE_ENGINE_PERMISSIONS,
];

View File

@@ -707,6 +707,29 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:trains:assign_wagons",
"Assign wagons to train",
),
// Granular splits of trains:update / trains:delete for the train-builder
// detail page's Actions menu — each item gets its own grant instead of
// sharing the coarse update/delete keys.
perm(
"e1c00001-0001-4000-8000-000000000006",
"edr_freight_app:trains:change_locomotives",
"Change train locomotives",
),
perm(
"e1c00001-0001-4000-8000-000000000007",
"edr_freight_app:trains:change_yard",
"Change train yard",
),
perm(
"e1c00001-0001-4000-8000-000000000008",
"edr_freight_app:trains:toggle_active",
"Activate or deactivate train",
),
perm(
"e1c00001-0001-4000-8000-000000000009",
"edr_freight_app:trains:disband",
"Disband train",
),
perm(
"e1d00001-0001-4000-8000-000000000001",
"edr_freight_app:routes:view",
@@ -1622,11 +1645,24 @@ export const FREIGHT_PERMS = {
dispatch: "edr_freight_app:train_scheduling:dispatch",
markPaid: "edr_freight_app:train_scheduling:mark_paid",
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
/**
* Edit a schedule's operational run numbers (train + voyage) before
* dispatch. Separate from `update`: these numbers are what yards and
* customs quote, so changing them is narrower than general scheduling edits.
*/
editTrainNumber: "edr_freight_app:train_scheduling:edit_train_number",
},
fleet: {
view: "edr_freight_app:fleet:view",
manage: "edr_freight_app:fleet:manage",
},
/**
* Audit trail. View-only: the module has no write routes, so this is the
* only key it needs — see AUDIT_LOG_PERMISSIONS in edr-freight.seed.ts.
*/
auditLog: {
view: "edr_freight_app:audit_log:view",
},
admin: "edr_freight_app:admin",
ruleEngine: {
view: (slug: RuleEngineResourceSlug) =>
@@ -1732,6 +1768,10 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:trains:update",
delete: "edr_freight_app:trains:delete",
assignWagons: "edr_freight_app:trains:assign_wagons",
changeLocomotives: "edr_freight_app:trains:change_locomotives",
changeYard: "edr_freight_app:trains:change_yard",
toggleActive: "edr_freight_app:trains:toggle_active",
disband: "edr_freight_app:trains:disband",
},
routes: {
view: "edr_freight_app:routes:view",
@@ -1895,9 +1935,6 @@ export const FREIGHT_PERMS = {
manage: "edr_freight_app:settings:support_content:manage",
},
},
audit: {
view: "edr_freight_app:audit:view",
},
support: {
agentView: "edr_freight_app:support:agent_view",
agentSend: "edr_freight_app:support:agent_send",
@@ -2049,6 +2086,10 @@ const FLEET_GRANULAR_KEYS: string[] = [
FREIGHT_PERMS.trains.update,
FREIGHT_PERMS.trains.delete,
FREIGHT_PERMS.trains.assignWagons,
FREIGHT_PERMS.trains.changeLocomotives,
FREIGHT_PERMS.trains.changeYard,
FREIGHT_PERMS.trains.toggleActive,
FREIGHT_PERMS.trains.disband,
FREIGHT_PERMS.routes.view,
FREIGHT_PERMS.routes.create,
FREIGHT_PERMS.routes.update,
@@ -2129,6 +2170,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.dispatch,
FREIGHT_PERMS.trainScheduling.markPaid,
FREIGHT_PERMS.trainScheduling.expireBooking,
FREIGHT_PERMS.trainScheduling.editTrainNumber,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...FLEET_GRANULAR_KEYS,
@@ -2284,7 +2326,8 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.payments.view,
]),
// Director additionally manages train scheduling + rail fleet (same block the
// operation officer/chief hold), on top of the approval-chain role preset.
// operation officer/chief hold), on top of the approval-chain role preset,
// and carries the same full warehouse authority the chief tier holds.
director: dedupe([
...ROLE_PERMISSION_PRESETS.director,
FREIGHT_PERMS.trainScheduling.view,
@@ -2293,9 +2336,22 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.cancel,
FREIGHT_PERMS.trainScheduling.reschedule,
FREIGHT_PERMS.trainScheduling.rulesManage,
FREIGHT_PERMS.trainScheduling.editTrainNumber,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...FLEET_GRANULAR_KEYS,
// Warehouse — full CRUD, matching the chief tier. Unlike the dispatcher,
// the director also owns the allocation and fee rules themselves.
FREIGHT_PERMS.warehouseDashboard.view,
...Object.values(FREIGHT_PERMS.warehouses),
...Object.values(FREIGHT_PERMS.warehouseYards),
...Object.values(FREIGHT_PERMS.warehouseZones),
...Object.values(FREIGHT_PERMS.warehouseAllocationRules),
...Object.values(FREIGHT_PERMS.warehouseFeeRules),
...Object.values(FREIGHT_PERMS.warehouseInventory),
...Object.values(FREIGHT_PERMS.warehouseInspectionReports),
...Object.values(FREIGHT_PERMS.interchangeDocuments),
...Object.values(FREIGHT_PERMS.warehouseFeeInvoices),
]),
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]),

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import {
Application,
Organization,
Permission,
Position,
@@ -8,7 +9,23 @@ import {
} from '@tria-plc/iamapi-common';
import { DataSource, EntityManager, In } from 'typeorm';
import { EDR_FREIGHT_POSITIONS } from './edr-freight.seed';
import { EDR_FREIGHT_APPLICATION, EDR_FREIGHT_POSITIONS } from './edr-freight.seed';
/**
* Turn a permission key into a readable fallback name for a row this seeder has
* to mint itself: `edr_freight_app:train_scheduling:edit_train_number` becomes
* "Train scheduling: edit train number". Only used for keys absent from the
* catalog — a key that IS catalogued keeps its curated Amharic/English name.
*/
const nameForKey = (key: string): { en: string } => {
const [, ...rest] = key.split(':');
const [resource, ...action] = rest;
const humanize = (s: string) => s.replace(/_/g, ' ');
const label = action.length
? `${humanize(resource)}: ${humanize(action.join(' '))}`
: humanize(resource);
return { en: label.charAt(0).toUpperCase() + label.slice(1) };
};
const SEED_FLAG = 'SEED_EDR_ORG';
const EDR_ORG_KEY = 'edr_freight';
@@ -83,7 +100,19 @@ export class FreightPositionsSeeder {
);
}
/** Resolve every permission key referenced by any position to its id. */
/**
* Resolve every permission key referenced by any position to its id, minting
* the rows that do not exist yet.
*
* The position presets draw from FREIGHT_PERMS (the registry), which is
* broader than the EDR_FREIGHT_PERMISSIONS catalog EdrOrgSeeder inserts —
* module keys like `train_scheduling:*` live only in the registry. So every
* newly-added preset key would otherwise abort boot with
* `missing_permissions:<key>` until someone hand-inserted it. Ensuring them
* here keeps this seeder self-sufficient: it declares the keys it needs, so
* it is the one that guarantees they exist. Same approach, and the same
* id-less insert reasoning, as FreightNotificationPermissionsSeeder.
*/
private async loadPermissionIds(
manager: EntityManager,
): Promise<Map<string, string>> {
@@ -91,16 +120,54 @@ export class FreightPositionsSeeder {
...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)),
];
const permissions = await manager.getRepository(Permission).find({
where: { key: In(keys) },
select: { id: true, key: true },
});
const map = new Map(permissions.map((p) => [p.key, p.id as string]));
const read = async () => {
const rows = await manager.getRepository(Permission).find({
where: { key: In(keys) },
select: { id: true, key: true },
});
return new Map(rows.map((p) => [p.key, p.id as string]));
};
let map = await read();
const missing = keys.filter((key) => !map.has(key));
if (missing.length > 0) {
throw new Error(`missing_permissions:${missing.join(',')}`);
if (missing.length === 0) {
return map;
}
const application = await manager.getRepository(Application).findOne({
where: { key: EDR_FREIGHT_APPLICATION.key },
select: { id: true },
});
if (!application?.id) {
throw new Error(`missing_application:${EDR_FREIGHT_APPLICATION.key}`);
}
// 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 already owned by a retired key would slip past
// ON CONFLICT (key) and die on the PK.
await manager
.createQueryBuilder()
.insert()
.into(Permission)
.values(
missing.map((key) => ({
key,
name: nameForKey(key),
applicationId: application.id as string,
})),
)
.orIgnore()
.execute();
this.logger.log(
`Seeded ${missing.length} permission(s) referenced by positions but absent from the catalog: ${missing.join(', ')}`,
);
map = await read();
const stillMissing = keys.filter((key) => !map.has(key));
if (stillMissing.length > 0) {
throw new Error(`missing_permissions:${stillMissing.join(',')}`);
}
return map;