feat(seed): implement local IAM baseline seeder with configurable settings

This commit is contained in:
Nathnael
2026-07-27 07:09:09 +00:00
parent 89a0bc6948
commit 1fe10ee3c2
6 changed files with 1149 additions and 4 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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,
};

View File

@@ -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<ObjectLiteral>,
keys: string[],
errorPrefix: string,
): Promise<Map<string, string>> {
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;
}
}

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 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<string>,
ownerId: string,
): (Omit<TSetting, "value"> & { value?: string })[] {
return defaults
.filter((setting) => !existingPairs.has(`${ownerId}:${setting.key}`))
.map((setting) => ({ ...setting, value: setting.value ?? undefined }));
}