This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
# 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)
|
||||
|
||||
- [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
|
||||
|
||||
> **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
|
||||
|
||||
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
|
||||
|
||||
### 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<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
|
||||
|
||||
```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<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
|
||||
|
||||
```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<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
|
||||
|
||||
### 5.1 API Client Updates
|
||||
|
||||
- Update all API calls to use UUID in URL paths instead of INT id
|
||||
- Update TanStack Query cache keys to use UUID
|
||||
- Update Zustand stores to key by UUID
|
||||
|
||||
### 5.2 Route Parameters
|
||||
|
||||
```typescript
|
||||
// BEFORE: /correspondences/[id]
|
||||
// AFTER: /correspondences/[uuid]
|
||||
```
|
||||
|
||||
### 5.3 Form Handling
|
||||
|
||||
- Hidden `uuid` field in forms for edit operations
|
||||
- No changes needed for create operations (UUID generated server-side)
|
||||
|
||||
---
|
||||
|
||||
## 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 | Depends On |
|
||||
|-------|------|--------|------------|
|
||||
| 1 | UuidBaseEntity (no transformer needed — MariaDB native UUID) | S | Phase 1 |
|
||||
| 2 | Install `uuid` package | XS | — |
|
||||
| 3 | Update 14 entity files with uuid column | M | Task 1 |
|
||||
| 4 | Create ParseUuidPipe | S | — |
|
||||
| 5 | Update controllers to use UUID params | L | Tasks 3, 4 |
|
||||
| 6 | Update services with findByUuid methods | L | Task 3 |
|
||||
| 7 | Update DTOs to expose uuid, hide id | M | Task 3 |
|
||||
| 8 | Update frontend API calls | L | Tasks 5, 6, 7 |
|
||||
| 9 | Update frontend routes | M | Task 8 |
|
||||
| 10 | Write unit + integration tests | M | Tasks 1-7 |
|
||||
ำ
|
||||
**Estimated Total Effort:** ~3-5 days for backend, ~2-3 days for frontend
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
Reference in New Issue
Block a user