import { Injectable, Logger } from '@nestjs/common'; import { Application, Organization, Permission, Position, PositionPermission, Unit, } from '@tria-plc/iamapi-common'; import { DataSource, EntityManager, In } from 'typeorm'; 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'; const EDR_UNIT_KEY = 'edr_freight_app'; /** * Seeds the operational freight positions (CEO, Chief, Director, Marketer, * Operation, Ethiopian GL, Djibouti GL) as Position + PositionPermission rows * on the `edr_freight_app` unit. Positions-as-roles: users get their freight * access by being assigned to a Position (via EmployeePosition), and the * position's PositionPermission grants come from EDR_FREIGHT_POSITIONS. * * Gated behind the same SEED_EDR_ORG flag as EdrOrgSeeder and depends on the * org/unit/permission catalog it seeds, so it must run AFTER EdrOrgSeeder. * Idempotent: positions upsert by (key, unitId); grants insert only the * permission ids a position is still missing. */ @Injectable() export class FreightPositionsSeeder { private readonly logger = new Logger(FreightPositionsSeeder.name); constructor(private readonly dataSource: DataSource) {} async run() { if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { this.logger.log( `Skipping freight positions seed because ${SEED_FLAG} is not enabled`, ); return; } await this.dataSource.transaction(async (manager) => { const organization = await manager.getRepository(Organization).findOne({ where: { key: EDR_ORG_KEY }, select: { id: true }, }); if (!organization) { throw new Error(`missing_organization:${EDR_ORG_KEY}`); } const unit = await manager.getRepository(Unit).findOne({ where: { key: EDR_UNIT_KEY, organizationId: organization.id }, select: { id: true }, }); if (!unit) { throw new Error(`missing_unit:${EDR_UNIT_KEY}`); } const permissionKeyToId = await this.loadPermissionIds(manager); for (const seed of EDR_FREIGHT_POSITIONS) { const positionId = await this.ensurePosition( manager, seed, unit.id as string, organization.id as string, ); await this.ensurePositionPermissions( manager, positionId, seed, permissionKeyToId, ); } }); this.logger.log( `Ensured ${EDR_FREIGHT_POSITIONS.length} freight positions on unit '${EDR_UNIT_KEY}'`, ); } /** * 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:` 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> { const keys = [ ...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)), ]; 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) { 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; } private async ensurePosition( manager: EntityManager, seed: (typeof EDR_FREIGHT_POSITIONS)[number], unitId: string, organizationId: string, ): Promise { const positionRepository = manager.getRepository(Position); const existing = await positionRepository.findOne({ where: { key: seed.key, unitId }, select: { id: true }, }); if (existing) { return existing.id as string; } const inserted = await positionRepository.insert({ key: seed.key, name: { ...seed.name }, rank: seed.rank, unitId, organizationId, }); this.logger.log(`Seeded freight position '${seed.key}'`); return inserted.identifiers[0]?.id as string; } private async ensurePositionPermissions( manager: EntityManager, positionId: string, seed: (typeof EDR_FREIGHT_POSITIONS)[number], permissionKeyToId: Map, ) { const positionPermissionRepository = manager.getRepository(PositionPermission); const existing = await positionPermissionRepository.find({ where: { positionId }, select: { permissionId: true }, }); const existingPermissionIds = new Set( existing.map((row) => row.permissionId), ); const rowsToInsert = seed.permissionKeys .map((key) => permissionKeyToId.get(key) as string) .filter((permissionId) => !existingPermissionIds.has(permissionId)) .map((permissionId) => ({ positionId, permissionId })); if (rowsToInsert.length === 0) { return; } await positionPermissionRepository.insert(rowsToInsert); this.logger.log( `Granted ${rowsToInsert.length} permissions to position '${seed.key}'`, ); } }