Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts
2026-08-18 12:55:11 +00:00

195 lines
6.8 KiB
TypeScript

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<YardPositionRow[]> {
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<YardPositionRow[]> {
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<YardPositionRow[]> {
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<string[]> {
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<void> {
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<void> {
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<void> {
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');
}
}
}