mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #972 from Tria-plc/freight/nati-1
IAM Seeder centralized
This commit is contained in:
@@ -24,6 +24,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
|
||||
| ---------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `@edr/types` | Shared TypeScript interfaces and enums |
|
||||
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||
| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
|
||||
| `@edr/ui-common` | Shared React components and theme |
|
||||
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
||||
| `@edr/tsconfig` | Shared TypeScript configurations |
|
||||
|
||||
@@ -42,8 +42,17 @@ 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_DEFAULT_PASSWORD=
|
||||
DEFAULT_PASSWORD=password@tria
|
||||
|
||||
# IAM baseline shared with edr-passenger-api (roles, IAM app + permissions,
|
||||
# position types, organization types + default units, org/unit settings, super
|
||||
# admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see
|
||||
# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only.
|
||||
# Set to false to opt out.
|
||||
SEED_IAM_BASELINE=true
|
||||
|
||||
# Freight org + staff (bookings / rule-engine IAM)
|
||||
SEED_EDR_ORG=true
|
||||
SEED_FREIGHT_STAFF=true
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@edr/iam-seed": "workspace:*",
|
||||
"@edr/payment-providers": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@golevelup/nestjs-rabbitmq": "^5.5.0",
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ensurePostgresSchemas,
|
||||
APPLICATION_SEARCH_PATH,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
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";
|
||||
@@ -153,6 +154,18 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
applications: [EDR_FREIGHT_APPLICATION],
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
// Replaces the package's DataSeeder. Shared with edr-passenger-api, which
|
||||
// seeds the same `iam` schema — see packages/iam-seed.
|
||||
IamSeedModule.forRoot({
|
||||
superAdmin: {
|
||||
username: "superadmin",
|
||||
name: { am: "ሱፐር አድሚን", en: "Super Admin" },
|
||||
roleKey: "super_admin",
|
||||
organizationKey: "edr_freight",
|
||||
unitKey: "edr_freight_app",
|
||||
fallbackEmail: "superadmin@tria.com",
|
||||
},
|
||||
}),
|
||||
BookingsModule,
|
||||
ContractsModule,
|
||||
SignaturesModule,
|
||||
@@ -231,7 +244,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 +274,22 @@ 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 → @edr/iam-seed: IAM app, roles, permissions,
|
||||
// position types, organization types +
|
||||
// default units, org/unit settings and the
|
||||
// super-admin account. Replaces the package's
|
||||
// DataSeeder, and is shared with
|
||||
// edr-passenger-api so one writer owns the
|
||||
// `iam` schema. Runs after edrOrgSeeder
|
||||
// because the super admin attaches 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.
|
||||
|
||||
@@ -181,6 +181,17 @@ GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
|
||||
# Login endpoint for backoffice users: POST /v1/auth/login
|
||||
SEED_EDR_PASSENGER_ORG=false
|
||||
SEED_PASSENGER_STAFF=false
|
||||
# IAM baseline shared with edr-freight-api (roles, IAM app + permissions, position
|
||||
# types, organization types + default units, org/unit settings, super admin).
|
||||
# Replaces the seeder that used to ship inside @tria-plc/iamapi-common — see
|
||||
# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only.
|
||||
# Set to false to opt out.
|
||||
SEED_IAM_BASELINE=true
|
||||
# Super-admin account seeded by the above. Shared across the apps on this schema.
|
||||
SUPER_ADMIN_EMAIL=superadmin@tria.com
|
||||
SUPER_ADMIN_PHONE=
|
||||
# Falls back to DEFAULT_PASSWORD when empty.
|
||||
SUPER_ADMIN_DEFAULT_PASSWORD=
|
||||
# Plain-text password set on seeded staff accounts. Defaults to '12345678' if unset.
|
||||
DEFAULT_PASSWORD=Admin@1234
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"prisma:verify": "ts-node prisma/verify-backfill.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/iam-seed": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@golevelup/nestjs-rabbitmq": "^5.5.0",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
|
||||
@@ -4,8 +4,8 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module";
|
||||
import { DataSeeder } from "@tria-plc/iamapi-common/db/seed/seeder";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
import {
|
||||
EDR_PASSENGER_APPLICATION,
|
||||
@@ -103,6 +103,17 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
`Set your EDR Passenger password using this link: ${route}`,
|
||||
},
|
||||
}),
|
||||
// Replaces the package's DataSeeder. Shared with edr-freight-api, which
|
||||
// seeds the same `iam` schema — see packages/iam-seed.
|
||||
IamSeedModule.forRoot({
|
||||
superAdmin: {
|
||||
username: "superadmin",
|
||||
name: { am: "ሱፐር አድሚን", en: "Super Admin" },
|
||||
roleKey: "super_admin",
|
||||
organizationKey: "edr",
|
||||
fallbackEmail: "superadmin@tria.com",
|
||||
},
|
||||
}),
|
||||
SharedAuthModule,
|
||||
PrismaModule,
|
||||
AuditModule,
|
||||
@@ -151,18 +162,23 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(AppModule.name);
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly iamBaselineSeeder: IamBaselineSeeder,
|
||||
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
|
||||
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
|
||||
private readonly segmentFareSeeder: SegmentFareSeeder,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
// Runs first so the roles it seeds exist before EdrPassengerOrgSeeder links
|
||||
// super_admin permissions. Its own super-admin account attaches to the `edr`
|
||||
// organization, which that seeder creates — so on a brand-new database the
|
||||
// account lands on the next boot; it logs a warning and skips until then.
|
||||
// Non-fatal internally, but the wrapper stays for symmetry with the rest.
|
||||
try {
|
||||
await this.seeder.run();
|
||||
await this.iamBaselineSeeder.run();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[DataSeeder] Seed failed (non-fatal):",
|
||||
"[IamBaselineSeeder] Seed failed (non-fatal):",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
|
||||
50
packages/iam-seed/package.json
Normal file
50
packages/iam-seed/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@edr/iam-seed",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Shared IAM baseline seeder for the apps that share the `iam` schema",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsc -w -p tsconfig.json",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"argon2": "^0.43.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"typeorm": "^0.3.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
"@edr/tsconfig": "workspace:*",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^20.14.0",
|
||||
"jest": "^29.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typeorm": "^0.3.20",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
645
packages/iam-seed/src/iam-baseline.seed.ts
Normal file
645
packages/iam-seed/src/iam-baseline.seed.ts
Normal 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,
|
||||
};
|
||||
875
packages/iam-seed/src/iam-baseline.seeder.ts
Normal file
875
packages/iam-seed/src/iam-baseline.seeder.ts
Normal 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}`));
|
||||
}
|
||||
}
|
||||
2
packages/iam-seed/src/iam-seed.constants.ts
Normal file
2
packages/iam-seed/src/iam-seed.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** DI token for the options passed to `IamSeedModule.forRoot(...)`. */
|
||||
export const IAM_SEED_OPTIONS = Symbol("IAM_SEED_OPTIONS");
|
||||
39
packages/iam-seed/src/iam-seed.module.ts
Normal file
39
packages/iam-seed/src/iam-seed.module.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
||||
100
packages/iam-seed/src/iam-seed.types.ts
Normal file
100
packages/iam-seed/src/iam-seed.types.ts
Normal 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;
|
||||
};
|
||||
20
packages/iam-seed/src/index.ts
Normal file
20
packages/iam-seed/src/index.ts
Normal 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";
|
||||
27
packages/iam-seed/src/missing-settings.util.spec.ts
Normal file
27
packages/iam-seed/src/missing-settings.util.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
20
packages/iam-seed/src/missing-settings.util.ts
Normal file
20
packages/iam-seed/src/missing-settings.util.ts
Normal 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 }));
|
||||
}
|
||||
11
packages/iam-seed/tsconfig.json
Normal file
11
packages/iam-seed/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@edr/tsconfig/nestjs.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
}
|
||||
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
@@ -45,6 +45,9 @@ importers:
|
||||
'@edr/api-common':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/api-common
|
||||
'@edr/iam-seed':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/iam-seed
|
||||
'@edr/payment-providers':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/payment-providers
|
||||
@@ -754,6 +757,9 @@ importers:
|
||||
|
||||
apps/edr-passenger-api:
|
||||
dependencies:
|
||||
'@edr/iam-seed':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/iam-seed
|
||||
'@edr/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
@@ -1292,6 +1298,43 @@ importers:
|
||||
|
||||
packages/config/tsconfig: {}
|
||||
|
||||
packages/iam-seed:
|
||||
dependencies:
|
||||
argon2:
|
||||
specifier: ^0.43.1
|
||||
version: 0.43.1
|
||||
devDependencies:
|
||||
'@edr/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../config/eslint-config
|
||||
'@edr/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../config/tsconfig
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@types/jest':
|
||||
specifier: ^29.5.13
|
||||
version: 29.5.14
|
||||
'@types/node':
|
||||
specifier: ^20.14.0
|
||||
version: 20.19.42
|
||||
jest:
|
||||
specifier: ^29.7.0
|
||||
version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
ts-jest:
|
||||
specifier: ^29.2.5
|
||||
version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3)
|
||||
typeorm:
|
||||
specifier: ^0.3.20
|
||||
version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||
typescript:
|
||||
specifier: ^5.5.4
|
||||
version: 5.9.3
|
||||
|
||||
packages/payment-providers:
|
||||
dependencies:
|
||||
'@edr/types':
|
||||
|
||||
Reference in New Issue
Block a user