fix(auth): load every position a user holds

Staff given a post in Smart Office and another in freight only ever
loaded one of them. Two causes, both in how the IAM guard collapses the
login snapshot:

- `x-current-position-id` is read two ways inside one function: the
  employee row is matched on `position.id`, the position on
  `employeePositionId`. Freight sends the latter, Smart Office the
  former, so whichever value arrives one lookup matches nothing and
  falls back to `positions[0]`. FreightJwtGuard now matches both fields.

- IAM keeps one employee row per organization, and EDR and EDR Freight
  are separate organizations, so a user holding a post in each owns two
  rows. Only the active row reached `collectPermissionKeys`, so the
  freight post's permissions disappeared whenever the other row won the
  active slot. `employee.positions` now unions every row, which is what
  the util already does for the array shape.

`delegatedPositions` stays scoped to the active row on purpose: yard
scope widens on it, and someone standing in on another organization's
row is not this desk's stand-in.

/auth/me now returns every employee row, active row first, so the
position picker can offer a desk that is not on the active row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathnael
2026-09-01 07:03:58 +00:00
parent 3f2bcf1a1a
commit db9d6e49c7
3 changed files with 355 additions and 53 deletions

View File

@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { DataSource } from 'typeorm';
import type { SnapshotEmployee } from '../../common/freight-jwt.guard';
import {
collectPermissionKeys,
isSuperAdmin,
@@ -82,8 +83,26 @@ export class FreightMeService {
? [employeeRecord.position]
: [];
const enrichedPositions = await Promise.all(
rawPositions.map(async (position) => {
// IAM keeps one employee row per organization, so a user holding a freight
// post and a Smart Office post owns two rows. The backoffice reads
// `employee` as an array and the position picker lists what it finds there
// — returning only the active row hides the other desk and makes it
// unselectable. `FreightJwtGuard` leaves the full set here.
const employeeRows = (user as { employeeRows?: SnapshotEmployee[] })
.employeeRows;
// Active row first: the backoffice reads `employee[0]` for
// unitId/organizationId, so the desk the caller is acting as must lead.
const rows: SnapshotEmployee[] = employeeRows?.length
? [
...employeeRows.filter((row) => row.id === employeeRecord?.id),
...employeeRows.filter((row) => row.id !== employeeRecord?.id),
]
: employeeRecord
? [{ ...employeeRecord, positions: rawPositions } as SnapshotEmployee]
: [];
const enrichPosition = async (position: TokenPosition) => {
const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(position.id),
this.lookupPositionTypePermissions(position.id),
@@ -114,20 +133,24 @@ export class FreightMeService {
positionType,
},
};
}),
};
const enrichedRows = await Promise.all(
rows.map(async (row) => ({
row,
positions: await Promise.all(
((row.positions ?? []) as TokenPosition[]).map(enrichPosition),
),
})),
);
const employee = employeeRecord
? [
{
id: employeeRecord.id,
organizationId: employeeRecord.organizationId,
unitId: employeeRecord.unitId,
name: employeeRecord.name,
positions: enrichedPositions.map((p) => p.position),
},
]
: [];
const employee = enrichedRows.map(({ row, positions }) => ({
id: row.id as string,
organizationId: row.organizationId as string,
unitId: row.unitId as string,
name: row.name,
positions: positions.map((p) => p.position),
}));
// `collectPermissionKeys` reads the raw token (position-level only), so
// union the type-level grants in — the backoffice prefers this flat list
@@ -135,7 +158,9 @@ export class FreightMeService {
const permissionKeys = [
...new Set([
...collectPermissionKeys(user),
...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys),
...enrichedRows.flatMap(({ positions }) =>
positions.flatMap((p) => p.positionTypePermissionKeys),
),
]),
];