From b671f07ce607d09a3c6178434a538f010fa81539 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 30 Jul 2026 09:25:02 +0000 Subject: [PATCH] fix(rule-engine): merge duplicate Sebeta yards, block dupes Two active Sebeta yards (LEGACY_DEST/'Sebeta' and SEBETA/'sebeta') split rates and routes across different yard ids, so route-scoped rate lookups missed. Migration repoints every yard reference to the survivor, retires the duplicate, and adds partial unique indexes on active label and code. Service now rejects case-insensitive duplicate labels on create/update - the old guard only compared generated codes, which missed labels whose existing code differs (LEGACY_DEST). Co-Authored-By: Claude Fable 5 --- ...3050000000000-MergeDuplicateSebetaYards.ts | 67 +++++++++++++++++++ .../interfaces/yards.repository.interface.ts | 1 + .../repositories/yards.repository.ts | 9 +++ .../services/yards.duplicate-label.spec.ts | 36 ++++++++++ .../rule-engine/services/yards.service.ts | 12 ++++ 5 files changed, 125 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts diff --git a/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts b/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts new file mode 100644 index 000000000..c8a2c5722 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Two active "Sebeta" yards existed (code LEGACY_DEST label "Sebeta", and code + * SEBETA label "sebeta") — rates and routes pointed at one or the other, so a + * rate configured against one never matched a contract routed via the other. + * Merge them: keep the row all rates/distances/facilities reference + * (LEGACY_DEST), repoint every yard reference from the duplicate to it, retire + * the duplicate, and give the survivor the clean SEBETA code. Then make + * duplicate active yard labels/codes impossible at the DB level. + */ +export class MergeDuplicateSebetaYards3050000000000 implements MigrationInterface { + name = "MergeDuplicateSebetaYards3050000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + DECLARE + survivor uuid; + dupe uuid; + col record; + BEGIN + SELECT id INTO survivor FROM freight.yards + WHERE code = 'LEGACY_DEST' AND lower(trim(label)) = 'sebeta' AND deleted_at IS NULL; + SELECT id INTO dupe FROM freight.yards + WHERE code = 'SEBETA' AND deleted_at IS NULL; + IF survivor IS NULL OR dupe IS NULL OR survivor = dupe THEN + RETURN; + END IF; + + -- Every yard-referencing column in the schema, so rows created between + -- authoring and running this migration are repointed too. + FOR col IN + SELECT table_name, column_name FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name <> 'yards' + AND (column_name LIKE '%yard_id%' OR column_name LIKE '%station_id%') + LOOP + EXECUTE format( + 'UPDATE freight.%I SET %I = $1 WHERE %I = $2', + col.table_name, col.column_name, col.column_name + ) USING survivor, dupe; + END LOOP; + + UPDATE freight.yards + SET code = 'SEBETA@merged', label = 'sebeta@merged', deleted_at = now() + WHERE id = dupe; + UPDATE freight.yards SET code = 'SEBETA', label = 'Sebeta' WHERE id = survivor; + END $$; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_label_active" + ON freight.yards (lower(trim(label))) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_code_active" + ON freight.yards (lower(trim(code))) WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Data repair — not reversible. The uniqueness indexes are the new invariant. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_label_active"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_code_active"`); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts index 75baca5de..104603c6d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts @@ -6,6 +6,7 @@ import { Yard } from '../entities/yard.entity'; export interface IYardsRepository { findById(id: string): Promise; findByCode(code: string): Promise; + findByLabelInsensitive(label: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Yard[], number]>; findPaged(query: ListYardsQueryDto): Promise>; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts index 1cb74d9ce..5db5b72ae 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts @@ -22,6 +22,15 @@ export class YardsRepository implements IYardsRepository { return this.repo.findOne({ where: { code } }); } + /** Case/whitespace-insensitive label lookup — backs the duplicate-yard guard. */ + findByLabelInsensitive(label: string): Promise { + return this.repo + .createQueryBuilder('yard') + .where('LOWER(TRIM(yard.label)) = LOWER(TRIM(:label))', { label }) + .andWhere('yard.deleted_at IS NULL') + .getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts new file mode 100644 index 000000000..8affab199 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts @@ -0,0 +1,36 @@ +import { ConflictException } from '@nestjs/common'; + +import { YardsService } from './yards.service'; +import type { Yard } from '../entities/yard.entity'; + +const sebeta = { id: 'yard-1', code: 'LEGACY_DEST', label: 'Sebeta' } as Yard; + +const service = (): YardsService => + new YardsService( + { + findById: async (id: string) => ({ ...sebeta, id }), + findByCode: async () => null, + findByLabelInsensitive: async (label: string) => + label.trim().toLowerCase() === 'sebeta' ? sebeta : null, + create: async (d: Partial) => d as Yard, + update: async (_id: string, d: Partial) => d as Yard, + } as never, + { resolveCreateOrder: async () => 1 } as never, + ); + +describe('duplicate yard labels are rejected', () => { + it('blocks create even when the generated code differs (Sebeta vs LEGACY_DEST)', async () => { + await expect( + service().create({ label: ' sebeta ', country: 'ET' } as never), + ).rejects.toThrow(ConflictException); + }); + + it('blocks renaming a yard onto another yard label, allows renaming itself', async () => { + await expect( + service().update('yard-2', { label: 'SEBETA' } as never), + ).rejects.toThrow(ConflictException); + await expect( + service().update('yard-1', { label: 'Sebeta' } as never), + ).resolves.toBeTruthy(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index b698b456c..7dd29ded1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -31,6 +31,9 @@ export class YardsService { /** Create a yard. */ async create(dto: CreateYardDto): Promise { + // Label check first: the code check alone let "sebeta" in next to "Sebeta" + // when the existing yard's code didn't match its label (LEGACY_DEST). + await this.assertLabelAvailable(dto.label); const code = generateCode(dto.label).slice(0, 40); const existing = await this.repository.findByCode(code); if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`); @@ -53,11 +56,20 @@ export class YardsService { /** Update a yard. */ async update(id: string, dto: UpdateYardDto): Promise { await this.findById(id); + if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id); const updated = await this.repository.update(id, dto); if (!updated) throw new NotFoundException(`Yard ${id} not found`); return updated; } + /** No two active yards may share a label (case/whitespace-insensitive). */ + private async assertLabelAvailable(label: string, exceptId?: string): Promise { + const dupe = await this.repository.findByLabelInsensitive(label); + if (dupe && dupe.id !== exceptId) { + throw new ConflictException(`A yard named "${dupe.label}" already exists`); + } + } + /** * Soft-delete a yard. The unique `code` (and the label) get a `@` * suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the