From 1fe10ee3c22c1476da9f2b66450e74e99c667061 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 27 Jul 2026 07:09:09 +0000 Subject: [PATCH] feat(seed): implement local IAM baseline seeder with configurable settings --- apps/edr-freight-api/.env.example | 7 + apps/edr-freight-api/src/app.module.ts | 20 +- .../src/seed/iam-baseline.seed.ts | 611 ++++++++++++++++++ .../src/seed/iam-baseline.seeder.ts | 468 ++++++++++++++ .../src/seed/missing-settings.util.spec.ts | 27 + .../src/seed/missing-settings.util.ts | 20 + 6 files changed, 1149 insertions(+), 4 deletions(-) create mode 100644 apps/edr-freight-api/src/seed/iam-baseline.seed.ts create mode 100644 apps/edr-freight-api/src/seed/iam-baseline.seeder.ts create mode 100644 apps/edr-freight-api/src/seed/missing-settings.util.spec.ts create mode 100644 apps/edr-freight-api/src/seed/missing-settings.util.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index d80ce75c6..4df20a9a2 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -42,8 +42,15 @@ JWT_REFRESH_TOKEN_EXPIRES=7d # IAM seed defaults (used by @tria-plc/iamapi-common on first boot) SUPER_ADMIN_EMAIL=superadmin@tria.com SUPER_ADMIN_PHONE= +# Super-admin password. Falls back to DEFAULT_PASSWORD when empty. +SUPER_ADMIN_PASSWORD= DEFAULT_PASSWORD=password@tria +# IAM baseline (roles, IAM app + permissions, position types, org/unit settings, +# super-admin account). Local replacement for the package DataSeeder — +# see src/seed/iam-baseline.seed.ts. +SEED_IAM_BASELINE=false + # Freight org + staff (bookings / rule-engine IAM) SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index aab9e0e4a..2e2da880f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,7 +12,7 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; -import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; +import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import appConfig from "./config/app.config"; @@ -55,6 +55,7 @@ import { EDR_FREIGHT_PERMISSIONS, } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; +import { IamBaselineSeeder } from "./seed/iam-baseline.seeder"; import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; // Disabled seeds — imports commented out with their provider/injection/run below. // import { DemoUsersSeeder } from "./seed/demo-users.seeder"; @@ -206,6 +207,7 @@ import { LoggerMiddleware } from "./logger.middleware"; AiModule, ], providers: [ + IamBaselineSeeder, EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, @@ -231,7 +233,7 @@ import { LoggerMiddleware } from "./logger.middleware"; }) export class AppModule implements OnApplicationBootstrap { constructor( - private readonly seeder: DataSeeder, + private readonly iamBaselineSeeder: IamBaselineSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, @@ -261,13 +263,23 @@ export class AppModule implements OnApplicationBootstrap { // Permissions foundation — keep enabled: // freightPermissionKeyMigration → renames legacy permission keys - // seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions // edrOrgSeeder → seeds org/unit + the Permission catalog + // iamBaselineSeeder → seeds the IAM app, roles, permissions, + // position types, org/unit settings and the + // super-admin account. Local replacement for + // the package's DataSeeder (still exported as + // `DataSeeder` from @tria-plc/iamapi-common + // and runnable via `pnpm iam:seed:run`) — + // see src/seed/iam-baseline.seed.ts for + // what it seeds and what it drops. Runs after + // edrOrgSeeder because the super admin is + // attached to the edr_freight org/unit. + // Writes nothing unless SEED_IAM_BASELINE=true. // freightPositionsSeeder → seeds Position + PositionPermission rows // (depends on edrOrgSeeder, must run after) await this.freightPermissionKeyMigrationSeeder.run(); - await this.seeder.run(); await this.edrOrgSeeder.run(); + await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); // File upload settings — keep enabled. diff --git a/apps/edr-freight-api/src/seed/iam-baseline.seed.ts b/apps/edr-freight-api/src/seed/iam-baseline.seed.ts new file mode 100644 index 000000000..f55fa125c --- /dev/null +++ b/apps/edr-freight-api/src/seed/iam-baseline.seed.ts @@ -0,0 +1,611 @@ +/** + * IAM baseline seed data — the rows `IamBaselineSeeder` writes. + * + * These started as a copy of the seed constants inside + * `@tria-plc/iamapi-common` (`dist/db/seed/role.seed`, + * `organization-setting.seed`, `unit-setting.seed`) as of 0.7.12, and are now + * owned here: edit, extend or delete anything below and the seeder follows. + * Nothing in this file is imported from the package, so a version bump cannot + * change what gets seeded — it also will not hand you new IAM permissions, so + * diff against those files when upgrading. + * + * Ids are the package's originals. Keep them: the rows already in every + * environment carry these ids. + * + * Deliberately NOT seeded (the package's DataSeeder does all of it): + * - TRIA super-admin organization + its `superadmin` user and password + * - 11 Addis Ababa sub-city organizations + * - woreda / subcity / office / branch organization types + 65 default units + * - the 7 non-IAM Smart Office applications (booking, bot, chronicle, dms, + * metabase, performance, record) + */ + +export type LocalizedName = { am: string; en: string }; + +export type SeedApplication = { id: string; key: string; name: LocalizedName }; + +export type SeedRole = { id: string; key: string; name: LocalizedName }; + +export type SeedPermission = { + id: string; + key: string; + /** Omitted for the 21 org/unit/location permissions the package leaves unlinked. */ + applicationKey?: string; + name: LocalizedName; +}; + +export type SeedPositionType = { + id: string; + key: string; + isSystem: boolean; + name: LocalizedName; +}; + +export type SeedSuperAdmin = { + username: string; + name: LocalizedName; + /** Key in `roles` — the grant that carries the access. */ + roleKey: string; + /** Existing organization/unit to attach the employee to. */ + organizationKey: string; + unitKey: string; + /** Used when SUPER_ADMIN_EMAIL / SUPER_ADMIN_PASSWORD / DEFAULT_PASSWORD are all unset. */ + fallbackEmail: string; + fallbackPassword: string; +}; + +export type SettingDefault = { + key: string; + displayName: string; + /** Matches the package's ESettingType. */ + type: "value" | "file"; + value?: string | null; +}; + +/** Applications permissions hang off. `edr_freight_app` is seeded by EdrOrgSeeder. */ +const APPLICATIONS: SeedApplication[] = [ + { + id: "019bcb17-5470-7604-8708-7ed04d842b41", + key: "iam", + name: { am: "የስማርት ኦፊስ ማንነት እና መዳረሻ አስተዳደር", en: "Smart Office Identity and Access Management" }, + }, +]; + +const ROLES: SeedRole[] = [ + { + id: "520836ee-dc13-4c08-b572-8eced5bfd309", + key: "super_admin", + name: { am: "ዋና ተቆጣጣሪ", en: "Super Admin" }, + }, + { + id: "b2de1eae-ef93-4e90-8ec9-351f0dd8a6a9", + key: "organization_admin", + name: { am: "የመስሪያ ቤት ዋና ተቆጣጣሪ", en: "Organization Admin" }, + }, + { + id: "b3a9a5b5-9825-4290-8498-c62fb5925acd", + key: "unit_admin", + name: { am: "የመስሪያ ቤት ጽሕፈት ቤት ዋና ተቆጣጣሪ", en: "Organization Unit Admin" }, + }, + { + id: "ffe82427-ab16-4571-913c-553deb1b0f0f", + key: "guest", + name: { am: "ተጠቃሚ", en: "Guest" }, + }, +]; + +const PERMISSIONS: SeedPermission[] = [ + { + id: "019b5993-0000-0000-0000-000000000001", + key: "can:create:role", + applicationKey: "iam", + name: { am: "ሚና መፍጠር", en: "Create Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000002", + key: "can:update:role", + applicationKey: "iam", + name: { am: "ሚና ማሻሻል", en: "Update Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000003", + key: "can:delete:role", + applicationKey: "iam", + name: { am: "ሚና ማጥፋት", en: "Delete Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000006", + key: "can:update:permission", + applicationKey: "iam", + name: { am: "ፈቃድ ማሻሻል", en: "Update Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000007", + key: "can:delete:permission", + applicationKey: "iam", + name: { am: "ፈቃድ ማጥፋት", en: "Delete Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000009", + key: "can:create:role_permission", + applicationKey: "iam", + name: { am: "የሚና-ፈቃድ መፍጠር", en: "Create Role-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000010", + key: "can:delete:role_permission", + applicationKey: "iam", + name: { am: "የሚና-ፈቃድ ማጥፋት", en: "Delete Role-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000011", + key: "can:view:role_permission", + applicationKey: "iam", + name: { am: "የሚና-ፈቃድ መመልከት", en: "View Role-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000012", + key: "can:create:user_role", + applicationKey: "iam", + name: { am: "የተጠቃሚ-ሚና መፍጠር", en: "Create User-Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000013", + key: "can:delete:user_role", + applicationKey: "iam", + name: { am: "የተጠቃሚ-ሚና ማጥፋት", en: "Delete User-Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000014", + key: "can:view:user_role", + applicationKey: "iam", + name: { am: "የተጠቃሚ-ሚና መመልከት", en: "View User-Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000015", + key: "can:create:position_permission", + applicationKey: "iam", + name: { am: "የመደብ-ሚና መፍጠር", en: "Create Position-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000016", + key: "can:delete:position_permission", + applicationKey: "iam", + name: { am: "የመደብ-ሚና ማጥፋት", en: "Delete Position-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000017", + key: "can:view:position_permission", + applicationKey: "iam", + name: { am: "የመደብ-ሚና መመልከት", en: "View Position-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000018", + key: "can:find_all:organization", + name: { am: "ሁሉንም ድርጅቶች መፈለግ", en: "Find All Organizations" }, + }, + { + id: "019b5993-0000-0000-0000-000000000019", + key: "can:update:organization", + name: { am: "ድርጅት ማሻሻል", en: "Update Organization" }, + }, + { + id: "019b5993-0000-0000-0000-000000000020", + key: "can:delete:organization", + name: { am: "ድርጅት ማጥፋት", en: "Delete Organization" }, + }, + { + id: "019b5993-0000-0000-0000-000000000021", + key: "can:create:unit", + name: { am: "ክፍል መፍጠር", en: "Create Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000022", + key: "can:update:unit", + name: { am: "ክፍል ማሻሻል", en: "Update Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000023", + key: "can:delete:unit", + name: { am: "ክፍል ማጥፋት", en: "Delete Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000024", + key: "can:create:default_unit", + name: { am: "የዩኒት አይነት መፍጠር", en: "Create Default Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000025", + key: "can:update:default_unit", + name: { am: "የዩኒት አይነት ማሻሻል", en: "Update Default Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000026", + key: "can:delete:default_unit", + name: { am: "የዩኒት አይነት ማጥፋት", en: "Delete Default Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000027", + key: "can:create:default_position", + name: { am: "የስራ መደብ አይነት መፍጠር", en: "Create Default Position" }, + }, + { + id: "019b5993-0000-0000-0000-000000000028", + key: "can:update:default_position", + name: { am: "የስራ መደብ አይነት ማሻሻል", en: "Update Default Position" }, + }, + { + id: "019b5993-0000-0000-0000-000000000029", + key: "can:delete:default_position", + name: { am: "የስራ መደብ አይነት ማጥፋት", en: "Delete Default Position" }, + }, + { + id: "019b5993-0000-0000-0000-000000000030", + key: "can:create:organization_type", + name: { am: "የድርጅት አይነት መፍጠር", en: "Create Organization Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000031", + key: "can:update:organization_type", + name: { am: "የድርጅት አይነት ማሻሻል", en: "Update Organization Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000032", + key: "can:delete:organization_type", + name: { am: "የድርጅት አይነት ማጥፋት", en: "Delete Organization Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000033", + key: "can:create:location_type", + name: { am: "የአካባቢ አይነት መፍጠር", en: "Create Location Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000034", + key: "can:update:location_type", + name: { am: "የአካባቢ አይነት ማሻሻል", en: "Update Location Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000035", + key: "can:delete:location_type", + name: { am: "የአካባቢ አይነት ማጥፋት", en: "Delete Location Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000036", + key: "can:create:location", + name: { am: "አካባቢ መፍጠር", en: "Create Location" }, + }, + { + id: "019b5993-0000-0000-0000-000000000037", + key: "can:update:location", + name: { am: "አካባቢ ማሻሻል", en: "Update Location" }, + }, + { + id: "019b5993-0000-0000-0000-000000000038", + key: "can:delete:location", + name: { am: "አካባቢ ማጥፋት", en: "Delete Location" }, + }, + { + id: "a9a7c0fa-e4fc-4c0e-b1c2-f74f9da40421", + key: "create:organization", + applicationKey: "iam", + name: { am: "የመስሪያ ቤት መፍጠር", en: "Create Organization" }, + }, + { + id: "c807691b-2693-4079-9dc5-1e080b67006c", + key: "activate:organization", + applicationKey: "iam", + name: { am: "የመስሪያ ቤት አስተካክል", en: "Activate Organization" }, + }, + { + id: "457a3659-ed96-456a-a6a1-2881226a86ed", + key: "can:debarOrganization", + applicationKey: "iam", + name: { am: "መቼት መቆጣጠር ይችላል", en: "Debar Organization" }, + }, + { + id: "2cd9e3c5-bd41-48f3-a849-f497b18b2906", + key: "manage:organizationAdmin", + applicationKey: "iam", + name: { am: "መቼት መቆጣጠር ይችላል", en: "Manage Organization Admin" }, + }, + { + id: "257d8c6e-ef30-4510-892f-d4a2ad4c814d", + key: "manage:unitAdmin", + applicationKey: "iam", + name: { am: "የጽሕፈት ቤት መቼት መቆጣጠር ይችላል", en: "Manage Unit Admin" }, + }, + { + id: "4052b7b8-e9f6-4bfe-9b93-eb59f9e4e576", + key: "can:createEmployee", + applicationKey: "iam", + name: { am: "ሰራተኞችን መመደብ/መፍጠር ይችላሉ", en: "Can create employees" }, + }, + { + id: "965ec76d-bbdb-47a1-a916-07c52f609fa7", + key: "can:deactivateEmployee", + applicationKey: "iam", + name: { am: "ሰራተኞችን ማባረር ይችላሉ", en: "Can deactivate employees" }, + }, + { + id: "019cbdc6-3d7a-73aa-ac57-51436dfa50e9", + key: "can:activateEmployee", + applicationKey: "iam", + name: { am: "ሰራተኞችን መቀበል ይችላሉ", en: "Can activate employees" }, + }, + { + id: "ebd8cf49-243d-4887-b2c9-cbe61e86a17a", + key: "can:uploadUserCSV", + applicationKey: "iam", + name: { am: "የሰራተኞችን መረጃ መጫን ይችላል", en: "Can Upload User CSV" }, + }, + { + id: "68bebc03-c1a8-832f-ae5a-b66553e6bcef", + key: "can:exportUnitUsers", + applicationKey: "iam", + name: { am: "የተቀጣሪዎችን መረጃ ማውጣት ይችላሉ", en: "Can Export Unit Users" }, + }, + { + id: "c2566286-248e-4cb4-923d-b113d6a5d4ba", + key: "can:changeUsersProfile", + applicationKey: "iam", + name: { am: "ተጠቃሚዎች መግለጫ መቀየር ይችላል", en: "Can Change Users Profile" }, + }, + { + id: "14f438c6-9295-44a0-a4f3-4ac9855efc37", + key: "can:activateUser", + applicationKey: "iam", + name: { am: "ተጠቃሚዎችን መቆጣጠር ይችላል", en: "Can Activate/Deactivate User" }, + }, + { + id: "31a39f88-47c0-4322-80ef-f397a6791ff2", + key: "can:approveNewUser", + applicationKey: "iam", + name: { am: "አዲስ ተመዝጋቢ ማፅደቅ ይችላል", en: "Can Approve New User" }, + }, + { + id: "69694936-8b54-8330-b176-16ffc98c33a7", + key: "can:viewAllUsers", + applicationKey: "iam", + name: { am: "ሁሉንም ተጠቃሚዎች ማየት ይችላል", en: "Can View All Users" }, + }, + { + id: "68f1017f-4c60-8322-984d-e4317986e641", + key: "can:manageUsersAccountConfiguration", + applicationKey: "iam", + name: { am: "የተጠቃሚ መለያ አዋቂነት መቆጣጠር ይችላል", en: "Can Manage Users Account Configuration" }, + }, + { + id: "fceaa4c5-621f-45ce-b5a7-0ffc9e366fe1", + key: "can:setUserRequirementDocument", + applicationKey: "iam", + name: { am: "ተመዝጋቢዎች የሚያስገቡትን መረጃ መቆጣጠር ይችላል", en: "Can Set User Requirement Document" }, + }, +]; + +/** + * Role → permission-key grants. Applied additively: the seeder only inserts + * missing pairs, so grants made through the IAM UI survive a reseed. + */ +const ROLE_PERMISSIONS: { roleKey: string; permissionKeys: string[] }[] = [ + { + roleKey: "super_admin", + permissionKeys: [ + "can:create:role", + "can:update:role", + "can:delete:role", + "can:update:permission", + "can:delete:permission", + "can:create:role_permission", + "can:delete:role_permission", + "can:view:role_permission", + "can:create:user_role", + "can:delete:user_role", + "can:view:user_role", + "can:create:position_permission", + "can:delete:position_permission", + "can:view:position_permission", + "can:find_all:organization", + "can:update:organization", + "can:delete:organization", + "can:create:unit", + "can:update:unit", + "can:delete:unit", + "can:create:default_unit", + "can:update:default_unit", + "can:delete:default_unit", + "can:create:default_position", + "can:update:default_position", + "can:delete:default_position", + "can:create:organization_type", + "can:update:organization_type", + "can:delete:organization_type", + "can:create:location_type", + "can:update:location_type", + "can:delete:location_type", + "create:organization", + "activate:organization", + "can:debarOrganization", + "manage:organizationAdmin", + "manage:unitAdmin", + "can:activateUser", + "can:approveNewUser", + "can:viewAllUsers", + "can:setUserRequirementDocument", + "can:create:location", + "can:update:location", + "can:delete:location", + ], + }, + { + roleKey: "organization_admin", + permissionKeys: [ + "can:uploadUserCSV", + "can:changeUsersProfile", + "can:createEmployee", + "can:deactivateEmployee", + "can:exportUnitUsers", + "can:create:position_permission", + "can:delete:position_permission", + "can:view:position_permission", + "can:create:unit", + "can:update:unit", + "can:delete:unit", + "manage:unitAdmin", + ], + }, + { + roleKey: "unit_admin", + permissionKeys: [ + "can:uploadUserCSV", + "can:createEmployee", + "can:exportUnitUsers", + "can:changeUsersProfile", + "can:deactivateEmployee", + "can:manageUsersAccountConfiguration", + "can:create:position_permission", + "can:delete:position_permission", + "can:view:position_permission", + "can:create:unit", + "can:update:unit", + "can:delete:unit", + ], + }, + { + roleKey: "guest", + permissionKeys: [ + + ], + }, +]; + +const POSITION_TYPES: SeedPositionType[] = [ + { + id: "457a3659-ed96-456a-a6a1-2881226a86ec", + key: "employee", + isSystem: true, + name: { am: "ባለሙያ", en: "Employee" }, + }, + { + id: "34a7f69c-3f30-47a0-81c3-fcfc3087e456", + key: "teamLeader", + isSystem: true, + name: { am: "ቡድን መሪ", en: "Team Leader" }, + }, + { + id: "2cd9e3c5-bd41-48f3-a849-f497b18b2905", + key: "director", + isSystem: true, + name: { am: "ዳይሬክተር", en: "Director" }, + }, + { + id: "db310acf-7a78-40a3-83c7-9a9e9e6d1fc7", + key: "deputy", + isSystem: true, + name: { am: "ዘርፍ ኃላፊ", en: "Deputy" }, + }, + { + id: "1a4b4d40-e4fc-4f38-99a6-f81dc5fcff23", + key: "officeHead", + isSystem: true, + name: { am: "ቢሮ ኃላፊ", en: "Office Head" }, + }, + { + id: "83bc6cd3-119e-4a41-917c-c763fb3fd013", + key: "recordOfficer", + isSystem: true, + name: { am: "መዝገብ ቤት", en: "Record Officer" }, + }, +]; + +/** + * PositionType → permission-key grants. Empty because freight grants through + * Position/PositionPermission (see FreightPositionsSeeder), not position types. + */ +const POSITION_TYPE_PERMISSIONS: { + positionTypeKey: string; + permissionKeys: string[]; +}[] = []; + +/** Seeded once per organization. All start unset; the IAM UI fills them in. */ +const ORGANIZATION_SETTINGS: SettingDefault[] = [ + { key: "logoFileUrl", displayName: "Logo", type: "file" }, + { key: "faviconFileUrl", displayName: "Favicon", type: "file" }, + { key: "loginBackgroundFileUrl", displayName: "Login Background", type: "file" }, + { key: "stampImageFileUrl", displayName: "Stamp Image", type: "file" }, + { key: "customCssFileUrl", displayName: "Custom CSS", type: "file" }, + { key: "primaryColor", displayName: "Primary Color", type: "value" }, + { key: "secondaryColor", displayName: "Secondary Color", type: "value" }, + { key: "accentColor", displayName: "Accent Color", type: "value" }, + { key: "loginTitle", displayName: "Login Title", type: "value" }, + { key: "loginSubtitle", displayName: "Login Subtitle", type: "value" }, + { key: "sidebarColor", displayName: "Sidebar Color", type: "value" }, + { key: "headerColor", displayName: "Header Color", type: "value" }, + { key: "stampText", displayName: "Stamp Text", type: "value" }, + { key: "footerText", displayName: "Footer Text", type: "value" }, + { key: "supportEmail", displayName: "Support Email", type: "value" }, + { key: "supportPhone", displayName: "Support Phone", type: "value" }, +]; + +/** Seeded once per unit. */ +const UNIT_SETTINGS: SettingDefault[] = [ + { key: "isMultipleDelegationAllowed", displayName: "Is Multiple Delegation Allowed", type: "value" }, + { key: "internalSuffix", displayName: "Internal Suffix", type: "value" }, + { key: "internalPrefix", displayName: "Internal Prefix", type: "value" }, + { key: "internalSuffixCC", displayName: "Internal Suffix CC", type: "value" }, + { key: "internalPrefixCC", displayName: "Internal Prefix CC", type: "value" }, + { key: "referenceNumberPrefix", displayName: "Reference Number Prefix", type: "value" }, + { key: "externalReferenceNumberPrefix", displayName: "External Reference Number Prefix", type: "value" }, + { key: "internalMemoReferenceNumberPrefix", displayName: "Internal Memo Reference Number Prefix", type: "value" }, + { key: "escalationHour", displayName: "Escalation Hour", type: "value" }, + { key: "urgentLetterEscalationHour", displayName: "Urgent Letter Escalation Hour", type: "value" }, + { key: "onReviewLetterEscalationHour", displayName: "On Review Letter Escalation Hour", type: "value" }, + { key: "urgentOnReviewLetterEscalationHour", displayName: "Urgent On Review Letter Escalation Hour", type: "value" }, + { key: "shouldCollaboratorAlwaysSign", displayName: "Should Collaborator Always Sign", type: "value" }, + { key: "waitAllCollaboratorsBeforeAction", displayName: "Wait All Collaborators Before Action", type: "value" }, + { key: "shouldIncludeForYourReferenceInCC", displayName: "Should Include For Your Reference In CC", type: "value" }, + { key: "forwardWithTeeterSignature", displayName: "Forward With Teeter Signature", type: "value" }, + { key: "attachSignatureOnAttachment", displayName: "Attach Signature On Attachment", type: "value" }, + { key: "positionScopeToFetch", displayName: "Position Scope To Fetch", type: "value" }, +]; + +/** + * The super-admin account. Its access comes entirely from the `super_admin` + * UserRole: `isSuperAdmin()` in `common/freight-permission.util.ts` short-circuits + * `hasFreightPermission`, so this account passes every FreightPermissionGuard + * and every approval-step check without holding one freight permission row. + * + * Unlike the package seeder, the account lands in the freight organization + * (`edr_freight` / unit `edr_freight_app`) rather than a separate `tria` + * organization, so its employee context matches the app it administers. Both + * must already exist — EdrOrgSeeder creates them, and must run first. + * + * Read from env at seed time: SUPER_ADMIN_EMAIL, SUPER_ADMIN_PHONE, and for the + * password SUPER_ADMIN_PASSWORD first, then DEFAULT_PASSWORD. The values below + * are the last resort. Set this to null to seed no account at all. + */ +const SUPER_ADMIN: SeedSuperAdmin | null = { + username: "superadmin", + name: { am: "ሱፐር አድሚን", en: "Super Admin" }, + roleKey: "super_admin", + organizationKey: "edr_freight", + unitKey: "edr_freight_app", + fallbackEmail: "superadmin@tria.com", + fallbackPassword: "password@tria", +}; + +/** + * What `IamBaselineSeeder` writes. Empty an array to skip that section + * entirely; the seeder never deletes rows, so emptying one leaves whatever is + * already in the database alone. + */ +export const IAM_BASELINE_SEED = { + applications: APPLICATIONS, + roles: ROLES, + permissions: PERMISSIONS, + rolePermissions: ROLE_PERMISSIONS, + positionTypes: POSITION_TYPES, + positionTypePermissions: POSITION_TYPE_PERMISSIONS, + organizationSettings: ORGANIZATION_SETTINGS, + unitSettings: UNIT_SETTINGS, + superAdmin: SUPER_ADMIN, +}; diff --git a/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts b/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts new file mode 100644 index 000000000..9c718710a --- /dev/null +++ b/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts @@ -0,0 +1,468 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { + Application, + EEmployeeStatus, + Employee, + EUserStatus, + EUserType, + Organization, + Permission, + PositionType, + PositionTypePermission, + Role, + RolePermission, + Unit, + UnitSetting, + User, + UserCredential, + UserRole, +} from "@tria-plc/iamapi-common"; +// Not re-exported from the package root, unlike UnitSetting. +import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity"; +import type { ESettingType } from "@tria-plc/iamapi-common/enums/setting-type.enum"; +import { + DataSource, + EntityManager, + EntityTarget, + In, + ObjectLiteral, +} from "typeorm"; + +import { IAM_BASELINE_SEED } from "./iam-baseline.seed"; +import { missingSettings } from "./missing-settings.util"; + +const SEED_FLAG = "SEED_IAM_BASELINE"; + +/** + * Local stand-in for `DataSeeder` from `@tria-plc/iamapi-common`, seeding only + * what `IAM_BASELINE_SEED` lists (see that file for what was deliberately left + * out). Differences from the upstream seeder, all deliberate: + * + * - Nothing is deleted. Upstream wipes every `position_type_permissions` row + * for the 6 system position types on each run and nulls their `unit_id`. + * - Settings are matched on (organization|unit, key). Upstream upserts them on + * `id`, which it never supplies, so every run inserts a duplicate set. + * - Ids are resolved from the database by key rather than from the seed + * constants, so rows that already exist under a different id still link up. + * + * Gated behind SEED_IAM_BASELINE=true so a normal boot never writes. + */ +@Injectable() +export class IamBaselineSeeder { + private readonly logger = new Logger(IamBaselineSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== "true") { + this.logger.log( + `Skipping IAM baseline seed because ${SEED_FLAG} is not enabled`, + ); + return; + } + + await this.dataSource.transaction(async (manager) => { + await this.seedApplications(manager); + await this.seedPermissions(manager); + await this.seedRoles(manager); + await this.seedRolePermissions(manager); + await this.seedPositionTypes(manager); + await this.seedPositionTypePermissions(manager); + await this.seedOrganizationSettings(manager); + await this.seedUnitSettings(manager); + await this.seedSuperAdmin(manager); + }); + + this.logger.log("IAM baseline seed complete"); + } + + private async seedApplications(manager: EntityManager) { + const { applications } = IAM_BASELINE_SEED; + if (applications.length === 0) { + return; + } + + await manager.getRepository(Application).upsert( + applications.map((application) => ({ + id: application.id, + key: application.key, + name: { ...application.name }, + })), + { conflictPaths: { key: true } }, + ); + + this.logger.log(`Ensured ${applications.length} applications`); + } + + private async seedPermissions(manager: EntityManager) { + const { permissions } = IAM_BASELINE_SEED; + if (permissions.length === 0) { + return; + } + + // Permissions without an applicationKey stay unlinked (application_id null), + // which is how the package ships the org/unit/location ones. + const applicationIdByKey = await this.loadIdsByKey( + manager, + Application, + permissions.flatMap((permission) => + permission.applicationKey ? [permission.applicationKey] : [], + ), + "missing_applications", + ); + + await manager.getRepository(Permission).upsert( + permissions.map((permission) => ({ + id: permission.id, + key: permission.key, + name: { ...permission.name }, + applicationId: permission.applicationKey + ? applicationIdByKey.get(permission.applicationKey) + : undefined, + })), + { conflictPaths: { key: true } }, + ); + + this.logger.log(`Ensured ${permissions.length} permissions`); + } + + private async seedRoles(manager: EntityManager) { + const { roles } = IAM_BASELINE_SEED; + if (roles.length === 0) { + return; + } + + await manager.getRepository(Role).upsert( + roles.map((role) => ({ + id: role.id, + key: role.key, + name: { ...role.name }, + })), + { conflictPaths: { key: true } }, + ); + + this.logger.log(`Ensured ${roles.length} roles`); + } + + private async seedRolePermissions(manager: EntityManager) { + const { rolePermissions } = IAM_BASELINE_SEED; + if (rolePermissions.length === 0) { + return; + } + + const roleIdByKey = await this.loadIdsByKey( + manager, + Role, + rolePermissions.map((mapping) => mapping.roleKey), + "missing_roles", + ); + const permissionIdByKey = await this.loadIdsByKey( + manager, + Permission, + rolePermissions.flatMap((mapping) => mapping.permissionKeys), + "missing_permissions", + ); + + const repository = manager.getRepository(RolePermission); + const roleIds = [...roleIdByKey.values()]; + const existing = await repository.find({ + where: { roleId: In(roleIds) }, + select: { roleId: true, permissionId: true }, + }); + const existingPairs = new Set( + existing.map((row) => `${row.roleId}:${row.permissionId}`), + ); + + const rows = rolePermissions.flatMap((mapping) => { + const roleId = roleIdByKey.get(mapping.roleKey) as string; + return mapping.permissionKeys + .map((key) => permissionIdByKey.get(key) as string) + .filter((permissionId) => !existingPairs.has(`${roleId}:${permissionId}`)) + .map((permissionId) => ({ roleId, permissionId })); + }); + + if (rows.length === 0) { + return; + } + + await repository.insert(rows); + this.logger.log(`Granted ${rows.length} role permissions`); + } + + private async seedPositionTypes(manager: EntityManager) { + const { positionTypes } = IAM_BASELINE_SEED; + if (positionTypes.length === 0) { + return; + } + + // unitId is intentionally left alone — upstream resets it to null here. + await manager.getRepository(PositionType).upsert( + positionTypes.map((positionType) => ({ + id: positionType.id, + key: positionType.key, + name: { ...positionType.name }, + isSystem: positionType.isSystem ?? true, + })), + { conflictPaths: { key: true } }, + ); + + this.logger.log(`Ensured ${positionTypes.length} position types`); + } + + private async seedPositionTypePermissions(manager: EntityManager) { + const { positionTypePermissions } = IAM_BASELINE_SEED; + if (positionTypePermissions.length === 0) { + return; + } + + const positionTypeIdByKey = await this.loadIdsByKey( + manager, + PositionType, + positionTypePermissions.map((mapping) => mapping.positionTypeKey), + "missing_position_types", + ); + const permissionIdByKey = await this.loadIdsByKey( + manager, + Permission, + positionTypePermissions.flatMap((mapping) => mapping.permissionKeys), + "missing_permissions", + ); + + const repository = manager.getRepository(PositionTypePermission); + const existing = await repository.find({ + where: { positionTypeId: In([...positionTypeIdByKey.values()]) }, + select: { positionTypeId: true, permissionId: true }, + }); + const existingPairs = new Set( + existing.map((row) => `${row.positionTypeId}:${row.permissionId}`), + ); + + const rows = positionTypePermissions.flatMap((mapping) => { + const positionTypeId = positionTypeIdByKey.get( + mapping.positionTypeKey, + ) as string; + return mapping.permissionKeys + .map((key) => permissionIdByKey.get(key) as string) + .filter( + (permissionId) => + !existingPairs.has(`${positionTypeId}:${permissionId}`), + ) + .map((permissionId) => ({ positionTypeId, permissionId })); + }); + + if (rows.length === 0) { + return; + } + + await repository.insert(rows); + this.logger.log(`Granted ${rows.length} position type permissions`); + } + + private async seedOrganizationSettings(manager: EntityManager) { + const defaults = IAM_BASELINE_SEED.organizationSettings; + if (defaults.length === 0) { + return; + } + + const organizations = await manager + .getRepository(Organization) + .find({ select: { id: true } }); + const existing = await manager + .getRepository(OrganizationSetting) + .find({ select: { organizationId: true, key: true } }); + const existingPairs = new Set( + existing.map((setting) => `${setting.organizationId}:${setting.key}`), + ); + + const rows = organizations.flatMap((organization) => + missingSettings(defaults, existingPairs, organization.id as string).map( + (setting) => ({ + ...setting, + type: setting.type as ESettingType, + organizationId: organization.id as string, + }), + ), + ); + + if (rows.length === 0) { + return; + } + + await manager.getRepository(OrganizationSetting).insert(rows); + this.logger.log( + `Seeded ${rows.length} organization settings across ${organizations.length} organizations`, + ); + } + + private async seedUnitSettings(manager: EntityManager) { + const defaults = IAM_BASELINE_SEED.unitSettings; + if (defaults.length === 0) { + return; + } + + const units = await manager.getRepository(Unit).find({ select: { id: true } }); + const existing = await manager + .getRepository(UnitSetting) + .find({ select: { unitId: true, key: true } }); + const existingPairs = new Set( + existing.map((setting) => `${setting.unitId}:${setting.key}`), + ); + + const rows = units.flatMap((unit) => + missingSettings(defaults, existingPairs, unit.id as string).map( + (setting) => ({ + ...setting, + type: setting.type as ESettingType, + unitId: unit.id as string, + }), + ), + ); + + if (rows.length === 0) { + return; + } + + await manager.getRepository(UnitSetting).insert(rows); + this.logger.log( + `Seeded ${rows.length} unit settings across ${units.length} units`, + ); + } + + /** + * User + credential + employee + `super_admin` UserRole. Every step is + * skip-if-present: an existing account keeps its password, its organization + * and any extra roles it was given through the IAM UI. + */ + private async seedSuperAdmin(manager: EntityManager) { + const seed = IAM_BASELINE_SEED.superAdmin; + if (!seed) { + return; + } + + const email = process.env.SUPER_ADMIN_EMAIL?.trim() || seed.fallbackEmail; + // SUPER_ADMIN_PASSWORD wins, DEFAULT_PASSWORD is the shared fallback. + const password = + process.env.SUPER_ADMIN_PASSWORD?.trim() || + process.env.DEFAULT_PASSWORD?.trim() || + seed.fallbackPassword; + const phoneNumber = process.env.SUPER_ADMIN_PHONE?.trim() || undefined; + + const roleIdByKey = await this.loadIdsByKey( + manager, + Role, + [seed.roleKey], + "missing_roles", + ); + + const organization = await manager.getRepository(Organization).findOne({ + where: { key: seed.organizationKey }, + select: { id: true }, + }); + if (!organization) { + throw new Error(`missing_organization:${seed.organizationKey}`); + } + + const unit = await manager.getRepository(Unit).findOne({ + where: { key: seed.unitKey, organizationId: organization.id }, + select: { id: true }, + }); + if (!unit) { + throw new Error(`missing_unit:${seed.unitKey}`); + } + + const userRepository = manager.getRepository(User); + const existingUser = await userRepository.findOne({ + where: [{ username: seed.username }, { email }], + select: { id: true }, + }); + + let userId = existingUser?.id as string | undefined; + if (!userId) { + const inserted = await userRepository.insert({ + username: seed.username, + email, + phoneNumber, + name: { ...seed.name }, + userType: EUserType.EMPLOYEE, + status: EUserStatus.ACCEPTED, + isActive: true, + hasSetPassword: true, + }); + userId = inserted.identifiers[0]?.id as string; + this.logger.log(`Seeded super admin user '${seed.username}' (${email})`); + } + + const credentialRepository = manager.getRepository(UserCredential); + if (!(await credentialRepository.existsBy({ userId }))) { + await credentialRepository.insert({ + userId, + password: await hashPassword(password), + isActive: true, + }); + this.logger.log(`Seeded super admin credential for '${seed.username}'`); + } + + const employeeRepository = manager.getRepository(Employee); + if ( + !(await employeeRepository.existsBy({ + userId, + organizationId: organization.id, + })) + ) { + await employeeRepository.insert({ + userId, + organizationId: organization.id, + unitId: unit.id, + isCurrent: true, + status: EEmployeeStatus.ACCEPTED, + name: { ...seed.name }, + }); + this.logger.log( + `Attached super admin to organization '${seed.organizationKey}'`, + ); + } + + // user_roles is UNIQUE (user_id, role_id). + await manager.getRepository(UserRole).upsert( + { + userId, + roleId: roleIdByKey.get(seed.roleKey) as string, + organizationId: organization.id, + unitId: unit.id, + }, + { conflictPaths: { userId: true, roleId: true } }, + ); + + this.logger.log(`Ensured '${seed.roleKey}' role on '${seed.username}'`); + } + + /** + * Resolve `key → id` from the database for the given entity, throwing when a + * key the seed references has no row (the seed order is wrong, or the caller + * emptied the section that creates it). + */ + private async loadIdsByKey( + manager: EntityManager, + entity: EntityTarget, + keys: string[], + errorPrefix: string, + ): Promise> { + const wanted = [...new Set(keys)]; + const rows = (await manager + .getRepository(entity) + .find({ + where: { key: In(wanted) }, + select: { id: true, key: true }, + })) as { id?: string; key: string }[]; + + const idByKey = new Map(rows.map((row) => [row.key, row.id as string])); + + const missing = wanted.filter((key) => !idByKey.has(key)); + if (missing.length > 0) { + throw new Error(`${errorPrefix}:${missing.join(",")}`); + } + + return idByKey; + } +} diff --git a/apps/edr-freight-api/src/seed/missing-settings.util.spec.ts b/apps/edr-freight-api/src/seed/missing-settings.util.spec.ts new file mode 100644 index 000000000..a664117a5 --- /dev/null +++ b/apps/edr-freight-api/src/seed/missing-settings.util.spec.ts @@ -0,0 +1,27 @@ +import { missingSettings } from "./missing-settings.util"; + +const defaults = [ + { key: "primaryColor", displayName: "Primary Color", value: null }, + { key: "logoFileUrl", displayName: "Logo", value: null }, +]; + +describe("missingSettings", () => { + it("returns every default when the owner has none", () => { + expect(missingSettings(defaults, new Set(), "org-1")).toHaveLength(2); + }); + + it("skips keys the owner has, but not the same key on another owner", () => { + const existing = new Set(["org-1:primaryColor"]); + + expect( + missingSettings(defaults, existing, "org-1").map((s) => s.key), + ).toEqual(["logoFileUrl"]); + expect(missingSettings(defaults, existing, "org-2")).toHaveLength(2); + }); + + it("drops nulls so the column default applies", () => { + expect( + missingSettings(defaults, new Set(), "org-1")[0].value, + ).toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/seed/missing-settings.util.ts b/apps/edr-freight-api/src/seed/missing-settings.util.ts new file mode 100644 index 000000000..9ec4ccf5a --- /dev/null +++ b/apps/edr-freight-api/src/seed/missing-settings.util.ts @@ -0,0 +1,20 @@ +/** + * Settings of `ownerId` that are not in `existingPairs` (`":"`) + * yet. This is what keeps the IAM baseline seed idempotent: `organization_settings` + * and `unit_settings` have no unique index on (owner, key), so re-running an + * INSERT (or the package seeder's upsert-on-id, which never supplies an id) + * silently duplicates every row. + * + * `value: null` becomes `undefined` so the column default applies on insert. + */ +export function missingSettings< + TSetting extends { key: string; value?: string | null }, +>( + defaults: TSetting[], + existingPairs: Set, + ownerId: string, +): (Omit & { value?: string })[] { + return defaults + .filter((setting) => !existingPairs.has(`${ownerId}:${setting.key}`)) + .map((setting) => ({ ...setting, value: setting.value ?? undefined })); +}