diff --git a/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts b/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts new file mode 100644 index 000000000..102a5b85d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Which desks work at which yard — the input to yard access scoping. + * + * Many-to-many: a position (what the user-management tree calls a department) + * can cover several yards, and a yard is staffed by several positions. The + * scope resolver reads it to answer "which yards may this caller touch?". + * + * `yard_id` carries a real FK; `position_id` deliberately does NOT. Positions + * live in `iam`, which is owned by the vendored @tria-plc/iamapi-common package + * and shared with the passenger app: a hard FK would let freight block an IAM + * delete, and would have to be dropped the day IAM moves to its own database. + * Reads join `iam.positions … WHERE deleted_at IS NULL` instead, so a + * soft-deleted position silently drops out of scope rather than granting it. + * + * The unique index is PARTIAL — soft-deleted rows must not block re-adding the + * same pair later. + */ +export class YardPositions3560000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE, + position_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_yard_positions_pair + ON freight.yard_positions (yard_id, position_id) + WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_yard_positions_position + ON freight.yard_positions (position_id) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts b/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts new file mode 100644 index 000000000..0a53bd530 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seed `edr_freight_app:yards:view_all` — the cross-yard bypass for yard access + * scoping. + * + * The permission catalog is otherwise written by `EdrOrgSeeder`, which skips + * itself unless `SEED_EDR_ORG` is set. That flag is off in normal environments, + * so a key added to the registry never reaches `iam.permissions` and cannot be + * granted to anyone — the bypass would exist in code and be unusable in the + * database. A migration is the one path that runs everywhere. + * + * Idempotent on `key`, which is the identity every consumer resolves by (the + * registry's uuid is only used where a seed row needs one). Skips silently when + * the freight application row is absent, since there is nothing to attach to. + */ +export class YardViewAllPermission3570000000000 implements MigrationInterface { + private static readonly KEY = 'edr_freight_app:yards:view_all'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `INSERT INTO iam.permissions (id, key, name, application_id) + SELECT gen_random_uuid(), + $1::varchar, + '{"am": "Access every yard (bypass yard scoping)", "en": "Access every yard (bypass yard scoping)"}'::jsonb, + a.id + FROM iam.application a + WHERE a.key = 'edr_freight_app' + AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`, + [YardViewAllPermission3570000000000.KEY], + ); + } + + /** + * Removes only the permission row itself. Any grant of it goes first, or the + * delete trips the position/role permission foreign keys — and a half-removed + * permission is worse than one left in place. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM iam.position_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [YardViewAllPermission3570000000000.KEY], + ); + await queryRunner.query( + `DELETE FROM iam.role_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [YardViewAllPermission3570000000000.KEY], + ); + await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [ + YardViewAllPermission3570000000000.KEY, + ]); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index d46f63a7d..17b2d1580 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -633,6 +633,11 @@ export const AUDIT_ENDPOINTS: Readonly> = { "DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"], // Yard + // Yard Position (desk↔yard mapping — an input to yard access scoping, so + // every change to it is evidence of who widened or narrowed someone's reach) + "PUT /api/yard-positions/yard/:yardId": ["Replace a yard's whole position set", "PUT", "Yard Position"], + "PUT /api/yard-positions/position/:positionId": ["Replace a position's whole yard set", "PUT", "Yard Position"], + "POST /api/yards": ["Create a yard", "POST", "Yard"], "PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"], "DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"], diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts new file mode 100644 index 000000000..44ae27607 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts @@ -0,0 +1,85 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '@edr/api-common'; +import { StaffReference } from '../../../common/booking-guards'; +import { RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards'; +import { + ListYardPositionsQueryDto, + SetPositionYardsDto, + SetYardPositionsDto, +} from '../dto/yard-positions.dto'; +import { YardPositionsService } from '../services/yard-positions.service'; +import { YardScopeService } from '../services/yard-scope.service'; + +/** + * Desk↔yard mapping — which positions ("departments" in the user-management + * tree) staff which yard. It is yard configuration, so it is gated by the same + * rule-engine yard keys as the rest of the yards screen. + * + * Writes REPLACE the whole set for the side being edited. The admin UI submits + * the full multi-select value; a caller sending a delta will drop everything it + * omits. Both write paths flush the scope resolver's cache so a mapping change + * takes effect on the next request instead of up to a minute later. + */ +@ApiTags('yard-positions') +@Controller('yard-positions') +@ApiBearerAuth() +export class YardPositionsController { + constructor( + private readonly service: YardPositionsService, + private readonly scope: YardScopeService, + ) {} + + @Get() + @RuleEngineView('yards') + @ApiOperation({ summary: 'List desk↔yard mappings, optionally by yard or position' }) + list(@Query() query: ListYardPositionsQueryDto) { + return this.service.list(query); + } + + @Get('positions') + @RuleEngineView('yards') + @ApiOperation({ summary: 'Positions selectable as yard desks' }) + listPositions() { + return this.service.listSelectablePositions(); + } + + @Get('my-yards') + // Any signed-in staff member, NOT gated on the yards keys: this returns the + // caller's own access and nothing else, and the frontend needs it to + // preselect yard filters. Gating it on `rule_engine:yards:view` 403'd every + // desk that does not administer yards — i.e. exactly the users it is for. + @StaffReference() + @ApiOperation({ + summary: "The caller's own yard scope (null yardIds = unrestricted)", + }) + async myYards(@CurrentUser() user: unknown) { + const yardIds = await this.scope.getScopedYardIds(user as never); + return { yardIds, unrestricted: yardIds === null, enforced: this.scope.enforced }; + } + + @Put('yard/:yardId') + @RuleEngineUpdate('yards') + @ApiOperation({ summary: "Replace a yard's whole position set" }) + async setPositionsForYard( + @Param('yardId', ParseUUIDPipe) yardId: string, + @Body() dto: SetYardPositionsDto, + ) { + const rows = await this.service.setPositionsForYard(yardId, dto.positionIds); + this.scope.invalidate(); + return rows; + } + + @Put('position/:positionId') + @RuleEngineUpdate('yards') + @ApiOperation({ summary: "Replace a position's whole yard set" }) + async setYardsForPosition( + @Param('positionId', ParseUUIDPipe) positionId: string, + @Body() dto: SetPositionYardsDto, + ) { + const rows = await this.service.setYardsForPosition(positionId, dto.yardIds); + this.scope.invalidate(); + return rows; + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts new file mode 100644 index 000000000..b18648d78 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsUUID } from 'class-validator'; + +export class ListYardPositionsQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + positionId?: string; +} + +/** Replaces the yard's whole position set — see the controller's PUT docs. */ +export class SetYardPositionsDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @IsUUID('4', { each: true }) + positionIds!: string[]; +} + +/** Replaces the position's whole yard set. */ +export class SetPositionYardsDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @IsUUID('4', { each: true }) + yardIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts new file mode 100644 index 000000000..de12b5674 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts @@ -0,0 +1,28 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * One desk staffed at one yard. + * + * The pairing that yard access scoping resolves against: a caller's active + * position decides which yards they may touch. Position rows live in `iam` + * (`iam.positions` — what the user-management tree labels "departments"), so + * `positionId` is an unconstrained uuid by design; see the migration for why. + */ +@Entity({ schema: 'freight', name: 'yard_positions' }) +@Index(['yardId']) +@Index(['positionId']) +export class YardPosition extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + /** `iam.positions.id`. No FK — IAM is package-owned and soft-deletes. */ + @Column({ name: 'position_id', type: 'uuid' }) + positionId!: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 549a35fa2..97e89cc3b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -13,6 +13,7 @@ import { ShippingLinesController } from './controllers/shipping-lines.controller import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; import { YardDistancesController } from './controllers/yard-distances.controller'; import { YardsController } from './controllers/yards.controller'; +import { YardPositionsController } from './controllers/yard-positions.controller'; import { ApprovalRule } from './entities/approval-rule.entity'; import { CargoType } from './entities/cargo-type.entity'; @@ -28,6 +29,7 @@ import { Yard } from './entities/yard.entity'; import { YardDistance } from './entities/yard-distance.entity'; import { YardFacility } from './entities/yard-facility.entity'; import { YardLocation } from './entities/yard-location.entity'; +import { YardPosition } from './entities/yard-position.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; @@ -65,6 +67,8 @@ import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; import { YardDistancesService } from './services/yard-distances.service'; import { YardFacilitiesService } from './services/yard-facilities.service'; +import { YardPositionsService } from './services/yard-positions.service'; +import { YardScopeService } from './services/yard-scope.service'; import { RuleEngineService } from './rule-engine.service'; @@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardDistance, YardFacility, YardLocation, + YardPosition, ShippingLine, Rate, ApprovalRule, @@ -116,6 +121,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesController, WeightLimitRulesController, YardsController, + YardPositionsController, YardDistancesController, ShippingLinesController, RatesController, @@ -152,6 +158,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardsService, YardDistancesService, YardFacilitiesService, + YardPositionsService, + YardScopeService, ShippingLinesService, RatesService, ApprovalRulesService, @@ -168,6 +176,10 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardsService, YardDistancesService, YardFacilitiesService, + YardPositionsService, + // Exported so any module can narrow its yard queries through the one + // resolver — the module is @Global, so no import is needed to inject it. + YardScopeService, ShippingLinesService, RatesService, ApprovalRulesService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts new file mode 100644 index 000000000..93f9752f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts @@ -0,0 +1,194 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; + +import { YardPosition } from '../entities/yard-position.entity'; +import { Yard } from '../entities/yard.entity'; + +/** A mapped desk, joined to its IAM position for display. */ +export interface YardPositionRow { + id: string; + yardId: string; + yardCode: string; + yardLabel: string; + positionId: string; + /** Localised name from `iam.positions.name` — null if the position is gone. */ + positionName: { am?: string; en?: string } | null; + positionTypeKey: string | null; +} + +/** + * The desk↔yard mapping behind yard access scoping. + * + * Reads always join `iam.positions` and drop soft-deleted rows: the mapping has + * no FK to IAM (see the migration), so a position deleted in the admin UI leaves + * an orphan row here. Dropping it on read means the orphan can never widen + * someone's scope — it just disappears. + */ +@Injectable() +export class YardPositionsService { + constructor(private readonly dataSource: DataSource) {} + + /** Mapping rows, optionally narrowed to one yard or one position. */ + async list(filter: { + yardId?: string; + positionId?: string; + }): Promise { + const params: unknown[] = []; + const where: string[] = ['yp.deleted_at IS NULL', 'y.deleted_at IS NULL']; + + if (filter.yardId) { + params.push(filter.yardId); + where.push(`yp.yard_id = $${params.length}`); + } + if (filter.positionId) { + params.push(filter.positionId); + where.push(`yp.position_id = $${params.length}`); + } + + return this.dataSource.query( + `SELECT yp.id AS "id", + yp.yard_id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + yp.position_id AS "positionId", + p.name AS "positionName", + pt.key AS "positionTypeKey" + FROM freight.yard_positions yp + JOIN freight.yards y ON y.id = yp.yard_id + -- INNER join: a mapping whose position was deleted grants nothing and + -- is not shown. The row stays for audit until someone re-saves the set. + JOIN iam.positions p ON p.id = yp.position_id AND p.deleted_at IS NULL + LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id + WHERE ${where.join(' AND ')} + ORDER BY y.display_order ASC, y.label ASC, p.name->>'en' ASC`, + params, + ); + } + + /** + * Replace the yard's entire position set. + * + * Replace, not append — the admin UI submits the full multi-select value, so a + * partial payload would silently keep desks the user just unticked. Callers + * sending a delta will remove everything they omit. + */ + async setPositionsForYard( + yardId: string, + positionIds: string[], + ): Promise { + await this.assertYardExists(yardId); + await this.assertPositionsExist(positionIds); + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(YardPosition); + await repo.delete({ yardId }); + if (positionIds.length) { + await repo.insert( + [...new Set(positionIds)].map((positionId) => ({ yardId, positionId })), + ); + } + }); + + return this.list({ yardId }); + } + + /** Replace the position's entire yard set. Same replace semantics. */ + async setYardsForPosition( + positionId: string, + yardIds: string[], + ): Promise { + await this.assertPositionsExist([positionId]); + await this.assertYardsExist(yardIds); + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(YardPosition); + await repo.delete({ positionId }); + if (yardIds.length) { + await repo.insert( + [...new Set(yardIds)].map((yardId) => ({ yardId, positionId })), + ); + } + }); + + return this.list({ positionId }); + } + + /** + * Positions offered by the mapping picker. + * + * Reads `iam.positions` directly rather than going through IAM's + * `/positions/list/{unitId}`: that endpoint needs the caller to resolve a unit + * first, and the picker wants every desk that could staff a yard regardless of + * which unit it hangs under. + */ + async listSelectablePositions(): Promise< + Array<{ + id: string; + name: { am?: string; en?: string } | null; + positionTypeKey: string | null; + unitKey: string | null; + }> + > { + return this.dataSource.query( + `SELECT p.id AS "id", + p.name AS "name", + pt.key AS "positionTypeKey", + u.key AS "unitKey" + FROM iam.positions p + LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id + LEFT JOIN iam.units u ON u.id = p.unit_id + WHERE p.deleted_at IS NULL + ORDER BY p.name->>'en' ASC`, + ); + } + + /** Yard ids mapped to any of these positions — the scope resolver's read. */ + async yardIdsForPositions(positionIds: string[]): Promise { + if (!positionIds.length) return []; + const rows: { yardId: string }[] = await this.dataSource.query( + `SELECT DISTINCT yp.yard_id AS "yardId" + FROM freight.yard_positions yp + JOIN freight.yards y ON y.id = yp.yard_id AND y.deleted_at IS NULL + WHERE yp.deleted_at IS NULL + AND yp.position_id = ANY($1)`, + [positionIds], + ); + return rows.map((r) => r.yardId); + } + + private async assertYardExists(yardId: string): Promise { + const yard = await this.dataSource + .getRepository(Yard) + .findOne({ where: { id: yardId, deletedAt: IsNull() } }); + if (!yard) throw new NotFoundException(`Yard ${yardId} not found`); + } + + private async assertYardsExist(yardIds: string[]): Promise { + if (!yardIds.length) return; + const found = await this.dataSource + .getRepository(Yard) + .count({ where: { id: In(yardIds), deletedAt: IsNull() } }); + if (found !== new Set(yardIds).size) { + throw new BadRequestException('One or more yards do not exist'); + } + } + + /** + * Validated in the service because the database cannot: there is no FK to + * `iam.positions`, so an unchecked payload would happily store a typo'd uuid + * that silently grants nothing and reads as a configuration bug later. + */ + private async assertPositionsExist(positionIds: string[]): Promise { + if (!positionIds.length) return; + const unique = [...new Set(positionIds)]; + const rows: { count: string }[] = await this.dataSource.query( + `SELECT COUNT(*)::text AS count + FROM iam.positions + WHERE id = ANY($1) AND deleted_at IS NULL`, + [unique], + ); + if (Number(rows[0]?.count ?? 0) !== unique.length) { + throw new BadRequestException('One or more positions do not exist'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts new file mode 100644 index 000000000..c9af80ad3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts @@ -0,0 +1,139 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { YardScopeService } from './yard-scope.service'; + +/** + * The resolver answers "which yards", never "may they act at all" — that stays + * with the permission guard. So a mapped desk is narrowed to its yards, and an + * unmapped one keeps the reach its permissions already gave it. + */ +describe('YardScopeService', () => { + const yardIdsForPositions = jest.fn(); + const service = () => + new YardScopeService({ yardIdsForPositions } as never); + + const staff = (positionId: string, permissions: string[] = []) => ({ + roles: [{ key: 'staff' }], + permissions: permissions.map((key) => ({ key })), + employee: { position: { id: positionId, permissions: [] } }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.YARD_SCOPE_ENFORCE; + }); + + it('resolves a mapped position to its yards', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + const scope = await service().getScopedYardIds(staff('pos-officer')); + + expect(scope).toEqual(['yard-kality', 'yard-mojo']); + expect(yardIdsForPositions).toHaveBeenCalledWith(['pos-officer']); + }); + + it('leaves an unmapped position unrestricted — permissions still gate the action', async () => { + yardIdsForPositions.mockResolvedValue([]); + + expect(await service().getScopedYardIds(staff('pos-unmapped'))).toBeNull(); + }); + + it('leaves a caller with no resolvable position unrestricted', async () => { + const noPosition = { roles: [{ key: 'staff' }], employee: { position: {} } }; + + expect(await service().getScopedYardIds(noPosition)).toBeNull(); + expect(yardIdsForPositions).not.toHaveBeenCalled(); + }); + + it('narrows nothing for an anonymous caller but grants nothing either', async () => { + expect(await service().getScopedYardIds(null)).toEqual([]); + }); + + it('returns unrestricted only for super admins and view_all holders', async () => { + const superAdmin = { roles: [{ key: 'super_admin' }] }; + const hqDesk = staff('pos-occ', ['edr_freight_app:yards:view_all']); + + expect(await service().getScopedYardIds(superAdmin)).toBeNull(); + expect(await service().getScopedYardIds(hqDesk)).toBeNull(); + expect(yardIdsForPositions).not.toHaveBeenCalled(); + }); + + it('includes delegated positions — standing in must not lose the yard', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + await service().getScopedYardIds({ + roles: [{ key: 'staff' }], + employee: { + position: { id: 'pos-own' }, + delegatedPositions: [{ id: 'pos-gelan-director' }], + }, + }); + + expect(yardIdsForPositions).toHaveBeenCalledWith([ + 'pos-own', + 'pos-gelan-director', + ]); + }); + + describe('listFilterYardIds', () => { + it('narrows nothing while shadow-logging', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'), + ).toBeNull(); + }); + + it('narrows to the mapped yards once enforcing', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'), + ).toEqual(['yard-kality', 'yard-mojo']); + }); + + it('keeps an in-scope yard filter as the caller asked', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), 'yard-mojo', 'list'), + ).toEqual(['yard-mojo']); + }); + + it('returns an empty set — not everything — for an out-of-scope yard filter', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), 'yard-djibouti', 'list'), + ).toEqual([]); + }); + + it('never narrows an unmapped desk', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue([]); + + expect( + await service().listFilterYardIds(staff('pos-unmapped'), undefined, 'list'), + ).toBeNull(); + }); + }); + + it('only logs an out-of-scope yard until YARD_SCOPE_ENFORCE is set', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + const shadow = service(); + + await expect( + shadow.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'), + ).resolves.toBeUndefined(); + + process.env.YARD_SCOPE_ENFORCE = 'true'; + const enforcing = service(); + + await expect( + enforcing.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts new file mode 100644 index 000000000..0a7fbe2e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts @@ -0,0 +1,186 @@ +import { ForbiddenException, Injectable, Logger } from "@nestjs/common"; + +import { hasFreightPermission, isSuperAdmin } from "../../../common/freight-permission.util"; +import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry"; +import { YardPositionsService } from "./yard-positions.service"; + +/** + * Caller shape the resolver reads — the `/auth/me` user in either of its two + * shapes. Structurally compatible with what `freight-permission.util` accepts, + * so the same object serves both the permission checks and the position walk. + */ +type PositionLike = { + id?: string; + permissions?: { key?: string }[]; + positionType?: { key?: string } | null; +}; + +type ScopeUser = { + roles?: { key?: string }[]; + permissions?: { key?: string }[]; + employee?: + | { + position?: PositionLike; + delegatedPositions?: PositionLike[]; + } + | { positions?: PositionLike[] }[] + | null; +}; + +/** + * Which yards a caller may touch. + * + * Scope follows the caller's ACTIVE position, not a union of every position they + * have ever held: the frontends already send `x-current-position-id` and the + * token snapshots that one position, so switching desks switches yards — which + * is what staff covering two yards actually do. Delegated positions are added on + * top, otherwise standing in for the Gelan director silently loses Gelan. + * + * `null` means unrestricted, and an UNMAPPED caller gets it. Scoping narrows a + * desk that has been given yards; it does not hand out access. Whether the + * caller may perform the action at all is the permission guard's job — this + * resolver only answers "which yards", so a desk with the permission and no + * mapping keeps the reach it had before the mapping existed. + * + * The trade-off is deliberate and worth knowing: an accidentally-cleared + * mapping widens access rather than blocking work, so the mapping is not a + * containment barrier on its own — the permission keys still are. Super admins + * and holders of `yards:view_all` are unrestricted regardless of mapping. + * + * ENFORCEMENT IS OFF until `YARD_SCOPE_ENFORCE=true`. Until then + * {@link assertYardInScope} logs what it would have blocked and returns. Flip it + * only once the mapping table is populated and the log is quiet — on an empty + * table, enforcing locks out every staff member at once. + */ +@Injectable() +export class YardScopeService { + private readonly logger = new Logger(YardScopeService.name); + + // ponytail: 60s cache keyed by the position-id set, no invalidation hook. A + // mapping change takes up to a minute to reach the resolver. Call + // `invalidate()` from the mutation if that lag ever matters. + private static readonly CACHE_TTL_MS = 60_000; + private readonly cache = new Map(); + + constructor(private readonly yardPositions: YardPositionsService) {} + + /** True when the deny path is live; false while shadow-logging. */ + get enforced(): boolean { + return process.env.YARD_SCOPE_ENFORCE === "false"; + } + + /** Yard ids the caller is scoped to, or `null` for unrestricted. */ + async getScopedYardIds(user: ScopeUser | null | undefined): Promise { + // No user at all is an unauthenticated call the guards should already have + // rejected — narrow to nothing rather than trusting it. + if (!user) return []; + if (isSuperAdmin(user)) return null; + if (hasFreightPermission(user, FREIGHT_PERMS.yards.viewAll)) return null; + + const positionIds = this.effectivePositionIds(user); + // No resolvable position — nothing to narrow by, so nothing is narrowed. + if (!positionIds.length) return null; + + const key = positionIds.join(","); + const hit = this.cache.get(key); + if (hit && Date.now() - hit.at < YardScopeService.CACHE_TTL_MS) { + return hit.yardIds.length ? hit.yardIds : null; + } + + const yardIds = await this.yardPositions.yardIdsForPositions(positionIds); + this.cache.set(key, { yardIds, at: Date.now() }); + // Unmapped desk → unrestricted. Mapping narrows; absence of one does not. + return yardIds.length ? yardIds : null; + } + + async isYardInScope( + user: ScopeUser | null | undefined, + yardId: string | null | undefined, + ): Promise { + if (!yardId) return true; + const scope = await this.getScopedYardIds(user); + return scope === null || scope.includes(yardId); + } + + /** + * Gate an action on a yard. While `YARD_SCOPE_ENFORCE` is unset this only + * logs — wire it into write paths first and read filters second, so the + * shadow log shows what enforcement would break before it breaks it. + */ + async assertYardInScope( + user: ScopeUser | null | undefined, + yardId: string | null | undefined, + context: string, + ): Promise { + if (await this.isYardInScope(user, yardId)) return; + + const positions = this.effectivePositionIds(user).join(",") || "none"; + if (!this.enforced) { + this.logger.warn( + `[yard-scope shadow] would block ${context}: yard=${yardId} positions=${positions}`, + ); + return; + } + throw new ForbiddenException("This yard is outside your assigned yards"); + } + + /** + * Yard ids a list query should be narrowed to, or `null` for no narrowing. + * + * Returns an EMPTY array only when the caller explicitly asked for a yard + * outside their scope and enforcement is on — the caller should answer with an + * empty result rather than silently widening back to everything. + * + * While `YARD_SCOPE_ENFORCE` is unset this always returns `null` and logs what + * it would have narrowed, so the mapping can be populated against real traffic + * before it starts hiding rows. + */ + async listFilterYardIds( + user: ScopeUser | null | undefined, + requestedYardId: string | null | undefined, + context: string, + ): Promise { + const scope = await this.getScopedYardIds(user); + if (scope === null) return null; + + const outOfScope = !!requestedYardId && !scope.includes(requestedYardId); + + if (!this.enforced) { + this.logger.warn( + `[yard-scope shadow] would narrow ${context} to [${scope.join(", ")}]` + + (outOfScope ? ` and reject yard=${requestedYardId}` : ""), + ); + return null; + } + + if (outOfScope) return []; + return requestedYardId ? [requestedYardId] : scope; + } + + /** Drops the memoised scopes — call after editing the mapping. */ + invalidate(): void { + this.cache.clear(); + } + + /** Active position plus any delegated ones, across both `employee` shapes. */ + private effectivePositionIds(user: ScopeUser | null | undefined): string[] { + const ids = new Set(); + const employee = user?.employee; + if (!employee) return []; + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const position of emp.positions ?? []) { + if (position?.id) ids.add(position.id); + } + } + return [...ids]; + } + + if (employee.position?.id) ids.add(employee.position.id); + for (const delegated of employee.delegatedPositions ?? []) { + if (delegated?.id) ids.add(delegated.id); + } + return [...ids]; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 459b5009e..19d3b4b7a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -38,15 +38,21 @@ export class WarehouseInventoryController { @Get() @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List warehouse inventory' }) - findAll(@Query() filter: FilterWarehouseInventoryDto) { - return this.inventoryService.findAll(filter); + findAll( + @Query() filter: FilterWarehouseInventoryDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.findAll(filter, user); } @Get('ready-for-loading') @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List inventory ready for loading' }) - findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) { - return this.inventoryService.findReadyForLoading(filter); + findReadyForLoading( + @Query() filter: FilterWarehouseInventoryDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.findReadyForLoading(filter, user); } @Get('inquiry') 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 7412f0269..126998bae 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 @@ -39,6 +39,7 @@ import { import { SignaturesService } from '../signatures/signatures.service'; import { StampSettingsService } from '../stamp-settings/stamp-settings.service'; import { LogoSettingsService } from '../logo-settings/logo-settings.service'; +import { YardScopeService } from '../rule-engine/services/yard-scope.service'; import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util'; import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; @@ -423,6 +424,7 @@ export class WarehouseInventoryService { private readonly events: EventEmitter2, private readonly stampSettings: StampSettingsService, private readonly logoSettings: LogoSettingsService, + private readonly yardScope: YardScopeService, ) {} /** @@ -978,7 +980,16 @@ export class WarehouseInventoryService { // ── Listing ──────────────────────────────────────────────────────────── - async findAll(filter: FilterWarehouseInventoryDto): Promise { + /** + * `user` drives yard access scoping: a desk mapped to yards sees only those + * yards' inventory. Optional so internal callers that are not serving a + * request (schedulers, other services) are unaffected — they pass nothing and + * get the unscoped list, which is what they had before. + */ + async findAll( + filter: FilterWarehouseInventoryDto, + user?: unknown, + ): Promise { const createdAt = filter.dateFrom && filter.dateTo ? Between(new Date(filter.dateFrom), new Date(filter.dateTo)) @@ -1020,6 +1031,32 @@ export class WarehouseInventoryService { }); } + // Yard scoping — applied to `base` before the search branch splits it, so + // both OR arms carry the constraint. A null result means "do not narrow". + // + // Scoped on `warehouse.stationId`, NOT on `inventory.yardId`: those are two + // different id spaces that share a name. `warehouse_inventory.yard_id` is a + // FK to `warehouse_yards` — a yard INSIDE a warehouse — while the desk↔yard + // mapping is against `freight.yards`, the network yard, which inventory + // reaches through `warehouses.station_id`. Filtering `yardId` against + // mapped network yards matches nothing and hides every row (observed: all + // 34 rows disappeared before this was corrected). + // + // `filter.yardId` is likewise a warehouse-yard id, so it is NOT passed as + // the requested yard here; `filter.facilityId` is the station-yard filter. + const scopedYardIds = await this.yardScope.listFilterYardIds( + user as never, + filter.facilityId, + 'warehouse-inventory list', + ); + if (scopedYardIds) { + if (!scopedYardIds.length) return []; + base.warehouse = { + ...((base.warehouse as FindOptionsWhere) ?? {}), + stationId: scopedYardIds.length === 1 ? scopedYardIds[0] : In(scopedYardIds), + }; + } + const search = filter.search?.trim(); const where: FindManyOptions['where'] = search ? [ @@ -1037,8 +1074,11 @@ export class WarehouseInventoryService { return items; } - findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise { - return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }); + findReadyForLoading( + filter: FilterWarehouseInventoryDto, + user?: unknown, + ): Promise { + return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }, user); } /** 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 01c1d67d0..e39b08b59 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -459,6 +459,26 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +/** + * Yard access scoping. `yard_positions` maps desks to yards and the resolver + * (`YardScopeService`) narrows a caller to the yards their active position is + * mapped to. This key is the deliberate way out of that narrowing, for the HQ + * desks that are cross-yard by nature (OCC, CEO, rolling stock). Without it, + * "unmapped" would have to mean "sees everything", which is a bypass by + * accident rather than by grant. + * + * Editing the mapping itself needs no key of its own: it is yard configuration, + * so it rides on `rule_engine:yards:view` / `:update` like every other field on + * a yard. + */ +export const YARD_SCOPE_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f4a00001-0001-4000-8000-000000000001", + "edr_freight_app:yards:view_all", + "Access every yard (bypass yard scoping)", + ), +]; + /** * Advanced backoffice resources — full CRUD + workflow-action keys. * See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing @@ -1667,6 +1687,7 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, ...GAP_CONTROLLER_PERMISSIONS, + ...YARD_SCOPE_PERMISSIONS, ...ADVANCED_BACKOFFICE_PERMISSIONS, ]; @@ -1844,6 +1865,10 @@ export const FREIGHT_PERMS = { allocation: { manage: "edr_freight_app:allocation:manage", }, + yards: { + /** Bypasses yard scoping entirely — see YARD_SCOPE_PERMISSIONS. */ + viewAll: "edr_freight_app:yards:view_all", + }, customers: { view: "edr_freight_app:customers:view", create: "edr_freight_app:customers:create", diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 688ed3ce2..a83a90fb4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -26,6 +26,7 @@ import { PageContainer, PageHeader } from "@/components/page"; import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog"; import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection"; import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection"; +import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal"; import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; @@ -167,6 +168,10 @@ const RuleEngineResourcePage = () => { null, ); const [chainOpen, setChainOpen] = useState(false); + // Yards only: which desks work at this yard (input to yard access scoping). + const [desksYard, setDesksYard] = useState | null>( + null, + ); const [orderDialogOpen, setOrderDialogOpen] = useState(false); const { viewMode, setViewMode } = useRuleEngineViewMode( config?.slug ?? DEFAULT_CONFIGURATION_SLUG, @@ -566,6 +571,17 @@ const RuleEngineResourcePage = () => { cell: ({ row }) => (
e.stopPropagation()} data-stop-row-click> + {config.slug === "yards" ? ( + + + + ) : null} {config.orderConfig && canUpdateControls ? ( { + setDesksYard(null)} + readOnly={!canUpdateControls} + yard={ + desksYard + ? { + id: String(desksYard.id), + code: String(desksYard.code ?? ""), + label: String(desksYard.label ?? ""), + } + : null + } + /> + void; + yard: { id: string; code: string; label: string } | null; + /** Read-only when the caller lacks the yards update permission. */ + readOnly?: boolean; +} + +const positionLabel = ( + name: { am?: string; en?: string } | null, + fallback: string, +) => name?.en?.trim() || name?.am?.trim() || fallback; + +/** + * Which desks staff a yard — the input to yard access scoping. + * + * Saving REPLACES the yard's whole set (the API's PUT is a replace), which is + * why the control is a multi-select holding the complete list rather than + * add/remove buttons. + */ +export function YardDesksModal({ + opened, + onClose, + yard, + readOnly = false, +}: YardDesksModalProps) { + const queryClient = useQueryClient(); + const [selected, setSelected] = useState([]); + + const positions = useQuery({ + queryKey: ["yard-positions", "positions"], + queryFn: yardPositionsService.listPositions, + enabled: opened, + staleTime: 5 * 60 * 1000, + }); + + const mapping = useQuery({ + queryKey: ["yard-positions", "yard", yard?.id], + queryFn: () => yardPositionsService.listByYard(yard!.id), + enabled: opened && !!yard?.id, + }); + + // Reset to what the server holds whenever the modal opens on a new yard, so a + // cancelled edit never leaks into the next one. + useEffect(() => { + if (mapping.data) setSelected(mapping.data.map((row) => row.positionId)); + }, [mapping.data]); + + const save = useMutation({ + mutationFn: () => yardPositionsService.setForYard(yard!.id, selected), + onSuccess: () => { + toast.success("Yard desks updated"); + queryClient.invalidateQueries({ queryKey: ["yard-positions"] }); + onClose(); + }, + onError: (error) => + toast.error(extractErrorMessage(error, "Failed to update yard desks")), + }); + + const options = (positions.data ?? []).map((position) => ({ + value: position.id, + label: positionLabel(position.name, position.id.slice(0, 8)), + })); + + return ( + + + + + Positions mapped here are the desks that work at this yard. Yard + access scoping reads this mapping — a staff member acting on this + desk is scoped to this yard. + + + + {positions.isLoading || mapping.isLoading ? ( + + + + ) : ( + + )} + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/yardPositions.service.ts b/apps/edr-freight-web/backoffice/src/services/yardPositions.service.ts new file mode 100644 index 000000000..db8984827 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/yardPositions.service.ts @@ -0,0 +1,64 @@ +import { api as apiClient } from "../auth/http"; + +// NOTE: `auth/http`'s response interceptor already unwraps the API's +// `{ success, data }` envelope, so `response.data` IS the payload here — a +// second `.data` hop reads undefined and silently yields an empty list. + +/** A desk mapped to a yard, joined to its IAM position for display. */ +export interface YardPositionRow { + id: string; + yardId: string; + yardCode: string; + yardLabel: string; + positionId: string; + positionName: { am?: string; en?: string } | null; + positionTypeKey: string | null; +} + +export interface SelectablePosition { + id: string; + name: { am?: string; en?: string } | null; + positionTypeKey: string | null; + unitKey: string | null; +} + +export interface MyYardScope { + /** null = unrestricted (super admin or `yards:view_all`). */ + yardIds: string[] | null; + unrestricted: boolean; + /** False while the backend is still shadow-logging instead of denying. */ + enforced: boolean; +} + +export const yardPositionsService = { + listByYard: async (yardId: string): Promise => { + const { data } = await apiClient.get(`/yard-positions`, { + params: { yardId }, + }); + return data ?? []; + }, + + listPositions: async (): Promise => { + const { data } = await apiClient.get(`/yard-positions/positions`); + return data ?? []; + }, + + myScope: async (): Promise => { + const { data } = await apiClient.get(`/yard-positions/my-yards`); + return data; + }, + + /** + * Replaces the yard's whole desk set — send every position that should remain + * mapped, not just the additions. + */ + setForYard: async ( + yardId: string, + positionIds: string[], + ): Promise => { + const { data } = await apiClient.put(`/yard-positions/yard/${yardId}`, { + positionIds, + }); + return data ?? []; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx index 127806334..4e8f30561 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx @@ -1,250 +1,258 @@ -import { Link, useLocation } from "react-router-dom"; -import { - Archive, - BarChart, - Building2, - ChartAreaIcon, - ClipboardList, - FileText, - Globe, - Settings, - Users2, - UsersRound, -} from "lucide-react"; -import { useTranslation } from "react-i18next"; - -import { useAuth } from "@/shared/context/AuthContext"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/shared/common/ui/sidebar"; - -export interface MenuItem { - label: string; - href: string; - icon: React.ReactNode; - roles?: string[]; - /** Sidebar section this item is bucketed under. */ - group: string; -} - -// Section render order; groups with no role-visible items are skipped. -const GROUP_ORDER = [ - "Overview", - "Organizations", - "Content", - "Records", - "Configuration", - "Archive", - "System", -]; - -export const AppMenuTabs = () => { - const { user } = useAuth(); - const { pathname } = useLocation(); - const { setOpenMobile } = useSidebar(); - const { t } = useTranslation(); - - const userRoles = user?.roles.map((role) => role.key) || []; - - const menuItems: MenuItem[] = [ - { - label: "dashboard", - href: "/user-management/dashboard", - icon: , - roles: ["super_admin"], - group: "Overview", - }, - { - label: "organizations", - href: "/user-management/organizations", - icon: , - roles: ["super_admin"], - group: "Organizations", - }, - { - label: "organizationAdmins", - href: "/user-management/organization_admins", - icon: , - roles: ["super_admin"], - group: "Organizations", - }, - { - label: "externalUsers", - href: "/user-management/external_users", - icon: , - roles: ["super_admin"], - group: "Organizations", - }, - { - label: "dashboard", - href: "/user-management/user_management-dashboard", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Overview", - }, - { - label: "userManagement", - href: "/user-management/user_management", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Overview", - }, - { - label: "contentManagement", - href: "/user-management/content-management", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Content", - }, - { - label: "webManagement", - href: "/user-management/web-management", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Content", - }, - { - label: "Bulk", - href: "/user-management/bulk-upload", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Content", - }, - { - label: "Position", - href: "/user-management/position-management", - icon: , - roles: ["admin", "organization_admin", "unit_admin", "super_admin"], - group: "Configuration", - }, - { - label: "settings", - href: "/user-management/organization-settings", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Configuration", - }, - { - label: "Add Site", - href: "/user-management/add-site", - icon: , - roles: ["super_admin"], - group: "Configuration", - }, - { - label: "migratedRecords", - href: "/user-management/migrated-records-management", - icon: , - roles: ["super_admin"], - group: "Records", - }, - { - label: "Sector Reports", - href: "/user-management/sector-reports", - icon: , - roles: ["unit_admin", "admin", "organization_admin"], - group: "Records", - }, - { - label: "Archive Users", - href: "/user-management/archive-users", - icon: , - roles: ["super_admin"], - group: "Archive", - }, - { - label: "Archived Organizations", - href: "/user-management/archived-organizations", - icon: , - roles: ["super_admin"], - group: "Archive", - }, - { - label: "Archive Users", - href: "/user-management/archives", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Archive", - }, - { - label: "Archived Units & Positions", - href: "/user-management/archived", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - group: "Archive", - }, - { - label: "activityLog", - href: "/user-management/activity_log", - icon: , - roles: ["super_admin"], - group: "System", - }, - { - label: "setting", - href: "/user-management/settings", - icon: , - roles: ["super_admin"], - group: "System", - }, - { - label: "Letter Template", - href: "/user-management/templates", - icon: , - roles: ["super_admin"], - group: "System", - }, - ]; - - const filteredMenu = menuItems.filter((item) => - item.roles?.some((r) => userRoles.includes(r)), - ); - - const isActive = (href: string) => - pathname === href || pathname.startsWith(`${href}/`); - - return ( - <> - {GROUP_ORDER.map((group) => { - const items = filteredMenu.filter((item) => item.group === group); - if (items.length === 0) return null; - - return ( - - {group} - - - {items.map((item) => { - const label = t(`organization.${item.label}`, item.label); - return ( - - - setOpenMobile(false)} - > - {item.icon} - {label} - - - - ); - })} - - - - ); - })} - - ); -}; +import { Link, useLocation } from "react-router-dom"; +import { + Archive, + BarChart, + Building2, + ChartAreaIcon, + ClipboardList, + FileText, + Globe, + MapPin, + Settings, + Users2, + UsersRound, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { useAuth } from "@/shared/context/AuthContext"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/shared/common/ui/sidebar"; + +export interface MenuItem { + label: string; + href: string; + icon: React.ReactNode; + roles?: string[]; + /** Sidebar section this item is bucketed under. */ + group: string; +} + +// Section render order; groups with no role-visible items are skipped. +const GROUP_ORDER = [ + "Overview", + "Organizations", + "Content", + "Records", + "Configuration", + "Archive", + "System", +]; + +export const AppMenuTabs = () => { + const { user } = useAuth(); + const { pathname } = useLocation(); + const { setOpenMobile } = useSidebar(); + const { t } = useTranslation(); + + const userRoles = user?.roles.map((role) => role.key) || []; + + const menuItems: MenuItem[] = [ + { + label: "dashboard", + href: "/user-management/dashboard", + icon: , + roles: ["super_admin"], + group: "Overview", + }, + { + label: "organizations", + href: "/user-management/organizations", + icon: , + roles: ["super_admin"], + group: "Organizations", + }, + { + label: "organizationAdmins", + href: "/user-management/organization_admins", + icon: , + roles: ["super_admin"], + group: "Organizations", + }, + { + label: "externalUsers", + href: "/user-management/external_users", + icon: , + roles: ["super_admin"], + group: "Organizations", + }, + { + label: "dashboard", + href: "/user-management/user_management-dashboard", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Overview", + }, + { + label: "userManagement", + href: "/user-management/user_management", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Overview", + }, + { + label: "contentManagement", + href: "/user-management/content-management", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Content", + }, + { + label: "webManagement", + href: "/user-management/web-management", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Content", + }, + { + label: "Bulk", + href: "/user-management/bulk-upload", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Content", + }, + { + label: "Position", + href: "/user-management/position-management", + icon: , + roles: ["admin", "organization_admin", "unit_admin", "super_admin"], + group: "Configuration", + }, + { + label: "Locations", + href: "/user-management/locations", + icon: , + roles: ["admin", "organization_admin", "unit_admin", "super_admin"], + group: "Configuration", + }, + { + label: "settings", + href: "/user-management/organization-settings", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Configuration", + }, + { + label: "Add Site", + href: "/user-management/add-site", + icon: , + roles: ["super_admin"], + group: "Configuration", + }, + { + label: "migratedRecords", + href: "/user-management/migrated-records-management", + icon: , + roles: ["super_admin"], + group: "Records", + }, + { + label: "Sector Reports", + href: "/user-management/sector-reports", + icon: , + roles: ["unit_admin", "admin", "organization_admin"], + group: "Records", + }, + { + label: "Archive Users", + href: "/user-management/archive-users", + icon: , + roles: ["super_admin"], + group: "Archive", + }, + { + label: "Archived Organizations", + href: "/user-management/archived-organizations", + icon: , + roles: ["super_admin"], + group: "Archive", + }, + { + label: "Archive Users", + href: "/user-management/archives", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Archive", + }, + { + label: "Archived Units & Positions", + href: "/user-management/archived", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Archive", + }, + { + label: "activityLog", + href: "/user-management/activity_log", + icon: , + roles: ["super_admin"], + group: "System", + }, + { + label: "setting", + href: "/user-management/settings", + icon: , + roles: ["super_admin"], + group: "System", + }, + { + label: "Letter Template", + href: "/user-management/templates", + icon: , + roles: ["super_admin"], + group: "System", + }, + ]; + + const filteredMenu = menuItems.filter((item) => + item.roles?.some((r) => userRoles.includes(r)), + ); + + const isActive = (href: string) => + pathname === href || pathname.startsWith(`${href}/`); + + return ( + <> + {GROUP_ORDER.map((group) => { + const items = filteredMenu.filter((item) => item.group === group); + if (items.length === 0) return null; + + return ( + + {group} + + + {items.map((item) => { + const label = t(`organization.${item.label}`, item.label); + return ( + + + setOpenMobile(false)} + > + {item.icon} + {label} + + + + ); + })} + + + + ); + })} + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/location-management/LocationForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/location-management/LocationForm.tsx new file mode 100644 index 000000000..aeb03cd71 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/user-management/components/location-management/LocationForm.tsx @@ -0,0 +1,365 @@ +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + APIProvider, + Map as GoogleMap, + Marker, + type MapMouseEvent, +} from "@vis.gl/react-google-maps"; + +import { Button } from "@/shared/common/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shared/common/ui/form"; +import { Input } from "@/shared/common/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { Textarea } from "@/shared/common/ui/textarea"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import type { + Location, + LocationPayload, + LocationType, +} from "@/user-management/dto/locations/location.type"; +import { useLocations } from "@/user-management/hooks/useLocations"; + +const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim(); +/** Addis Ababa — where every EDR location is within a map pan. */ +const DEFAULT_CENTER = { lat: 9.032, lng: 38.7469 }; + +const NO_PARENT = "__none__"; + +const numeric = (label: string) => + z + .string() + .trim() + .optional() + .refine((v) => !v || !Number.isNaN(Number(v)), `${label} must be a number`); + +const locationSchema = z.object({ + nameAm: z.string().trim().min(1, "Amharic name is required"), + nameEn: z.string().trim().optional(), + code: z.string().trim().min(1, "Code is required"), + locationTypeId: z.string().uuid("Location type is required"), + parentId: z.string().optional(), + latitude: numeric("Latitude"), + longitude: numeric("Longitude"), + area: numeric("Area"), + boundaryJson: z + .string() + .trim() + .optional() + .refine((v) => { + if (!v) return true; + try { + const parsed = JSON.parse(v); + return typeof parsed === "object" && parsed !== null; + } catch { + return false; + } + }, "Boundary must be a JSON object"), +}); + +export type LocationFormValues = z.infer; + +interface LocationFormProps { + mode: "create" | "edit"; + location?: Location; + locationTypes: LocationType[]; + /** Every location, for the parent picker — the API has no filter endpoint. */ + allLocations: Location[]; + onSuccess?: () => void; +} + +export function LocationForm({ + mode, + location, + locationTypes, + allLocations, + onSuccess, +}: LocationFormProps) { + const localizedName = useLocalizedName(); + const { createLocation, updateLocation, isCreatingLocation, isUpdatingLocation } = + useLocations(); + + const form = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: { + nameAm: location?.names?.am ?? "", + nameEn: location?.names?.en ?? "", + code: location?.code ?? "", + locationTypeId: location?.locationTypeId ?? "", + parentId: location?.parentId ?? NO_PARENT, + latitude: location?.latitude ?? "", + longitude: location?.longitude ?? "", + area: location?.area ?? "", + boundaryJson: location?.boundaryJson + ? JSON.stringify(location.boundaryJson, null, 2) + : "", + }, + }); + + const [lat, lng] = [form.watch("latitude"), form.watch("longitude")]; + const pin = + lat && lng && !Number.isNaN(Number(lat)) && !Number.isNaN(Number(lng)) + ? { lat: Number(lat), lng: Number(lng) } + : null; + + const dropPin = (event: MapMouseEvent) => { + const point = event.detail.latLng; + if (!point) return; + form.setValue("latitude", point.lat.toFixed(6), { shouldDirty: true }); + form.setValue("longitude", point.lng.toFixed(6), { shouldDirty: true }); + }; + + // ponytail: self only, not descendants — the API accepts any parentId, so a + // deep cycle (A → B → A) is still possible. Walk the chain here if it bites. + const parentOptions = allLocations.filter((item) => item.id !== location?.id); + + const submit = (values: LocationFormValues) => { + const payload: LocationPayload = { + names: { + am: values.nameAm, + ...(values.nameEn ? { en: values.nameEn } : {}), + }, + code: values.code, + locationTypeId: values.locationTypeId, + parentId: + values.parentId && values.parentId !== NO_PARENT + ? values.parentId + : undefined, + latitude: values.latitude || undefined, + longitude: values.longitude || undefined, + area: values.area || undefined, + boundaryJson: values.boundaryJson + ? (JSON.parse(values.boundaryJson) as Record) + : undefined, + }; + + if (mode === "create") { + createLocation(payload, { + onSuccess: () => { + form.reset(); + onSuccess?.(); + }, + }); + return; + } + if (location) { + updateLocation( + { id: location.id, payload }, + { onSuccess: () => onSuccess?.() }, + ); + } + }; + + return ( +
+ +
+ ( + + Amharic Name * + + + + + + )} + /> + ( + + English Name + + + + + + )} + /> + ( + + Code * + + + + + + )} + /> + ( + + Location Type * + + + + )} + /> + ( + + Parent Location + + + + )} + /> +
+ +
+ Coordinates + {GOOGLE_MAPS_API_KEY ? ( +
+ + + {pin ? : null} + + +
+ ) : ( + // Name the missing variable rather than rendering a dead grey box. +

+ Map picker unavailable — VITE_GOOGLE_MAPS_API_KEY is + not set. Type the coordinates below instead. +

+ )} + {GOOGLE_MAPS_API_KEY ? ( +

+ Click the map to drop a pin, or type the values. +

+ ) : null} +
+ +
+ ( + + Latitude + + + + + + )} + /> + ( + + Longitude + + + + + + )} + /> + ( + + Area + + + + + + )} + /> +
+ + ( + + Boundary (GeoJSON) + +