# Implementation Plan: Hybrid UUID Strategy (ADR-019) **Version:** 1.8.1 **Created:** 2026-03-16 **Related ADR:** [ADR-019](../06-Decision-Records/ADR-019-hybrid-identifier-strategy.md) --- ## 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) - [x] Create ADR-019 document - [x] Add `uuid UUID` columns (MariaDB native type) to 14 public-facing tables in schema SQL - [x] Add UNIQUE INDEX on each uuid column - [x] Update data dictionary with uuid column documentation - [x] 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` ```typescript 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 ```bash 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 ```typescript // 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` ```typescript import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common'; import { validate as uuidValidate, version as uuidVersion } from 'uuid'; @Injectable() export class ParseUuidPipe implements PipeTransform { 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 ```typescript // 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 ```typescript async findByUuid(uuid: string): Promise { 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 ```typescript // 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: ```typescript async findByUuidOrId(identifier: string): Promise { 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) - [x] Update all API calls to use UUID in URL paths instead of INT id - [x] Update TanStack Query cache keys to use UUID - [x] Service functions renamed `getById` → `getByUuid` (12 services) - [x] Hooks updated with UUID-based cache keys and mutation params ### 5.2 Route Parameters (✅ COMPLETED) ```typescript // BEFORE: /correspondences/[id] // AFTER: /correspondences/[uuid] ``` - [x] `/correspondences/[uuid]`, `/circulation/[uuid]`, `/drawings/[uuid]` migrated - [ ] `/rfas/[id]` and `/transmittals/[id]` — NOT migrated (separate feature scope) ### 5.3 Form Handling (✅ PARTIAL) - [x] Drawing search: `projectUuid` sent to backend (resolved in controller) - [x] 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: ```typescript 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