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 <noreply@anthropic.com>
This commit is contained in:
Marshal
2026-07-30 09:25:02 +00:00
parent 9241ce01d2
commit b671f07ce6
5 changed files with 125 additions and 0 deletions

View File

@@ -31,6 +31,9 @@ export class YardsService {
/** Create a yard. */
async create(dto: CreateYardDto): Promise<Yard> {
// 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<Yard> {
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<void> {
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 `@<epoch-ms>`
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the