import { ForbiddenException } from '@nestjs/common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; const SUPER_ADMIN_ROLE = 'super_admin'; const ORGANIZATION_ADMIN_ROLE = 'organization_admin'; type PermissionLike = { key?: string }; type PositionTypeLike = { key?: string }; type MeLikeUser = { roles?: { key?: string }[]; permissions?: PermissionLike[]; employee?: | { position?: { permissions?: PermissionLike[]; positionType?: PositionTypeLike | null; }; delegatedPositions?: { permissions?: PermissionLike[] }[]; } | { positions?: { permissions?: PermissionLike[]; positionType?: PositionTypeLike | null; }[]; }[] | null; }; export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean { if (!user?.roles?.length) return false; return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE); } export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean { if (!user?.roles?.length) return false; return user.roles.some((r) => r.key === ORGANIZATION_ADMIN_ROLE); } export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boolean { return isSuperAdmin(user) || isOrganizationAdmin(user); } /** Flat permission keys from JWT / session user (roles + position permissions). */ export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { if (!user) return []; const keys = new Set(); for (const p of user.permissions ?? []) { if (p.key) keys.add(p.key); } const employee = user.employee; if (!employee) { return [...keys]; } if (Array.isArray(employee)) { for (const emp of employee) { for (const pos of emp.positions ?? []) { for (const p of pos.permissions ?? []) { if (p.key) keys.add(p.key); } } } return [...keys]; } for (const p of employee.position?.permissions ?? []) { if (p.key) keys.add(p.key); } for (const delegated of employee.delegatedPositions ?? []) { for (const p of delegated.permissions ?? []) { if (p.key) keys.add(p.key); } } return [...keys]; } export function hasFreightPermission( user: MeLikeUser | null | undefined, permissionKey: string, ): boolean { if (!user) return false; if (isSuperAdmin(user)) return true; return collectPermissionKeys(user).includes(permissionKey); } export function assertFreightPermission( user: TCurrentUser | MeLikeUser | null | undefined, permissionKey: string, ): void { if (hasFreightPermission(user, permissionKey)) return; throw new ForbiddenException(`Missing permission: ${permissionKey}`); } /** * The caller's IAM position-type keys (`iam.position_types.key`). A position * type is the platform's notion of a role — it is what carries permissions via * `iam.position_type_permissions` — and it is the vocabulary contract approval * chains are configured in. * * Mirrors `collectPermissionKeys`' handling of both JWT shapes: `employee` is * an object on some tokens and an array on others. * * Note delegated positions carry no `positionType` in the token, so a delegate * is not reachable here — they authorize through the permission arm of * `assertCanApproveContractStep` instead. */ export function collectPositionTypeKeys( user: MeLikeUser | null | undefined, ): string[] { const employee = user?.employee; if (!employee) return []; const keys = new Set(); if (Array.isArray(employee)) { for (const emp of employee) { for (const pos of emp.positions ?? []) { if (pos.positionType?.key) keys.add(pos.positionType.key); } } return [...keys]; } if (employee.position?.positionType?.key) { keys.add(employee.position.positionType.key); } return [...keys]; } /** * Legacy chain roles predate position types. Historical `approval_rules` and * in-flight `contract_approval_steps` rows still carry them, so map each to the * position types that stand in for it. Without this, an approver holding a * modern position type could not action an older step. */ const LEGACY_ROLE_POSITION_TYPES: Record = { LINE_STAFF: ['employee', 'teamLeader', 'officeHead', 'recordOfficer'], DIRECTOR: ['director', 'operation-director'], CEO: ['chief', 'deputy'], }; const APPROVE_ROLE_PERMISSION: Record = { LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff, DIRECTOR: FREIGHT_PERMS.bookings.approveDirector, CEO: FREIGHT_PERMS.bookings.approveCeo, }; const CONTRACT_APPROVE_ROLE_PERMISSION: Record = { LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff, DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, CEO: FREIGHT_PERMS.contracts.approveCeo, }; const ANY_CONTRACT_APPROVE_PERMISSION = [ FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.approveCeo, ]; /** * May this caller action a contract approval step requiring `requiredRole`? * * `requiredRole` is an `iam.position_types.key` for chains configured by an * admin, or one of the legacy LINE_STAFF/DIRECTOR/CEO strings for older rows. * A caller passes when any of these hold: * * - they are a super/organization admin (blanket bypass); * - their position type matches the step, directly or via a legacy alias; * - they hold the approve permission the legacy role maps to; * - they hold any contract approve permission — this covers delegates (whose * position type is absent from the token) and staff whose IAM position has * no position type assigned yet. */ export function assertCanApproveContractStep( user: TCurrentUser | MeLikeUser | null | undefined, requiredRole: string, ): void { if (isFreightApprovalAdmin(user)) return; const positionTypes = collectPositionTypeKeys(user); if (positionTypes.includes(requiredRole)) return; const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? []; if (aliases.some((alias) => positionTypes.includes(alias))) return; const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole]; if (legacyPermission && hasFreightPermission(user, legacyPermission)) return; if (ANY_CONTRACT_APPROVE_PERMISSION.some((p) => hasFreightPermission(user, p))) { return; } throw new ForbiddenException( `You are not the required approver (${requiredRole}) for this step.`, ); } /** * Strict "is it exactly this caller's turn?" test — mirrors the backoffice * `canApproveContractStep`. Same passes as {@link assertCanApproveContractStep} * EXCEPT the blanket "holds any contract-approve permission" fallback is * dropped: a line-staff holding `approveLineStaff` must NOT read as the director * for a director step. Used to gate contract-document editing so approval hands * edit rights to the NEXT approver only — a previous approver who already acted * (but still holds an approve permission) loses the edit button, as required. * * (Kept separate from the approve/reject gate, which keeps the blanket fallback * so delegates whose token omits a position type can still action their step.) */ export function canEditContractStep( user: TCurrentUser | MeLikeUser | null | undefined, requiredRole: string, ): boolean { if (isFreightApprovalAdmin(user)) return true; const positionTypes = collectPositionTypeKeys(user); if (positionTypes.includes(requiredRole)) return true; const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? []; if (aliases.some((alias) => positionTypes.includes(alias))) return true; const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole]; return Boolean(legacyPermission && hasFreightPermission(user, legacyPermission)); } export function assertCanApproveBookingStep( user: TCurrentUser | MeLikeUser | null | undefined, requiredRole: string, ): void { if (isFreightApprovalAdmin(user)) return; const perm = APPROVE_ROLE_PERMISSION[requiredRole]; if (!perm) { throw new ForbiddenException(`Unknown approval role: ${requiredRole}`); } assertFreightPermission(user, perm); }