refactor: centralized the iam seeder

This commit is contained in:
Nathnael
2026-07-27 08:28:57 +00:00
parent 1fe10ee3c2
commit 03b8ca0ce7
19 changed files with 1339 additions and 592 deletions

View File

@@ -0,0 +1,645 @@
import {
IamBaselineSeed,
SeedApplication,
SeedOrganizationType,
SeedPermission,
SeedPositionType,
SeedRole,
SeedRolePermission,
SettingDefault,
} from "./iam-seed.types";
/**
* The default IAM baseline: what `IamBaselineSeeder` writes when an app does not
* override a section via `IamSeedModule.forRoot()`.
*
* These rows started as a copy of the seed constants inside
* `@tria-plc/iamapi-common` (`dist/db/seed/*`) as of 0.7.12, and are owned here
* now. Nothing is read from that package at runtime, so the apps sharing the
* `iam` schema stay consistent even while they resolve different versions of it
* — but a package upgrade will not hand you new IAM permissions either. Diff
* against `db/seed/role.seed` and `db/seed/org-type.seed` when upgrading.
*
* Ids are the package's originals. Keep them: rows in every existing
* environment already carry them.
*
* Deliberately absent, because nothing in EDR reads them: the 11 Addis Ababa
* sub-city organizations, the TRIA super-admin organization (`is_super_admin` is
* only used to block edits to that row and hide it from one list query), and the
* 7 non-IAM Smart Office applications.
*/
/** Applications permissions hang off. Each app seeds its own separately. */
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: only missing pairs are
* inserted, so grants made through the IAM UI survive a reseed.
*/
const ROLE_PERMISSIONS: SeedRolePermission[] = [
{
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" },
},
];
/**
* Organization types and the units an organization of that type is created
* with. Both are read at runtime, which is why they are seeded: the New
* Organization form posts `organizationTypeId`, and
* `createOrganizationWithStructure` builds the new org's units from
* `default_units`.
*/
const ORGANIZATION_TYPES: SeedOrganizationType[] = [
{
key: "woreda",
name: { am: "ወረዳ", en: "Woreda" },
defaultUnits: [
{ key: "ዋና ስራ አስፈጻሚ ጽ/ቤት", description: "ዋና ስራ አስፈጻሚ ጽ/ቤት", name: { am: "ዋና ስራ አስፈጻሚ ጽ/ቤት", en: "Chief Executive Office" } },
{ key: "አስተዳደርና ፋይናንስ ጽ/ቤት", description: "አስተዳደርና ፋይናንስ ጽ/ቤት", name: { am: "አስተዳደርና ፋይናንስ ጽ/ቤት", en: "Administration and Finance Office" } },
{ key: "ፋይናንስ ፅህፈት ቤት", description: "ፋይናንስ ፅህፈት ቤት", name: { am: "ፋይናንስ ፅህፈት ቤት", en: "Finance Office" } },
{ key: "ምክር ቤት ጽ/ቤት", description: "ምክር ቤት ጽ/ቤት", name: { am: "ምክር ቤት ጽ/ቤት", en: "Council Office" } },
{ key: "አቃቤ ህግ ጽ/ቤት", description: "አቃቤ ህግ ጽ/ቤት", name: { am: "አቃቤ ህግ ጽ/ቤት", en: "Prosecutor's Office" } },
{ key: "ሰላምና ፀጥታ ጽ/ቤት", description: "ሰላምና ፀጥታ ጽ/ቤት", name: { am: "ሰላምና ፀጥታ ጽ/ቤት", en: "Peace and Security Office" } },
{ key: "ደንብ ማስከበር ጽ/ቤት", description: "ደንብ ማስከበር ጽ/ቤት", name: { am: "ደንብ ማስከበር ጽ/ቤት", en: "Enforcement Office" } },
{ key: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", description: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", name: { am: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", en: "Planning and Development Commission Office" } },
{ key: "ህብረት ስራ ጽ/ቤት", description: "ህብረት ስራ ጽ/ቤት", name: { am: "ህብረት ስራ ጽ/ቤት", en: "Cooperative Office" } },
{ key: "የንግድ ፅ/ቤት", description: "የንግድ ፅ/ቤት", name: { am: "የንግድ ፅ/ቤት", en: "Business Office" } },
{ key: "የአ/ አና ከተማ ግብርና ጽ/ቤት", description: "የአ/ አና ከተማ ግብርና ጽ/ቤት", name: { am: "የአ/ አና ከተማ ግብርና ጽ/ቤት", en: "Rural and Urban Agriculture Office" } },
{ key: "የመሬት ልማትና አስተዳደር ጽ/ቤት", description: "የመሬት ልማትና አስተዳደር ጽ/ቤት", name: { am: "የመሬት ልማትና አስተዳደር ጽ/ቤት", en: "Land Development and Administration Office" } },
{ key: "ደረቅ ቆሻሻ ጽ/ቤት", description: "ደረቅ ቆሻሻ ጽ/ቤት", name: { am: "ደረቅ ቆሻሻ ጽ/ቤት", en: "Solid waste office" } },
{ key: "አካባቢ ጥበቃ ጽ/ቤት", description: "አካባቢ ጥበቃ ጽ/ቤት", name: { am: "አካባቢ ጥበቃ ጽ/ቤት", en: "Environmental Protection Office" } },
{ key: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", description: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", name: { am: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", en: "Urban Beautification and Green Development Office" } },
{ key: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", description: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", name: { am: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", en: "Public Service Human Resource Management" } },
{ key: "መንግስት ህንጻ ጽ/ቤት", description: "መንግስት ህንጻ ጽ/ቤት", name: { am: "መንግስት ህንጻ ጽ/ቤት", en: "Government Building Office" } },
{ key: "ባህልና ቱሪዝም ጽ/ቤት", description: "ባህልና ቱሪዝም ጽ/ቤት", name: { am: "ባህልና ቱሪዝም ጽ/ቤት", en: "Culture and Tourism Office" } },
{ key: "ጤና ጽ/ቤት", description: "ጤና ጽ/ቤት", name: { am: "ጤና ጽ/ቤት", en: "Health Office" } },
{ key: "ኮሚኒኬሽን ጽ/ቤት", description: "ኮሚኒኬሽን ጽ/ቤት", name: { am: "ኮሚኒኬሽን ጽ/ቤት", en: "Communication Office" } },
{ key: "ትምህርት ጽ/ቤት", description: "ትምህርት ጽ/ቤት", name: { am: "ትምህርት ጽ/ቤት", en: "Education Office" } },
{ key: "ሴቶችህጻናትና ማህበራዊ", description: "ሴቶችህጻናትና ማህበራዊ", name: { am: "ሴቶችህጻናትና ማህበራዊ", en: "Women, Children and Social Affairs Office" } },
{ key: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", description: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", name: { am: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", en: "Design and Construction Works Office" } },
{ key: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", description: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", name: { am: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", en: "Construction Permit and Supervision Office" } },
{ key: "የቤቶች አስተዳደር ጽ/ቤት", description: "የቤቶች አስተዳደር ጽ/ቤት", name: { am: "የቤቶች አስተዳደር ጽ/ቤት", en: "Housing Management Office" } },
{ key: "የወጣቶችና ስፖርት ጽ/ቤት", description: "የወጣቶችና ስፖርት ጽ/ቤት", name: { am: "የወጣቶችና ስፖርት ጽ/ቤት", en: "Youth and Sports Office" } },
{ key: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", description: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", name: { am: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", en: "Community Participation and Charity Coordination Office" } },
{ key: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", description: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", name: { am: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", en: "Innovation and Technology Development Office" } },
{ key: "የቴክኒክና ሙያ ጽ/ቤት", description: "የቴክኒክና ሙያ ጽ/ቤት", name: { am: "የቴክኒክና ሙያ ጽ/ቤት", en: "Technical and Vocational Office" } },
{ key: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", description: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", en: "Employment and Industry Development Office" } },
{ key: "የስራና ክህሎት ጽ/ቤት", description: "የስራና ክህሎት ጽ/ቤት", name: { am: "የስራና ክህሎት ጽ/ቤት", en: "Labor and Skills Office" } },
{ key: "ኢንዱስትሪ ልማት ጽ/ቤት", description: "ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "ኢንዱስትሪ ልማት ጽ/ቤት", en: "Industry Development Office" } },
],
},
{
key: "subcity",
name: { am: "ክፍለ ከተማ", en: "Sub City" },
defaultUnits: [
{ key: "ዋና ስራ አስፈጻሚ ጽ/ቤት", description: "ዋና ስራ አስፈጻሚ ጽ/ቤት", name: { am: "ዋና ስራ አስፈጻሚ ጽ/ቤት", en: "Main Executive Office" } },
{ key: "አስተዳደርና ፋይናንስ ጽ/ቤት", description: "አስተዳደርና ፋይናንስ ጽ/ቤት", name: { am: "አስተዳደርና ፋይናንስ ጽ/ቤት", en: "Administration and Finance Office" } },
{ key: "ፋይናንስ ፅህፈት ቤት", description: "ፋይናንስ ፅህፈት ቤት", name: { am: "ፋይናንስ ፅህፈት ቤት", en: "Finance Office" } },
{ key: "ምክር ቤት ጽ/ቤት", description: "ምክር ቤት ጽ/ቤት", name: { am: "ምክር ቤት ጽ/ቤት", en: "Council Office" } },
{ key: "አቃቤ ህግ ጽ/ቤት", description: "አቃቤ ህግ ጽ/ቤት", name: { am: "አቃቤ ህግ ጽ/ቤት", en: "Legal Affairs Office" } },
{ key: "ሰላምና ፀጥታ ጽ/ቤት", description: "ሰላምና ፀጥታ ጽ/ቤት", name: { am: "ሰላምና ፀጥታ ጽ/ቤት", en: "Peace and Security Office" } },
{ key: "ደንብ ማስከበር ጽ/ቤት", description: "ደንብ ማስከበር ጽ/ቤት", name: { am: "ደንብ ማስከበር ጽ/ቤት", en: "Regulations Enforcement Office" } },
{ key: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", description: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", name: { am: "ፕላንና ልማት ኮሚሽን ጽ/ቤት", en: "Planning and Development Commission Office" } },
{ key: "ህብረት ስራ ጽ/ቤት", description: "ህብረት ስራ ጽ/ቤት", name: { am: "ህብረት ስራ ጽ/ቤት", en: "Community Work Office" } },
{ key: "የንግድ ፅ/ቤት", description: "የንግድ ፅ/ቤት", name: { am: "የንግድ ፅ/ቤት", en: "Trade Office" } },
{ key: "የአ/ አና ከተማ ግብርና ጽ/ቤት", description: "የአ/ አና ከተማ ግብርና ጽ/ቤት", name: { am: "የአ/ አና ከተማ ግብርና ጽ/ቤት", en: "Rural and Urban Agriculture Office" } },
{ key: "የመሬት ልማትና አስተዳደር ጽ/ቤት", description: "የመሬት ልማትና አስተዳደር ጽ/ቤት", name: { am: "የመሬት ልማትና አስተዳደር ጽ/ቤት", en: "Land Development and Administration Office" } },
{ key: "ደረቅ ቆሻሻ ጽ/ቤት", description: "ደረቅ ቆሻሻ ጽ/ቤት", name: { am: "ደረቅ ቆሻሻ ጽ/ቤት", en: "Solid Waste Management Office" } },
{ key: "አካባቢ ጥበቃ ጽ/ቤት", description: "አካባቢ ጥበቃ ጽ/ቤት", name: { am: "አካባቢ ጥበቃ ጽ/ቤት", en: "Environmental Protection Office" } },
{ key: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", description: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", name: { am: "የከተማ ውበትና አረንጓዴ ልማት ጽ/ቤት", en: "Urban Beautification and Green Development Office" } },
{ key: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", description: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", name: { am: "ፐብሊክ ሰርቪስ የሰዉ ሀብት አስተዳደር", en: "Public Service and Human Resource Management" } },
{ key: "መንግስት ህንጻ ጽ/ቤት", description: "መንግስት ህንጻ ጽ/ቤት", name: { am: "መንግስት ህንጻ ጽ/ቤት", en: "Public Buildings Office" } },
{ key: "ባህልና ቱሪዝም ጽ/ቤት", description: "ባህልና ቱሪዝም ጽ/ቤት", name: { am: "ባህልና ቱሪዝም ጽ/ቤት", en: "Culture and Tourism Office" } },
{ key: "ጤና ጽ/ቤት", description: "ጤና ጽ/ቤት", name: { am: "ጤና ጽ/ቤት", en: "Health Office" } },
{ key: "ኮሚኒኬሽን ጽ/ቤት", description: "ኮሚኒኬሽን ጽ/ቤት", name: { am: "ኮሚኒኬሽን ጽ/ቤት", en: "Communication Office" } },
{ key: "ትምህርት ጽ/ቤት", description: "ትምህርት ጽ/ቤት", name: { am: "ትምህርት ጽ/ቤት", en: "Education Office" } },
{ key: "ሴቶችህጻናትና ማህበራዊ", description: "ሴቶችህጻናትና ማህበራዊ", name: { am: "ሴቶችህጻናትና ማህበራዊ", en: "Women, Children and Social Affairs Office" } },
{ key: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", description: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", name: { am: "ዲዛይንና ግንባታ ስራዎች ጽ/ቤት", en: "Design and Construction Works Office" } },
{ key: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", description: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", name: { am: "የግንባታ ፈቃድና ቁጥጥር ጽ/ቤት", en: "Construction Permit and Control Office" } },
{ key: "የቤቶች አስተዳደር ጽ/ቤት", description: "የቤቶች አስተዳደር ጽ/ቤት", name: { am: "የቤቶች አስተዳደር ጽ/ቤት", en: "Housing Management Office" } },
{ key: "የወጣቶችና ስፖርት ጽ/ቤት", description: "የወጣቶችና ስፖርት ጽ/ቤት", name: { am: "የወጣቶችና ስፖርት ጽ/ቤት", en: "Youth and Sports Office" } },
{ key: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", description: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", name: { am: "የህብረተሰብ ተሳትፎና በጎ ፈቃድ ማስተባበሪያ ጽ/ቤት", en: "Community Participation and Voluntarism Coordination Office" } },
{ key: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", description: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", name: { am: "የኢኖቬሽንና ቴክኖሎጂ ልማት ጽ/ቤት", en: "Innovation and Technology Development Office" } },
{ key: "የቴክኒክና ሙያ ጽ/ቤት", description: "የቴክኒክና ሙያ ጽ/ቤት", name: { am: "የቴክኒክና ሙያ ጽ/ቤት", en: "Technical and Vocational Office" } },
{ key: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", description: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "የስራ ኢን/ኢንዱስትሪ ልማት ጽ/ቤት", en: "Labor and Industry Development Office" } },
{ key: "የስራና ክህሎት ጽ/ቤት", description: "የስራና ክህሎት ጽ/ቤት", name: { am: "የስራና ክህሎት ጽ/ቤት", en: "Labor and Skills Office" } },
{ key: "ኢንዱስትሪ ልማት ጽ/ቤት", description: "ኢንዱስትሪ ልማት ጽ/ቤት", name: { am: "ኢንዱስትሪ ልማት ጽ/ቤት", en: "Industry Development Office" } },
],
},
{
key: "office",
name: { am: "ቢሮ", en: "Bureau" },
defaultUnits: [
{ key: "ቢሮ", description: "ቢሮ", name: { am: "ቢሮ", en: "Bureau" } },
],
},
{
key: "branch",
name: { am: "ቅርንጫፍ", en: "Branch" },
defaultUnits: [
],
},
];
/** 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" },
];
/**
* Defaults for every section except `superAdmin`, which has no sensible default
* — the account has to be attached to an organization only the consuming app
* knows about.
*/
export const DEFAULT_IAM_BASELINE_SEED: IamBaselineSeed = {
applications: APPLICATIONS,
roles: ROLES,
permissions: PERMISSIONS,
rolePermissions: ROLE_PERMISSIONS,
positionTypes: POSITION_TYPES,
positionTypePermissions: [],
organizationTypes: ORGANIZATION_TYPES,
organizationSettings: ORGANIZATION_SETTINGS,
unitSettings: UNIT_SETTINGS,
superAdmin: null,
};

View File

@@ -0,0 +1,875 @@
import { Inject, Injectable, Logger, Optional } from "@nestjs/common";
import * as argon2 from "argon2";
import { DataSource, EntityManager } from "typeorm";
import { DEFAULT_IAM_BASELINE_SEED } from "./iam-baseline.seed";
import { IAM_SEED_OPTIONS } from "./iam-seed.constants";
import {
IamBaselineSeed,
IamSeedOptions,
LocalizedName,
SettingDefault,
} from "./iam-seed.types";
import { missingSettings } from "./missing-settings.util";
/** Owned by @tria-plc/iamapi-common's migrations; never created here. */
const SCHEMA = "iam";
const DEFAULT_ENABLE_FLAG = "SEED_IAM_BASELINE";
/**
* Advisory lock key, held for the seed transaction. Every app using this package
* takes the same key, so two services — or two replicas of one — can never seed
* concurrently. Arbitrary constant; nothing else uses it.
*/
const SEED_LOCK_KEY = 748_231_905;
/** Postgres caps a statement at 65535 parameters; stay far below it. */
const INSERT_CHUNK = 500;
type KeyedRow = { id: string; key: string };
/**
* Seeds the IAM baseline shared by every app on the `iam` schema, replacing
* `DataSeeder` from `@tria-plc/iamapi-common`.
*
* The schema has more than one writer, rows may already exist partially, and the
* apps resolve different versions of the IAM package. Every write is built for
* that:
*
* - **Insert-only.** A row that already exists by key is left exactly as it is —
* no name overwrite, and above all no id rewrite, which would break foreign
* keys other apps already point at. Drift is logged, not corrected.
* - **Ids resolved from the database**, never assumed from the seed constants.
* - **`ON CONFLICT DO NOTHING` on every insert**, so a row appearing between the
* read and the write is a no-op rather than a crash.
* - **An advisory lock** around the whole transaction, shared by all consumers.
* - **Nothing is deleted.** The package seeder wipes every
* `position_type_permissions` row for the system position types on each run
* and nulls their `unit_id`; this one does not.
* - **Never fatal.** A failure is logged and boot continues; missing
* prerequisites skip that section with a warning.
*
* Raw SQL throughout, deliberately: importing the package's entity classes would
* tie this package to one copy of `@tria-plc/iamapi-common`, and TypeORM matches
* entity metadata by class identity — the apps would need the exact same
* resolved version forever. Column names come from the package's own migrations.
*
* Apps call `run()` themselves so it can be ordered against their own seeders.
* Runs unless the enable flag (default SEED_IAM_BASELINE) is explicitly turned
* off — insert-only makes seeding the safe default.
*/
@Injectable()
export class IamBaselineSeeder {
private readonly logger = new Logger(IamBaselineSeeder.name);
private readonly seed: IamBaselineSeed;
private readonly enableFlag: string;
constructor(
private readonly dataSource: DataSource,
@Optional()
@Inject(IAM_SEED_OPTIONS)
options?: IamSeedOptions,
) {
const { enableFlag, ...overrides } = options ?? {};
this.enableFlag = enableFlag ?? DEFAULT_ENABLE_FLAG;
this.seed = { ...DEFAULT_IAM_BASELINE_SEED, ...overrides };
}
async run() {
// Opt-out, not opt-in: an unset flag seeds. Every write is insert-only, so
// the safe default is "keep the baseline current" — a new environment that
// forgot the variable gets a working IAM rather than an empty one.
const flag = process.env[this.enableFlag]?.trim().toLowerCase();
if (flag === "false" || flag === "0" || flag === "off") {
this.logger.log(
`Skipping IAM baseline seed because ${this.enableFlag}=${flag}`,
);
return;
}
try {
await this.dataSource.transaction(async (manager) => {
// Never wait forever on a row another service holds: the seed is
// optional, boot is not.
await manager.query("SET LOCAL lock_timeout = '15s'");
// Try, don't wait — another service seeding right now is a reason to
// skip, not to queue. Released on commit or rollback.
const [{ locked }] = (await manager.query(
"SELECT pg_try_advisory_xact_lock($1) AS locked",
[SEED_LOCK_KEY],
)) as [{ locked: boolean }];
if (!locked) {
this.logger.log(
"Skipping IAM baseline seed: another service holds the seed lock",
);
return;
}
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.seedOrganizationTypes(manager);
await this.seedOrganizationSettings(manager);
await this.seedUnitSettings(manager);
await this.seedSuperAdmin(manager);
});
this.logger.log("IAM baseline seed complete");
} catch (error) {
// Boot must not depend on the seed: the schema is shared, and a lock
// timeout or a row another service wrote first is not worth an outage.
this.logger.error(
`IAM baseline seed failed, continuing boot: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
private async seedApplications(manager: EntityManager) {
const { applications } = this.seed;
if (applications.length === 0) {
return;
}
const existing = await this.loadByKey(
manager,
"application",
applications.map((application) => application.key),
);
this.warnOnIdDrift(applications, existing, "applications");
const inserted = await this.insertIgnoringConflicts(
manager,
"application",
["id", "key", "name::jsonb"],
applications
.filter((application) => !existing.has(application.key))
.map((application) => [
application.id,
application.key,
JSON.stringify(application.name),
]),
);
this.logger.log(
`Applications: ${inserted} inserted, ${applications.length - inserted} already present`,
);
}
private async seedPermissions(manager: EntityManager) {
const { permissions } = this.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] : [],
),
"application",
);
const existing = await this.loadByKey(
manager,
"permissions",
permissions.map((permission) => permission.key),
);
this.warnOnIdDrift(permissions, existing, "permissions");
const inserted = await this.insertIgnoringConflicts(
manager,
"permissions",
["id", "key", "name::jsonb", "application_id"],
permissions
.filter((permission) => !existing.has(permission.key))
.map((permission) => [
permission.id,
permission.key,
JSON.stringify(permission.name),
permission.applicationKey
? (applicationIdByKey.get(permission.applicationKey) ?? null)
: null,
]),
);
this.logger.log(
`Permissions: ${inserted} inserted, ${permissions.length - inserted} already present`,
);
}
private async seedRoles(manager: EntityManager) {
const { roles } = this.seed;
if (roles.length === 0) {
return;
}
const existing = await this.loadByKey(
manager,
"roles",
roles.map((role) => role.key),
);
this.warnOnIdDrift(roles, existing, "roles");
const inserted = await this.insertIgnoringConflicts(
manager,
"roles",
["id", "key", "name::jsonb"],
roles
.filter((role) => !existing.has(role.key))
.map((role) => [role.id, role.key, JSON.stringify(role.name)]),
);
this.logger.log(
`Roles: ${inserted} inserted, ${roles.length - inserted} already present`,
);
}
private async seedRolePermissions(manager: EntityManager) {
const { rolePermissions } = this.seed;
if (rolePermissions.length === 0) {
return;
}
const roleIdByKey = await this.loadIdsByKey(
manager,
"roles",
rolePermissions.map((mapping) => mapping.roleKey),
"role",
);
const permissionIdByKey = await this.loadIdsByKey(
manager,
"permissions",
rolePermissions.flatMap((mapping) => mapping.permissionKeys),
"permission",
);
const existingPairs = await this.loadPairs(
manager,
"role_permissions",
"role_id",
"permission_id",
[...roleIdByKey.values()],
);
const rows = rolePermissions.flatMap((mapping) => {
const roleId = roleIdByKey.get(mapping.roleKey);
if (!roleId) {
return [];
}
return mapping.permissionKeys.flatMap((key) => {
const permissionId = permissionIdByKey.get(key);
if (!permissionId || existingPairs.has(`${roleId}:${permissionId}`)) {
return [];
}
return [[roleId, permissionId]];
});
});
const inserted = await this.insertIgnoringConflicts(
manager,
"role_permissions",
["role_id", "permission_id"],
rows,
);
if (inserted > 0) {
this.logger.log(`Granted ${inserted} role permissions`);
}
}
private async seedPositionTypes(manager: EntityManager) {
const { positionTypes } = this.seed;
if (positionTypes.length === 0) {
return;
}
const existing = await this.loadByKey(
manager,
"position_types",
positionTypes.map((positionType) => positionType.key),
);
this.warnOnIdDrift(positionTypes, existing, "position types");
// unit_id is intentionally left alone — the package seeder resets it to null.
const inserted = await this.insertIgnoringConflicts(
manager,
"position_types",
["id", "key", "name::jsonb", "is_system"],
positionTypes
.filter((positionType) => !existing.has(positionType.key))
.map((positionType) => [
positionType.id,
positionType.key,
JSON.stringify(positionType.name),
positionType.isSystem,
]),
);
this.logger.log(
`Position types: ${inserted} inserted, ${positionTypes.length - inserted} already present`,
);
}
private async seedPositionTypePermissions(manager: EntityManager) {
const { positionTypePermissions } = this.seed;
if (positionTypePermissions.length === 0) {
return;
}
const positionTypeIdByKey = await this.loadIdsByKey(
manager,
"position_types",
positionTypePermissions.map((mapping) => mapping.positionTypeKey),
"position type",
);
const permissionIdByKey = await this.loadIdsByKey(
manager,
"permissions",
positionTypePermissions.flatMap((mapping) => mapping.permissionKeys),
"permission",
);
const existingPairs = await this.loadPairs(
manager,
"position_type_permissions",
"position_type_id",
"permission_id",
[...positionTypeIdByKey.values()],
);
const rows = positionTypePermissions.flatMap((mapping) => {
const positionTypeId = positionTypeIdByKey.get(mapping.positionTypeKey);
if (!positionTypeId) {
return [];
}
return mapping.permissionKeys.flatMap((key) => {
const permissionId = permissionIdByKey.get(key);
if (
!permissionId ||
existingPairs.has(`${positionTypeId}:${permissionId}`)
) {
return [];
}
return [[positionTypeId, permissionId]];
});
});
const inserted = await this.insertIgnoringConflicts(
manager,
"position_type_permissions",
["position_type_id", "permission_id"],
rows,
);
if (inserted > 0) {
this.logger.log(`Granted ${inserted} position type permissions`);
}
}
private async seedOrganizationTypes(manager: EntityManager) {
const { organizationTypes } = this.seed;
if (organizationTypes.length === 0) {
return;
}
const existing = await this.loadByKey(
manager,
"organization_types",
organizationTypes.map((organizationType) => organizationType.key),
);
const insertedTypes = await this.insertIgnoringConflicts(
manager,
"organization_types",
["key", "name::jsonb"],
organizationTypes
.filter((organizationType) => !existing.has(organizationType.key))
.map((organizationType) => [
organizationType.key,
JSON.stringify(organizationType.name),
]),
);
// Re-read: ids are database-generated here, unlike the keyed catalogs above.
const typeIdByKey = await this.loadIdsByKey(
manager,
"organization_types",
organizationTypes.map((organizationType) => organizationType.key),
"organization type",
);
const existingUnits = await this.loadPairs(
manager,
"default_units",
"organization_type_id",
"key",
[...typeIdByKey.values()],
);
const unitRows = organizationTypes.flatMap((organizationType) => {
const typeId = typeIdByKey.get(organizationType.key);
if (!typeId) {
return [];
}
return organizationType.defaultUnits
.filter((unit) => !existingUnits.has(`${typeId}:${unit.key}`))
.map((unit) => [
unit.key,
JSON.stringify(unit.name),
unit.description,
typeId,
]);
});
const insertedUnits = await this.insertIgnoringConflicts(
manager,
"default_units",
["key", "name::jsonb", "description", "organization_type_id"],
unitRows,
);
this.logger.log(
`Organization types: ${insertedTypes} inserted, ${insertedUnits} default units inserted`,
);
}
private async seedOrganizationSettings(manager: EntityManager) {
const defaults = this.seed.organizationSettings;
if (defaults.length === 0) {
return;
}
const owners = (await manager.query(
`SELECT id FROM ${SCHEMA}.organizations`,
)) as { id: string }[];
const inserted = await this.insertOwnerSettings(
manager,
"organization_settings",
"organization_id",
owners,
defaults,
);
if (inserted > 0) {
this.logger.log(
`Seeded ${inserted} organization settings across ${owners.length} organizations`,
);
}
}
private async seedUnitSettings(manager: EntityManager) {
const defaults = this.seed.unitSettings;
if (defaults.length === 0) {
return;
}
const owners = (await manager.query(`SELECT id FROM ${SCHEMA}.units`)) as {
id: string;
}[];
const inserted = await this.insertOwnerSettings(
manager,
"unit_settings",
"unit_id",
owners,
defaults,
);
if (inserted > 0) {
this.logger.log(
`Seeded ${inserted} unit settings across ${owners.length} units`,
);
}
}
/** Settings rows missing for each owner. Matched on (owner, key), never on id. */
private async insertOwnerSettings(
manager: EntityManager,
table: string,
ownerColumn: string,
owners: { id: string }[],
defaults: SettingDefault[],
): Promise<number> {
if (owners.length === 0) {
return 0;
}
const existing = (await manager.query(
`SELECT "${ownerColumn}" AS owner, key FROM ${SCHEMA}.${table}`,
)) as { owner: string; key: string }[];
const existingPairs = new Set(
existing.map((row) => `${row.owner}:${row.key}`),
);
const rows = owners.flatMap((owner) =>
missingSettings(defaults, existingPairs, owner.id).map((setting) => [
owner.id,
setting.key,
setting.displayName,
setting.type,
setting.value ?? null,
]),
);
return this.insertIgnoringConflicts(
manager,
table,
[ownerColumn, "key", "display_name", "type", "value"],
rows,
);
}
/**
* User + credential + employee + role grant. Every step is skip-if-present: an
* existing account keeps its password, its organizations and any extra roles it
* was given through the IAM UI. Apps sharing this schema share the account and
* each attach their own employee row.
*/
private async seedSuperAdmin(manager: EntityManager) {
const seed = this.seed.superAdmin;
if (!seed) {
return;
}
const email = process.env.SUPER_ADMIN_EMAIL?.trim() || seed.fallbackEmail;
// SUPER_ADMIN_DEFAULT_PASSWORD wins, DEFAULT_PASSWORD is the shared fallback. There
// is deliberately no hardcoded default — see SeedSuperAdmin.
const password =
process.env.SUPER_ADMIN_DEFAULT_PASSWORD?.trim() ||
process.env.DEFAULT_PASSWORD?.trim();
const phoneNumber = process.env.SUPER_ADMIN_PHONE?.trim() || null;
const roleId = (
await this.loadIdsByKey(manager, "roles", [seed.roleKey], "role")
).get(seed.roleKey);
if (!roleId) {
this.logger.warn(
`Skipping super admin: role '${seed.roleKey}' is not in the database`,
);
return;
}
// The organization is optional. Access rides on the role grant, whose
// organization_id is nullable, and login does not require an employee — so
// the account is created either way and the employee row attaches on a later
// run, once the app's own org seeder (gated on its own flag) has run.
const [organization] = (await manager.query(
`SELECT id FROM ${SCHEMA}.organizations WHERE key = $1 LIMIT 1`,
[seed.organizationKey],
)) as { id: string }[];
const organizationId = organization?.id ?? null;
if (!organizationId) {
this.logger.warn(
`Organization '${seed.organizationKey}' does not exist yet: seeding the super admin without an employee record`,
);
}
let unitId: string | null = null;
if (seed.unitKey && organizationId) {
const [unit] = (await manager.query(
`SELECT id FROM ${SCHEMA}.units WHERE key = $1 AND organization_id = $2 LIMIT 1`,
[seed.unitKey, organizationId],
)) as { id: string }[];
if (!unit) {
this.logger.warn(
`Super admin unit '${seed.unitKey}' not found, attaching without a unit`,
);
}
unitId = unit?.id ?? null;
}
const userId = await this.ensureSuperAdminUser(manager, {
username: seed.username,
email,
phoneNumber,
name: seed.name,
});
if (!userId) {
this.logger.warn("Skipping super admin: could not resolve the user row");
return;
}
const [credential] = (await manager.query(
`SELECT id FROM ${SCHEMA}.user_credentials WHERE user_id = $1 LIMIT 1`,
[userId],
)) as { id: string }[];
if (!credential && !password) {
this.logger.warn(
`Super admin '${seed.username}' has no credential: set SUPER_ADMIN_DEFAULT_PASSWORD or DEFAULT_PASSWORD, or set the password through the IAM reset flow`,
);
}
if (!credential && password) {
await this.insertIgnoringConflicts(
manager,
"user_credentials",
["user_id", "password", "is_active"],
[[userId, await argon2.hash(password), true]],
);
this.logger.log(`Seeded super admin credential for '${seed.username}'`);
}
const [employee] = organizationId
? ((await manager.query(
`SELECT id FROM ${SCHEMA}.employees WHERE user_id = $1 AND organization_id = $2 LIMIT 1`,
[userId, organizationId],
)) as { id: string }[])
: [undefined];
if (organizationId && !employee) {
await this.insertIgnoringConflicts(
manager,
"employees",
[
"user_id",
"organization_id",
"unit_id",
"is_current",
"status",
"name::jsonb",
],
[
[
userId,
organizationId,
unitId,
true,
"accepted",
JSON.stringify(seed.name),
],
],
);
this.logger.log(
`Attached super admin to organization '${seed.organizationKey}'`,
);
}
// user_roles is UNIQUE (user_id, role_id), so a concurrent grant is a no-op.
await this.insertIgnoringConflicts(
manager,
"user_roles",
["user_id", "role_id", "organization_id", "unit_id"],
[[userId, roleId, organizationId, unitId]],
);
this.logger.log(`Ensured '${seed.roleKey}' role on '${seed.username}'`);
}
/**
* The super-admin user row, whether we create it or another service already
* did. Matches on username OR email because either is enough to make the
* insert fail on its unique index.
*/
private async ensureSuperAdminUser(
manager: EntityManager,
account: {
username: string;
email: string;
phoneNumber: string | null;
name: LocalizedName;
},
): Promise<string | undefined> {
const find = async () => {
const [row] = (await manager.query(
`SELECT id FROM ${SCHEMA}.users WHERE username = $1 OR email = $2 LIMIT 1`,
[account.username, account.email],
)) as { id: string }[];
return row?.id;
};
const existing = await find();
if (existing) {
return existing;
}
await this.insertIgnoringConflicts(
manager,
"users",
[
"username",
"email",
"phone_number",
"name::jsonb",
"user_type",
"status",
"is_active",
"has_set_password",
],
[
[
account.username,
account.email,
account.phoneNumber,
JSON.stringify(account.name),
"employee",
"accepted",
true,
true,
],
],
);
// Re-read rather than trusting the insert: the row may have been skipped
// because another service created it a moment earlier.
const created = await find();
if (created) {
this.logger.log(
`Seeded super admin user '${account.username}' (${account.email})`,
);
}
return created;
}
/**
* Report seed rows whose stored id differs from ours — a sign this environment
* was seeded by something else, and the reason links are resolved by key.
*/
private warnOnIdDrift(
rows: { id: string; key: string }[],
existing: Map<string, KeyedRow>,
label: string,
) {
const drifted = rows.filter((row) => {
const stored = existing.get(row.key);
return stored && stored.id !== row.id;
});
if (drifted.length > 0) {
this.logger.warn(
`${drifted.length} ${label} exist under a different id than the seed (left untouched): ${drifted
.map((row) => row.key)
.join(", ")}`,
);
}
}
/**
* `INSERT … ON CONFLICT DO NOTHING`, chunked. Every table here is written by
* more than one service, so losing a race must cost nothing.
*
* A column spec may carry a cast for non-text types — `"name::jsonb"`.
*/
private async insertIgnoringConflicts(
manager: EntityManager,
table: string,
columnSpecs: string[],
rows: unknown[][],
): Promise<number> {
if (rows.length === 0) {
return 0;
}
const columns = columnSpecs.map((spec) => spec.split("::")[0]);
const casts = columnSpecs.map((spec) => {
const [, cast] = spec.split("::");
return cast ? `::${cast}` : "";
});
const columnList = columns.map((column) => `"${column}"`).join(", ");
let inserted = 0;
for (let start = 0; start < rows.length; start += INSERT_CHUNK) {
const chunk = rows.slice(start, start + INSERT_CHUNK);
const params: unknown[] = [];
const tuples = chunk.map((row) => {
const placeholders = row.map((value, columnIndex) => {
params.push(value);
return `$${params.length}${casts[columnIndex]}`;
});
return `(${placeholders.join(", ")})`;
});
const result = (await manager.query(
`INSERT INTO ${SCHEMA}.${table} (${columnList}) VALUES ${tuples.join(", ")} ON CONFLICT DO NOTHING RETURNING id`,
params,
)) as unknown[];
inserted += Array.isArray(result) ? result.length : 0;
}
return inserted;
}
/** Existing rows for the given keys, by key. */
private async loadByKey(
manager: EntityManager,
table: string,
keys: string[],
): Promise<Map<string, KeyedRow>> {
const wanted = [...new Set(keys)];
if (wanted.length === 0) {
return new Map();
}
const rows = (await manager.query(
`SELECT id, key FROM ${SCHEMA}.${table} WHERE key = ANY($1)`,
[wanted],
)) as KeyedRow[];
return new Map(rows.map((row) => [row.key, row]));
}
/**
* Resolve `key → id`. Keys with no row are warned about and left out: whatever
* references them is skipped rather than failing the whole seed, since another
* service may own that row.
*/
private async loadIdsByKey(
manager: EntityManager,
table: string,
keys: string[],
label: string,
): Promise<Map<string, string>> {
const wanted = [...new Set(keys)];
const rows = await this.loadByKey(manager, table, wanted);
const idByKey = new Map(
[...rows.values()].map((row) => [row.key, row.id] as [string, string]),
);
const missing = wanted.filter((key) => !idByKey.has(key));
if (missing.length > 0) {
this.logger.warn(
`Unresolved ${label} keys, anything referencing them is skipped: ${missing.join(", ")}`,
);
}
return idByKey;
}
/** Existing `left:right` pairs of a link table, for the given left-hand ids. */
private async loadPairs(
manager: EntityManager,
table: string,
leftColumn: string,
rightColumn: string,
leftIds: string[],
): Promise<Set<string>> {
if (leftIds.length === 0) {
return new Set();
}
const rows = (await manager.query(
`SELECT "${leftColumn}" AS left_value, "${rightColumn}" AS right_value
FROM ${SCHEMA}.${table} WHERE "${leftColumn}" = ANY($1)`,
[leftIds],
)) as { left_value: string; right_value: string }[];
return new Set(rows.map((row) => `${row.left_value}:${row.right_value}`));
}
}

View File

@@ -0,0 +1,2 @@
/** DI token for the options passed to `IamSeedModule.forRoot(...)`. */
export const IAM_SEED_OPTIONS = Symbol("IAM_SEED_OPTIONS");

View File

@@ -0,0 +1,39 @@
import { DynamicModule, Module } from "@nestjs/common";
import { IamBaselineSeeder } from "./iam-baseline.seeder";
import { IAM_SEED_OPTIONS } from "./iam-seed.constants";
import { IamSeedOptions } from "./iam-seed.types";
/**
* Provides `IamBaselineSeeder`. The app calls `run()` itself — usually from
* `onApplicationBootstrap`, after whatever seeder creates the organization the
* super admin attaches to.
*
* IamSeedModule.forRoot({
* superAdmin: {
* username: "superadmin",
* name: { am: "ሱፐር አድሚን", en: "Super Admin" },
* roleKey: "super_admin",
* organizationKey: "edr_freight",
* unitKey: "edr_freight_app",
* fallbackEmail: "superadmin@tria.com",
* },
* })
*
* Any section left out keeps the value from `DEFAULT_IAM_BASELINE_SEED`. The
* seeder needs a TypeORM `DataSource` that can reach the `iam` schema; the app's
* default one is used, so `TypeOrmModule.forRoot*` must be registered.
*/
@Module({})
export class IamSeedModule {
static forRoot(options: IamSeedOptions = {}): DynamicModule {
return {
module: IamSeedModule,
providers: [
{ provide: IAM_SEED_OPTIONS, useValue: options },
IamBaselineSeeder,
],
exports: [IamBaselineSeeder],
};
}
}

View File

@@ -0,0 +1,100 @@
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 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 SeedRolePermission = { roleKey: string; permissionKeys: string[] };
export type SeedPositionTypePermission = {
positionTypeKey: string;
permissionKeys: string[];
};
export type SeedDefaultUnit = {
key: string;
description: string;
name: LocalizedName;
};
export type SeedOrganizationType = {
key: string;
name: LocalizedName;
/** Units an organization of this type is created with. */
defaultUnits: SeedDefaultUnit[];
};
export type SettingDefault = {
key: string;
displayName: string;
/** Matches the package's ESettingType. */
type: "value" | "file";
value?: string | null;
};
/**
* The super-admin account. Its access comes from the `super_admin` role grant,
* so the account needs no permissions of its own.
*
* One account is shared by every app on this schema: whichever seeds first
* creates the user, the others find it and attach their own employee row for
* their organization.
*
* Email and phone are read from the environment at seed time —
* SUPER_ADMIN_EMAIL, SUPER_ADMIN_PHONE — falling back to `fallbackEmail`.
*
* The password has NO fallback, by design. It comes from SUPER_ADMIN_DEFAULT_PASSWORD,
* or DEFAULT_PASSWORD if that is unset. With neither set, the account is still
* created and granted its role but gets no credential, and the seeder says so —
* the password is then set through the IAM reset flow. A seeded default would
* otherwise become a known password in whatever environment forgot to override
* it.
*/
export type SeedSuperAdmin = {
username: string;
name: LocalizedName;
/** Key in `roles` — the grant that carries the access. */
roleKey: string;
/** Existing organization to attach the employee to. Must already exist. */
organizationKey: string;
/** Optional unit within that organization; the employee is unit-less without it. */
unitKey?: string;
fallbackEmail: string;
};
export type IamBaselineSeed = {
applications: SeedApplication[];
roles: SeedRole[];
permissions: SeedPermission[];
rolePermissions: SeedRolePermission[];
positionTypes: SeedPositionType[];
positionTypePermissions: SeedPositionTypePermission[];
organizationTypes: SeedOrganizationType[];
organizationSettings: SettingDefault[];
unitSettings: SettingDefault[];
superAdmin: SeedSuperAdmin | null;
};
/** Per-section override passed to `IamSeedModule.forRoot()`. */
export type IamSeedOptions = Partial<IamBaselineSeed> & {
/**
* Environment variable that must equal "true" for the seeder to write
* anything. Defaults to SEED_IAM_BASELINE.
*/
enableFlag?: string;
};

View File

@@ -0,0 +1,20 @@
export { IamSeedModule } from "./iam-seed.module";
export { IamBaselineSeeder } from "./iam-baseline.seeder";
export { IAM_SEED_OPTIONS } from "./iam-seed.constants";
export { DEFAULT_IAM_BASELINE_SEED } from "./iam-baseline.seed";
export { missingSettings } from "./missing-settings.util";
export type {
IamBaselineSeed,
IamSeedOptions,
LocalizedName,
SeedApplication,
SeedDefaultUnit,
SeedOrganizationType,
SeedPermission,
SeedPositionType,
SeedPositionTypePermission,
SeedRole,
SeedRolePermission,
SeedSuperAdmin,
SettingDefault,
} from "./iam-seed.types";

View File

@@ -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();
});
});

View File

@@ -0,0 +1,20 @@
/**
* Settings of `ownerId` that are not in `existingPairs` (`"<ownerId>:<key>"`)
* yet. This is what keeps the IAM baseline seed idempotent: `organization_settings`
* and `unit_settings` have no unique index on (owner, key), so a plain re-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<string>,
ownerId: string,
): (Omit<TSetting, "value"> & { value?: string })[] {
return defaults
.filter((setting) => !existingPairs.has(`${ownerId}:${setting.key}`))
.map((setting) => ({ ...setting, value: setting.value ?? undefined }));
}