From 1fe10ee3c22c1476da9f2b66450e74e99c667061 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 27 Jul 2026 07:09:09 +0000 Subject: [PATCH 1/8] feat(seed): implement local IAM baseline seeder with configurable settings --- apps/edr-freight-api/.env.example | 7 + apps/edr-freight-api/src/app.module.ts | 20 +- .../src/seed/iam-baseline.seed.ts | 611 ++++++++++++++++++ .../src/seed/iam-baseline.seeder.ts | 468 ++++++++++++++ .../src/seed/missing-settings.util.spec.ts | 27 + .../src/seed/missing-settings.util.ts | 20 + 6 files changed, 1149 insertions(+), 4 deletions(-) create mode 100644 apps/edr-freight-api/src/seed/iam-baseline.seed.ts create mode 100644 apps/edr-freight-api/src/seed/iam-baseline.seeder.ts create mode 100644 apps/edr-freight-api/src/seed/missing-settings.util.spec.ts create mode 100644 apps/edr-freight-api/src/seed/missing-settings.util.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index d80ce75c6..4df20a9a2 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -42,8 +42,15 @@ JWT_REFRESH_TOKEN_EXPIRES=7d # IAM seed defaults (used by @tria-plc/iamapi-common on first boot) SUPER_ADMIN_EMAIL=superadmin@tria.com SUPER_ADMIN_PHONE= +# Super-admin password. Falls back to DEFAULT_PASSWORD when empty. +SUPER_ADMIN_PASSWORD= DEFAULT_PASSWORD=password@tria +# IAM baseline (roles, IAM app + permissions, position types, org/unit settings, +# super-admin account). Local replacement for the package DataSeeder — +# see src/seed/iam-baseline.seed.ts. +SEED_IAM_BASELINE=false + # Freight org + staff (bookings / rule-engine IAM) SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index aab9e0e4a..2e2da880f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,7 +12,7 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; -import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; +import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import appConfig from "./config/app.config"; @@ -55,6 +55,7 @@ import { EDR_FREIGHT_PERMISSIONS, } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; +import { IamBaselineSeeder } from "./seed/iam-baseline.seeder"; import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; // Disabled seeds — imports commented out with their provider/injection/run below. // import { DemoUsersSeeder } from "./seed/demo-users.seeder"; @@ -206,6 +207,7 @@ import { LoggerMiddleware } from "./logger.middleware"; AiModule, ], providers: [ + IamBaselineSeeder, EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, @@ -231,7 +233,7 @@ import { LoggerMiddleware } from "./logger.middleware"; }) export class AppModule implements OnApplicationBootstrap { constructor( - private readonly seeder: DataSeeder, + private readonly iamBaselineSeeder: IamBaselineSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, @@ -261,13 +263,23 @@ export class AppModule implements OnApplicationBootstrap { // Permissions foundation — keep enabled: // freightPermissionKeyMigration → renames legacy permission keys - // seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions // edrOrgSeeder → seeds org/unit + the Permission catalog + // iamBaselineSeeder → seeds the IAM app, roles, permissions, + // position types, org/unit settings and the + // super-admin account. Local replacement for + // the package's DataSeeder (still exported as + // `DataSeeder` from @tria-plc/iamapi-common + // and runnable via `pnpm iam:seed:run`) — + // see src/seed/iam-baseline.seed.ts for + // what it seeds and what it drops. Runs after + // edrOrgSeeder because the super admin is + // attached to the edr_freight org/unit. + // Writes nothing unless SEED_IAM_BASELINE=true. // freightPositionsSeeder → seeds Position + PositionPermission rows // (depends on edrOrgSeeder, must run after) await this.freightPermissionKeyMigrationSeeder.run(); - await this.seeder.run(); await this.edrOrgSeeder.run(); + await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); // File upload settings — keep enabled. diff --git a/apps/edr-freight-api/src/seed/iam-baseline.seed.ts b/apps/edr-freight-api/src/seed/iam-baseline.seed.ts new file mode 100644 index 000000000..f55fa125c --- /dev/null +++ b/apps/edr-freight-api/src/seed/iam-baseline.seed.ts @@ -0,0 +1,611 @@ +/** + * IAM baseline seed data — the rows `IamBaselineSeeder` writes. + * + * These started as a copy of the seed constants inside + * `@tria-plc/iamapi-common` (`dist/db/seed/role.seed`, + * `organization-setting.seed`, `unit-setting.seed`) as of 0.7.12, and are now + * owned here: edit, extend or delete anything below and the seeder follows. + * Nothing in this file is imported from the package, so a version bump cannot + * change what gets seeded — it also will not hand you new IAM permissions, so + * diff against those files when upgrading. + * + * Ids are the package's originals. Keep them: the rows already in every + * environment carry these ids. + * + * Deliberately NOT seeded (the package's DataSeeder does all of it): + * - TRIA super-admin organization + its `superadmin` user and password + * - 11 Addis Ababa sub-city organizations + * - woreda / subcity / office / branch organization types + 65 default units + * - the 7 non-IAM Smart Office applications (booking, bot, chronicle, dms, + * metabase, performance, record) + */ + +export type LocalizedName = { am: string; en: string }; + +export type SeedApplication = { id: string; key: string; name: LocalizedName }; + +export type SeedRole = { id: string; key: string; name: LocalizedName }; + +export type SeedPermission = { + id: string; + key: string; + /** Omitted for the 21 org/unit/location permissions the package leaves unlinked. */ + applicationKey?: string; + name: LocalizedName; +}; + +export type SeedPositionType = { + id: string; + key: string; + isSystem: boolean; + name: LocalizedName; +}; + +export type SeedSuperAdmin = { + username: string; + name: LocalizedName; + /** Key in `roles` — the grant that carries the access. */ + roleKey: string; + /** Existing organization/unit to attach the employee to. */ + organizationKey: string; + unitKey: string; + /** Used when SUPER_ADMIN_EMAIL / SUPER_ADMIN_PASSWORD / DEFAULT_PASSWORD are all unset. */ + fallbackEmail: string; + fallbackPassword: string; +}; + +export type SettingDefault = { + key: string; + displayName: string; + /** Matches the package's ESettingType. */ + type: "value" | "file"; + value?: string | null; +}; + +/** Applications permissions hang off. `edr_freight_app` is seeded by EdrOrgSeeder. */ +const APPLICATIONS: SeedApplication[] = [ + { + id: "019bcb17-5470-7604-8708-7ed04d842b41", + key: "iam", + name: { am: "የስማርት ኦፊስ ማንነት እና መዳረሻ አስተዳደር", en: "Smart Office Identity and Access Management" }, + }, +]; + +const ROLES: SeedRole[] = [ + { + id: "520836ee-dc13-4c08-b572-8eced5bfd309", + key: "super_admin", + name: { am: "ዋና ተቆጣጣሪ", en: "Super Admin" }, + }, + { + id: "b2de1eae-ef93-4e90-8ec9-351f0dd8a6a9", + key: "organization_admin", + name: { am: "የመስሪያ ቤት ዋና ተቆጣጣሪ", en: "Organization Admin" }, + }, + { + id: "b3a9a5b5-9825-4290-8498-c62fb5925acd", + key: "unit_admin", + name: { am: "የመስሪያ ቤት ጽሕፈት ቤት ዋና ተቆጣጣሪ", en: "Organization Unit Admin" }, + }, + { + id: "ffe82427-ab16-4571-913c-553deb1b0f0f", + key: "guest", + name: { am: "ተጠቃሚ", en: "Guest" }, + }, +]; + +const PERMISSIONS: SeedPermission[] = [ + { + id: "019b5993-0000-0000-0000-000000000001", + key: "can:create:role", + applicationKey: "iam", + name: { am: "ሚና መፍጠር", en: "Create Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000002", + key: "can:update:role", + applicationKey: "iam", + name: { am: "ሚና ማሻሻል", en: "Update Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000003", + key: "can:delete:role", + applicationKey: "iam", + name: { am: "ሚና ማጥፋት", en: "Delete Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000006", + key: "can:update:permission", + applicationKey: "iam", + name: { am: "ፈቃድ ማሻሻል", en: "Update Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000007", + key: "can:delete:permission", + applicationKey: "iam", + name: { am: "ፈቃድ ማጥፋት", en: "Delete Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000009", + key: "can:create:role_permission", + applicationKey: "iam", + name: { am: "የሚና-ፈቃድ መፍጠር", en: "Create Role-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000010", + key: "can:delete:role_permission", + applicationKey: "iam", + name: { am: "የሚና-ፈቃድ ማጥፋት", en: "Delete Role-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000011", + key: "can:view:role_permission", + applicationKey: "iam", + name: { am: "የሚና-ፈቃድ መመልከት", en: "View Role-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000012", + key: "can:create:user_role", + applicationKey: "iam", + name: { am: "የተጠቃሚ-ሚና መፍጠር", en: "Create User-Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000013", + key: "can:delete:user_role", + applicationKey: "iam", + name: { am: "የተጠቃሚ-ሚና ማጥፋት", en: "Delete User-Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000014", + key: "can:view:user_role", + applicationKey: "iam", + name: { am: "የተጠቃሚ-ሚና መመልከት", en: "View User-Role" }, + }, + { + id: "019b5993-0000-0000-0000-000000000015", + key: "can:create:position_permission", + applicationKey: "iam", + name: { am: "የመደብ-ሚና መፍጠር", en: "Create Position-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000016", + key: "can:delete:position_permission", + applicationKey: "iam", + name: { am: "የመደብ-ሚና ማጥፋት", en: "Delete Position-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000017", + key: "can:view:position_permission", + applicationKey: "iam", + name: { am: "የመደብ-ሚና መመልከት", en: "View Position-Permission" }, + }, + { + id: "019b5993-0000-0000-0000-000000000018", + key: "can:find_all:organization", + name: { am: "ሁሉንም ድርጅቶች መፈለግ", en: "Find All Organizations" }, + }, + { + id: "019b5993-0000-0000-0000-000000000019", + key: "can:update:organization", + name: { am: "ድርጅት ማሻሻል", en: "Update Organization" }, + }, + { + id: "019b5993-0000-0000-0000-000000000020", + key: "can:delete:organization", + name: { am: "ድርጅት ማጥፋት", en: "Delete Organization" }, + }, + { + id: "019b5993-0000-0000-0000-000000000021", + key: "can:create:unit", + name: { am: "ክፍል መፍጠር", en: "Create Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000022", + key: "can:update:unit", + name: { am: "ክፍል ማሻሻል", en: "Update Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000023", + key: "can:delete:unit", + name: { am: "ክፍል ማጥፋት", en: "Delete Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000024", + key: "can:create:default_unit", + name: { am: "የዩኒት አይነት መፍጠር", en: "Create Default Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000025", + key: "can:update:default_unit", + name: { am: "የዩኒት አይነት ማሻሻል", en: "Update Default Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000026", + key: "can:delete:default_unit", + name: { am: "የዩኒት አይነት ማጥፋት", en: "Delete Default Unit" }, + }, + { + id: "019b5993-0000-0000-0000-000000000027", + key: "can:create:default_position", + name: { am: "የስራ መደብ አይነት መፍጠር", en: "Create Default Position" }, + }, + { + id: "019b5993-0000-0000-0000-000000000028", + key: "can:update:default_position", + name: { am: "የስራ መደብ አይነት ማሻሻል", en: "Update Default Position" }, + }, + { + id: "019b5993-0000-0000-0000-000000000029", + key: "can:delete:default_position", + name: { am: "የስራ መደብ አይነት ማጥፋት", en: "Delete Default Position" }, + }, + { + id: "019b5993-0000-0000-0000-000000000030", + key: "can:create:organization_type", + name: { am: "የድርጅት አይነት መፍጠር", en: "Create Organization Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000031", + key: "can:update:organization_type", + name: { am: "የድርጅት አይነት ማሻሻል", en: "Update Organization Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000032", + key: "can:delete:organization_type", + name: { am: "የድርጅት አይነት ማጥፋት", en: "Delete Organization Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000033", + key: "can:create:location_type", + name: { am: "የአካባቢ አይነት መፍጠር", en: "Create Location Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000034", + key: "can:update:location_type", + name: { am: "የአካባቢ አይነት ማሻሻል", en: "Update Location Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000035", + key: "can:delete:location_type", + name: { am: "የአካባቢ አይነት ማጥፋት", en: "Delete Location Type" }, + }, + { + id: "019b5993-0000-0000-0000-000000000036", + key: "can:create:location", + name: { am: "አካባቢ መፍጠር", en: "Create Location" }, + }, + { + id: "019b5993-0000-0000-0000-000000000037", + key: "can:update:location", + name: { am: "አካባቢ ማሻሻል", en: "Update Location" }, + }, + { + id: "019b5993-0000-0000-0000-000000000038", + key: "can:delete:location", + name: { am: "አካባቢ ማጥፋት", en: "Delete Location" }, + }, + { + id: "a9a7c0fa-e4fc-4c0e-b1c2-f74f9da40421", + key: "create:organization", + applicationKey: "iam", + name: { am: "የመስሪያ ቤት መፍጠር", en: "Create Organization" }, + }, + { + id: "c807691b-2693-4079-9dc5-1e080b67006c", + key: "activate:organization", + applicationKey: "iam", + name: { am: "የመስሪያ ቤት አስተካክል", en: "Activate Organization" }, + }, + { + id: "457a3659-ed96-456a-a6a1-2881226a86ed", + key: "can:debarOrganization", + applicationKey: "iam", + name: { am: "መቼት መቆጣጠር ይችላል", en: "Debar Organization" }, + }, + { + id: "2cd9e3c5-bd41-48f3-a849-f497b18b2906", + key: "manage:organizationAdmin", + applicationKey: "iam", + name: { am: "መቼት መቆጣጠር ይችላል", en: "Manage Organization Admin" }, + }, + { + id: "257d8c6e-ef30-4510-892f-d4a2ad4c814d", + key: "manage:unitAdmin", + applicationKey: "iam", + name: { am: "የጽሕፈት ቤት መቼት መቆጣጠር ይችላል", en: "Manage Unit Admin" }, + }, + { + id: "4052b7b8-e9f6-4bfe-9b93-eb59f9e4e576", + key: "can:createEmployee", + applicationKey: "iam", + name: { am: "ሰራተኞችን መመደብ/መፍጠር ይችላሉ", en: "Can create employees" }, + }, + { + id: "965ec76d-bbdb-47a1-a916-07c52f609fa7", + key: "can:deactivateEmployee", + applicationKey: "iam", + name: { am: "ሰራተኞችን ማባረር ይችላሉ", en: "Can deactivate employees" }, + }, + { + id: "019cbdc6-3d7a-73aa-ac57-51436dfa50e9", + key: "can:activateEmployee", + applicationKey: "iam", + name: { am: "ሰራተኞችን መቀበል ይችላሉ", en: "Can activate employees" }, + }, + { + id: "ebd8cf49-243d-4887-b2c9-cbe61e86a17a", + key: "can:uploadUserCSV", + applicationKey: "iam", + name: { am: "የሰራተኞችን መረጃ መጫን ይችላል", en: "Can Upload User CSV" }, + }, + { + id: "68bebc03-c1a8-832f-ae5a-b66553e6bcef", + key: "can:exportUnitUsers", + applicationKey: "iam", + name: { am: "የተቀጣሪዎችን መረጃ ማውጣት ይችላሉ", en: "Can Export Unit Users" }, + }, + { + id: "c2566286-248e-4cb4-923d-b113d6a5d4ba", + key: "can:changeUsersProfile", + applicationKey: "iam", + name: { am: "ተጠቃሚዎች መግለጫ መቀየር ይችላል", en: "Can Change Users Profile" }, + }, + { + id: "14f438c6-9295-44a0-a4f3-4ac9855efc37", + key: "can:activateUser", + applicationKey: "iam", + name: { am: "ተጠቃሚዎችን መቆጣጠር ይችላል", en: "Can Activate/Deactivate User" }, + }, + { + id: "31a39f88-47c0-4322-80ef-f397a6791ff2", + key: "can:approveNewUser", + applicationKey: "iam", + name: { am: "አዲስ ተመዝጋቢ ማፅደቅ ይችላል", en: "Can Approve New User" }, + }, + { + id: "69694936-8b54-8330-b176-16ffc98c33a7", + key: "can:viewAllUsers", + applicationKey: "iam", + name: { am: "ሁሉንም ተጠቃሚዎች ማየት ይችላል", en: "Can View All Users" }, + }, + { + id: "68f1017f-4c60-8322-984d-e4317986e641", + key: "can:manageUsersAccountConfiguration", + applicationKey: "iam", + name: { am: "የተጠቃሚ መለያ አዋቂነት መቆጣጠር ይችላል", en: "Can Manage Users Account Configuration" }, + }, + { + id: "fceaa4c5-621f-45ce-b5a7-0ffc9e366fe1", + key: "can:setUserRequirementDocument", + applicationKey: "iam", + name: { am: "ተመዝጋቢዎች የሚያስገቡትን መረጃ መቆጣጠር ይችላል", en: "Can Set User Requirement Document" }, + }, +]; + +/** + * Role → permission-key grants. Applied additively: the seeder only inserts + * missing pairs, so grants made through the IAM UI survive a reseed. + */ +const ROLE_PERMISSIONS: { roleKey: string; permissionKeys: string[] }[] = [ + { + roleKey: "super_admin", + permissionKeys: [ + "can:create:role", + "can:update:role", + "can:delete:role", + "can:update:permission", + "can:delete:permission", + "can:create:role_permission", + "can:delete:role_permission", + "can:view:role_permission", + "can:create:user_role", + "can:delete:user_role", + "can:view:user_role", + "can:create:position_permission", + "can:delete:position_permission", + "can:view:position_permission", + "can:find_all:organization", + "can:update:organization", + "can:delete:organization", + "can:create:unit", + "can:update:unit", + "can:delete:unit", + "can:create:default_unit", + "can:update:default_unit", + "can:delete:default_unit", + "can:create:default_position", + "can:update:default_position", + "can:delete:default_position", + "can:create:organization_type", + "can:update:organization_type", + "can:delete:organization_type", + "can:create:location_type", + "can:update:location_type", + "can:delete:location_type", + "create:organization", + "activate:organization", + "can:debarOrganization", + "manage:organizationAdmin", + "manage:unitAdmin", + "can:activateUser", + "can:approveNewUser", + "can:viewAllUsers", + "can:setUserRequirementDocument", + "can:create:location", + "can:update:location", + "can:delete:location", + ], + }, + { + roleKey: "organization_admin", + permissionKeys: [ + "can:uploadUserCSV", + "can:changeUsersProfile", + "can:createEmployee", + "can:deactivateEmployee", + "can:exportUnitUsers", + "can:create:position_permission", + "can:delete:position_permission", + "can:view:position_permission", + "can:create:unit", + "can:update:unit", + "can:delete:unit", + "manage:unitAdmin", + ], + }, + { + roleKey: "unit_admin", + permissionKeys: [ + "can:uploadUserCSV", + "can:createEmployee", + "can:exportUnitUsers", + "can:changeUsersProfile", + "can:deactivateEmployee", + "can:manageUsersAccountConfiguration", + "can:create:position_permission", + "can:delete:position_permission", + "can:view:position_permission", + "can:create:unit", + "can:update:unit", + "can:delete:unit", + ], + }, + { + roleKey: "guest", + permissionKeys: [ + + ], + }, +]; + +const POSITION_TYPES: SeedPositionType[] = [ + { + id: "457a3659-ed96-456a-a6a1-2881226a86ec", + key: "employee", + isSystem: true, + name: { am: "ባለሙያ", en: "Employee" }, + }, + { + id: "34a7f69c-3f30-47a0-81c3-fcfc3087e456", + key: "teamLeader", + isSystem: true, + name: { am: "ቡድን መሪ", en: "Team Leader" }, + }, + { + id: "2cd9e3c5-bd41-48f3-a849-f497b18b2905", + key: "director", + isSystem: true, + name: { am: "ዳይሬክተር", en: "Director" }, + }, + { + id: "db310acf-7a78-40a3-83c7-9a9e9e6d1fc7", + key: "deputy", + isSystem: true, + name: { am: "ዘርፍ ኃላፊ", en: "Deputy" }, + }, + { + id: "1a4b4d40-e4fc-4f38-99a6-f81dc5fcff23", + key: "officeHead", + isSystem: true, + name: { am: "ቢሮ ኃላፊ", en: "Office Head" }, + }, + { + id: "83bc6cd3-119e-4a41-917c-c763fb3fd013", + key: "recordOfficer", + isSystem: true, + name: { am: "መዝገብ ቤት", en: "Record Officer" }, + }, +]; + +/** + * PositionType → permission-key grants. Empty because freight grants through + * Position/PositionPermission (see FreightPositionsSeeder), not position types. + */ +const POSITION_TYPE_PERMISSIONS: { + positionTypeKey: string; + permissionKeys: string[]; +}[] = []; + +/** Seeded once per organization. All start unset; the IAM UI fills them in. */ +const ORGANIZATION_SETTINGS: SettingDefault[] = [ + { key: "logoFileUrl", displayName: "Logo", type: "file" }, + { key: "faviconFileUrl", displayName: "Favicon", type: "file" }, + { key: "loginBackgroundFileUrl", displayName: "Login Background", type: "file" }, + { key: "stampImageFileUrl", displayName: "Stamp Image", type: "file" }, + { key: "customCssFileUrl", displayName: "Custom CSS", type: "file" }, + { key: "primaryColor", displayName: "Primary Color", type: "value" }, + { key: "secondaryColor", displayName: "Secondary Color", type: "value" }, + { key: "accentColor", displayName: "Accent Color", type: "value" }, + { key: "loginTitle", displayName: "Login Title", type: "value" }, + { key: "loginSubtitle", displayName: "Login Subtitle", type: "value" }, + { key: "sidebarColor", displayName: "Sidebar Color", type: "value" }, + { key: "headerColor", displayName: "Header Color", type: "value" }, + { key: "stampText", displayName: "Stamp Text", type: "value" }, + { key: "footerText", displayName: "Footer Text", type: "value" }, + { key: "supportEmail", displayName: "Support Email", type: "value" }, + { key: "supportPhone", displayName: "Support Phone", type: "value" }, +]; + +/** Seeded once per unit. */ +const UNIT_SETTINGS: SettingDefault[] = [ + { key: "isMultipleDelegationAllowed", displayName: "Is Multiple Delegation Allowed", type: "value" }, + { key: "internalSuffix", displayName: "Internal Suffix", type: "value" }, + { key: "internalPrefix", displayName: "Internal Prefix", type: "value" }, + { key: "internalSuffixCC", displayName: "Internal Suffix CC", type: "value" }, + { key: "internalPrefixCC", displayName: "Internal Prefix CC", type: "value" }, + { key: "referenceNumberPrefix", displayName: "Reference Number Prefix", type: "value" }, + { key: "externalReferenceNumberPrefix", displayName: "External Reference Number Prefix", type: "value" }, + { key: "internalMemoReferenceNumberPrefix", displayName: "Internal Memo Reference Number Prefix", type: "value" }, + { key: "escalationHour", displayName: "Escalation Hour", type: "value" }, + { key: "urgentLetterEscalationHour", displayName: "Urgent Letter Escalation Hour", type: "value" }, + { key: "onReviewLetterEscalationHour", displayName: "On Review Letter Escalation Hour", type: "value" }, + { key: "urgentOnReviewLetterEscalationHour", displayName: "Urgent On Review Letter Escalation Hour", type: "value" }, + { key: "shouldCollaboratorAlwaysSign", displayName: "Should Collaborator Always Sign", type: "value" }, + { key: "waitAllCollaboratorsBeforeAction", displayName: "Wait All Collaborators Before Action", type: "value" }, + { key: "shouldIncludeForYourReferenceInCC", displayName: "Should Include For Your Reference In CC", type: "value" }, + { key: "forwardWithTeeterSignature", displayName: "Forward With Teeter Signature", type: "value" }, + { key: "attachSignatureOnAttachment", displayName: "Attach Signature On Attachment", type: "value" }, + { key: "positionScopeToFetch", displayName: "Position Scope To Fetch", type: "value" }, +]; + +/** + * The super-admin account. Its access comes entirely from the `super_admin` + * UserRole: `isSuperAdmin()` in `common/freight-permission.util.ts` short-circuits + * `hasFreightPermission`, so this account passes every FreightPermissionGuard + * and every approval-step check without holding one freight permission row. + * + * Unlike the package seeder, the account lands in the freight organization + * (`edr_freight` / unit `edr_freight_app`) rather than a separate `tria` + * organization, so its employee context matches the app it administers. Both + * must already exist — EdrOrgSeeder creates them, and must run first. + * + * Read from env at seed time: SUPER_ADMIN_EMAIL, SUPER_ADMIN_PHONE, and for the + * password SUPER_ADMIN_PASSWORD first, then DEFAULT_PASSWORD. The values below + * are the last resort. Set this to null to seed no account at all. + */ +const SUPER_ADMIN: SeedSuperAdmin | null = { + username: "superadmin", + name: { am: "ሱፐር አድሚን", en: "Super Admin" }, + roleKey: "super_admin", + organizationKey: "edr_freight", + unitKey: "edr_freight_app", + fallbackEmail: "superadmin@tria.com", + fallbackPassword: "password@tria", +}; + +/** + * What `IamBaselineSeeder` writes. Empty an array to skip that section + * entirely; the seeder never deletes rows, so emptying one leaves whatever is + * already in the database alone. + */ +export const IAM_BASELINE_SEED = { + applications: APPLICATIONS, + roles: ROLES, + permissions: PERMISSIONS, + rolePermissions: ROLE_PERMISSIONS, + positionTypes: POSITION_TYPES, + positionTypePermissions: POSITION_TYPE_PERMISSIONS, + organizationSettings: ORGANIZATION_SETTINGS, + unitSettings: UNIT_SETTINGS, + superAdmin: SUPER_ADMIN, +}; diff --git a/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts b/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts new file mode 100644 index 000000000..9c718710a --- /dev/null +++ b/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts @@ -0,0 +1,468 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { + Application, + EEmployeeStatus, + Employee, + EUserStatus, + EUserType, + Organization, + Permission, + PositionType, + PositionTypePermission, + Role, + RolePermission, + Unit, + UnitSetting, + User, + UserCredential, + UserRole, +} from "@tria-plc/iamapi-common"; +// Not re-exported from the package root, unlike UnitSetting. +import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity"; +import type { ESettingType } from "@tria-plc/iamapi-common/enums/setting-type.enum"; +import { + DataSource, + EntityManager, + EntityTarget, + In, + ObjectLiteral, +} from "typeorm"; + +import { IAM_BASELINE_SEED } from "./iam-baseline.seed"; +import { missingSettings } from "./missing-settings.util"; + +const SEED_FLAG = "SEED_IAM_BASELINE"; + +/** + * Local stand-in for `DataSeeder` from `@tria-plc/iamapi-common`, seeding only + * what `IAM_BASELINE_SEED` lists (see that file for what was deliberately left + * out). Differences from the upstream seeder, all deliberate: + * + * - Nothing is deleted. Upstream wipes every `position_type_permissions` row + * for the 6 system position types on each run and nulls their `unit_id`. + * - Settings are matched on (organization|unit, key). Upstream upserts them on + * `id`, which it never supplies, so every run inserts a duplicate set. + * - Ids are resolved from the database by key rather than from the seed + * constants, so rows that already exist under a different id still link up. + * + * Gated behind SEED_IAM_BASELINE=true so a normal boot never writes. + */ +@Injectable() +export class IamBaselineSeeder { + private readonly logger = new Logger(IamBaselineSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== "true") { + this.logger.log( + `Skipping IAM baseline seed because ${SEED_FLAG} is not enabled`, + ); + return; + } + + await this.dataSource.transaction(async (manager) => { + await this.seedApplications(manager); + await this.seedPermissions(manager); + await this.seedRoles(manager); + await this.seedRolePermissions(manager); + await this.seedPositionTypes(manager); + await this.seedPositionTypePermissions(manager); + await this.seedOrganizationSettings(manager); + await this.seedUnitSettings(manager); + await this.seedSuperAdmin(manager); + }); + + this.logger.log("IAM baseline seed complete"); + } + + private async seedApplications(manager: EntityManager) { + const { applications } = IAM_BASELINE_SEED; + if (applications.length === 0) { + return; + } + + await manager.getRepository(Application).upsert( + applications.map((application) => ({ + id: application.id, + key: application.key, + name: { ...application.name }, + })), + { conflictPaths: { key: true } }, + ); + + this.logger.log(`Ensured ${applications.length} applications`); + } + + private async seedPermissions(manager: EntityManager) { + const { permissions } = IAM_BASELINE_SEED; + if (permissions.length === 0) { + return; + } + + // Permissions without an applicationKey stay unlinked (application_id null), + // which is how the package ships the org/unit/location ones. + const applicationIdByKey = await this.loadIdsByKey( + manager, + Application, + permissions.flatMap((permission) => + permission.applicationKey ? [permission.applicationKey] : [], + ), + "missing_applications", + ); + + await manager.getRepository(Permission).upsert( + permissions.map((permission) => ({ + id: permission.id, + key: permission.key, + name: { ...permission.name }, + applicationId: permission.applicationKey + ? applicationIdByKey.get(permission.applicationKey) + : undefined, + })), + { conflictPaths: { key: true } }, + ); + + this.logger.log(`Ensured ${permissions.length} permissions`); + } + + private async seedRoles(manager: EntityManager) { + const { roles } = IAM_BASELINE_SEED; + if (roles.length === 0) { + return; + } + + await manager.getRepository(Role).upsert( + roles.map((role) => ({ + id: role.id, + key: role.key, + name: { ...role.name }, + })), + { conflictPaths: { key: true } }, + ); + + this.logger.log(`Ensured ${roles.length} roles`); + } + + private async seedRolePermissions(manager: EntityManager) { + const { rolePermissions } = IAM_BASELINE_SEED; + if (rolePermissions.length === 0) { + return; + } + + const roleIdByKey = await this.loadIdsByKey( + manager, + Role, + rolePermissions.map((mapping) => mapping.roleKey), + "missing_roles", + ); + const permissionIdByKey = await this.loadIdsByKey( + manager, + Permission, + rolePermissions.flatMap((mapping) => mapping.permissionKeys), + "missing_permissions", + ); + + const repository = manager.getRepository(RolePermission); + const roleIds = [...roleIdByKey.values()]; + const existing = await repository.find({ + where: { roleId: In(roleIds) }, + select: { roleId: true, permissionId: true }, + }); + const existingPairs = new Set( + existing.map((row) => `${row.roleId}:${row.permissionId}`), + ); + + const rows = rolePermissions.flatMap((mapping) => { + const roleId = roleIdByKey.get(mapping.roleKey) as string; + return mapping.permissionKeys + .map((key) => permissionIdByKey.get(key) as string) + .filter((permissionId) => !existingPairs.has(`${roleId}:${permissionId}`)) + .map((permissionId) => ({ roleId, permissionId })); + }); + + if (rows.length === 0) { + return; + } + + await repository.insert(rows); + this.logger.log(`Granted ${rows.length} role permissions`); + } + + private async seedPositionTypes(manager: EntityManager) { + const { positionTypes } = IAM_BASELINE_SEED; + if (positionTypes.length === 0) { + return; + } + + // unitId is intentionally left alone — upstream resets it to null here. + await manager.getRepository(PositionType).upsert( + positionTypes.map((positionType) => ({ + id: positionType.id, + key: positionType.key, + name: { ...positionType.name }, + isSystem: positionType.isSystem ?? true, + })), + { conflictPaths: { key: true } }, + ); + + this.logger.log(`Ensured ${positionTypes.length} position types`); + } + + private async seedPositionTypePermissions(manager: EntityManager) { + const { positionTypePermissions } = IAM_BASELINE_SEED; + if (positionTypePermissions.length === 0) { + return; + } + + const positionTypeIdByKey = await this.loadIdsByKey( + manager, + PositionType, + positionTypePermissions.map((mapping) => mapping.positionTypeKey), + "missing_position_types", + ); + const permissionIdByKey = await this.loadIdsByKey( + manager, + Permission, + positionTypePermissions.flatMap((mapping) => mapping.permissionKeys), + "missing_permissions", + ); + + const repository = manager.getRepository(PositionTypePermission); + const existing = await repository.find({ + where: { positionTypeId: In([...positionTypeIdByKey.values()]) }, + select: { positionTypeId: true, permissionId: true }, + }); + const existingPairs = new Set( + existing.map((row) => `${row.positionTypeId}:${row.permissionId}`), + ); + + const rows = positionTypePermissions.flatMap((mapping) => { + const positionTypeId = positionTypeIdByKey.get( + mapping.positionTypeKey, + ) as string; + return mapping.permissionKeys + .map((key) => permissionIdByKey.get(key) as string) + .filter( + (permissionId) => + !existingPairs.has(`${positionTypeId}:${permissionId}`), + ) + .map((permissionId) => ({ positionTypeId, permissionId })); + }); + + if (rows.length === 0) { + return; + } + + await repository.insert(rows); + this.logger.log(`Granted ${rows.length} position type permissions`); + } + + private async seedOrganizationSettings(manager: EntityManager) { + const defaults = IAM_BASELINE_SEED.organizationSettings; + if (defaults.length === 0) { + return; + } + + const organizations = await manager + .getRepository(Organization) + .find({ select: { id: true } }); + const existing = await manager + .getRepository(OrganizationSetting) + .find({ select: { organizationId: true, key: true } }); + const existingPairs = new Set( + existing.map((setting) => `${setting.organizationId}:${setting.key}`), + ); + + const rows = organizations.flatMap((organization) => + missingSettings(defaults, existingPairs, organization.id as string).map( + (setting) => ({ + ...setting, + type: setting.type as ESettingType, + organizationId: organization.id as string, + }), + ), + ); + + if (rows.length === 0) { + return; + } + + await manager.getRepository(OrganizationSetting).insert(rows); + this.logger.log( + `Seeded ${rows.length} organization settings across ${organizations.length} organizations`, + ); + } + + private async seedUnitSettings(manager: EntityManager) { + const defaults = IAM_BASELINE_SEED.unitSettings; + if (defaults.length === 0) { + return; + } + + const units = await manager.getRepository(Unit).find({ select: { id: true } }); + const existing = await manager + .getRepository(UnitSetting) + .find({ select: { unitId: true, key: true } }); + const existingPairs = new Set( + existing.map((setting) => `${setting.unitId}:${setting.key}`), + ); + + const rows = units.flatMap((unit) => + missingSettings(defaults, existingPairs, unit.id as string).map( + (setting) => ({ + ...setting, + type: setting.type as ESettingType, + unitId: unit.id as string, + }), + ), + ); + + if (rows.length === 0) { + return; + } + + await manager.getRepository(UnitSetting).insert(rows); + this.logger.log( + `Seeded ${rows.length} unit settings across ${units.length} units`, + ); + } + + /** + * User + credential + employee + `super_admin` UserRole. Every step is + * skip-if-present: an existing account keeps its password, its organization + * and any extra roles it was given through the IAM UI. + */ + private async seedSuperAdmin(manager: EntityManager) { + const seed = IAM_BASELINE_SEED.superAdmin; + if (!seed) { + return; + } + + const email = process.env.SUPER_ADMIN_EMAIL?.trim() || seed.fallbackEmail; + // SUPER_ADMIN_PASSWORD wins, DEFAULT_PASSWORD is the shared fallback. + const password = + process.env.SUPER_ADMIN_PASSWORD?.trim() || + process.env.DEFAULT_PASSWORD?.trim() || + seed.fallbackPassword; + const phoneNumber = process.env.SUPER_ADMIN_PHONE?.trim() || undefined; + + const roleIdByKey = await this.loadIdsByKey( + manager, + Role, + [seed.roleKey], + "missing_roles", + ); + + const organization = await manager.getRepository(Organization).findOne({ + where: { key: seed.organizationKey }, + select: { id: true }, + }); + if (!organization) { + throw new Error(`missing_organization:${seed.organizationKey}`); + } + + const unit = await manager.getRepository(Unit).findOne({ + where: { key: seed.unitKey, organizationId: organization.id }, + select: { id: true }, + }); + if (!unit) { + throw new Error(`missing_unit:${seed.unitKey}`); + } + + const userRepository = manager.getRepository(User); + const existingUser = await userRepository.findOne({ + where: [{ username: seed.username }, { email }], + select: { id: true }, + }); + + let userId = existingUser?.id as string | undefined; + if (!userId) { + const inserted = await userRepository.insert({ + username: seed.username, + email, + phoneNumber, + name: { ...seed.name }, + userType: EUserType.EMPLOYEE, + status: EUserStatus.ACCEPTED, + isActive: true, + hasSetPassword: true, + }); + userId = inserted.identifiers[0]?.id as string; + this.logger.log(`Seeded super admin user '${seed.username}' (${email})`); + } + + const credentialRepository = manager.getRepository(UserCredential); + if (!(await credentialRepository.existsBy({ userId }))) { + await credentialRepository.insert({ + userId, + password: await hashPassword(password), + isActive: true, + }); + this.logger.log(`Seeded super admin credential for '${seed.username}'`); + } + + const employeeRepository = manager.getRepository(Employee); + if ( + !(await employeeRepository.existsBy({ + userId, + organizationId: organization.id, + })) + ) { + await employeeRepository.insert({ + userId, + organizationId: organization.id, + unitId: unit.id, + isCurrent: true, + status: EEmployeeStatus.ACCEPTED, + name: { ...seed.name }, + }); + this.logger.log( + `Attached super admin to organization '${seed.organizationKey}'`, + ); + } + + // user_roles is UNIQUE (user_id, role_id). + await manager.getRepository(UserRole).upsert( + { + userId, + roleId: roleIdByKey.get(seed.roleKey) as string, + organizationId: organization.id, + unitId: unit.id, + }, + { conflictPaths: { userId: true, roleId: true } }, + ); + + this.logger.log(`Ensured '${seed.roleKey}' role on '${seed.username}'`); + } + + /** + * Resolve `key → id` from the database for the given entity, throwing when a + * key the seed references has no row (the seed order is wrong, or the caller + * emptied the section that creates it). + */ + private async loadIdsByKey( + manager: EntityManager, + entity: EntityTarget, + keys: string[], + errorPrefix: string, + ): Promise> { + const wanted = [...new Set(keys)]; + const rows = (await manager + .getRepository(entity) + .find({ + where: { key: In(wanted) }, + select: { id: true, key: true }, + })) as { id?: string; key: string }[]; + + const idByKey = new Map(rows.map((row) => [row.key, row.id as string])); + + const missing = wanted.filter((key) => !idByKey.has(key)); + if (missing.length > 0) { + throw new Error(`${errorPrefix}:${missing.join(",")}`); + } + + return idByKey; + } +} diff --git a/apps/edr-freight-api/src/seed/missing-settings.util.spec.ts b/apps/edr-freight-api/src/seed/missing-settings.util.spec.ts new file mode 100644 index 000000000..a664117a5 --- /dev/null +++ b/apps/edr-freight-api/src/seed/missing-settings.util.spec.ts @@ -0,0 +1,27 @@ +import { missingSettings } from "./missing-settings.util"; + +const defaults = [ + { key: "primaryColor", displayName: "Primary Color", value: null }, + { key: "logoFileUrl", displayName: "Logo", value: null }, +]; + +describe("missingSettings", () => { + it("returns every default when the owner has none", () => { + expect(missingSettings(defaults, new Set(), "org-1")).toHaveLength(2); + }); + + it("skips keys the owner has, but not the same key on another owner", () => { + const existing = new Set(["org-1:primaryColor"]); + + expect( + missingSettings(defaults, existing, "org-1").map((s) => s.key), + ).toEqual(["logoFileUrl"]); + expect(missingSettings(defaults, existing, "org-2")).toHaveLength(2); + }); + + it("drops nulls so the column default applies", () => { + expect( + missingSettings(defaults, new Set(), "org-1")[0].value, + ).toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/seed/missing-settings.util.ts b/apps/edr-freight-api/src/seed/missing-settings.util.ts new file mode 100644 index 000000000..9ec4ccf5a --- /dev/null +++ b/apps/edr-freight-api/src/seed/missing-settings.util.ts @@ -0,0 +1,20 @@ +/** + * Settings of `ownerId` that are not in `existingPairs` (`":"`) + * yet. This is what keeps the IAM baseline seed idempotent: `organization_settings` + * and `unit_settings` have no unique index on (owner, key), so re-running an + * INSERT (or the package seeder's upsert-on-id, which never supplies an id) + * silently duplicates every row. + * + * `value: null` becomes `undefined` so the column default applies on insert. + */ +export function missingSettings< + TSetting extends { key: string; value?: string | null }, +>( + defaults: TSetting[], + existingPairs: Set, + ownerId: string, +): (Omit & { value?: string })[] { + return defaults + .filter((setting) => !existingPairs.has(`${ownerId}:${setting.key}`)) + .map((setting) => ({ ...setting, value: setting.value ?? undefined })); +} From 500668a415101658ec36df28b4ffb65ea94a843c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 27 Jul 2026 08:05:12 +0000 Subject: [PATCH 2/8] =?UTF-8?q?Truck=20detention=20was=20timed=20once=20pe?= =?UTF-8?q?r=20delivery,=20so=20every=20truck=20on=20a=20multi-truck=20las?= =?UTF-8?q?t=20mile=20was=20billed=20the=20same=20number=20of=20days=20reg?= =?UTF-8?q?ardless=20of=20when=20it=20actually=20arrived=20or=20was=20rele?= =?UTF-8?q?ased.=20Each=20truck=20now=20carries=20its=20own=20detention=20?= =?UTF-8?q?clock=20(destination=20arrival=20=E2=86=92=20release)=20with=20?= =?UTF-8?q?its=20own=20rule=20match,=20chargeable=20days=20and=20amount;?= =?UTF-8?q?=20the=20modal=20records=20and=20displays=20the=20window=20per?= =?UTF-8?q?=20truck,=20and=20the=20summary=20shows=20the=20longest=20deten?= =?UTF-8?q?tion=20plus=20the=20combined=20total.=20Also=20corrected=20a=20?= =?UTF-8?q?false=20no=20detention=20rule=20matches=20warning=20that=20appe?= =?UTF-8?q?ared=20whenever=20more=20than=20one=20truck=20was=20assigned.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...860000000000-AddPerTruckDetentionWindow.ts | 35 +++ .../last-mile/dto/set-detention-times.dto.ts | 29 ++ .../last-mile-vehicle-assignment.entity.ts | 15 ++ .../modules/last-mile/last-mile.controller.ts | 13 + .../modules/last-mile/last-mile.service.ts | 40 +++ .../warehouses/per-truck-detention.spec.ts | 82 ++++++ .../warehouse-fee.bulk-quantity.spec.ts | 4 + .../warehouses/warehouse-fee.service.ts | 111 +++++--- .../operations/TruckDetentionModal.tsx | 249 ++++++++++++++---- .../src/services/last-mile.service.ts | 12 + .../backoffice/src/types/warehouse.ts | 8 +- 11 files changed, 512 insertions(+), 86 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts diff --git a/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts b/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts new file mode 100644 index 000000000..710ad12ad --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck detention clocks. Detention was timed once per last-mile leg + * (last_mile.arrived_at / delivered_at), so every truck on a multi-truck + * delivery shared one window and was billed identical days — wrong the moment + * two trucks arrive or return at different times. + * + * Deliberately NEW columns rather than reusing the existing per-truck + * arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out + * events stamped by release(), whereas detention runs from arrival at the + * DESTINATION until the truck is released/returned. + * + * Both nullable — a truck without its own window falls back to the leg-level + * timestamps, so legacy legs keep billing exactly as before. + */ +export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface { + name = 'AddPerTruckDetentionWindow2860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz, + ADD COLUMN IF NOT EXISTS returned_at timestamptz; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS returned_at, + DROP COLUMN IF EXISTS destination_arrived_at; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts new file mode 100644 index 000000000..9f103e15b --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts @@ -0,0 +1,29 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +/** + * One truck's detention window. Each truck reaches the destination and is + * released at its own time, so detention days differ between trucks on the + * same delivery. Null clears the value (falls back to the leg-level pair). + */ +export class TruckDetentionTimeInput { + @IsUUID() + vehicleId!: string; + + /** Detention clock start — this truck reached the destination. */ + @IsOptional() + @IsDateString() + destinationArrivedAt?: string | null; + + /** Detention clock end — this truck was released/returned. Omit = still out. */ + @IsOptional() + @IsDateString() + returnedAt?: string | null; +} + +export class SetDetentionTimesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TruckDetentionTimeInput) + trucks!: TruckDetentionTimeInput[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts index e57c16b8a..f2b4e2467 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity { @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) departedAt?: Date | null; + /** + * Detention clock START for THIS truck: reached the delivery destination. + * Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the + * leg-level `last_mile.arrived_at`. + */ + @Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true }) + destinationArrivedAt?: Date | null; + + /** + * Detention clock END for THIS truck: released / returned by the customer. + * Null (with no leg-level `delivered_at`) means still out — detention accrues. + */ + @Column({ name: 'returned_at', type: 'timestamptz', nullable: true }) + returnedAt?: Date | null; + /** Weighed gross on exit, in TONNES (not kg — see the migration note). */ @Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) grossWeightTons?: number | null; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 29e857e2f..e8fa57cdc 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDetentionTimesDto } from './dto/set-detention-times.dto'; import { SetDistancesDto } from './dto/set-distances.dto'; import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMileStatus } from './entities/last-mile.entity'; @@ -131,6 +132,18 @@ export class LastMileController { return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); } + @Post(':id/detention-times') + @BookingStaff(FREIGHT_PERMS.lastMile.update) + @ApiOperation({ + summary: 'Set each truck\'s own detention window (arrived at destination / returned)', + }) + async setDetentionTimes( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDetentionTimesDto, + ) { + return this.lastMileService.setDetentionTimes(id, dto.trucks); + } + @Post(':id/proof-of-delivery') @BookingStaff(FREIGHT_PERMS.lastMile.update) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 601e03aec..d7c45bb3c 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -869,6 +869,46 @@ export class LastMileService { * sum and drives billing; `remainingPayment` (total km × rate) is recomputed * client-side. Does NOT generate an invoice — that's a separate explicit step. */ + /** + * Per-truck detention windows. Each truck reaches the destination and is + * released at its own time, so every truck gets its own clock (and therefore + * its own chargeable days). Locked once the detention invoice exists. + */ + async setDetentionTimes( + id: string, + trucks: Array<{ + vehicleId: string; + destinationArrivedAt?: string | null; + returnedAt?: string | null; + }>, + ): Promise { + await this.findById(id); + + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Detention times cannot be changed after the invoice is generated', + ); + } + + for (const t of trucks) { + const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null; + const end = t.returnedAt ? new Date(t.returnedAt) : null; + if (start && end && end.getTime() < start.getTime()) { + throw new BadRequestException( + 'A truck cannot be returned before it arrived — check the detention times', + ); + } + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: t.vehicleId }, + { destinationArrivedAt: start, returnedAt: end }, + ); + } + + return this.findById(id); + } + async setDistances( id: string, distances: Array<{ vehicleId: string; distanceKm: number }>, diff --git a/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts b/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts new file mode 100644 index 000000000..471b82965 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts @@ -0,0 +1,82 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; + +/** + * Detention is per truck: two trucks on the same delivery with different + * windows must produce different chargeable days and amounts (the old + * leg-level clock billed them identically). + */ +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +const svc = Object.create(WarehouseFeeService.prototype) as { + computeTruckDetention: ( + rule: Record | null, + row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number }, + now: Date, + billingCurrency: string, + ) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>; + normalizeCurrency: (c?: string | null) => string; + convertAmount: (a: number, from: string, to: string) => Promise; + calculateTieredAmount: unknown; +}; +svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD'); +svc.convertAmount = async (a) => a; + +// 3h grace, 50/truck/day, no tiers. +const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] }; +const now = new Date('2026-07-25T12:00:00Z'); + +describe('per-truck detention', () => { + it('bills each truck on its own window', async () => { + // Truck A: out ~1 day past grace. Truck B: out ~3 days past grace. + const a = await svc.computeTruckDetention( + rule, + { + arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR), + deliveredAt: now, + truckCount: 1, + }, + now, + 'USD', + ); + const b = await svc.computeTruckDetention( + rule, + { + arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR), + deliveredAt: now, + truckCount: 1, + }, + now, + 'USD', + ); + + expect(a.chargeableDays).toBe(2); + expect(b.chargeableDays).toBe(4); + expect(a.amount).toBe(100); + expect(b.amount).toBe(200); + // The whole point: same delivery, different bills. + expect(a.amount).not.toBe(b.amount); + }); + + it('charges nothing inside the grace window', async () => { + const out = await svc.computeTruckDetention( + rule, + { arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 }, + now, + 'USD', + ); + expect(out.chargeableDays).toBe(0); + expect(out.amount).toBe(0); + }); + + it('keeps accruing against now when a truck has not returned', async () => { + const out = await svc.computeTruckDetention( + rule, + { arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 }, + now, + 'USD', + ); + expect(out.endIsOpen).toBe(true); + expect(out.chargeableDays).toBe(2); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts index aa4d5e7b1..e23c6d1f8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts @@ -37,6 +37,10 @@ describe('WarehouseFeeService bulk quantity billing', () => { inventoryWeight: 25, bookingContainerCount: 0, cargoUnitOfMeasure: null, + // Double handling now bills only when staff answered Yes after unloading; + // these quantity-basis cases assume that answer (the gate itself is covered + // in double-handling-gate.spec.ts). + doubleHandling: true, facilityId: null, warehouseId: null, yardId: null, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 0d8c6549f..aa30b31e7 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -92,10 +92,20 @@ export interface FeePreview { ratePerDay: number; amount: number; }>; - /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ + /** + * Truck detention: one row PER TRUCK — each truck has its own detention + * window (it arrives and is released at its own time) and its own matching + * rule by truck type, so days and amount differ between trucks. + */ groups?: Array<{ + assignmentId: string | null; + vehicleId: string | null; + plateNumber: string | null; vehicleType: string | null; truckCount: number; + startDate: string | null; + endDate: string | null; + endIsOpen: boolean; chargeableDays: number; ratePerDay: number; amount: number; @@ -827,25 +837,48 @@ export class WarehouseFeeService { }; } - // Group the leg's vehicles by CANONICAL truck type so each type is billed - // by its own matching rule (rates differ by truck type). The FK to - // truck_types is the source of truth — renaming a type's label no longer - // silently unmatches its rule; the normalized legacy vehicle_type code is - // only a fallback for vehicles without the FK (LEFT JOIN keeps them billed - // instead of dropping them). Falls back to one untyped group. - const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = - await this.dataSource.query( - `SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", - count(*)::int AS "truckCount" - FROM freight.last_mile_vehicle_assignments va - JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL - LEFT JOIN freight.truck_types t - ON t.id = v.truck_type_id AND t.deleted_at IS NULL - WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL - GROUP BY 1`, - [lastMileId], - ); - const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; + // One row PER TRUCK: each truck has its own detention window (it reaches the + // destination and is released at its own time) and resolves its own rule by + // CANONICAL truck type — the truck_types FK is the source of truth, with the + // normalized legacy vehicle_type code as fallback so FK-less vehicles keep + // billing. Per-truck timestamps fall back to the leg-level pair for legacy + // legs recorded before per-truck tracking. + const truckRows: Array<{ + assignmentId: string; + vehicleId: string; + plateNumber: string | null; + vehicleType: string | null; + startAt: Date | string | null; + endAt: Date | string | null; + }> = await this.dataSource.query( + `SELECT va.id AS "assignmentId", + va.vehicle_id AS "vehicleId", + COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber", + COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", + COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt", + COALESCE(va.returned_at, $3::timestamptz) AS "endAt" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN freight.truck_types t + ON t.id = v.truck_type_id AND t.deleted_at IS NULL + WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL + ORDER BY va.created_at ASC`, + [lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null], + ); + // No trucks assigned yet: keep the leg-level single-truck estimate so the + // preview still tells the operator what detention would cost. + const trucks = truckRows.length + ? truckRows + : [ + { + assignmentId: null as string | null, + vehicleId: null as string | null, + plateNumber: null as string | null, + vehicleType: null as string | null, + startAt: leg.arrivedAt ?? null, + endAt: leg.deliveredAt ?? null, + }, + ]; const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); @@ -853,7 +886,7 @@ export class WarehouseFeeService { const targetCurrency = this.normalizeCurrency(billingCurrency); const computed = await Promise.all( - groups.map(async (g) => { + trucks.map(async (t) => { const item: ItemAttributes = { arrivedAt: null, gateClearedAt: null, @@ -862,7 +895,7 @@ export class WarehouseFeeService { tradeDirection: leg.tradeDirection ?? null, cargoTypeCode: null, containerTypeCode: null, - vehicleType: g.vehicleType ?? null, + vehicleType: t.vehicleType ?? null, inventoryQuantity: 1, inventoryWeight: 0, bookingContainerCount: 1, @@ -875,37 +908,47 @@ export class WarehouseFeeService { zoneId: null, }; const rule = this.bestRule(detentionRules, item); + // truckCount 1 — this row IS one truck. const c = await this.computeTruckDetention( rule, - { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, + { arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 }, now, billingCurrency, ); - return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; + return { ...t, c }; }), ); const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; - const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); + const totalTrucks = computed.length; const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); - const chargeableDays = computed[0]?.c.chargeableDays ?? 0; + // Header days: the worst truck — a single number can't represent per-truck + // windows, and the longest detention is the one operations must act on. + const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0); const single = computed.length === 1 ? computed[0].c : null; const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; + const earliestStart = computed + .map((x) => (x.startAt ? new Date(x.startAt).getTime() : null)) + .filter((n): n is number => n != null) + .sort((a, b) => a - b)[0]; + const anyOpen = computed.some((x) => x.c.endIsOpen); return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, unitLabel: 'truck', ruleId: single?.ruleId ?? null, - ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, + ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName, freeDays: 0, ratePerDay: single?.ratePerDay ?? 0, currency: targetCurrency, ruleCurrency: single?.ruleCurrency ?? null, billingCurrency: targetCurrency, - startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, - endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), - endIsOpen: !leg.deliveredAt, + startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null, + endDate: (anyOpen ? now : new Date(Math.max( + ...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())), + ))).toISOString(), + endIsOpen: anyOpen, elapsedDays: chargeableDays, chargeableDays, containerCount: totalTrucks, @@ -913,8 +956,14 @@ export class WarehouseFeeService { amount: totalAmount, tiers: single ? single.tiers : [], groups: computed.map((x) => ({ + assignmentId: x.assignmentId, + vehicleId: x.vehicleId, + plateNumber: x.plateNumber, vehicleType: x.vehicleType, - truckCount: x.truckCount, + truckCount: 1, + startDate: x.startAt ? new Date(x.startAt).toISOString() : null, + endDate: x.c.endDate, + endIsOpen: x.c.endIsOpen, chargeableDays: x.c.chargeableDays, ratePerDay: x.c.ratePerDay, amount: x.c.amount, diff --git a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx index c9748d80a..934832c34 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx @@ -43,21 +43,56 @@ function Stat({ label, value, strong }: { label: string; value: React.ReactNode; ); } +type TruckRow = { + vehicleId: string; + label: string; + arrived: Date | null; + returned: Date | null; +}; + +const plateOf = (a: NonNullable[number]) => + [a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId; + /** - * View/override the detention clock (arrival + delivery/return) for a last-mile - * leg, preview the per-truck-per-day charge, and generate the detention invoice. + * Detention is PER TRUCK: every truck reaches the destination and is released at + * its own time, so each row carries its own clock, days and amount. Legs with no + * trucks assigned fall back to the single leg-level window. */ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) { const { toast } = useToast(); const qc = useQueryClient(); const id = record?.id ?? null; + const assignments = record?.vehicleAssignments ?? []; + const perTruck = assignments.length > 0; + + const [rows, setRows] = useState([]); + // Leg-level fallback (no trucks assigned yet). const [arrived, setArrived] = useState(null); const [delivered, setDelivered] = useState(null); useEffect(() => { + setRows( + assignments.map((a) => ({ + vehicleId: a.vehicleId, + label: plateOf(a), + // Fall back to the leg-level pair so a truck without its own window + // shows what it is actually being billed on today. + arrived: a.destinationArrivedAt + ? new Date(a.destinationArrivedAt) + : record?.arrivedAt + ? new Date(record.arrivedAt) + : null, + returned: a.returnedAt + ? new Date(a.returnedAt) + : record?.deliveredAt + ? new Date(record.deliveredAt) + : null, + })), + ); setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null); setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null); - }, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [record?.id, record?.arrivedAt, record?.deliveredAt, assignments.length, opened]); const previewQuery = useQuery({ queryKey: ['truck-detention-preview', id], @@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM enabled: opened && Boolean(id), }); const preview = previewQuery.data; + // With several trucks the header rule is null by design (each truck resolves + // its own) — only warn when NO truck matched a rule. + const hasAnyRule = Boolean(preview?.ruleId) || (preview?.groups ?? []).some((g) => g.ruleId); + const byVehicle = new Map((preview?.groups ?? []).map((g) => [g.vehicleId ?? '', g])); const saveTimes = useMutation({ mutationFn: () => - lastMileService.update(id as string, { - arrivedAt: arrived ? arrived.toISOString() : null, - deliveredAt: delivered ? delivered.toISOString() : null, - }), + perTruck + ? lastMileService.setDetentionTimes( + id as string, + rows.map((r) => ({ + vehicleId: r.vehicleId, + destinationArrivedAt: r.arrived ? r.arrived.toISOString() : null, + returnedAt: r.returned ? r.returned.toISOString() : null, + })), + ) + : lastMileService.update(id as string, { + arrivedAt: arrived ? arrived.toISOString() : null, + deliveredAt: delivered ? delivered.toISOString() : null, + }), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); void previewQuery.refetch(); toast({ title: 'Detention times saved' }); }, - onError: () => toast({ title: 'Save failed', variant: 'destructive' }), + onError: (e: unknown) => { + const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast({ title: 'Save failed', description, variant: 'destructive' }); + }, }); const generate = useMutation({ @@ -93,12 +144,40 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM }, }); + const handleSave = () => { + const values = perTruck + ? rows.flatMap((r) => [r.arrived, r.returned]) + : [arrived, delivered]; + // No backdating: detention times are recorded as they happen. + if (values.some((v) => isBackdated(v))) { + toast({ variant: 'destructive', title: 'Detention times cannot be in the past' }); + return; + } + const reversed = perTruck + ? rows.find((r) => r.arrived && r.returned && r.returned < r.arrived) + : arrived && delivered && delivered < arrived + ? { label: 'this delivery' } + : undefined; + if (reversed) { + toast({ + variant: 'destructive', + title: 'Return time is before arrival', + description: `Check the times for ${reversed.label}.`, + }); + return; + } + saveTimes.mutate(); + }; + + const patchRow = (vehicleId: string, patch: Partial) => + setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r))); + return ( Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''} @@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM } > - - setArrived(v ? new Date(v) : null)} - minDate={new Date()} - clearable - /> - setDelivered(v ? new Date(v) : null)} - minDate={new Date()} - clearable - /> - + {perTruck ? ( + + + Each truck has its own detention clock — record when it reached the destination and + when it was released. Days and charges are calculated per truck. + + {rows.map((r) => { + const g = byVehicle.get(r.vehicleId); + return ( + + + + + {r.label} + + {g?.vehicleType && ( + + {g.vehicleType} + + )} + + {g && ( + + + {g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'} + {g.endIsOpen ? ' · still out' : ''} + + + {money(g.amount, preview?.currency ?? 'USD')} + + + )} + + + patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })} + minDate={new Date()} + clearable + /> + patchRow(r.vehicleId, { returned: v ? new Date(v) : null })} + minDate={new Date()} + clearable + /> + + {g && !g.ruleId && ( + + No detention rule matches this truck type — it will not be billed. + + )} + + ); + })} + + ) : ( + <> + + No trucks assigned yet — this records the delivery-level detention window. Assign + trucks to track each one separately. + + + setArrived(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> + setDelivered(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> + + + )} - @@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM No preview available. - ) : !preview.ruleId ? ( + ) : !hasAnyRule ? ( No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules (rule type "Truck Detention Cost"). @@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM ) : ( - + - + {preview.endIsOpen && ( - Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned. + Still accruing — at least one truck has no release time yet. The amount grows until + every truck is returned. )} - {preview.groups && preview.groups.length > 1 ? ( + {preview.groups && preview.groups.length > 0 ? ( - Truck type - Trucks + Truck + Type Days - Rate / truck / day + Rate / day Amount {preview.groups.map((g, i) => ( - + - {g.vehicleType ?? 'Unknown'} + {g.plateNumber ?? 'Unassigned'} {!g.ruleId && ( {' '}· no rule )} - {g.truckCount} - {g.chargeableDays} + {g.vehicleType ?? 'Unknown'} + + {g.chargeableDays} + {g.endIsOpen && ( + + {' '}· open + + )} + {money(g.ratePerDay, preview.currency)} {money(g.amount, preview.currency)} diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 16e02abf5..91e95ef2f 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -75,6 +75,9 @@ export interface LastMileRecord { /** Per-truck arrival / exit, stamped by the warehouse weighing steps. */ arrivedAt?: string | null; departedAt?: string | null; + /** This truck's own detention window (destination arrival → released). */ + destinationArrivedAt?: string | null; + returnedAt?: string | null; grossWeightTons?: number | null; netWeightTons?: number | null; vehicle?: LastMileVehicle | null; @@ -143,4 +146,13 @@ export const lastMileService = { /** Preview the truck-detention charge for a last-mile leg. */ truckDetentionPreview: (id: string) => api.get(`${LM.BASE}/${id}/truck-detention-preview`), + /** Per-truck detention windows — each truck has its own clock. */ + setDetentionTimes: ( + id: string, + trucks: Array<{ + vehicleId: string; + destinationArrivedAt?: string | null; + returnedAt?: string | null; + }>, + ) => api.post(`${LM.BASE}/${id}/detention-times`, { trucks }), }; diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index c38d9b40d..24713f738 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -856,10 +856,16 @@ export interface FeePreview { billableUnits: number; amount: number; tiers?: FeePreviewTier[]; - /** Truck detention: per-vehicle-type breakdown. */ + /** Truck detention: one row per truck — each has its own window and rule. */ groups?: Array<{ + assignmentId?: string | null; + vehicleId?: string | null; + plateNumber?: string | null; vehicleType: string | null; truckCount: number; + startDate?: string | null; + endDate?: string | null; + endIsOpen?: boolean; chargeableDays: number; ratePerDay: number; amount: number; From 03b8ca0ce7bb11aed1d1bbb7e02709333eff4b16 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 27 Jul 2026 08:28:57 +0000 Subject: [PATCH 3/8] refactor: centralized the iam seeder --- CLAUDE.md | 1 + apps/edr-freight-api/.env.example | 12 +- apps/edr-freight-api/package.json | 2 +- apps/edr-freight-api/src/app.module.ts | 34 +- .../src/seed/iam-baseline.seeder.ts | 468 ---------- apps/edr-passenger-api/.env.example | 11 + apps/edr-passenger-api/package.json | 1 + apps/edr-passenger-api/src/app.module.ts | 24 +- packages/iam-seed/package.json | 50 + .../iam-seed/src}/iam-baseline.seed.ts | 232 +++-- packages/iam-seed/src/iam-baseline.seeder.ts | 875 ++++++++++++++++++ packages/iam-seed/src/iam-seed.constants.ts | 2 + packages/iam-seed/src/iam-seed.module.ts | 39 + packages/iam-seed/src/iam-seed.types.ts | 100 ++ packages/iam-seed/src/index.ts | 20 + .../src}/missing-settings.util.spec.ts | 0 .../iam-seed/src}/missing-settings.util.ts | 6 +- packages/iam-seed/tsconfig.json | 11 + pnpm-lock.yaml | 43 + 19 files changed, 1339 insertions(+), 592 deletions(-) delete mode 100644 apps/edr-freight-api/src/seed/iam-baseline.seeder.ts create mode 100644 packages/iam-seed/package.json rename {apps/edr-freight-api/src/seed => packages/iam-seed/src}/iam-baseline.seed.ts (52%) create mode 100644 packages/iam-seed/src/iam-baseline.seeder.ts create mode 100644 packages/iam-seed/src/iam-seed.constants.ts create mode 100644 packages/iam-seed/src/iam-seed.module.ts create mode 100644 packages/iam-seed/src/iam-seed.types.ts create mode 100644 packages/iam-seed/src/index.ts rename {apps/edr-freight-api/src/seed => packages/iam-seed/src}/missing-settings.util.spec.ts (100%) rename {apps/edr-freight-api/src/seed => packages/iam-seed/src}/missing-settings.util.ts (84%) create mode 100644 packages/iam-seed/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index d67e6c3d5..b90d3b1dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 4df20a9a2..58fb60b18 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -43,13 +43,15 @@ JWT_REFRESH_TOKEN_EXPIRES=7d SUPER_ADMIN_EMAIL=superadmin@tria.com SUPER_ADMIN_PHONE= # Super-admin password. Falls back to DEFAULT_PASSWORD when empty. -SUPER_ADMIN_PASSWORD= +SUPER_ADMIN_DEFAULT_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 +# 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 diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 66909a037..0531d056b 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 2e2da880f..64df33f35 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,6 +12,7 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; +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"; @@ -55,7 +56,6 @@ 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"; @@ -154,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, @@ -207,7 +219,6 @@ import { LoggerMiddleware } from "./logger.middleware"; AiModule, ], providers: [ - IamBaselineSeeder, EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, @@ -264,16 +275,15 @@ export class AppModule implements OnApplicationBootstrap { // Permissions foundation — keep enabled: // freightPermissionKeyMigration → renames legacy permission keys // 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. + // 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) diff --git a/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts b/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts deleted file mode 100644 index 9c718710a..000000000 --- a/apps/edr-freight-api/src/seed/iam-baseline.seeder.ts +++ /dev/null @@ -1,468 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { hashPassword } from "@tria-plc/api-common/utils/argon"; -import { - Application, - EEmployeeStatus, - Employee, - EUserStatus, - EUserType, - Organization, - Permission, - PositionType, - PositionTypePermission, - Role, - RolePermission, - Unit, - UnitSetting, - User, - UserCredential, - UserRole, -} from "@tria-plc/iamapi-common"; -// Not re-exported from the package root, unlike UnitSetting. -import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity"; -import type { ESettingType } from "@tria-plc/iamapi-common/enums/setting-type.enum"; -import { - DataSource, - EntityManager, - EntityTarget, - In, - ObjectLiteral, -} from "typeorm"; - -import { IAM_BASELINE_SEED } from "./iam-baseline.seed"; -import { missingSettings } from "./missing-settings.util"; - -const SEED_FLAG = "SEED_IAM_BASELINE"; - -/** - * Local stand-in for `DataSeeder` from `@tria-plc/iamapi-common`, seeding only - * what `IAM_BASELINE_SEED` lists (see that file for what was deliberately left - * out). Differences from the upstream seeder, all deliberate: - * - * - Nothing is deleted. Upstream wipes every `position_type_permissions` row - * for the 6 system position types on each run and nulls their `unit_id`. - * - Settings are matched on (organization|unit, key). Upstream upserts them on - * `id`, which it never supplies, so every run inserts a duplicate set. - * - Ids are resolved from the database by key rather than from the seed - * constants, so rows that already exist under a different id still link up. - * - * Gated behind SEED_IAM_BASELINE=true so a normal boot never writes. - */ -@Injectable() -export class IamBaselineSeeder { - private readonly logger = new Logger(IamBaselineSeeder.name); - - constructor(private readonly dataSource: DataSource) {} - - async run() { - if (process.env[SEED_FLAG]?.trim().toLowerCase() !== "true") { - this.logger.log( - `Skipping IAM baseline seed because ${SEED_FLAG} is not enabled`, - ); - return; - } - - await this.dataSource.transaction(async (manager) => { - await this.seedApplications(manager); - await this.seedPermissions(manager); - await this.seedRoles(manager); - await this.seedRolePermissions(manager); - await this.seedPositionTypes(manager); - await this.seedPositionTypePermissions(manager); - await this.seedOrganizationSettings(manager); - await this.seedUnitSettings(manager); - await this.seedSuperAdmin(manager); - }); - - this.logger.log("IAM baseline seed complete"); - } - - private async seedApplications(manager: EntityManager) { - const { applications } = IAM_BASELINE_SEED; - if (applications.length === 0) { - return; - } - - await manager.getRepository(Application).upsert( - applications.map((application) => ({ - id: application.id, - key: application.key, - name: { ...application.name }, - })), - { conflictPaths: { key: true } }, - ); - - this.logger.log(`Ensured ${applications.length} applications`); - } - - private async seedPermissions(manager: EntityManager) { - const { permissions } = IAM_BASELINE_SEED; - if (permissions.length === 0) { - return; - } - - // Permissions without an applicationKey stay unlinked (application_id null), - // which is how the package ships the org/unit/location ones. - const applicationIdByKey = await this.loadIdsByKey( - manager, - Application, - permissions.flatMap((permission) => - permission.applicationKey ? [permission.applicationKey] : [], - ), - "missing_applications", - ); - - await manager.getRepository(Permission).upsert( - permissions.map((permission) => ({ - id: permission.id, - key: permission.key, - name: { ...permission.name }, - applicationId: permission.applicationKey - ? applicationIdByKey.get(permission.applicationKey) - : undefined, - })), - { conflictPaths: { key: true } }, - ); - - this.logger.log(`Ensured ${permissions.length} permissions`); - } - - private async seedRoles(manager: EntityManager) { - const { roles } = IAM_BASELINE_SEED; - if (roles.length === 0) { - return; - } - - await manager.getRepository(Role).upsert( - roles.map((role) => ({ - id: role.id, - key: role.key, - name: { ...role.name }, - })), - { conflictPaths: { key: true } }, - ); - - this.logger.log(`Ensured ${roles.length} roles`); - } - - private async seedRolePermissions(manager: EntityManager) { - const { rolePermissions } = IAM_BASELINE_SEED; - if (rolePermissions.length === 0) { - return; - } - - const roleIdByKey = await this.loadIdsByKey( - manager, - Role, - rolePermissions.map((mapping) => mapping.roleKey), - "missing_roles", - ); - const permissionIdByKey = await this.loadIdsByKey( - manager, - Permission, - rolePermissions.flatMap((mapping) => mapping.permissionKeys), - "missing_permissions", - ); - - const repository = manager.getRepository(RolePermission); - const roleIds = [...roleIdByKey.values()]; - const existing = await repository.find({ - where: { roleId: In(roleIds) }, - select: { roleId: true, permissionId: true }, - }); - const existingPairs = new Set( - existing.map((row) => `${row.roleId}:${row.permissionId}`), - ); - - const rows = rolePermissions.flatMap((mapping) => { - const roleId = roleIdByKey.get(mapping.roleKey) as string; - return mapping.permissionKeys - .map((key) => permissionIdByKey.get(key) as string) - .filter((permissionId) => !existingPairs.has(`${roleId}:${permissionId}`)) - .map((permissionId) => ({ roleId, permissionId })); - }); - - if (rows.length === 0) { - return; - } - - await repository.insert(rows); - this.logger.log(`Granted ${rows.length} role permissions`); - } - - private async seedPositionTypes(manager: EntityManager) { - const { positionTypes } = IAM_BASELINE_SEED; - if (positionTypes.length === 0) { - return; - } - - // unitId is intentionally left alone — upstream resets it to null here. - await manager.getRepository(PositionType).upsert( - positionTypes.map((positionType) => ({ - id: positionType.id, - key: positionType.key, - name: { ...positionType.name }, - isSystem: positionType.isSystem ?? true, - })), - { conflictPaths: { key: true } }, - ); - - this.logger.log(`Ensured ${positionTypes.length} position types`); - } - - private async seedPositionTypePermissions(manager: EntityManager) { - const { positionTypePermissions } = IAM_BASELINE_SEED; - if (positionTypePermissions.length === 0) { - return; - } - - const positionTypeIdByKey = await this.loadIdsByKey( - manager, - PositionType, - positionTypePermissions.map((mapping) => mapping.positionTypeKey), - "missing_position_types", - ); - const permissionIdByKey = await this.loadIdsByKey( - manager, - Permission, - positionTypePermissions.flatMap((mapping) => mapping.permissionKeys), - "missing_permissions", - ); - - const repository = manager.getRepository(PositionTypePermission); - const existing = await repository.find({ - where: { positionTypeId: In([...positionTypeIdByKey.values()]) }, - select: { positionTypeId: true, permissionId: true }, - }); - const existingPairs = new Set( - existing.map((row) => `${row.positionTypeId}:${row.permissionId}`), - ); - - const rows = positionTypePermissions.flatMap((mapping) => { - const positionTypeId = positionTypeIdByKey.get( - mapping.positionTypeKey, - ) as string; - return mapping.permissionKeys - .map((key) => permissionIdByKey.get(key) as string) - .filter( - (permissionId) => - !existingPairs.has(`${positionTypeId}:${permissionId}`), - ) - .map((permissionId) => ({ positionTypeId, permissionId })); - }); - - if (rows.length === 0) { - return; - } - - await repository.insert(rows); - this.logger.log(`Granted ${rows.length} position type permissions`); - } - - private async seedOrganizationSettings(manager: EntityManager) { - const defaults = IAM_BASELINE_SEED.organizationSettings; - if (defaults.length === 0) { - return; - } - - const organizations = await manager - .getRepository(Organization) - .find({ select: { id: true } }); - const existing = await manager - .getRepository(OrganizationSetting) - .find({ select: { organizationId: true, key: true } }); - const existingPairs = new Set( - existing.map((setting) => `${setting.organizationId}:${setting.key}`), - ); - - const rows = organizations.flatMap((organization) => - missingSettings(defaults, existingPairs, organization.id as string).map( - (setting) => ({ - ...setting, - type: setting.type as ESettingType, - organizationId: organization.id as string, - }), - ), - ); - - if (rows.length === 0) { - return; - } - - await manager.getRepository(OrganizationSetting).insert(rows); - this.logger.log( - `Seeded ${rows.length} organization settings across ${organizations.length} organizations`, - ); - } - - private async seedUnitSettings(manager: EntityManager) { - const defaults = IAM_BASELINE_SEED.unitSettings; - if (defaults.length === 0) { - return; - } - - const units = await manager.getRepository(Unit).find({ select: { id: true } }); - const existing = await manager - .getRepository(UnitSetting) - .find({ select: { unitId: true, key: true } }); - const existingPairs = new Set( - existing.map((setting) => `${setting.unitId}:${setting.key}`), - ); - - const rows = units.flatMap((unit) => - missingSettings(defaults, existingPairs, unit.id as string).map( - (setting) => ({ - ...setting, - type: setting.type as ESettingType, - unitId: unit.id as string, - }), - ), - ); - - if (rows.length === 0) { - return; - } - - await manager.getRepository(UnitSetting).insert(rows); - this.logger.log( - `Seeded ${rows.length} unit settings across ${units.length} units`, - ); - } - - /** - * User + credential + employee + `super_admin` UserRole. Every step is - * skip-if-present: an existing account keeps its password, its organization - * and any extra roles it was given through the IAM UI. - */ - private async seedSuperAdmin(manager: EntityManager) { - const seed = IAM_BASELINE_SEED.superAdmin; - if (!seed) { - return; - } - - const email = process.env.SUPER_ADMIN_EMAIL?.trim() || seed.fallbackEmail; - // SUPER_ADMIN_PASSWORD wins, DEFAULT_PASSWORD is the shared fallback. - const password = - process.env.SUPER_ADMIN_PASSWORD?.trim() || - process.env.DEFAULT_PASSWORD?.trim() || - seed.fallbackPassword; - const phoneNumber = process.env.SUPER_ADMIN_PHONE?.trim() || undefined; - - const roleIdByKey = await this.loadIdsByKey( - manager, - Role, - [seed.roleKey], - "missing_roles", - ); - - const organization = await manager.getRepository(Organization).findOne({ - where: { key: seed.organizationKey }, - select: { id: true }, - }); - if (!organization) { - throw new Error(`missing_organization:${seed.organizationKey}`); - } - - const unit = await manager.getRepository(Unit).findOne({ - where: { key: seed.unitKey, organizationId: organization.id }, - select: { id: true }, - }); - if (!unit) { - throw new Error(`missing_unit:${seed.unitKey}`); - } - - const userRepository = manager.getRepository(User); - const existingUser = await userRepository.findOne({ - where: [{ username: seed.username }, { email }], - select: { id: true }, - }); - - let userId = existingUser?.id as string | undefined; - if (!userId) { - const inserted = await userRepository.insert({ - username: seed.username, - email, - phoneNumber, - name: { ...seed.name }, - userType: EUserType.EMPLOYEE, - status: EUserStatus.ACCEPTED, - isActive: true, - hasSetPassword: true, - }); - userId = inserted.identifiers[0]?.id as string; - this.logger.log(`Seeded super admin user '${seed.username}' (${email})`); - } - - const credentialRepository = manager.getRepository(UserCredential); - if (!(await credentialRepository.existsBy({ userId }))) { - await credentialRepository.insert({ - userId, - password: await hashPassword(password), - isActive: true, - }); - this.logger.log(`Seeded super admin credential for '${seed.username}'`); - } - - const employeeRepository = manager.getRepository(Employee); - if ( - !(await employeeRepository.existsBy({ - userId, - organizationId: organization.id, - })) - ) { - await employeeRepository.insert({ - userId, - organizationId: organization.id, - unitId: unit.id, - isCurrent: true, - status: EEmployeeStatus.ACCEPTED, - name: { ...seed.name }, - }); - this.logger.log( - `Attached super admin to organization '${seed.organizationKey}'`, - ); - } - - // user_roles is UNIQUE (user_id, role_id). - await manager.getRepository(UserRole).upsert( - { - userId, - roleId: roleIdByKey.get(seed.roleKey) as string, - organizationId: organization.id, - unitId: unit.id, - }, - { conflictPaths: { userId: true, roleId: true } }, - ); - - this.logger.log(`Ensured '${seed.roleKey}' role on '${seed.username}'`); - } - - /** - * Resolve `key → id` from the database for the given entity, throwing when a - * key the seed references has no row (the seed order is wrong, or the caller - * emptied the section that creates it). - */ - private async loadIdsByKey( - manager: EntityManager, - entity: EntityTarget, - keys: string[], - errorPrefix: string, - ): Promise> { - const wanted = [...new Set(keys)]; - const rows = (await manager - .getRepository(entity) - .find({ - where: { key: In(wanted) }, - select: { id: true, key: true }, - })) as { id?: string; key: string }[]; - - const idByKey = new Map(rows.map((row) => [row.key, row.id as string])); - - const missing = wanted.filter((key) => !idByKey.has(key)); - if (missing.length > 0) { - throw new Error(`${errorPrefix}:${missing.join(",")}`); - } - - return idByKey; - } -} diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index 215490361..cf00cfe51 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -181,6 +181,17 @@ GITHUB_PACKAGE_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 diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index c4587e98a..f58cd7bcf 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -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", diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index cce0f5e7d..014e4f5c6 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -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, ); } diff --git a/packages/iam-seed/package.json b/packages/iam-seed/package.json new file mode 100644 index 000000000..05c56670b --- /dev/null +++ b/packages/iam-seed/package.json @@ -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" + } +} diff --git a/apps/edr-freight-api/src/seed/iam-baseline.seed.ts b/packages/iam-seed/src/iam-baseline.seed.ts similarity index 52% rename from apps/edr-freight-api/src/seed/iam-baseline.seed.ts rename to packages/iam-seed/src/iam-baseline.seed.ts index f55fa125c..01feac6c3 100644 --- a/apps/edr-freight-api/src/seed/iam-baseline.seed.ts +++ b/packages/iam-seed/src/iam-baseline.seed.ts @@ -1,68 +1,35 @@ +import { + IamBaselineSeed, + SeedApplication, + SeedOrganizationType, + SeedPermission, + SeedPositionType, + SeedRole, + SeedRolePermission, + SettingDefault, +} from "./iam-seed.types"; + /** - * IAM baseline seed data — the rows `IamBaselineSeeder` writes. + * The default IAM baseline: what `IamBaselineSeeder` writes when an app does not + * override a section via `IamSeedModule.forRoot()`. * - * 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. + * 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: the rows already in every - * environment carry these ids. + * Ids are the package's originals. Keep them: rows in every existing + * environment already carry them. * - * 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) + * 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. */ -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. */ +/** Applications permissions hang off. Each app seeds its own separately. */ const APPLICATIONS: SeedApplication[] = [ { id: "019bcb17-5470-7604-8708-7ed04d842b41", @@ -383,10 +350,10 @@ const PERMISSIONS: SeedPermission[] = [ ]; /** - * Role → permission-key grants. Applied additively: the seeder only inserts - * missing pairs, so grants made through the IAM UI survive a reseed. + * Role → permission-key grants, applied additively: only missing pairs are + * inserted, so grants made through the IAM UI survive a reseed. */ -const ROLE_PERMISSIONS: { roleKey: string; permissionKeys: string[] }[] = [ +const ROLE_PERMISSIONS: SeedRolePermission[] = [ { roleKey: "super_admin", permissionKeys: [ @@ -518,13 +485,104 @@ const POSITION_TYPES: SeedPositionType[] = [ ]; /** - * PositionType → permission-key grants. Empty because freight grants through - * Position/PositionPermission (see FreightPositionsSeeder), not position types. + * 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 POSITION_TYPE_PERMISSIONS: { - positionTypeKey: string; - permissionKeys: string[]; -}[] = []; +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[] = [ @@ -569,43 +627,19 @@ const UNIT_SETTINGS: SettingDefault[] = [ ]; /** - * 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. + * 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. */ -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 = { +export const DEFAULT_IAM_BASELINE_SEED: IamBaselineSeed = { applications: APPLICATIONS, roles: ROLES, permissions: PERMISSIONS, rolePermissions: ROLE_PERMISSIONS, positionTypes: POSITION_TYPES, - positionTypePermissions: POSITION_TYPE_PERMISSIONS, + positionTypePermissions: [], + organizationTypes: ORGANIZATION_TYPES, organizationSettings: ORGANIZATION_SETTINGS, unitSettings: UNIT_SETTINGS, - superAdmin: SUPER_ADMIN, + superAdmin: null, }; diff --git a/packages/iam-seed/src/iam-baseline.seeder.ts b/packages/iam-seed/src/iam-baseline.seeder.ts new file mode 100644 index 000000000..fe18e31a7 --- /dev/null +++ b/packages/iam-seed/src/iam-baseline.seeder.ts @@ -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 { + 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 { + 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, + 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 { + 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> { + 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> { + 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> { + 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}`)); + } +} diff --git a/packages/iam-seed/src/iam-seed.constants.ts b/packages/iam-seed/src/iam-seed.constants.ts new file mode 100644 index 000000000..33664be79 --- /dev/null +++ b/packages/iam-seed/src/iam-seed.constants.ts @@ -0,0 +1,2 @@ +/** DI token for the options passed to `IamSeedModule.forRoot(...)`. */ +export const IAM_SEED_OPTIONS = Symbol("IAM_SEED_OPTIONS"); diff --git a/packages/iam-seed/src/iam-seed.module.ts b/packages/iam-seed/src/iam-seed.module.ts new file mode 100644 index 000000000..bc5d93f96 --- /dev/null +++ b/packages/iam-seed/src/iam-seed.module.ts @@ -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], + }; + } +} diff --git a/packages/iam-seed/src/iam-seed.types.ts b/packages/iam-seed/src/iam-seed.types.ts new file mode 100644 index 000000000..35007c25c --- /dev/null +++ b/packages/iam-seed/src/iam-seed.types.ts @@ -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 & { + /** + * Environment variable that must equal "true" for the seeder to write + * anything. Defaults to SEED_IAM_BASELINE. + */ + enableFlag?: string; +}; diff --git a/packages/iam-seed/src/index.ts b/packages/iam-seed/src/index.ts new file mode 100644 index 000000000..04fa0d689 --- /dev/null +++ b/packages/iam-seed/src/index.ts @@ -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"; diff --git a/apps/edr-freight-api/src/seed/missing-settings.util.spec.ts b/packages/iam-seed/src/missing-settings.util.spec.ts similarity index 100% rename from apps/edr-freight-api/src/seed/missing-settings.util.spec.ts rename to packages/iam-seed/src/missing-settings.util.spec.ts diff --git a/apps/edr-freight-api/src/seed/missing-settings.util.ts b/packages/iam-seed/src/missing-settings.util.ts similarity index 84% rename from apps/edr-freight-api/src/seed/missing-settings.util.ts rename to packages/iam-seed/src/missing-settings.util.ts index 9ec4ccf5a..f3e7a36ca 100644 --- a/apps/edr-freight-api/src/seed/missing-settings.util.ts +++ b/packages/iam-seed/src/missing-settings.util.ts @@ -1,9 +1,9 @@ /** * Settings of `ownerId` that are not in `existingPairs` (`":"`) * yet. This is what keeps the IAM baseline seed idempotent: `organization_settings` - * and `unit_settings` have no unique index on (owner, key), so re-running an - * INSERT (or the package seeder's upsert-on-id, which never supplies an id) - * silently duplicates every row. + * 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. */ diff --git a/packages/iam-seed/tsconfig.json b/packages/iam-seed/tsconfig.json new file mode 100644 index 000000000..6cf0770cb --- /dev/null +++ b/packages/iam-seed/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@edr/tsconfig/nestjs.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true + }, + "include": ["src"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b89d65b6..0423be502 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': From 101bf69271f3814631c19a82a49f6cae274485c7 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 27 Jul 2026 09:55:15 +0000 Subject: [PATCH 4/8] feat(warehouses): map GRN numbers to the goods owner GRN--- carried no owner, so a note couldn't be identified by who owns the cargo. Add an owner segment sourced from the booking's company at every generation point (import, export, facility, manual receive), keep REF8 for uniqueness, and label the GRN document row Owner's Name. --- .../src/common/grn.util.spec.ts | 40 +++++ apps/edr-freight-api/src/common/grn.util.ts | 34 +++- ...990000000000-IndodeYardsAndCargoRouting.ts | 131 +++++++++++++++ .../facility-handling.service.ts | 2 + .../dto/create-warehouse-yard.dto.ts | 27 +++- .../entities/warehouse-yard.entity.ts | 31 +++- .../warehouses/scheduling-read.facade.ts | 7 + .../warehouses/warehouse-inventory.service.ts | 61 +++++-- .../warehouses/warehouse-yards.repository.ts | 19 ++- .../warehouses/warehouse-yards.service.ts | 14 +- .../warehouses/ReceiveInventoryModal.tsx | 81 ++++++++-- .../src/components/warehouses/options.test.ts | 153 ++++++++++++++++++ .../src/components/warehouses/options.ts | 54 +++++++ .../src/pages/warehouses/ArrivalQueuePage.tsx | 89 +++++++--- .../backoffice/src/types/warehouse.ts | 11 ++ 15 files changed, 690 insertions(+), 64 deletions(-) create mode 100644 apps/edr-freight-api/src/common/grn.util.spec.ts create mode 100644 apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts diff --git a/apps/edr-freight-api/src/common/grn.util.spec.ts b/apps/edr-freight-api/src/common/grn.util.spec.ts new file mode 100644 index 000000000..d95c934ca --- /dev/null +++ b/apps/edr-freight-api/src/common/grn.util.spec.ts @@ -0,0 +1,40 @@ +import { generateGrnNumber, grnOwnerSlug } from './grn.util'; + +/** + * The GRN is mapped to the goods owner for BOTH directions, so a note is + * identifiable by who owns the cargo. The reference slice stays the uniqueness + * anchor — one owner can have several bookings received the same day. + */ +const date = new Date('2026-07-27T09:15:00Z'); +const bookingId = '1a2b3c4d-1111-2222-3333-444455556666'; + +describe('GRN number', () => { + it('maps an import GRN to the owner', () => { + expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe( + 'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D', + ); + }); + + it('maps an export GRN to the owner the same way', () => { + expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe( + 'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D', + ); + }); + + it('keeps the owner-less format when there is no owner (manual walk-in)', () => { + expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D'); + expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D'); + }); + + it('stays unique per booking for one owner on one day', () => { + const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme'); + const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme'); + expect(a).not.toBe(b); + }); + + it('strips punctuation and caps the owner segment', () => { + expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT'); + expect(grnOwnerSlug('a/b c')).toBe('ABC'); + expect(grnOwnerSlug(null)).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts index 5cae30302..128e0496a 100644 --- a/apps/edr-freight-api/src/common/grn.util.ts +++ b/apps/edr-freight-api/src/common/grn.util.ts @@ -1,13 +1,41 @@ /** - * Goods Received Note number: `GRN---`. + * Goods Received Note number: `GRN----`. + * + * The GRN is mapped to the goods OWNER (the booking's customer / consignee) for + * both import and export, so a note is identifiable by who owns the cargo + * without opening it. The trailing reference slice stays as the uniqueness + * anchor — one owner can have several bookings received on the same day. + * Owner-less receipts (manual walk-ins with no booking) fall back to the + * original `GRN---` form. * * Shared so a GRN raised at a load/unload facility is indistinguishable from one * raised in a warehouse — the two live in different tables * (facility_handling_events vs warehouse_inventory), and a second generator would * eventually let their formats drift apart. */ -export function generateGrnNumber(direction: string, referenceId: string, date: Date): string { +export function generateGrnNumber( + direction: string, + referenceId: string, + date: Date, + ownerName?: string | null, +): string { const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); - return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + const owner = grnOwnerSlug(ownerName); + const base = `GRN-${direction.toUpperCase()}-${stamp}`; + return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`; +} + +/** + * Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a + * long company name can't run away with the number. Null when there is nothing + * usable, which drops the segment rather than emitting an empty `--`. + */ +export function grnOwnerSlug(ownerName?: string | null): string | null { + const slug = (ownerName ?? '') + .normalize('NFKD') + .replace(/[^a-zA-Z0-9]+/g, '') + .toUpperCase() + .slice(0, 12); + return slug || null; } diff --git a/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts new file mode 100644 index 000000000..d89e0c04b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts @@ -0,0 +1,131 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Indode's real 11-yard layout, plus the plumbing to auto-route a booking to + * the right yard by cargo type (and, for container yards, trade direction): + * + * - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only + * meaningful for CONTAINER_YARD, where import and export stacks are + * physically separate (Yard 5 vs Yard 6). Everything else takes cargo + * either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means + * "not a customer cargo yard" — Yards 10/11 (service/equipment) are + * CONTAINER_YARD structurally but must never be offered for ordinary + * import/export cargo, so the frontend match requires an EXACT IMPORT/ + * EXPORT direction hit for container freight rather than treating BOTH as + * a wildcard. + * - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors + * the existing `cargo_type_wagon_types` join table). Empty = open to any + * cargo type of the yard's structural type (additive, never restrictive + * by default), so this cannot break a yard nobody has configured yet. + * + * Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here + * so Yards 1 and 9 have a real mapping ready for when they reopen. + */ +export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface { + name = "IndodeYardsAndCargoRouting2990000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_yards + ADD COLUMN IF NOT EXISTS direction varchar(10) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types ( + yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE, + cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE, + PRIMARY KEY (yard_id, cargo_type_id) + ) + `); + + // New cargo types Indode's yard list names but the catalog didn't have yet. + await queryRunner.query(` + INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active) + VALUES + ('FERTILIZER', 'Fertilizer', 'PER_TON', true), + ('COFFEE', 'Coffee', 'PER_TON', true), + ('TEA', 'Tea', 'PER_TON', true) + ON CONFLICT (code) DO NOTHING + `); + + // The 11 real yards at Indode Open Warehouse (code 'IOW'). + await queryRunner.query(` + INSERT INTO freight.warehouse_yards + (warehouse_id, name, code, type, direction, status, is_active) + SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE' + FROM freight.warehouses w + CROSS JOIN (VALUES + ('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'), + ('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'), + ('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'), + ('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'), + ('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'), + ('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'), + ('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'), + ('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'), + ('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'), + ('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'), + ('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE') + ) AS y(code, name, type, direction, status) + WHERE w.code = 'IOW' + ON CONFLICT (warehouse_id, code) DO NOTHING + `); + + // One default zone per new yard, matching its yard's type — every existing + // yard (CY-1, CY-A) already follows this one-zone-per-yard shape. + await queryRunner.query(` + INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active) + SELECT y.id, y.name || ' Zone 1', 'Z1', + CASE y.type + WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE' + WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE' + WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE' + WHEN 'BULK_YARD' THEN 'BULK_ZONE' + ELSE 'GENERAL_CARGO_ZONE' + END, + y.status, y.status = 'ACTIVE' + FROM freight.warehouse_yards y + JOIN freight.warehouses w ON w.id = y.warehouse_id + WHERE w.code = 'IOW' AND y.code LIKE 'Y%' + ON CONFLICT (yard_id, code) DO NOTHING + `); + + // Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally + // left with no rows — direction alone decides those, per the entity comment. + await queryRunner.query(` + INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id) + SELECT y.id, ct.id + FROM freight.warehouses w + JOIN freight.warehouse_yards y ON y.warehouse_id = w.id + JOIN (VALUES + ('Y1', 'FERTILIZER'), + ('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'), + ('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'), + ('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'), + ('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'), + ('Y7', 'PERISHABLE'), + ('Y9', 'COFFEE'), ('Y9', 'TEA') + ) AS m(yard_code, cargo_code) ON m.yard_code = y.code + JOIN freight.cargo_types ct ON ct.code = m.cargo_code + WHERE w.code = 'IOW' + ON CONFLICT (yard_id, cargo_type_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.warehouse_zones z + USING freight.warehouse_yards y, freight.warehouses w + WHERE z.yard_id = y.id AND y.warehouse_id = w.id + AND w.code = 'IOW' AND y.code LIKE 'Y%' + `); + await queryRunner.query(` + DELETE FROM freight.warehouse_yards y + USING freight.warehouses w + WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%' + `); + // Cargo types and the join table are left in place — other data may have + // started referencing them since; dropping columns/tables is not reversible + // once real rows exist, and leaving them is harmless. + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts index 20ac4859a..4fe20dbf5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts @@ -48,10 +48,12 @@ export class FacilityHandlingService { if (!facility?.hasFacility) return null; const occurredAt = input.occurredAt ?? new Date(); + // Mapped to the goods owner, same as every warehouse-raised GRN. const grnNumber = generateGrnNumber( booking.tradeDirection ?? 'DOMESTIC', booking.id, occurredAt, + booking.company?.name ?? null, ); // Link the storage record when this facility keeps cargo — that link is diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts index 56b9d0810..39a90ef8e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -1,7 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity'; +import { + WAREHOUSE_YARD_DIRECTIONS, + WAREHOUSE_YARD_TYPES, + WarehouseYardDirection, + WarehouseYardType, +} from '../entities/warehouse-yard.entity'; export class CreateWarehouseYardDto { @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) @@ -46,4 +51,22 @@ export class CreateWarehouseYardDto { @IsNumber() @Min(0) maxVolume?: number; + + @ApiPropertyOptional({ + enum: WAREHOUSE_YARD_DIRECTIONS, + description: 'Trade direction this yard serves. Only meaningful for CONTAINER_YARD — omit/BOTH for everything else.', + }) + @IsOptional() + @IsEnum(WAREHOUSE_YARD_DIRECTIONS) + direction?: WarehouseYardDirection; + + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: 'Cargo types this yard accepts. Empty/omitted = open to any cargo type of this yard\'s structural type.', + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + cargoTypeIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts index 6e3c93292..5169f486a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; import { Warehouse } from './warehouse.entity'; import { WarehouseZone } from './warehouse-zone.entity'; @@ -16,6 +17,15 @@ export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number]; export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const; export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number]; +/** + * Which trade direction this yard serves. Only meaningful for CONTAINER_YARD, + * where import and export stacks are physically separate areas (e.g. Indode's + * Yard 5 for import vs Yard 6 for export) — every other yard type takes cargo + * either way, so BOTH/null is the right default there. + */ +export const WAREHOUSE_YARD_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; +export type WarehouseYardDirection = (typeof WAREHOUSE_YARD_DIRECTIONS)[number]; + @Entity({ schema: 'freight', name: 'warehouse_yards' }) @Index(['warehouseId']) @Index(['type']) @@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + /** Null = BOTH (no direction restriction). Only relevant for CONTAINER_YARD. */ + @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) + direction?: WarehouseYardDirection | null; + + /** + * Cargo types this yard accepts — e.g. Yard 3 (Ro-Ro) takes Automobile/Truck, + * Yard 9 (Coffee and Tea) takes only those two. Empty/no rows = open to any + * cargo type of the yard's structural `type` (the pre-existing behavior), + * so this is additive and never blocks a yard that hasn't been configured. + */ + @ManyToMany(() => CargoType) + @JoinTable({ + name: 'warehouse_yard_cargo_types', + schema: 'freight', + joinColumn: { name: 'yard_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' }, + }) + cargoTypes?: CargoType[]; + @OneToMany(() => WarehouseZone, (zone) => zone.yard) zones?: WarehouseZone[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index fd967cc8c..59f4b5478 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -17,6 +17,8 @@ export interface ImportTrainRow { route: string | null; origin: string | null; destination: string | null; + /** freight.yards.id the train is heading to — lets the frontend restrict the unload warehouse picker to the warehouse actually at this station, instead of listing every warehouse. */ + destinationStationId: string | null; arrivalTime: string | null; totalBookings: number; totalContainers: number; @@ -38,6 +40,8 @@ export interface ImportTrainItemRow { freightType: string | null; containerNumber: string | null; cargoType: string | null; + /** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */ + cargoTypeCode: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null; @@ -205,6 +209,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", oy.country AS "originCountry", dy.country AS "destinationCountry", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", @@ -280,6 +285,7 @@ export class SchedulingReadFacade { WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + cgt.code AS "cargoTypeCode", b.cargo_total_weight_vgm AS "weight", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", COALESCE(inv.status, b.status) AS "currentStatus", @@ -376,6 +382,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", dy.label AS "destinationName", oy.country AS "originCountry", dy.country AS "destinationCountry", diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 275cf08dc..c0b3cc060 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1141,6 +1141,8 @@ export class WarehouseInventoryService { async autoUnloadArrived(): Promise { const arrived: { id: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; @@ -1158,10 +1160,12 @@ export class WarehouseInventoryService { WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.bookings b LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`, [this.ARRIVED_BOOKING_STATUSES], ); @@ -1198,7 +1202,7 @@ export class WarehouseInventoryService { status: 'RECEIVED', arrivedAt: new Date(), ...(booking.tradeDirection === 'EXPORT' - ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date(), booking.customer) } : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); @@ -1223,12 +1227,19 @@ export class WarehouseInventoryService { // A GRN is the receipt for cargo entering the warehouse, so every booking // gets one on unload — import as well as export. The direction only decides // the GRN prefix, not whether one is issued. - const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( - `SELECT trade_direction AS "tradeDirection" - FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, - [bookingId], - ); + // The GRN is mapped to the goods owner (the booking's company), so pull it + // alongside the direction rather than issuing an owner-less number. + const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> = + await this.dataSource.query( + `SELECT b.trade_direction AS "tradeDirection", + company.name AS "ownerName" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); const grnDirection = bookingRow?.tradeDirection ?? 'WH'; + const ownerName = bookingRow?.ownerName ?? null; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1252,7 +1263,7 @@ export class WarehouseInventoryService { // Keep an already-issued GRN rather than reissuing; mint one otherwise. ...(existing[0].grnNumber ? {} - : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1267,7 +1278,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); @@ -1577,7 +1588,7 @@ export class WarehouseInventoryService { } const now = new Date(); - const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); const truckEntrance = dto.truckEntrance ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) : undefined; @@ -2143,6 +2154,8 @@ export class WarehouseInventoryService { const bookings: { id: string; status: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; @@ -2165,10 +2178,12 @@ export class WarehouseInventoryService { WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.train_schedule_bookings tsb JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL AND b.destination_yard_id = $2`, [scheduleId, schedule.destinationStationId], @@ -2235,7 +2250,7 @@ export class WarehouseInventoryService { unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, // Import GRN is issued automatically at train unload. - ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }), + ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer) }), }); await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', @@ -2285,7 +2300,7 @@ export class WarehouseInventoryService { quantity: 1, weight: Number(booking.weight) || 0, status: 'UNLOADED', - grnNumber: this.generateGrnNumber('IMPORT', booking.id, now), + grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer), arrivedAt: now, unloadedAt: now, notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', @@ -2719,7 +2734,12 @@ export class WarehouseInventoryService { this.assertCapacity('Zone', zone, weight, volume, containerCount); const now = new Date(); - const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now); + const grnNumber = this.generateGrnNumber( + bookingDirection ?? 'WH', + dto.bookingId ?? 'MANUAL', + now, + truckEntrance?.ownerName ?? bookingSource?.customer, + ); const receiveNote = this.buildReceiveNote({ grnNumber, notes: dto.notes?.trim() || 'Single booking received', @@ -5350,7 +5370,9 @@ export class WarehouseInventoryService { }); const rows: Array<[string, unknown]> = [ ['Booking Reference', data.bookingReference], - ['Customer / Consignee', data.customerName], + // The GRN is mapped to the owner (import: consignee, export: shipper) — + // named explicitly so the note reads the same for both directions. + ["Owner's Name", data.customerName], ['Customer TIN', data.customerTin], ['Booking Status', data.bookingStatus], ['Service Type', data.serviceType], @@ -5981,8 +6003,13 @@ export class WarehouseInventoryService { } /** Shared with the facility handling flow — see common/grn.util.ts. */ - private generateGrnNumber(direction: string, referenceId: string, date: Date): string { - return generateGrnNumber(direction, referenceId, date); + private generateGrnNumber( + direction: string, + referenceId: string, + date: Date, + ownerName?: string | null, + ): string { + return generateGrnNumber(direction, referenceId, date, ownerName); } private async generateReleaseReference(item: WarehouseInventory): Promise { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts index 99bbdd21f..41f6aacaf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts @@ -1,8 +1,9 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DeepPartial, Repository } from 'typeorm'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @Injectable() @@ -10,4 +11,20 @@ export class WarehouseYardsRepository extends BaseRepository { constructor(@InjectRepository(WarehouseYard) repository: Repository) { super(repository); } + + /** The cargoTypes relation can't ride a column UPDATE — sync it via entity save, like the plain columns. */ + async update(id: string, data: DeepPartial): Promise { + const { cargoTypes, ...columns } = data; + if (Object.keys(columns).length) { + await this.repository.update(id, columns as never); + } + if (cargoTypes) { + const entity = await this.repository.findOne({ where: { id } as never }); + if (entity) { + entity.cargoTypes = cargoTypes as CargoType[]; + await this.repository.save(entity); + } + } + return this.findById(id); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 5b5e2b227..874de75db 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @@ -15,7 +16,7 @@ export class WarehouseYardsService { findAll(): Promise { return this.yardsRepository.findAll({ - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } @@ -23,14 +24,14 @@ export class WarehouseYardsService { findByWarehouse(warehouseId: string): Promise { return this.yardsRepository.findAll({ where: { warehouseId }, - relations: { zones: true }, + relations: { zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } async findById(id: string): Promise { const yard = await this.yardsRepository.findById(id, { - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, }); if (!yard) { @@ -51,6 +52,7 @@ export class WarehouseYardsService { name: dto.name.trim(), code: dto.code.trim(), type: dto.type, + direction: dto.direction ?? null, capacityWeight: dto.capacityWeight ?? null, capacityContainers: dto.capacityContainers ?? null, maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, @@ -60,6 +62,8 @@ export class WarehouseYardsService { currentVolume: 0, status: 'ACTIVE', isActive: true, + // Join rows are written by the save (RESTRICT FK rejects unknown ids). + cargoTypes: (dto.cargoTypeIds ?? []).map((id) => ({ id }) as CargoType), }); } @@ -84,12 +88,16 @@ export class WarehouseYardsService { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, + direction: dto.direction ?? existing.direction, capacityWeight: newCapacityWeight, capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, isActive: status === 'ACTIVE', + ...(dto.cargoTypeIds + ? { cargoTypes: dto.cargoTypeIds.map((cargoTypeId) => ({ id: cargoTypeId }) as CargoType) } + : {}), }); if (!updated) { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 54e60d8b2..cfbdd7b82 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -80,7 +80,7 @@ import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { StoreInventoryModal } from './StoreInventoryModal'; import { WarehouseInquiryTable } from './WarehouseInquiryTable'; -import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options'; import { openPdfBlob } from './pdf'; import '@/components/overview/overview.css'; @@ -1978,14 +1978,6 @@ function LoadedExportTab({ ); } -const importLocationTypesForFreight = (freightType: string | null | undefined) => { - const normalized = (freightType ?? '').toUpperCase(); - if (normalized === 'CONTAINER') { - return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] }; - } - return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] }; -}; - const isImportContainerFreight = (freightType: string | null | undefined) => (freightType ?? '').toUpperCase() === 'CONTAINER'; @@ -2039,10 +2031,60 @@ function ImportTrainDetailTable({ enabled: Boolean(train.scheduleId), }), ); - const warehouseOptions = useMemo( - () => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), - [warehouses], + // A train only ever unloads at the warehouse actually sitting at its + // destination station — Indode's train never offers Sebeta's warehouse. + const scopedWarehouses = useMemo( + () => warehousesAtStation(warehouses, train.destinationStationId), + [warehouses, train.destinationStationId], ); + const warehouseOptions = useMemo( + () => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), + [scopedWarehouses], + ); + // With exactly one warehouse at the station there is nothing to choose — + // pre-fill it so staff only has to pick yard/zone, not re-discover Indode. + useEffect(() => { + if (scopedWarehouses.length !== 1) return; + const onlyWarehouseId = scopedWarehouses[0].id; + items.filter(isImportUnloadPending).forEach((item) => { + if (!assignments[item.bookingId]?.warehouseId) { + onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId }); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopedWarehouses, items]); + + // Once a booking's warehouse is known, its yard (and then zone) follow from + // what the cargo actually is — a Wheat booking only ever has one candidate + // yard (Dry Bulk) once Indode's real yard layout is configured, so staff + // never see a picker for something that isn't actually a choice. + useEffect(() => { + items.filter(isImportUnloadPending).forEach((item) => { + const draft = assignments[item.bookingId]; + if (!draft?.warehouseId) return; + + if (!draft.yardId) { + const candidateYards = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: item.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: item.cargoTypeCode, + }); + if (candidateYards.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id }); + } + return; + } + + if (!draft.zoneId) { + const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId); + if (candidateZones.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id }); + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assignments, items, yards, zones]); useEffect(() => { const pending = items.filter(isImportUnloadPending); @@ -2093,12 +2135,17 @@ function ImportTrainDetailTable({ {items.map((it: ImportTrainItem) => { const draft = assignments[it.bookingId] ?? {}; - const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType); - const yardOptions = yards - .filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type)) - .map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + const yardOptions = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: it.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: it.cargoTypeCode, + }).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + // The yard is already scoped to what this cargo can go into — a + // zone's own type always matches its parent yard's purpose (see the + // Indode seed migration), so no separate zone-type filter is needed. const zoneOptions = zones - .filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type)) + .filter((zone) => zone.yardId === draft.yardId) .map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` })); const pending = isImportUnloadPending(it); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts new file mode 100644 index 000000000..9c0181b40 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; + +import { warehousesAtStation, yardsForBooking } from "./options"; +import type { Warehouse, WarehouseYard } from "@/types/warehouse"; + +// Mirrors Indode's real 11-yard layout at a reduced scale, so these cases read +// against the actual booking-routing decisions staff rely on. +const yard = (overrides: Partial): WarehouseYard => + ({ + id: overrides.code, + warehouseId: "indode", + name: overrides.code, + code: overrides.code, + type: "GENERAL_CARGO_YARD", + capacityWeight: null, + capacityContainers: null, + maxWeight: null, + maxVolume: null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: "ACTIVE", + isActive: true, + ...overrides, + }) as WarehouseYard; + +const YARDS: WarehouseYard[] = [ + yard({ code: "Y2", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "1", code: "STEEL_BILLET" }] }), + yard({ code: "Y3", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "2", code: "AUTOMOBILE" }, { id: "3", code: "TRUCK" }] }), + yard({ code: "Y4", type: "BULK_YARD", status: "INACTIVE", isActive: false, cargoTypes: [{ id: "4", code: "WHEAT" }] }), + yard({ code: "Y5", type: "CONTAINER_YARD", direction: "IMPORT" }), + yard({ code: "Y6", type: "CONTAINER_YARD", direction: "EXPORT" }), + yard({ code: "Y10", type: "CONTAINER_YARD", direction: "BOTH" }), // service yard + yard({ code: "Y11", type: "CONTAINER_YARD", direction: "BOTH" }), // equipment yard +]; + +describe("yardsForBooking", () => { + it("container import narrows to exactly the import stack", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).toEqual(["Y5"]); + }); + + it("container export narrows to exactly the export stack", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "EXPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).toEqual(["Y6"]); + }); + + it("never offers a BOTH-direction container yard (service/equipment) for ordinary cargo", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).not.toContain("Y10"); + expect(result.map((y) => y.code)).not.toContain("Y11"); + }); + + it("bulk cargo narrows to the yard configured for that exact cargo type", () => { + const automobile = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "AUTOMOBILE", + }); + expect(automobile.map((y) => y.code)).toEqual(["Y3"]); + + const steel = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "STEEL_BILLET", + }); + expect(steel.map((y) => y.code)).toEqual(["Y2"]); + }); + + it("falls back to every non-container yard when the one configured for this cargo type is closed", () => { + // Y4 (Dry Bulk, WHEAT) is inactive — never strand staff with an empty + // picker just because the ideal yard is closed; same safety net as + // warehousesAtStation falling back when a station has no mapped warehouse. + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "WHEAT", + }); + expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]); + }); + + it("falls back to every non-container yard when no yard is configured for that cargo type yet", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "SOMETHING_UNMAPPED", + }); + expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]); + }); + + it("a yard with no configured cargo types is open to anything (unconfigured, not restrictive)", () => { + const openYard = yard({ code: "GENERIC", type: "BULK_YARD" }); + const result = yardsForBooking([...YARDS, openYard], { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "STEEL_BILLET", + }); + expect(result.map((y) => y.code).sort()).toEqual(["GENERIC", "Y2"]); + }); + + it("only offers yards at the requested warehouse", () => { + const otherWarehouseYard = yard({ code: "SEBETA-Y1", warehouseId: "sebeta", type: "GENERAL_CARGO_YARD" }); + const result = yardsForBooking([...YARDS, otherWarehouseYard], { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).not.toContain("SEBETA-Y1"); + }); +}); + +describe("warehousesAtStation", () => { + const warehouse = (id: string, stationId: string | null): Warehouse => + ({ id, stationId, name: id, code: id } as Warehouse); + + it("restricts to the warehouse at the given station", () => { + const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")]; + const result = warehousesAtStation(warehouses, "station-a"); + expect(result.map((w) => w.id)).toEqual(["indode"]); + }); + + it("falls back to every warehouse when the station has no match", () => { + const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")]; + const result = warehousesAtStation(warehouses, "station-unknown"); + expect(result).toEqual(warehouses); + }); + + it("falls back to every warehouse when the station is null", () => { + const warehouses = [warehouse("indode", "station-a")]; + expect(warehousesAtStation(warehouses, null)).toEqual(warehouses); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts index d124a3325..958e2d7f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts @@ -4,6 +4,8 @@ import { WAREHOUSE_ZONE_TYPES, WAREHOUSE_STATUSES, INVENTORY_STATUSES, + type Warehouse, + type WarehouseYard, } from '@/types/warehouse'; export const humanizeEnum = (value: string) => @@ -16,6 +18,58 @@ export const humanizeEnum = (value: string) => const toOptions = (values: readonly string[]) => values.map((value) => ({ value, label: humanizeEnum(value) })); +/** + * Warehouses actually located at a train's station — e.g. a train destined for + * Indode should only offer Indode's own warehouse, not Sebeta's or Modjo's. + * Falls back to every warehouse when the station is unmapped (no `stationId` + * match anywhere), so unusual/legacy data never blocks the unload flow entirely. + */ +export const warehousesAtStation = (warehouses: Warehouse[], stationId: string | null | undefined) => { + if (!stationId) return warehouses; + const atStation = warehouses.filter((w) => w.stationId === stationId); + return atStation.length ? atStation : warehouses; +}; + +/** + * Yards at ONE warehouse eligible to receive a booking, given what it actually + * is — e.g. at Indode: container import always narrows to Yard 5, export to + * Yard 6; a Wheat booking narrows to Yard 4 (Dry Bulk), not Break Bulk or + * Coffee/Tea. Mirrors `warehousesAtStation`'s fallback philosophy: an + * unconfigured yard (no cargo types set) stays open rather than disappearing, + * but a yard that IS configured for other cargo never shows for a mismatch. + * + * Container yards are the one case with no such fallback: a CONTAINER_YARD + * left at direction BOTH/null (Indode's Yard 10 service yard, Yard 11 + * equipment yard) is a service/equipment yard, not a customer cargo yard, and + * must never be offered just because the exact-direction stack is missing. + */ +export const yardsForBooking = ( + yards: WarehouseYard[], + params: { + warehouseId: string | null | undefined; + freightType: string | null | undefined; + tradeDirection: string | null | undefined; + cargoTypeCode: string | null | undefined; + }, +): WarehouseYard[] => { + const atWarehouse = yards.filter((y) => y.warehouseId === params.warehouseId && y.isActive); + const isContainer = (params.freightType ?? '').toUpperCase() === 'CONTAINER'; + + if (isContainer) { + const direction = (params.tradeDirection ?? '').toUpperCase(); + return atWarehouse.filter((y) => y.type === 'CONTAINER_YARD' && y.direction === direction); + } + + const nonContainer = atWarehouse.filter((y) => y.type !== 'CONTAINER_YARD'); + if (!params.cargoTypeCode) return nonContainer; + + const cargoMatched = nonContainer.filter((y) => { + const codes = (y.cargoTypes ?? []).map((c) => c.code); + return codes.length === 0 || codes.includes(params.cargoTypeCode as string); + }); + return cargoMatched.length ? cargoMatched : nonContainer; +}; + export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES); export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES); export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES); diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index 61afa426c..8bb1080d9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -18,6 +18,8 @@ import { WarehouseOpsKpiStrip, formatDate, formatNumber, + warehousesAtStation, + yardsForBooking, } from '@/components/warehouses'; import { useAutoUnloadArrivedBookings, @@ -49,14 +51,6 @@ const getPendingUnloadBookings = (train: ImportTrain) => const isFullyUnloaded = (train: ImportTrain) => Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); -const locationTypesForFreight = (freightType: string | null | undefined) => { - const normalized = (freightType ?? '').toUpperCase(); - if (normalized === 'CONTAINER') { - return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] }; - } - return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] }; -}; - const isContainerFreight = (freightType: string | null | undefined) => (freightType ?? '').toUpperCase() === 'CONTAINER'; @@ -65,7 +59,7 @@ function isUnloadPending(item: ImportTrainItem) { } function ImportTrainDetailRows({ - scheduleId, + train, warehouses, yards, zones, @@ -73,7 +67,7 @@ function ImportTrainDetailRows({ onAssignmentChange, onReadyChange, }: { - scheduleId: string; + train: ImportTrain; warehouses: Warehouse[]; yards: WarehouseYard[]; zones: WarehouseZone[]; @@ -81,11 +75,61 @@ function ImportTrainDetailRows({ onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void; onReadyChange: (ready: boolean) => void; }) { - const { data: items = [], isLoading } = useImportTrainItems(scheduleId); - const warehouseOptions = useMemo( - () => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), - [warehouses], + const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId); + // A train only ever unloads at the warehouse actually sitting at its + // destination station — Indode's train never offers Sebeta's warehouse. + const scopedWarehouses = useMemo( + () => warehousesAtStation(warehouses, train.destinationStationId), + [warehouses, train.destinationStationId], ); + const warehouseOptions = useMemo( + () => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), + [scopedWarehouses], + ); + // With exactly one warehouse at the station there is nothing to choose — + // pre-fill it so staff only has to pick yard/zone, not re-discover Indode. + useEffect(() => { + if (scopedWarehouses.length !== 1) return; + const onlyWarehouseId = scopedWarehouses[0].id; + items.filter(isUnloadPending).forEach((item) => { + if (!assignments[item.bookingId]?.warehouseId) { + onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId }); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopedWarehouses, items]); + + // Once a booking's warehouse is known, its yard (and then zone) follow from + // what the cargo actually is — a Wheat booking only ever has one candidate + // yard (Dry Bulk) once Indode's real yard layout is configured, so staff + // never see a picker for something that isn't actually a choice. + useEffect(() => { + items.filter(isUnloadPending).forEach((item) => { + const draft = assignments[item.bookingId]; + if (!draft?.warehouseId) return; + + if (!draft.yardId) { + const candidateYards = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: item.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: item.cargoTypeCode, + }); + if (candidateYards.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id }); + } + return; + } + + if (!draft.zoneId) { + const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId); + if (candidateZones.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id }); + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assignments, items, yards, zones]); useEffect(() => { const pending = items.filter(isUnloadPending); @@ -135,12 +179,17 @@ function ImportTrainDetailRows({ {items.map((item: ImportTrainItem) => { const draft = assignments[item.bookingId] ?? {}; - const { yardTypes, zoneTypes } = locationTypesForFreight(item.freightType); - const yardOptions = yards - .filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type)) - .map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + const yardOptions = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: item.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: item.cargoTypeCode, + }).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + // The yard is already scoped to what this cargo can go into — a + // zone's own type always matches its parent yard's purpose (see the + // Indode seed migration), so no separate zone-type filter is needed. const zoneOptions = zones - .filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type)) + .filter((zone) => zone.yardId === draft.yardId) .map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` })); const pending = isUnloadPending(item); @@ -395,7 +444,7 @@ export default function ArrivalQueuePage() { ; capacityWeight: number | null; capacityContainers: number | null; maxWeight: number | null; @@ -518,6 +525,8 @@ export interface ImportTrain { route: string | null; origin: string | null; destination: string | null; + /** freight.yards.id the train is heading to — matches Warehouse.stationId, so the unload picker can be scoped to the warehouse actually at this station. */ + destinationStationId: string | null; departureTime?: string | null; arrivalTime: string | null; totalBookings: number; @@ -632,6 +641,8 @@ export interface ImportTrainItem { freightType: string | null; containerNumber: string | null; cargoType: string | null; + /** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */ + cargoTypeCode: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null; From 4e7b7d9f4ddf7075d67e4543a23b1f2b9704a5f5 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Mon, 27 Jul 2026 13:18:28 +0300 Subject: [PATCH 5/8] Change restart policy and remove npmrc secrets Updated restart policy for multiple services to 'always' and removed npmrc secrets. --- docker-compose.yaml | 41 ++++++++++++++--------------------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index a621749dc..3659e963f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,21 +9,18 @@ services: build: context: . dockerfile: apps/edr-freight-api/Dockerfile - secrets: - - npmrc ports: - "${FREIGHT_API_PORT:-3001}:${FREIGHT_API_PORT:-3001}" env_file: - apps/edr-freight-api/.env extra_hosts: - "paymentcallback.triaplc.com:10.18.7.179" + restart: always gps-tracker: build: context: . dockerfile: apps/edr-gps-tracker/Dockerfile - secrets: - - npmrc ports: - "${GT06_TCP_PORT:-5023}:5023" environment: @@ -31,8 +28,8 @@ services: GT06_TCP_HOST: "0.0.0.0" env_file: - apps/edr-gps-tracker/.env - restart: unless-stopped - + restart: always + passenger-api: build: context: . @@ -43,8 +40,8 @@ services: - apps/edr-passenger-api/.env extra_hosts: - "paymentcallback.triaplc.com:10.18.7.179" - restart: unless-stopped - + restart: always + freight-portal: build: context: . @@ -58,11 +55,10 @@ services: VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-} VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-} VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-} - secrets: - - npmrc ports: - "${FREIGHT_PORTAL_PORT:-5173}:80" - + restart: always + freight-backoffice: build: context: . @@ -76,11 +72,10 @@ services: VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-} VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-} VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-} - secrets: - - npmrc ports: - "${FREIGHT_BACKOFFICE_PORT:-5183}:80" - + restart: always + passenger-portal: build: context: . @@ -89,14 +84,12 @@ services: APP_PACKAGE: "@edr/passenger-portal" APP_PATH: apps/edr-passenger-web/portal NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} - secrets: - - npmrc ports: - "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}" env_file: - apps/edr-passenger-web/portal/.env - restart: unless-stopped - + restart: always + passenger-backoffice: build: context: . @@ -105,14 +98,12 @@ services: APP_PACKAGE: "@edr/passenger-backoffice" APP_PATH: apps/edr-passenger-web/backoffice NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} - secrets: - - npmrc ports: - "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}" env_file: - apps/edr-passenger-web/backoffice/.env - restart: unless-stopped - + restart: always + payment-api: build: context: . @@ -120,12 +111,8 @@ services: args: APP_PACKAGE: "@edr/payment-api" APP_PATH: apps/edr-payment-api - secrets: - - npmrc ports: - "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}" env_file: - apps/edr-payment-api/.env -secrets: - npmrc: - file: .npmrc + restart: always From d71f6dc1b3abc7113e909499f9447f65a7886091 Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Mon, 27 Jul 2026 14:42:57 +0300 Subject: [PATCH 6/8] Adding train delay minutes and also disabling no schedule days in the booking selection --- .../modules/bookings/bookings.controller.ts | 18 ++ .../src/modules/bookings/bookings.service.ts | 33 ++++ .../src/modules/live/live.module.ts | 2 +- .../notifications/notifications.service.ts | 12 ++ .../modules/schedules/schedules.controller.ts | 18 +- .../src/modules/schedules/schedules.dto.ts | 8 + .../src/modules/schedules/schedules.module.ts | 3 +- .../modules/schedules/schedules.service.ts | 70 +++++++- .../src/modules/search/search.controller.ts | 17 +- .../src/modules/search/search.dto.ts | 14 ++ .../src/modules/search/search.service.ts | 125 ++++++++++++-- .../src/modules/seats/seats.service.ts | 67 +++++++- .../reserve-seat-issue-booking.e2e-spec.ts | 155 +++++++++++++++++- .../backoffice/src/app/schedules/page.tsx | 94 ++++++++++- .../backoffice/src/app/seats/page.tsx | 84 ++++++++-- .../backoffice/src/lib/api/index.ts | 6 + .../portal/src/app/booking/search/page.tsx | 75 ++++++++- .../src/components/ModernDatePicker.tsx | 14 +- .../portal/src/components/SearchWidget.tsx | 84 +++++++++- 19 files changed, 845 insertions(+), 54 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 5953d17a2..a32cffc84 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -369,6 +369,24 @@ export class BookingsController { return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId); } + @Delete("reservations/:seatId") + @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ + summary: "Cancel a seat's pending-payment reservation and release the seat", + description: + "For a seat with an active PASSENGER-kind reservation (payment link sent, not yet paid): cancels that booking and releases the seat's hold, so it's genuinely free for someone else. The old payment link stops working immediately (the booking is no longer PENDING_PAYMENT).", + }) + @ApiQuery({ name: "scheduleId", required: true, description: "TrainSchedule UUID the reservation was issued on" }) + cancelReservationForSeat( + @Param("seatId") seatId: string, + @Query("scheduleId") scheduleId: string, + @Req() req: any, + ) { + const actingUserId = req.user?.id ?? req.user?.sub ?? null; + return this.service.cancelReservationForSeat(seatId, scheduleId, actingUserId); + } + @Get("pay/:token") @SetMetadata("isPublic", true) @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index b5825ddc5..26ab854d3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -2138,6 +2138,39 @@ export class BookingsService { return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency}; } + /** + * Staff releasing a seat that already has an in-flight backoffice reservation must not + * leave that booking dangling as PENDING_PAYMENT with a still-payable link — the traveler + * could pay for a seat that's since been given away. Finds the active reservation covering + * this exact seat+schedule and cancels it via the normal cancel() path (refund=0, since it's + * still unpaid), then separately releases the SeatHold issueBookingFromReservation created — + * cancel()'s releaseSeats() only deletes Journey/JourneySegment rows, which don't exist yet + * for an unpaid reservation, so without this the seat would stay held until the hold's own + * expiry. Once status flips to CANCELLED, getByPayToken's existing status check already + * rejects the old payToken with "This booking is no longer awaiting payment" — no separate + * payToken invalidation needed. + */ + async cancelReservationForSeat(seatId: string, scheduleId: string, actingUserId: string | null) { + const bookingSeat = await this.prisma.bookingSeat.findFirst({ + where: { + seatId, + scheduleId, + booking: { source: 'BACKOFFICE_RESERVATION', status: 'PENDING_PAYMENT' }, + }, + include: { booking: true }, + }); + if (!bookingSeat) throw new NotFoundException('No pending reservation found for this seat'); + + const { bookingRef } = bookingSeat.booking; + const result = await this.cancel(bookingRef, 'Seat released by staff before payment', actingUserId ?? undefined); + + await this.prisma.seatHold.deleteMany({ + where: { scheduleId, seatIds: { hasSome: [seatId] } }, + }); + + return { ...result, bookingRef }; + } + async update(id: string, dto: any) { const booking = await this.prisma.booking.findUnique({ where: { id } }); if (!booking) throw new NotFoundException('Booking not found'); diff --git a/apps/edr-passenger-api/src/modules/live/live.module.ts b/apps/edr-passenger-api/src/modules/live/live.module.ts index 268ba2385..aa46c7d57 100644 --- a/apps/edr-passenger-api/src/modules/live/live.module.ts +++ b/apps/edr-passenger-api/src/modules/live/live.module.ts @@ -2,5 +2,5 @@ import { Module } from '@nestjs/common'; import { LiveController } from './live.controller'; import { LiveService } from './live.service'; -@Module({ controllers: [LiveController], providers: [LiveService] }) +@Module({ controllers: [LiveController], providers: [LiveService], exports: [LiveService] }) export class LiveModule {} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 12f549e9f..9ef922bb3 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -312,6 +312,18 @@ export class NotificationsService { const ref = booking?.bookingRef ?? payload.booking.bookingRef; const passengerId = booking?.passengerId ?? payload.booking.passengerId; + // A backoffice-issued reservation already sends its own purpose-built message — + // GuestBookingService.issueBookingFromReservation texts /reserve/pay/ for a + // PASSENGER-kind booking (the traveler has no portal session, so this generic template's + // /booking/detail?ref= link doesn't work), and for STAFF kind the booking is finalized + // immediately after this event fires, so onPaymentSucceeded's "ticket ready" message is + // the correct one to send, not a redundant/contradictory "awaiting payment" notice. + const source = (booking as any)?.source ?? payload.booking?.source; + if (source === 'BACKOFFICE_RESERVATION') { + this.logger.log(`Skipping generic booking.created notification for ${ref} — reservation flow sends its own`); + return; + } + const template = await this.prisma.notificationTemplate.findUnique({ where: { code: 'booking.created' }, }); diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index b11ac3098..c6921210a 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; -import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto'; import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @@ -171,6 +171,22 @@ export class SchedulesController { @Body() dto: UpdateStopTimeDto, ) { return this.service.updateStop(id, sequence, dto); } + @Post(':id/delay') + @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @ApiOperation({ + summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount', + description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence +onward, if given) by delayMinutes. Since check-in cutoffs are derived directly from these planned +times, this is the only action needed for booking closure to reflect the delay — no separate cutoff +update. Also shifts the schedule's own departureAt/arrivalAt when the origin stop is included, and +records the accumulated delay on the schedule's live status. Does not change schedule/stop status.`, + }) + @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) + @ApiResponse({ status: 200, description: 'Schedule with shifted stop times' }) + applyDelay(@Param('id') id: string, @Body() dto: ApplyDelayDto) { + return this.service.applyDelay(id, dto); + } + @Put(':scheduleId/fares/:seatClassId') @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 49dec1d96..333eb7d26 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -113,6 +113,14 @@ export class UpdateScheduleStatusDto { @ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus; } +export class ApplyDelayDto { + @ApiProperty({ example: 60, description: 'Minutes to shift downstream stop times by. Negative to correct an over-reported delay.' }) + @IsInt() delayMinutes: number; + + @ApiPropertyOptional({ example: 3, description: 'Only shift stops from this sequence onward. Omit to default to every stop not yet BOARDED/COMPLETED.' }) + @IsOptional() @IsInt() @Min(1) fromSequence?: number; +} + export class BulkCreateSchedulesDto { @ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string; @ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string; diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts index 18d88d631..e01dc616b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts @@ -5,9 +5,10 @@ import { RoutesController } from './routes.controller'; import { RoutesService } from './routes.service'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; import { AuditModule } from '../../common/audit.module'; +import { LiveModule } from '../live/live.module'; @Module({ - imports: [FareEngineModule, AuditModule], + imports: [FareEngineModule, AuditModule, LiveModule], controllers: [RoutesController, SchedulesController], providers: [RoutesService, SchedulesService], exports: [RoutesService, SchedulesService], diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index be1c936b4..402f5eb66 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -2,10 +2,11 @@ import { Injectable, Logger, NotFoundException, BadRequestException } from '@nes import { PrismaService } from '../../common/prisma.service'; import { RoutesService } from './routes.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; -import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, ApplyDelayDto } from './schedules.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils'; import { AuditService } from '../../common/audit.service'; +import { LiveService } from '../live/live.service'; @Injectable() export class SchedulesService { @@ -16,6 +17,7 @@ export class SchedulesService { private routesService: RoutesService, private fareEngine: FareEngineService, private auditService: AuditService, + private liveService: LiveService, ) { } /** @@ -126,6 +128,7 @@ export class SchedulesService { include: { coach: true }, orderBy: { positionNumber: 'asc' }, }, + liveStatus: { select: { delayMinutes: true } }, _count: { select: { coachAssignments: true, bookings: true } }, }, orderBy: { departureAt: 'asc' }, @@ -246,6 +249,7 @@ export class SchedulesService { orderBy: { positionNumber: 'asc' }, }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + liveStatus: { select: { delayMinutes: true } }, }, }); if (!schedule) throw new NotFoundException('Schedule not found'); @@ -462,6 +466,70 @@ export class SchedulesService { }); } + /** + * Shifts stored planned times additively rather than reusing updateSchedulePartial's + * recompute-from-route-interpolation path — that path also guards `departureAt must be in the + * future`, which a delay report for an already-departed/EN_ROUTE train would legitimately + * fail. Check-in cutoffs (resolveCheckinCutoff, SeatsService.holdSeats) are both derived + * directly from TripStopTime.plannedArrivalAt/plannedDepartureAt at read time, so shifting the + * stored values here is the entire fix — neither of those needs to change. + */ + async applyDelay(scheduleId: string, dto: ApplyDelayDto) { + const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const stopWhere: any = { scheduleId }; + if (dto.fromSequence != null) { + stopWhere.sequence = { gte: dto.fromSequence }; + } else { + // Default: only stops the train hasn't reached yet — a delay report must not retroactively + // move a stop that's already BOARDED/COMPLETED. + stopWhere.status = { notIn: ['BOARDED', 'COMPLETED'] }; + } + + const stopsToShift = await this.prisma.tripStopTime.findMany({ where: stopWhere }); + const shiftMs = dto.delayMinutes * 60_000; + const includesOrigin = stopsToShift.some((s) => s.sequence === 1); + + await this.prisma.$transaction(async (tx) => { + for (const stop of stopsToShift) { + await tx.tripStopTime.update({ + where: { id: stop.id }, + data: { + plannedArrivalAt: stop.plannedArrivalAt ? new Date(stop.plannedArrivalAt.getTime() + shiftMs) : undefined, + plannedDepartureAt: stop.plannedDepartureAt ? new Date(stop.plannedDepartureAt.getTime() + shiftMs) : undefined, + }, + }); + } + + // Origin stop shifted → the schedule's own departureAt/arrivalAt drive search's day-window + // queries and the displayed departure time, so they must move too (both together, so + // durationMinutes stays correct). + if (includesOrigin) { + await tx.trainSchedule.update({ + where: { id: scheduleId }, + data: { + departureAt: new Date(schedule.departureAt.getTime() + shiftMs), + arrivalAt: new Date(schedule.arrivalAt.getTime() + shiftMs), + }, + }); + } + }); + + const currentLive = await this.prisma.tripLiveStatus.findUnique({ where: { scheduleId } }); + const accumulatedDelayMinutes = Math.max(0, (currentLive?.delayMinutes ?? 0) + dto.delayMinutes); + await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes }); + + await this.auditService.log({ + action: 'UPDATE', + entityType: 'Schedule', + entityId: scheduleId, + newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes }, + }); + + return this.getSchedule(scheduleId); + } + async upsertScheduleFare( scheduleId: string, seatClassId: string, diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index 384592dc9..92aff000a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Post, Get, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SearchService } from './search.service'; -import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, AvailableDatesQueryDto } from './search.dto'; @ApiTags('Search') @Controller('search') @@ -67,6 +67,21 @@ Nationality-Based: return this.service.getFareQuote(dto); } + @Get('available-dates') + @ApiOperation({ + summary: 'Which dates in a range have a bookable schedule for an origin/destination pair', + description: `Used to disable schedule-less dates on the search date picker before the user submits a search. + +For each date in the (server-clamped, max 90-day) range, a date is "available" if at least one +schedule exists for the origin→destination pair whose status/package/coach state is bookable and +whose check-in cutoff has not yet passed. This does not check seat-level availability — a date +can be marked available and still turn out fully booked when actually searched.`, + }) + @ApiResponse({ status: 200, description: 'routeExists flag plus a per-date availability list' }) + getAvailableDates(@Query() dto: AvailableDatesQueryDto) { + return this.service.getAvailableDates(dto); + } + @Get('fare-breakdown') @ApiOperation({ summary: 'Per-passenger fare breakdown for booking review page', diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index cfb1075ca..1c99ce418 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -29,6 +29,20 @@ export class SearchTripsDto { @IsOptional() @IsDateString() returnDate?: string; } +export class AvailableDatesQueryDto { + @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' }) + @IsString() destinationStationId: string; + + @ApiProperty({ example: '2026-06-15', description: 'Start of the date range (YYYY-MM-DD)' }) + @IsDateString() from: string; + + @ApiProperty({ example: '2026-09-13', description: 'End of the date range (YYYY-MM-DD), inclusive — server clamps to a max 90-day span' }) + @IsDateString() to: string; +} + export class FareQuoteDto { @ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' }) @IsString() scheduleId: string; diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 9c368a976..de07f5d6a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -5,6 +5,7 @@ import { FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto, + AvailableDatesQueryDto, } from "./search.dto"; import { CurrencyService } from "../currency/currency.service"; import { FareEngineService } from "../fare-engine/fare-engine.service"; @@ -383,16 +384,9 @@ export class SearchService { // 1. Does any active route connect these two stations, in this direction, at all — // ignoring date entirely? - const candidateRoutes = await this.prisma.route.findMany({ - where: { active: true, stops: { some: { stationId: originStationId } } }, - select: { stops: { select: { stationId: true, sequence: true } } }, - }); - const routeExists = candidateRoutes.some((r) => { - const o = r.stops.find((s) => s.stationId === originStationId); - const d = r.stops.find((s) => s.stationId === destinationStationId); - return !!o && !!d && o.sequence < d.sequence; - }); - if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute); + if (!(await this.routeExistsForPair(originStationId, destinationStationId))) { + return withCode(Passenger.SearchEmptyReasonCode.NoRoute); + } // 2. A route exists — is there any schedule at all on the requested date for this pair // (regardless of status/package/coach/cutoff — those are checked next)? @@ -425,12 +419,7 @@ export class SearchService { // 3. Schedules exist that date — narrow to ones that would otherwise be bookable // (right status, not package-only, has at least one coach assigned). - const bookable = sameDayForPair.filter( - (s) => - (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && - !s.isPackageOnly && - s.coachAssignments.length > 0, - ); + const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s)); if (bookable.length === 0) { if (sameDayForPair.every((s) => s.status === "CANCELLED")) return withCode(Passenger.SearchEmptyReasonCode.Cancelled); @@ -452,6 +441,110 @@ export class SearchService { return withCode(Passenger.SearchEmptyReasonCode.FullyBooked); } + /** + * Whether any active route connects originStationId → destinationStationId in this + * direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and + * getAvailableDates. + */ + private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise { + const candidateRoutes = await this.prisma.route.findMany({ + where: { active: true, stops: { some: { stationId: originStationId } } }, + select: { stops: { select: { stationId: true, sequence: true } } }, + }); + return candidateRoutes.some((r) => { + const o = r.stops.find((s) => s.stationId === originStationId); + const d = r.stops.find((s) => s.stationId === destinationStationId); + return !!o && !!d && o.sequence < d.sequence; + }); + } + + /** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */ + private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean { + return ( + (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && + !s.isPackageOnly && + s.coachAssignments.length > 0 + ); + } + + private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90; + private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000; + private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000; + + /** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */ + private toAddisDateStr(d: Date): string { + return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10); + } + + /** + * For each date in the (server-clamped) range, whether at least one bookable schedule exists + * for originStationId → destinationStationId — used to disable schedule-less dates on the + * search date picker before the user submits a search. Reuses the same route-existence and + * bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used + * throughout this service, but does not compute seat-level availability (see buildScheduleResult) + * — a date can be marked available and still turn out fully booked when actually searched. + */ + async getAvailableDates(dto: AvailableDatesQueryDto) { + const { originStationId, destinationStationId } = dto; + + const todayStr = this.toAddisDateStr(new Date()); + const from = dto.from > todayStr ? dto.from : todayStr; + const fromDate = new Date(`${from}T00:00:00+03:00`); + + const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS); + const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`); + const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate; + const to = this.toAddisDateStr(toDate); + + if (!(await this.routeExistsForPair(originStationId, destinationStationId))) { + return { + originStationId, + destinationStationId, + from, + to, + routeExists: false, + dates: [] as { date: string; available: boolean }[], + }; + } + + const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS); + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + departureAt: { gte: fromDate, lt: rangeEnd }, + stopTimes: { some: { stationId: originStationId } }, + }, + select: { + departureAt: true, + status: true, + isPackageOnly: true, + route: { + select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } }, + }, + stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + coachAssignments: { select: { id: true } }, + }, + }); + + const now = Date.now(); + const availableDays = new Set(); + for (const s of schedules) { + const originStop = s.stopTimes.find((st) => st.stationId === originStationId); + const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId); + if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue; + if (!this.isBookableSchedule(s)) continue; + if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue; + availableDays.add(this.toAddisDateStr(s.departureAt)); + } + + const dates: { date: string; available: boolean }[] = []; + for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) { + const dateStr = this.toAddisDateStr(cursor); + dates.push({ date: dateStr, available: availableDays.has(dateStr) }); + } + + return { originStationId, destinationStationId, from, to, routeExists: true, dates }; + } + // ── Transit search ───────────────────────────────────────────────────────── private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index bd0a3cadf..77e13740a 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -45,13 +45,16 @@ export class SeatsService { }); const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); - const effectiveStatuses = await this.resolveEffectiveStatuses( - scheduleId, - allSeatIds, - originStationId ?? schedule.originStationId, - destinationStationId ?? schedule.destinationStationId, - journeyDirection - ); + const [effectiveStatuses, reservations] = await Promise.all([ + this.resolveEffectiveStatuses( + scheduleId, + allSeatIds, + originStationId ?? schedule.originStationId, + destinationStationId ?? schedule.destinationStationId, + journeyDirection + ), + this.resolveActiveReservations(allSeatIds, scheduleId), + ]); return { coaches: assignments.map((a) => { @@ -70,6 +73,7 @@ export class SeatsService { ? this.resolveBedPosition(s.col, s.bedPosition) : s.bedPosition; const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE'); + const reservation = reservations.get(s.id); return { id: s.id, seatNumber: s.seatNumber, @@ -88,6 +92,15 @@ export class SeatsService { position: this.colToPosition(s.col, a.coach.arrangement), bed_type: this.bedPositionToType(resolvedBedPosition), } : {}), + // Backoffice-issued reservation covering this seat, if any — lets staff see who's + // paying/ticketed for a HELD (awaiting payment) or BLOCKED (ticketed) seat without + // leaving the seat map. See resolveActiveReservations. + ...(reservation ? { + bookingRef: reservation.bookingRef, + reservationStatus: reservation.status, + reservationPassengerName: reservation.passengerName, + reservationContactPhone: reservation.contactPhone, + } : {}), }; }); @@ -259,6 +272,46 @@ export class SeatsService { return statusMap; } + /** + * Batch-resolves the backoffice-issued reservation (if any) covering each of these seats on + * this schedule — a booking created via GuestBookingService.issueBookingFromReservation + * (`source: 'BACKOFFICE_RESERVATION'`), still PENDING_PAYMENT (payment link sent, not yet + * paid) or already CONFIRMED (ticketed). Used to surface the booking reference on the + * backoffice seat map so staff can see who's paying/ticketed for a given seat without + * looking it up separately. + */ + private async resolveActiveReservations( + seatIds: string[], + scheduleId: string, + ): Promise> { + const map = new Map(); + if (seatIds.length === 0) return map; + + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + seatId: { in: seatIds }, + scheduleId, + booking: { source: 'BACKOFFICE_RESERVATION', status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } }, + }, + select: { + seatId: true, + passengerName: true, + booking: { select: { bookingRef: true, status: true, contactPhone: true } }, + }, + }); + + for (const bs of bookingSeats) { + if (!bs.seatId) continue; + map.set(bs.seatId, { + bookingRef: bs.booking.bookingRef, + status: bs.booking.status, + passengerName: bs.passengerName, + contactPhone: bs.booking.contactPhone, + }); + } + return map; + } + async holdSeats(dto: HoldSeatsDto) { const passengerIds = dto.passengers.map(p => p.passengerId); const seatIds = dto.passengers.map(p => p.seatId); diff --git a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts index 75f822289..5d63d7a06 100644 --- a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts +++ b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts @@ -26,6 +26,7 @@ import { validateSync } from "class-validator"; import { plainToInstance } from "class-transformer"; import { SchedulesService } from "../src/modules/schedules/schedules.service"; import { SeatsService } from "../src/modules/seats/seats.service"; +import { SegmentsService } from "../src/modules/segments/segments.service"; import { TicketsService } from "../src/modules/tickets/tickets.service"; import { PaymentsService } from "../src/modules/payments/payments.service"; import { CurrencyService } from "../src/modules/currency/currency.service"; @@ -34,6 +35,7 @@ import { SystemConfigService } from "../src/modules/system-config/system-config. import { BookingsService } from "../src/modules/bookings/bookings.service"; import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; import { ReservationBookingKind, IssueReservationBookingDto } from "../src/modules/bookings/guest-booking.dto"; +import { NotificationsService } from "../src/modules/notifications/notifications.service"; import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; @@ -48,7 +50,9 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { let seatsService: SeatsService; let guestBookingService: GuestBookingService; let bookingsService: BookingsService; + let notificationsService: NotificationsService; let smsClient: { sendSms: jest.Mock }; + let emailClient: { sendEmail: jest.Mock }; beforeAll(async () => { harness = await createServiceHarness(); @@ -57,7 +61,11 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { const fareEngine = harness.moduleRef.get(FareEngineService); const systemConfig = new SystemConfigService(harness.prisma as any); - seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + // Real SegmentsService (not asyncStub) — the getSeatMap test below exercises + // resolveEffectiveStatuses, which calls segmentsService.getSeatAvailabilityMap and needs + // an actual Map back, not asyncStub's `async () => undefined`. + const segmentsService = new SegmentsService(harness.prisma as any); + seatsService = new SeatsService(harness.prisma as any, segmentsService, systemConfig, asyncStub(), asyncStub()); const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); const paymentsService = new PaymentsService( harness.prisma as any, @@ -85,12 +93,26 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { harness.prisma as any, asyncStub(), // dataSource seatsService, - { emit: () => true } as any, + ticketsService, + { emit: () => true } as any, // eventEmitter asyncStub(), // verifaydaService currencyService, fareEngine, asyncStub(), // auditService ); + + emailClient = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) }; + // Same smsClient instance guestBookingService uses — lets the notification-suppression + // test assert on ONE shared call count across both services, proving the reservation + // flow's own SMS is the only message sent for a BACKOFFICE_RESERVATION booking. + notificationsService = new NotificationsService( + harness.prisma as any, + asyncStub(), // dataSource (TypeORM) — only reached for non-UUID recipients / IAM lookups, + // never hit by these guest-passenger-id-keyed test bookings + emailClient as any, + smsClient as any, + asyncStub(), // pushAdapter + ); }); afterAll(async () => { @@ -99,6 +121,7 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { beforeEach(() => { smsClient.sendSms.mockClear(); + emailClient.sendEmail.mockClear(); }); /** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */ @@ -294,6 +317,53 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { expect(byToken.schedule.origin.id).toBe(IDS.stationA); }); + it("NotificationsService.onBookingCreated skips its own message for a BACKOFFICE_RESERVATION booking (issueBookingFromReservation already sent one), but still fires for a normal booking", async () => { + // Regression for: the customer got TWO conflicting messages for one reservation — + // the reservation-specific /reserve/pay/ SMS from issueBookingFromReservation, + // AND a second, generic booking.created notification pointing at /booking/detail?ref=, + // a page that doesn't work for a traveler with no portal session. + await resetAndSeedCore(harness.prisma); + await harness.prisma.notificationTemplate.upsert({ + where: { code: "booking.created" }, + update: { active: true }, + create: { code: "booking.created", channel: "SMS,EMAIL", bodyTemplate: "Booking {{bookingRef}} created. Pay: {{payLink}}", active: true }, + }); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-NOTIFY-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-5", + ); + expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // the reservation flow's own SMS + + // Directly invoke the event handler (the test harness's eventEmitter is a stub, so the + // real 'booking.created' emit from issueBookingFromReservation never reaches it) — this + // is what NotificationsService would have done had it received that event. + await notificationsService.onBookingCreated({ booking: { id: result.booking.id, bookingRef: result.booking.bookingRef } }); + expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // still 1 — onBookingCreated no-oped + expect(emailClient.sendEmail).not.toHaveBeenCalled(); + + // Control: a normal (non-reservation) booking must still get the generic notification. + const passenger = await harness.prisma.passenger.create({ data: {} }); + const normalBooking = await harness.prisma.booking.create({ + data: { + bookingRef: `WEB-CTRL-${Date.now()}`, + passengerId: passenger.id, + scheduleId: schedule.id, + status: "PENDING_PAYMENT", + totalMinor: 10_000, + contactPhone: "+251911234567", + source: "WEB", + }, + }); + await notificationsService.onBookingCreated({ booking: { id: normalBooking.id, bookingRef: normalBooking.bookingRef } }); + expect(smsClient.sendSms).toHaveBeenCalledTimes(2); // suppression didn't leak to non-reservation bookings + }); + it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => { // Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats() // was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and @@ -325,6 +395,87 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { ).rejects.toThrow(/already (held|booked)/i); }); + it("cancelReservationForSeat: cancels the pending booking, frees the seat, and kills the old pay link", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-7", + ); + const payToken = result.booking.payToken; + + const cancelResult: any = await bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-7"); + expect(cancelResult.cancelled).toBe(true); + expect(cancelResult.bookingRef).toBe(result.booking.bookingRef); + + const cancelledBooking = await harness.prisma.booking.findUnique({ where: { id: result.booking.id } }); + expect(cancelledBooking?.status).toBe("CANCELLED"); + + // The seat is genuinely free — a member of the public can now hold it. + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + passengers: [{ passengerId: "someone-else", seatId: seats[0].id }], + } as any), + ).resolves.toBeTruthy(); + + // The old payment link no longer works. + await expect(bookingsService.getByPayToken(payToken)).rejects.toThrow(/no longer awaiting payment/i); + }); + + it("cancelReservationForSeat 404s when there's no pending reservation for this seat", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-404-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await expect( + bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-8"), + ).rejects.toThrow(/no pending reservation/i); + }); + + it("getSeatMap surfaces the bookingRef (PNR) for a seat with an active reservation — pending payment AND ticketed", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-SEATMAP-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + // Seat 0: PASSENGER reservation — still PENDING_PAYMENT. + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const pending: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-6", + ); + + // Seat 1: STAFF reservation — fee-waived, ticketed, CONFIRMED immediately. + await seatsService.blockSeat(seats[1].id, "Reserved for staff issue", schedule.id); + const staffResult: any = await guestBookingService.issueBookingFromReservation( + seats[1].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any, + "staff-user-6", + ); + + const seatMap: any = await seatsService.getSeatMap(schedule.id); + const flatSeats = seatMap.coaches.flatMap((c: any) => c.seats ?? []); + const pendingSeat = flatSeats.find((s: any) => s.id === seats[0].id); + const ticketedSeat = flatSeats.find((s: any) => s.id === seats[1].id); + + expect(pendingSeat.bookingRef).toBe(pending.booking.bookingRef); + expect(pendingSeat.reservationStatus).toBe("PENDING_PAYMENT"); + expect(pendingSeat.status).toBe("HELD"); // covered by the SeatHold, not a SeatBlock + + expect(ticketedSeat.bookingRef).toBe(staffResult.booking.bookingRef); + expect(ticketedSeat.reservationStatus).toBe("CONFIRMED"); + }); + it("requires a phone number for a PASSENGER booking", async () => { await resetAndSeedCore(harness.prisma); const dep = new Date(Date.now() + 3 * 60 * 60_000); diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index dcaf6261c..c74b69b0b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react'; +import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, Clock } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -28,6 +28,7 @@ interface Schedule { destinationStation?: { id: string; name: string }; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; isPackageOnly?: boolean; + liveStatus?: { delayMinutes: number } | null; } interface Train { @@ -454,6 +455,16 @@ export default function SchedulesPage() { {formatDateTime(schedule.arrivalAt)} ), }, + { + key: 'liveStatus.delayMinutes', + label: 'Delay', + sortable: true, + render: (schedule: Schedule) => { + const delay = schedule.liveStatus?.delayMinutes ?? 0; + if (delay <= 0) return On time; + return +{delay} min; + }, + }, { key: 'coachAssignments', label: 'Coaches', @@ -486,6 +497,15 @@ export default function SchedulesPage() { const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const applyDelayMutation = useMutation({ + mutationFn: ({ id, minutes }: { id: string; minutes: number }) => + apiClient.post(`/schedules/${id}/delay`, { delayMinutes: minutes }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }), + }); + const [delayPrompt, setDelayPrompt] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const [delayMinutesInput, setDelayMinutesInput] = useState(''); + const [delayError, setDelayError] = useState(null); + const scheduleActions = [ { label: 'Edit', @@ -493,6 +513,17 @@ export default function SchedulesPage() { variant: 'secondary' as const, icon: Edit, }, + { + label: 'Report Delay', + onClick: (schedule: Schedule) => { + setDelayMinutesInput(''); + setDelayError(null); + setDelayPrompt({ isOpen: true, item: schedule }); + }, + variant: 'secondary' as const, + icon: Clock, + hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', + }, { label: 'Cancel', onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }), @@ -653,6 +684,67 @@ export default function SchedulesPage() { isLoading={cancelScheduleMutation.isPending} /> + setDelayPrompt({ isOpen: false, item: null })} + title={`Report Delay${delayPrompt.item ? `: ${delayPrompt.item.originStation?.name ?? ''} → ${delayPrompt.item.destinationStation?.name ?? ''}` : ''}`} + size="sm" + > + {delayPrompt.item && ( +
{ + e.preventDefault(); + const minutes = parseInt(delayMinutesInput, 10); + if (Number.isNaN(minutes)) { setDelayError('Enter a whole number of minutes.'); return; } + try { + await applyDelayMutation.mutateAsync({ id: delayPrompt.item!.id, minutes }); + setDelayPrompt({ isOpen: false, item: null }); + } catch (err: any) { + setDelayError(err?.response?.data?.message || 'Failed to apply delay.'); + } + }} + className="space-y-4" + > + {delayError && ( +
{delayError}
+ )} +
+ Current reported delay + {(delayPrompt.item.liveStatus?.delayMinutes ?? 0) > 0 ? ( + +{delayPrompt.item.liveStatus?.delayMinutes} min + ) : ( + On time + )} +
+
+ + setDelayMinutesInput(e.target.value)} + placeholder="e.g. 60" + className="input" + required + autoFocus + /> +

+ Adds to the current reported delay above and pushes every downstream station's + check-in cutoff back by this many minutes. Use a negative number to correct an + over-reported delay. +

+
+
+ setDelayPrompt({ isOpen: false, item: null })}> + Cancel + + + Apply Delay + +
+ + )} +
+ setDeleteConfirm({ isOpen: false, item: null })} diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 3ce651ac8..092e26309 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -39,7 +39,7 @@ export default function SeatsPage() { phone: '', email: '', }); - const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string } | null>(null); + const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -126,13 +126,19 @@ export default function SeatsPage() { bookingsApi.issueFromReservation(seatId, data), onSuccess: (result: any) => { invalidateSeatData(); - setIssueBookingResult({ payUrl: result?.payUrl }); - if (!result?.payUrl) { - // STAFF booking — nothing further to show the admin, close immediately. - setShowIssueBookingModal(false); - setSelectedSeat(null); - setIssueBookingCoach(null); - } + // Always show the reference — the PASSENGER path also needs the PNR alongside the + // pay link (staff need to know which booking a seat belongs to, whether it's + // awaiting payment or already ticketed), so no longer auto-closing for STAFF. + setIssueBookingResult({ payUrl: result?.payUrl, bookingRef: result?.booking?.bookingRef }); + }, + }); + + // Cancels a seat's still-unpaid reservation (payment link sent) and releases the seat — + // distinct from unblockMutation, which only handles a plain SeatBlock (no booking involved). + const cancelReservationMutation = useMutation({ + mutationFn: (seatId: string) => bookingsApi.cancelReservation(seatId, selectedSchedule), + onSuccess: () => { + invalidateSeatData(); }, }); @@ -222,6 +228,16 @@ export default function SeatsPage() { } }; + // Distinct from handleUnblock — this seat has no SeatBlock (issuing the reservation already + // released it), it's HELD by the SeatHold behind an unpaid booking. Cancelling that booking + // invalidates its payment link immediately, so warn staff explicitly about that. + const handleCancelReservation = async (seat: any) => { + if (!selectedSchedule) return; + if (confirm(`Cancel the reservation for seat ${seat.seatNumber} (PNR ${seat.bookingRef})? The payment link already sent to the traveler will stop working.`)) { + await cancelReservationMutation.mutateAsync(seat.id); + } + }; + const handleIssueBooking = (seat: any, coach: any) => { if (activeTab !== 'schedule' || !selectedSchedule) { alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.'); @@ -433,6 +449,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -529,6 +546,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -552,6 +570,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -888,12 +907,25 @@ export default function SeatsPage() { title="Issue Booking" size="md" > - {issueBookingResult?.payUrl ? ( + {issueBookingResult ? (

- Booking created. A payment link has been sent via SMS to the traveler. + {issueBookingResult.payUrl + ? 'Booking created. A payment link has been sent via SMS to the traveler.' + : 'Booking confirmed and ticketed.'}

-
{issueBookingResult.payUrl}
+ {issueBookingResult.bookingRef && ( +
+ +
{issueBookingResult.bookingRef}
+
+ )} + {issueBookingResult.payUrl && ( +
+ +
{issueBookingResult.payUrl}
+
+ )}
{ @@ -1231,6 +1263,7 @@ interface SeatIconProps { handleBlock: (seat: any) => void; handleRemoveSeat: (seat: any) => void; handleUnblock: (seat: any) => void; + handleCancelReservation: (seat: any) => void; handleUndoRemove: (seat: any) => void; handleSetMaintenance: (seat: any) => void; handleClearMaintenance: (seat: any) => void; @@ -1248,6 +1281,7 @@ function SeatIcon({ handleBlock, handleRemoveSeat, handleUnblock, + handleCancelReservation, handleUndoRemove, handleSetMaintenance, handleClearMaintenance, @@ -1285,6 +1319,10 @@ function SeatIcon({ const color = getSeatColor(status); const canBlock = status === 'AVAILABLE'; const canUnblock = status === 'BLOCKED'; + // A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting + // payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so + // it's not reachable via canUnblock anymore; this is the seat's own release path. + const canCancelReservation = status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT'; const canMaintenance = false; const canClearMaintenance = status === 'UNDER_MAINTENANCE'; @@ -1299,7 +1337,7 @@ function SeatIcon({ {isBedCoach ? (
@@ -1307,14 +1345,23 @@ function SeatIcon({ ) : (
)} - {(canBlock || canUnblock || canMaintenance || canClearMaintenance) && ( + {seat.bookingRef && ( + + {seat.bookingRef} + + )} + + {(canBlock || canUnblock || canCancelReservation || canMaintenance || canClearMaintenance) && (
{canBlock && ( <> @@ -1334,6 +1381,15 @@ function SeatIcon({ )} + {canCancelReservation && ( + + )} {canUnblock && ( <>