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'); } } }