From bb2cedc87f5309c2fee55cd287decb003c68771b Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 04:15:16 +0000 Subject: [PATCH] Add allocation management features and update permissions for container allocation --- .../src/common/booking-guards.ts | 4 + .../bookings/booking-allocation.controller.ts | 2 + .../src/seed/edr-freight.seed.ts | 23 +++ .../src/seed/edr-org.seeder.ts | 171 +++++++++++++++++- .../src/seed/freight-permissions.registry.ts | 40 ++++ .../src/seed/freight-staff-users.seeder.ts | 72 ++++++-- .../backoffice/src/lib/permissions.ts | 4 + 7 files changed, 304 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 8d55f1dc4..7eae4e94d 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -28,3 +28,7 @@ export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); + +/** Container allocation on a booking (allocate-containers endpoint). */ +export const AllocationManage = () => + BookingStaff(FREIGHT_PERMS.allocation.manage); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts index cfb9887c3..bb159c538 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; import { AllocateContainersDto } from './dto/allocate-containers.dto'; +import { AllocationManage } from '../../common/booking-guards'; @ApiTags('bookings') @Controller('bookings') @@ -10,6 +11,7 @@ export class BookingAllocationController { constructor(private readonly bookingsService: BookingsService) {} @Post(':bookingId/allocate-containers') + @AllocationManage() @ApiOperation({ summary: 'Allocate containers to vehicles' }) async allocateContainers( @Param('bookingId', ParseUUIDPipe) bookingId: string, diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 3c295c9d0..e6ea89fbe 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -1,6 +1,7 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, + POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; @@ -10,6 +11,13 @@ export type FreightSeedRole = { permissionKeys: string[]; }; +export type FreightSeedPosition = { + key: string; + name: { en: string }; + rank: number; + permissionKeys: string[]; +}; + const IAM_PERMISSION_KEYS = { activateEmployee: "can:activateEmployee", activateUser: "can:activateUser", @@ -282,3 +290,18 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ permissionKeys: [], }, ]; + +/** + * Operational positions (positions-as-roles). Seeded as Position + + * PositionPermission rows (NOT Role/RolePermission). Users get their access by + * being assigned to a Position via EmployeePosition. + */ +export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ + { key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] }, + { key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] }, + { key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] }, + { key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] }, + { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, + { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, + { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, +]; diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 8243ef267..cb379890a 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -3,14 +3,28 @@ import { Organization, OrganizationConfiguration, Permission, + Position, + PositionPermission, + PositionType, Role, RolePermission, + Unit, } from "@tria-plc/iamapi-common"; import { DataSource, EntityManager, In } from "typeorm"; import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry"; -import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed"; +import { + EDR_FREIGHT_POSITIONS, + EDR_FREIGHT_ROLES, + type FreightSeedPosition, + type FreightSeedRole, +} from "./edr-freight.seed"; + +const EDR_UNIT_KEY = "edr_freight_hq"; +const EDR_UNIT_NAME = { en: "EDR Freight HQ" }; +const EDR_POSITION_TYPE_KEY = "edr_freight_role"; +const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" }; const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; @@ -40,6 +54,19 @@ export class EdrOrgSeeder { await this.ensureRoles(manager, EDR_FREIGHT_ROLES); await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); await this.ensureSuperAdminPermissions(manager); + + // Positions-as-roles: seed operational positions and grant their + // permissions via PositionPermission (not Role/RolePermission). + const unit = await this.ensureDefaultUnit(manager, organization.id); + const positionType = await this.ensureDefaultPositionType(manager, unit.id); + await this.ensurePositions( + manager, + organization.id, + unit.id, + positionType.id, + EDR_FREIGHT_POSITIONS, + ); + await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS); }); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); @@ -205,4 +232,146 @@ export class EdrOrgSeeder { `Ensured ${permissions.length} booking+rule-engine permissions on super_admin`, ); } + + private async ensureDefaultUnit( + manager: EntityManager, + organizationId: string, + ): Promise<{ id: string }> { + const unitRepository = manager.getRepository(Unit); + + let unit = await unitRepository.findOne({ + where: { key: EDR_UNIT_KEY, organizationId }, + select: { id: true }, + }); + + if (!unit) { + const insertResult = await unitRepository.insert({ + key: EDR_UNIT_KEY, + name: EDR_UNIT_NAME, + organizationId, + }); + this.logger.log(`Seeded EDR unit '${EDR_UNIT_KEY}'`); + return { id: insertResult.identifiers[0]?.id as string }; + } + + this.logger.log(`Ensured EDR unit '${EDR_UNIT_KEY}'`); + return { id: unit.id }; + } + + private async ensureDefaultPositionType( + manager: EntityManager, + unitId: string, + ): Promise<{ id: string }> { + const positionTypeRepository = manager.getRepository(PositionType); + + // PositionType has no unique constraint on (key, unitId); find-then-insert. + let positionType = await positionTypeRepository.findOne({ + where: { key: EDR_POSITION_TYPE_KEY, unitId }, + select: { id: true }, + }); + + if (!positionType) { + const insertResult = await positionTypeRepository.insert({ + key: EDR_POSITION_TYPE_KEY, + name: EDR_POSITION_TYPE_NAME, + isSystem: true, + unitId, + }); + this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`); + return { id: insertResult.identifiers[0]?.id as string }; + } + + this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`); + return { id: positionType.id }; + } + + private async ensurePositions( + manager: EntityManager, + organizationId: string, + unitId: string, + positionTypeId: string, + seedPositions: FreightSeedPosition[], + ) { + await manager.getRepository(Position).upsert( + seedPositions.map(({ key, name, rank }) => ({ + key, + name, + rank, + organizationId, + unitId, + positionTypeId, + })), + { + conflictPaths: { key: true, unitId: true }, + }, + ); + + this.logger.log( + `Ensured ${seedPositions.length} EDR positions '${seedPositions + .map((position) => position.key) + .join("', '")}'`, + ); + } + + private async ensurePositionPermissions( + manager: EntityManager, + unitId: string, + seedPositions: FreightSeedPosition[], + ) { + const permissionKeys = [ + ...new Set(seedPositions.flatMap((position) => position.permissionKeys)), + ]; + + if (!permissionKeys.length) { + this.logger.log( + "No EDR position permissions configured; skipping position-permission links", + ); + return; + } + + const positions = await manager.getRepository(Position).find({ + where: { key: In(seedPositions.map((position) => position.key)), unitId }, + select: { id: true, key: true }, + }); + const seededPermissions = await manager.getRepository(Permission).find({ + where: { key: In(permissionKeys) }, + select: { id: true, key: true }, + }); + + const positionByKey = new Map( + positions.map((position) => [position.key, position]), + ); + const permissionByKey = new Map( + seededPermissions.map((permission) => [permission.key, permission]), + ); + + const positionPermissions = seedPositions.flatMap((position) => { + const seededPosition = positionByKey.get(position.key); + + if (!seededPosition) { + throw new Error(`missing_position:${position.key}`); + } + + return position.permissionKeys.map((permissionKey) => { + const seededPermission = permissionByKey.get(permissionKey); + + if (!seededPermission) { + throw new Error(`missing_permission:${permissionKey}`); + } + + return { + positionId: seededPosition.id as string, + permissionId: seededPermission.id, + }; + }); + }); + + await manager.getRepository(PositionPermission).upsert(positionPermissions, { + conflictPaths: { positionId: true, permissionId: true }, + }); + + this.logger.log( + `Ensured ${positionPermissions.length} EDR position-permission links`, + ); + } } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 56ca98626..d70cb5fb4 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -109,10 +109,19 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESO }, ); +/** + * Container-allocation permission for the previously-unguarded + * booking allocate-containers endpoint. + */ +export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'), +]; + export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...BOOKING_PERMISSIONS, ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, + ...GAP_CONTROLLER_PERMISSIONS, ]; export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( @@ -171,6 +180,9 @@ export const FREIGHT_PERMS = { manage: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, }, + allocation: { + manage: 'edr_freight_app:allocation:manage', + }, } as const; const allRuleEngineViewKeys = () => @@ -286,6 +298,34 @@ export const ROLE_PERMISSION_PRESETS = { orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; +/** + * Position permission presets (positions-as-roles). Grants flow to users via + * Position → PositionPermission (NOT Role/RolePermission). Each reuses the + * matching ROLE_PERMISSION_PRESETS key-array as a building block and adds the + * gap-controller keys the position needs. Deduped via Set. + */ +const dedupe = (keys: string[]): string[] => [...new Set(keys)]; + +export const POSITION_PERMISSION_PRESETS = { + // Chief: senior operational role — intake/line-staff approval + director + // approval + scheduling/ops, plus container allocation. + chief: dedupe([ + ...ROLE_PERMISSION_PRESETS.lineStaff, + ...ROLE_PERMISSION_PRESETS.director, + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + FREIGHT_PERMS.allocation.manage, + ]), + director: dedupe([...ROLE_PERMISSION_PRESETS.director]), + ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), + ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]), + djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]), + marketer: dedupe([...ROLE_PERMISSION_PRESETS.marketing]), + operation: dedupe([ + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + FREIGHT_PERMS.allocation.manage, + ]), +} as const; + export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ key: p.key, label: p.name.en, diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts index b6d83c138..a8c16ef05 100644 --- a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -3,8 +3,11 @@ import { hashPassword } from '@tria-plc/api-common/utils/argon'; import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; import { Employee, + EmployeePosition, Organization, + Position, Role, + Unit, User, UserCredential, UserRole, @@ -13,13 +16,19 @@ import { DataSource } from 'typeorm'; const SEED_FLAG = 'SEED_FREIGHT_STAFF'; const EDR_ORG_KEY = 'edr_freight'; +const EDR_UNIT_KEY = 'edr_freight_hq'; +// roleKey is kept only for backwards compatibility with existing UserRole rows; +// access is granted via the assigned position (positionKey) + PositionPermission. const STAFF_USERS = [ - { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' }, - { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, - { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, - { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' }, - { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' }, + { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff', positionKey: 'operation' }, + { email: 'chief@edr.local', username: 'chief', roleKey: 'edr_org_manager', positionKey: 'chief' }, + { email: 'director@edr.local', username: 'director', roleKey: 'edr_director', positionKey: 'director' }, + { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo', positionKey: 'ceo' }, + { email: 'marketer@edr.local', username: 'marketer', roleKey: 'edr_marketing', positionKey: 'marketer' }, + { email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' }, + { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' }, + { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' }, ] as const; @Injectable() @@ -47,11 +56,22 @@ export class FreightStaffUsersSeeder { throw new Error(`missing_organization:${EDR_ORG_KEY}`); } + const unit = await manager.getRepository(Unit).findOne({ + where: { key: EDR_UNIT_KEY, organizationId: organization.id }, + select: { id: true }, + }); + + if (!unit) { + throw new Error(`missing_unit:${EDR_UNIT_KEY}`); + } + const roleRepository = manager.getRepository(Role); const userRepository = manager.getRepository(User); const userCredentialRepository = manager.getRepository(UserCredential); const userRoleRepository = manager.getRepository(UserRole); const employeeRepository = manager.getRepository(Employee); + const positionRepository = manager.getRepository(Position); + const employeePositionRepository = manager.getRepository(EmployeePosition); const hashedPassword = await hashPassword(password); @@ -105,20 +125,50 @@ export class FreightStaffUsersSeeder { { conflictPaths: { userId: true, roleId: true } }, ); - const employeeExists = await employeeRepository.exists({ + let employee = await employeeRepository.findOne({ where: { userId: user.id, organizationId: organization.id, isCurrent: true, }, + select: { id: true }, }); - if (!employeeExists) { - await employeeRepository.insert({ - userId: user.id, - organizationId: organization.id, + if (!employee) { + employee = await employeeRepository.save( + employeeRepository.create({ + userId: user.id, + organizationId: organization.id, + unitId: unit.id, + isCurrent: true, + name: { en: staff.username }, + }), + ); + } + + // Grant access via the assigned position (positions-as-roles). + const position = await positionRepository.findOne({ + where: { key: staff.positionKey, unitId: unit.id }, + select: { id: true, key: true }, + }); + + if (!position) { + throw new Error(`missing_position:${staff.positionKey}`); + } + + const employeePositionExists = await employeePositionRepository.exists({ + where: { + employeeId: employee.id as string, + positionId: position.id as string, + }, + }); + + if (!employeePositionExists) { + await employeePositionRepository.insert({ + employeeId: employee.id as string, + positionId: position.id as string, + unitId: unit.id, isCurrent: true, - name: { en: staff.username }, }); } } diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 8ea3fdc28..b787b75cd 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -33,6 +33,7 @@ export const FREIGHT_PERMS = { clearanceReview: "edr_freight_app:contracts:clearance_review", finalizeClearance: "edr_freight_app:contracts:finalize_clearance", createBooking: "edr_freight_app:contracts:create_booking", + opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review", clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise", clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions", clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions", @@ -46,6 +47,9 @@ export const FREIGHT_PERMS = { manage: "edr_freight_app:fleet:manage", }, admin: "edr_freight_app:admin", + allocation: { + manage: "edr_freight_app:allocation:manage", + }, } as const; const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>