Add allocation management features and update permissions for container allocation

This commit is contained in:
Marshal
2026-07-03 04:15:16 +00:00
parent 56de90892d
commit bb2cedc87f
7 changed files with 304 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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