Files
lcbp3/specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md
admin da8579d21b
CI / CD Pipeline / build (push) Successful in 5m11s
CI / CD Pipeline / deploy (push) Failing after 4m28s
690328:1106 Fixing Refactor uuid by Kimi #01
2026-03-28 11:06:25 +07:00

13 KiB

Implementation Plan: Hybrid UUID Strategy (ADR-019)

Version: 1.8.1 Created: 2026-03-16 Related ADR: ADR-019


Overview

This document outlines the step-by-step implementation plan to integrate UUIDv7 public identifiers into the LCBP3-DMS backend, following the hybrid strategy defined in ADR-019.

Scope: 14 public-facing tables now have uuid UUID columns (MariaDB native type, stored as BINARY(16) internally) in the schema. This plan covers backend code changes to expose UUIDs through the API while keeping INT PKs for internal operations.


Phase 1: Database Foundation ( COMPLETED — 2026-03-16)

  • Create ADR-019 document
  • Add uuid UUID columns (MariaDB native type) to 14 public-facing tables in schema SQL
  • Add UNIQUE INDEX on each uuid column
  • Update data dictionary with uuid column documentation
  • Update AGENTS.md with ADR-019 reference

Affected Tables (14)

# Table PK Column UUID Index
1 organizations id idx_organizations_uuid
2 projects id idx_projects_uuid
3 contracts id idx_contracts_uuid
4 users user_id idx_users_uuid
5 correspondences id idx_correspondences_uuid
6 correspondence_revisions id idx_correspondence_revisions_uuid
7 circulations id idx_circulations_uuid
8 shop_drawings id idx_shop_drawings_uuid
9 shop_drawing_revisions id idx_shop_drawing_revisions_uuid
10 contract_drawings id idx_contract_drawings_uuid
11 asbuilt_drawings id idx_asbuilt_drawings_uuid
12 asbuilt_drawing_revisions id idx_asbuilt_drawing_revisions_uuid
13 attachments id idx_attachments_uuid
14 notifications id idx_notifications_uuid

Excluded Tables (Shared-PK / Junction — inherit UUID from parent)

  • rfas — shared PK with correspondences
  • rfa_revisions — shared PK with correspondence_revisions
  • transmittals — shared PK with correspondences
  • rfa_items — junction table (composite PK, no own identity)

Phase 2: Backend — TypeORM Base Entity & UUID Utilities ( COMPLETED)

Simplified by MariaDB Native UUID Type: MariaDB 10.7+ stores UUID as BINARY(16) internally but auto-converts to/from string format. No manual binary conversion utilities or TypeORM transformers needed.

2.1 Create Base Entity with UUID

File: backend/src/common/entities/uuid-base.entity.ts

import { Column, BeforeInsert } from 'typeorm';
import { v7 as uuidv7 } from 'uuid';

export abstract class UuidBaseEntity {
  @Column({
    type: 'uuid',
    unique: true,
    nullable: false,
    comment: 'UUID Public Identifier (ADR-019)',
  })
  uuid: string;

  @BeforeInsert()
  generateUuid(): void {
    if (!this.uuid) {
      this.uuid = uuidv7();
    }
  }
}

Note: MariaDB native UUID type handles string ↔ binary conversion automatically. TypeORM reads/writes UUID as standard string format (8-4-4-4-12) — no transformer required. DB DEFAULT UUID() generates UUID v1 as fallback; app generates UUIDv7 via @BeforeInsert().

2.2 Install uuid Package

cd backend
npm install uuid
npm install -D @types/uuid

Phase 3: Backend — Update Existing Entities ( COMPLETED)

For each of the 14 public-facing entities, extend or mix in the UUID column:

Pattern: Extend UuidBaseEntity

// Example: correspondence.entity.ts
import { Entity, PrimaryGeneratedColumn } from 'typeorm';
import { UuidBaseEntity } from '../../common/entities/uuid-base.entity';

@Entity('correspondences')
export class Correspondence extends UuidBaseEntity {
  @PrimaryGeneratedColumn()
  id: number;

  // ... existing columns (uuid + @BeforeInsert inherited from UuidBaseEntity)
}

Entities to Update

Entity File Table
organization.entity.ts organizations
project.entity.ts projects
contract.entity.ts contracts
user.entity.ts users
correspondence.entity.ts correspondences
correspondence-revision.entity.ts correspondence_revisions
circulation.entity.ts circulations
shop-drawing.entity.ts shop_drawings
shop-drawing-revision.entity.ts shop_drawing_revisions
contract-drawing.entity.ts contract_drawings
asbuilt-drawing.entity.ts asbuilt_drawings
asbuilt-drawing-revision.entity.ts asbuilt_drawing_revisions
attachment.entity.ts attachments
notification.entity.ts notifications

Phase 4: Backend — API Layer Changes ( COMPLETED)

4.1 UUID Pipe (Parameter Validation)

File: backend/src/common/pipes/parse-uuid.pipe.ts

import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { validate as uuidValidate, version as uuidVersion } from 'uuid';

@Injectable()
export class ParseUuidPipe implements PipeTransform<string> {
  transform(value: string): string {
    if (!uuidValidate(value) || uuidVersion(value) !== 7) {
      throw new BadRequestException(`Invalid UUID: ${value}`);
    }
    return value;
  }
}

4.2 Controller Pattern — UUID in URLs

// BEFORE (INT):  GET /api/correspondences/123
// AFTER (UUID):  GET /api/correspondences/01912345-6789-7abc-...

@Get(':uuid')
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
  return this.service.findByUuid(uuid);
}

4.3 Service Pattern — Internal UUID Lookup

async findByUuid(uuid: string): Promise<CorrespondenceDto> {
  const entity = await this.repository.findOne({
    where: { uuid },
    relations: ['revisions', 'recipients'],
  });
  if (!entity) throw new NotFoundException();
  return this.mapToDto(entity);
}

4.4 DTO Pattern — UUID Exposure

// Response DTO exposes uuid, hides id
export class CorrespondenceResponseDto {
  uuid: string; // ✅ Public identifier
  correspondenceNumber: string;
  // id: number;             // ❌ Never expose INT id
}

4.5 Migration Helper — findByUuidOrId

During transition, support both identifiers:

async findByUuidOrId(identifier: string): Promise<Entity> {
  const isUuid = uuidValidate(identifier);
  if (isUuid) {
    return this.repository.findOne({ where: { uuid: identifier } });
  }
  // Fallback to INT (internal/admin use only)
  const id = parseInt(identifier, 10);
  if (isNaN(id)) throw new BadRequestException();
  return this.repository.findOne({ where: { id } });
}

Phase 5: Frontend — UUID Integration (🔄 PARTIAL — see 5.4)

5.1 API Client Updates ( COMPLETED)

  • Update all API calls to use UUID in URL paths instead of INT id
  • Update TanStack Query cache keys to use UUID
  • Service functions renamed getByIdgetByUuid (12 services)
  • Hooks updated with UUID-based cache keys and mutation params

5.2 Route Parameters ( COMPLETED)

// BEFORE: /correspondences/[id]
// AFTER:  /correspondences/[uuid]
  • /correspondences/[uuid], /circulation/[uuid], /drawings/[uuid] migrated
  • /rfas/[id] and /transmittals/[id] — NOT migrated (separate feature scope)

5.3 Form Handling ( PARTIAL)

  • Drawing search: projectUuid sent to backend (resolved in controller)
  • Drawing detail page: UUID-based service calls replace mock API
  • Correspondence form: still sends parseInt(projectId) — see 5.4
  • User dialog: still sends parseInt(orgId) — see 5.4

5.4 Remaining: FK Reference UUID Migration ( PENDING)

Root Cause: Backend Create/Update DTOs still accept integer FK IDs (e.g., projectId, fromOrganizationId), but the API no longer returns integer IDs in responses (stripped by @Exclude() + instanceToPlain() in TransformInterceptor). Frontend forms that use parseInt() on Select values break because the values are either UUID strings or undefined.

Pattern: Drawing Search ( FIXED — reference implementation)

  • Backend DTO accepts projectPublicId: string instead of projectId: number
  • Controller resolves: projectService.findOneByUuid(dto.projectPublicId)dto.projectId = project.id
  • Frontend sends UUID string directly (no parseInt)
  • Frontend Type uses publicId only:
    type ProjectOption = {
      publicId?: string;
      projectName?: string;
    };
    

Remaining Issues (Updated Naming Convention)

File Field Entity Issue
correspondences/form.tsx projectPublicId Project Type uses id instead of publicId
correspondences/form.tsx fromOrganizationPublicId Organization Type uses uuid/id instead of publicId
correspondences/form.tsx toOrganizationPublicId Organization Type uses uuid/id instead of publicId
admin/users/page.tsx primaryOrganizationPublicId (filter) Organization Type uses id instead of publicId
admin/user-dialog.tsx primaryOrganizationPublicId Organization Type uses id instead of publicId
numbering/template-tester.tsx originatorOrganizationPublicId / recipientOrganizationPublicId Organization Type uses id instead of publicId
rfas/page.tsx projectPublicId (URL param) Project Type uses id instead of publicId
rfas/form.tsx projectPublicId, contractPublicId, toOrganizationPublicId Multiple FIXED — Now uses publicId exclusively

Fix Applied: rfas/form.tsx standardized to use publicId only (2026-03-28)

Fix Strategy (same pattern as Drawing Search fix)

For each affected backend DTO:

  1. Add projectUuid?: string / organizationUuid?: string field
  2. Controller resolves UUID → INT id via respective service's findOneByUuid()
  3. Frontend sends UUID string directly (remove parseInt)

Estimated Effort: M (2-3 days) — requires backend DTO changes for Correspondence, User, Numbering modules


Phase 6: Testing & Verification

6.1 Unit Tests

  • UUID generation produces valid UUIDv7
  • UuidBaseEntity @BeforeInsert() auto-generates UUID when not provided
  • ParseUuidPipe rejects invalid UUIDs
  • MariaDB native UUID column stores and retrieves string format correctly

6.2 Integration Tests

  • Entity creation auto-generates UUID
  • API endpoints accept UUID parameters
  • UUID lookup returns correct records
  • Duplicate UUID detection (unique constraint)

6.3 Performance Verification

  • Benchmark: UUID lookup via UNIQUE INDEX vs INT PK lookup
  • Acceptable threshold: < 2x overhead on single-row lookups
  • Verify B-tree ordering with time-sorted UUIDv7

Implementation Order (Priority)

Order Task Effort Status
1 UuidBaseEntity (no transformer needed — MariaDB native UUID) S Done

Estimated Remaining Effort: ~2-3 days for FK migration + ~2 days for tests


Rollback Strategy

If issues arise:

  1. Schema: UUID columns have DEFAULT — existing inserts still work without app changes
  2. API: INT-based endpoints can be restored by reverting controller/service changes
  3. Data: No data loss — UUID column is additive (no existing columns modified)
  4. Frontend: Route parameter changes are reversible

Notes

  • Seed files do not need UUID values — the DEFAULT UUID() clause auto-generates UUIDs at INSERT time
  • Notifications table uses a non-unique INDEX (not UNIQUE) for uuid because of its partitioned composite PK (id, created_at)
  • Workflow engine tables (workflow_instances, workflow_tasks) already use CHAR(36) UUIDs — no changes needed
  • Shared-PK tables (rfas, rfa_revisions, transmittals) inherit their parent's UUID via the correspondence relationship