12 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 UUIDcolumns (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 withcorrespondencesrfa_revisions— shared PK withcorrespondence_revisionstransmittals— shared PK withcorrespondencesrfa_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
UUIDtype handles string ↔ binary conversion automatically. TypeORM reads/writes UUID as standard string format (8-4-4-4-12) — no transformer required. DBDEFAULT 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
getById→getByUuid(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:
projectUuidsent 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()inTransformInterceptor). Frontend forms that useparseInt()on Select values break because the values are either UUID strings orundefined.
Pattern: Drawing Search (✅ FIXED — reference implementation)
- Backend DTO accepts
projectUuid: stringinstead ofprojectId: number - Controller resolves:
projectService.findOneByUuid(dto.projectUuid)→dto.projectId = project.id - Frontend sends UUID string directly (no
parseInt)
Remaining Issues
| File | Field | Entity | Issue |
|---|---|---|---|
correspondences/form.tsx:212 |
projectId |
Project | parseInt(p.id) where p.id = UUID string (garbled number) |
correspondences/form.tsx:326 |
fromOrganizationId |
Organization | parseInt(String(org.id)) where org.id = undefined (NaN) |
correspondences/form.tsx:349 |
toOrganizationId |
Organization | Same as above |
admin/users/page.tsx:47 |
primaryOrganizationId (filter) |
Organization | parseInt(selectedOrgId) where value = UUID string |
admin/user-dialog.tsx:226 |
primaryOrganizationId |
Organization | parseInt(val) where org.id = undefined → "0" fallback |
numbering/template-tester.tsx:71-74 |
originatorOrganizationId, recipientOrganizationId |
Organization | parseInt on org UUID |
rfas/page.tsx:17 |
projectId (URL param) |
Project | parseInt(searchParams.get('projectId')) — UUID if from URL |
Fix Strategy (same pattern as Drawing Search fix)
For each affected backend DTO:
- Add
projectUuid?: string/organizationUuid?: stringfield - Controller resolves UUID → INT id via respective service's
findOneByUuid() - 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 |
| 2 | Install uuid package |
XS | ✅ Done |
| 3 | Update 14 entity files with uuid column | M | ✅ Done |
| 4 | Create ParseUuidPipe | S | ✅ Done |
| 5 | Update controllers to use UUID params | L | ✅ Done |
| 6 | Update services with findByUuid methods | L | ✅ Done |
| 7 | Update DTOs to expose uuid, hide id | M | ✅ Done |
| 8 | Update frontend API calls & routes | L | ✅ Done |
| 9 | Drawing search: projectUuid migration | S | ✅ Done (2026-03-18) |
| 10 | FK reference UUID migration (Correspondence, User, Numbering) | M | ❌ Pending (see Phase 5.4) |
| 11 | Write unit + integration tests | M | ❌ Pending |
Estimated Remaining Effort: ~2-3 days for FK migration + ~2 days for tests
Rollback Strategy
If issues arise:
- Schema: UUID columns have
DEFAULT— existing inserts still work without app changes - API: INT-based endpoints can be restored by reverting controller/service changes
- Data: No data loss — UUID column is additive (no existing columns modified)
- 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 useCHAR(36)UUIDs — no changes needed - Shared-PK tables (
rfas,rfa_revisions,transmittals) inherit their parent's UUID via the correspondence relationship