mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
195 lines
6.8 KiB
TypeScript
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');
|
|
}
|
|
}
|
|
}
|