690328:1106 Fixing Refactor uuid by Kimi #01
CI / CD Pipeline / build (push) Successful in 5m11s
CI / CD Pipeline / deploy (push) Failing after 4m28s

This commit is contained in:
2026-03-28 11:06:25 +07:00
parent 76b18e7c37
commit da8579d21b
24 changed files with 606 additions and 182 deletions
+81 -2
View File
@@ -89,7 +89,7 @@ Reference these guidelines when:
- `db-hybrid-identifier` - **CRITICAL** ADR-019: INT PK + UUID public API
- `db-avoid-n-plus-one` - HIGH N+1 query prevention
- `db-use-transactions` - HIGH Transaction management
- `db-use-migrations` - N/A **ADR-009**: No TypeORM migrations - use SQL files
- `db-no-typeorm-migrations` - **CRITICAL** ADR-009: No TypeORM migrations - use SQL files
### 8. API Design (MEDIUM)
@@ -110,7 +110,86 @@ Reference these guidelines when:
- `devops-use-logging` - Structured logging
- `devops-graceful-shutdown` - Zero-downtime deployments
## How to Use
## NAP-DMS Project-Specific Rules (MUST FOLLOW)
These rules override general NestJS best practices for the NAP-DMS project:
### ADR-009: No TypeORM Migrations
- **ห้ามสร้างไฟล์ migration ของ TypeORM**
- แก้ไข schema โดยตรงที่: `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`
- ใช้ n8n workflow สำหรับ data migration ถ้าจำเป็น
### ADR-019: Hybrid Identifier Strategy (CRITICAL)
```typescript
@Entity()
export class Project {
@PrimaryGeneratedColumn()
@Exclude() // ห้ามส่งออกทาง API
id: number; // INT AUTO_INCREMENT - internal only
@Column({ type: 'uuid' })
@Expose({ name: 'id' }) // ส่งออกเป็น 'id' ทาง API
publicId: string; // UUIDv7 - public API identifier
}
```
### Two-Phase File Upload
```typescript
// Phase 1: Upload to temp
@Post('upload')
async uploadFile(@UploadedFile() file: Express.Multer.File) {
await this.virusScan(file);
const tempId = await this.fileStorage.saveToTemp(file);
return { temp_id: tempId, expires_at: addHours(new Date(), 24) };
}
// Phase 2: Commit in transaction
async createEntity(dto: CreateDto, tempIds: string[]) {
return this.dataSource.transaction(async (manager) => {
const entity = await manager.save(Entity, dto);
await this.fileStorage.commitFiles(tempIds, entity.id, manager);
return entity;
});
}
```
### Idempotency Requirement
- ทุก POST/PUT/PATCH ที่สำคัญต้องมี `Idempotency-Key` header
- ใช้ `IdempotencyInterceptor` ที่มีอยู่แล้ว
### Document Numbering (Double-Lock)
```typescript
async generateNextNumber(context: NumberingContext): Promise<string> {
const lockKey = `doc_num:${context.projectId}:${context.typeId}`;
const lock = await this.redisLock.acquire(lockKey, 3000);
try {
const counter = await this.counterRepo.findOne({
where: context,
lock: { mode: 'optimistic' },
});
counter.last_number++;
return this.formatNumber(await this.counterRepo.save(counter));
} finally {
await lock.release();
}
}
```
### Anti-Patterns (ห้ามทำ)
- ❌ ใช้ SQL Triggers สำหรับ business logic
- ❌ ใช้ `.env` ใน production (ใช้ Docker ENV)
- ❌ ใช้ `any` type (strict mode enforced)
- ❌ ใช้ `console.log` (ใช้ NestJS Logger)
- ❌ สร้างตาราง routing แยก (ใช้ Workflow Engine)
---
Read individual rule files for detailed explanations and code examples:
+156 -1
View File
@@ -165,7 +165,162 @@ See [self-hosting.md](./self-hosting.md) for:
- Cache handlers for multi-instance ISR
- What works vs needs extra setup
## Debug Tricks
## NAP-DMS Project-Specific Rules (MUST FOLLOW)
These rules are mandatory for the NAP-DMS LCBP3 frontend project:
### State Management (บังคับใช้)
**Server State - TanStack Query (React Query)**
```tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// ❌ ห้ามใช้ useEffect โดยตรง
// ✅ ใช้ TanStack Query
export function useCorrespondences(projectId: string) {
return useQuery({
queryKey: ['correspondences', projectId],
queryFn: () => correspondenceService.getAll(projectId),
staleTime: 5 * 60 * 1000,
});
}
```
**Form State - React Hook Form + Zod**
```tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
const schema = z.object({
title: z.string().min(1, 'กรุณาระบุหัวเรื่อง'),
projectUuid: z.string().uuid('กรุณาเลือกโปรเจกต์'),
});
const form = useForm({
resolver: zodResolver(schema),
});
```
### ADR-019 UUID Handling (CRITICAL)
```tsx
// Interface ต้องมีทั้ง id และ publicId
interface Contract {
id?: number; // Internal (อาจ undefined)
publicId?: string; // UUID - ใช้ตัวนี้
contractCode: string;
}
// Select options - ใช้ pattern นี้เสมอ
const options = contracts.map((c) => ({
label: `${c.contractName} (${c.contractCode})`,
value: String(c.publicId ?? c.id ?? ''), // fallback pattern
key: String(c.publicId ?? c.id ?? ''),
}));
// ❌ ห้ามใช้ parseInt บน UUID
// const id = parseInt(projectId); // WRONG!
// ✅ ส่ง UUID string ตรงๆ
apiClient.get(`/projects/${projectId}`); // projectId is UUID string
```
### Naming Conventions
**Code Identifiers - ภาษาอังกฤษ**
```tsx
// ✅ Correct
interface Correspondence {
documentNumber: string;
createdAt: string;
}
// ❌ Wrong
interface {
เลขที่: string;
}
```
**Comments - ภาษาไทย**
```tsx
// ✅ Correct - อธิบาย logic เป็นภาษาไทย
// ตรวจสอบว่ามีการระบุ projectUuid หรือไม่
if (!data.projectUuid) {
throw new Error('กรุณาเลือกโปรเจกต์');
}
// ❌ Wrong - ห้ามใช้ภาษาอังกฤษใน comments
// Check if projectUuid is provided
```
### UI Components
**บังคับใช้ shadcn/ui**
```tsx
// ✅ Correct
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
// ❌ Wrong - ไม่สร้าง component เองถ้ามีใน shadcn
const MyButton = () => <button className="...">Click</button>;
```
### File Upload Pattern
```tsx
import { useDropzone } from 'react-dropzone';
// Two-phase upload
const onDrop = useCallback(async (files: File[]) => {
// Phase 1: Upload to temp
const tempFiles = await Promise.all(files.map((file) => uploadService.uploadTemp(file)));
setTempIds(tempFiles.map((f) => f.tempId));
}, []);
// Phase 2: Commit on form submit
const onSubmit = async (data: FormData) => {
await correspondenceService.create({
...data,
tempFileIds,
});
};
```
### API Client Setup
```typescript
// lib/api/client.ts
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 30000,
});
// Auto-add Idempotency-Key
apiClient.interceptors.request.use((config) => {
if (['post', 'put', 'patch'].includes(config.method?.toLowerCase() || '')) {
config.headers['Idempotency-Key'] = uuidv4();
}
return config;
});
```
### Anti-Patterns (ห้ามทำ)
- ❌ Fetch data ใน useEffect โดยตรง
- ❌ Props drilling ลึกเกิน 3 levels
- ❌ Inline styles (ใช้ Tailwind)
- ❌ console.log ใน production
- ❌ parseInt() บน UUID values
- ❌ ใช้ index เป็น key ใน list
- ❌ Snake_case ใน form field names (ใช้ camelCase)
---
See [debug-tricks.md](./debug-tricks.md) for:
+62
View File
@@ -0,0 +1,62 @@
---
auto_execution_mode: 0
description: Review code changes for bugs, security issues, and improvements
---
You are a senior software engineer performing a thorough code review to identify potential bugs.
Your task is to find all potential bugs and code improvements in the code changes. Focus on:
1. Logic errors and incorrect behavior
2. Edge cases that aren't handled
3. Null/undefined reference issues
4. Race conditions or concurrency issues
5. Security vulnerabilities
6. Improper resource management or resource leaks
7. API contract violations
8. Incorrect caching behavior, including cache staleness issues, cache key-related bugs, incorrect cache invalidation, and ineffective caching
9. Violations of existing code patterns or conventions
## 🔴 Tier 1 Critical Rules (CI Blockers)
The following are **CI-blocking issues** that must be caught in code review. These align with project specs in `specs/05-Engineering-Guidelines/` and `specs/06-Decision-Records/`:
### ADR-019: UUID Handling
- **❌ NEVER use `parseInt()`, `Number()`, or `+` operator on UUID values**
- Example of violation: `parseInt(projectId)` where `projectId` is UUID string
- ✅ Correct: Use UUID string directly without conversion
- **❌ NEVER expose internal INT PK in API responses**
- API must expose only `publicId` (transformed to `id` via `@Expose()`)
- Verify DTOs have `@Exclude()` on `id: number` field
### TypeScript Strict Rules
- **❌ ZERO `any` types allowed** — use proper types or `unknown` + narrowing
- **❌ ZERO `console.log`** — must use NestJS `Logger` (backend) or remove (frontend)
- **❌ NO `req: any` in controllers** — use `RequestWithUser` typed interface
### Database & Architecture
- **❌ NO SQL Triggers for business logic** — use NestJS Service methods instead
- **❌ NO `.env` files in production** — use Docker environment variables
- **❌ NO direct table/column name invention** — verify against `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`
### Security (ADR-016)
- Idempotency validation for critical `POST`/`PUT`/`PATCH` endpoints
- Two-phase file upload pattern (Upload → Temp → Commit → Permanent)
- Input validation with class-validator (backend) and Zod (frontend)
### Test Coverage Requirements
- **Backend Services:** 80% minimum
- **Backend Overall:** 70% minimum
- **Business Logic:** 80% minimum
Make sure to:
1. If exploring the codebase, call multiple tools in parallel for increased efficiency. Do not spend too much time exploring.
2. If you find any pre-existing bugs in the code, you should also report those since it's important for us to maintain general code quality for the user.
3. Do NOT report issues that are speculative or low-confidence. All your conclusions should be based on a complete understanding of the codebase.
4. Remember that if you were given a specific git commit, it may not be checked out and local code states may be different.
+28 -4
View File
@@ -88,8 +88,29 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
| ---------------- | ------------------------- | ------------------------------------------- |
| Internal / DB FK | `INT AUTO_INCREMENT` | Never exposed in API |
| Public API / URL | `UUIDv7` (MariaDB native) | Stored as BINARY(16), no transformer needed |
| Entity Property | `publicId: string` | Exposed via `@Expose({ name: 'id' })` |
| API Response | `id: string` (UUID) | INT `id` has `@Exclude()` — never appears |
| Entity Property | `publicId: string` | Exposed directly in API (no transformation) |
| API Response | `publicId: string` (UUID) | INT `id` has `@Exclude()` — never appears |
### ✅ Updated Pattern (March 2026)
**Backend:** `UuidBaseEntity` exposes `publicId` directly — no `@Expose({ name: 'id' })` transformation
**Frontend:** Use `publicId` only — no `uuid` or `id` fallbacks:
```typescript
// ✅ CORRECT — Use publicId only
type ProjectOption = {
publicId?: string; // No uuid, no id fallback
projectName?: string;
};
// ❌ WRONG — Multiple identifiers cause confusion
type ProjectOption = {
publicId?: string;
uuid?: string; // Don't do this
id?: number; // Don't do this
};
```
### ❌ Forbidden UUID Patterns
@@ -97,8 +118,11 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
// ❌ NEVER use parseInt on UUID
parseInt(projectId); // "0195..." → 19 (WRONG!)
// ✅ CORRECT — Use UUID string directly
const id = projectId; // "019505a1-7c3e-7000-8000-abc123def456"
// ❌ NEVER use id ?? '' fallback
const value = c.publicId ?? c.id ?? ''; // Wrong!
// ✅ CORRECT — Use publicId only
const value = c.publicId; // "019505a1-7c3e-7000-8000-abc123def456"
```
Read `specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md` before any UUID-related work.
+1 -1
View File
@@ -1,4 +1,4 @@
{
"editor.fontSize": 20,
"editor.fontSize": 16,
"npm.packageManager": "pnpm"
}
+37
View File
@@ -17,6 +17,43 @@ Your task is to find all potential bugs and code improvements in the code change
8. Incorrect caching behavior, including cache staleness issues, cache key-related bugs, incorrect cache invalidation, and ineffective caching
9. Violations of existing code patterns or conventions
## 🔴 Tier 1 Critical Rules (CI Blockers)
The following are **CI-blocking issues** that must be caught in code review. These align with project specs in `specs/05-Engineering-Guidelines/` and `specs/06-Decision-Records/`:
### ADR-019: UUID Handling
- **❌ NEVER use `parseInt()`, `Number()`, or `+` operator on UUID values**
- Example of violation: `parseInt(projectId)` where `projectId` is UUID string
- ✅ Correct: Use UUID string directly without conversion
- **❌ NEVER expose internal INT PK in API responses**
- API must expose only `publicId` (transformed to `id` via `@Expose()`)
- Verify DTOs have `@Exclude()` on `id: number` field
### TypeScript Strict Rules
- **❌ ZERO `any` types allowed** — use proper types or `unknown` + narrowing
- **❌ ZERO `console.log`** — must use NestJS `Logger` (backend) or remove (frontend)
- **❌ NO `req: any` in controllers** — use `RequestWithUser` typed interface
### Database & Architecture
- **❌ NO SQL Triggers for business logic** — use NestJS Service methods instead
- **❌ NO `.env` files in production** — use Docker environment variables
- **❌ NO direct table/column name invention** — verify against `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`
### Security (ADR-016)
- Idempotency validation for critical `POST`/`PUT`/`PATCH` endpoints
- Two-phase file upload pattern (Upload → Temp → Commit → Permanent)
- Input validation with class-validator (backend) and Zod (frontend)
### Test Coverage Requirements
- **Backend Services:** 80% minimum
- **Backend Overall:** 70% minimum
- **Business Logic:** 80% minimum
Make sure to:
1. If exploring the codebase, call multiple tools in parallel for increased efficiency. Do not spend too much time exploring.
+30 -6
View File
@@ -1,7 +1,7 @@
# NAP-DMS Project Context & Rules
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Amazon Q, AGENTS.md tools)
- Version: 1.8.5 (Refactored) | Last synced from repo: 2026-03-27
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
- Version: 1.8.5 (Refactored) | Last synced from repo: 2026-03-27
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
---
@@ -84,8 +84,29 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
| ---------------- | ------------------------- | ------------------------------------------- |
| Internal / DB FK | `INT AUTO_INCREMENT` | Never exposed in API |
| Public API / URL | `UUIDv7` (MariaDB native) | Stored as BINARY(16), no transformer needed |
| Entity Property | `publicId: string` | Exposed via `@Expose({ name: 'id' })` |
| API Response | `id: string` (UUID) | INT `id` has `@Exclude()` — never appears |
| Entity Property | `publicId: string` | Exposed directly in API (no transformation) |
| API Response | `publicId: string` (UUID) | INT `id` has `@Exclude()` — never appears |
### ✅ Updated Pattern (March 2026)
**Backend:** `UuidBaseEntity` exposes `publicId` directly — no `@Expose({ name: 'id' })` transformation
**Frontend:** Use `publicId` only — no `uuid` or `id` fallbacks:
```typescript
// ✅ CORRECT — Use publicId only
type ProjectOption = {
publicId?: string; // No uuid, no id fallback
projectName?: string;
};
// ❌ WRONG — Multiple identifiers cause confusion
type ProjectOption = {
publicId?: string;
uuid?: string; // Don't do this
id?: number; // Don't do this
};
```
### ❌ Forbidden UUID Patterns
@@ -93,8 +114,11 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
// ❌ NEVER use parseInt on UUID
parseInt(projectId); // "0195..." → 19 (WRONG!)
// ✅ CORRECT — Use UUID string directly
const id = projectId; // "019505a1-7c3e-7000-8000-abc123def456"
// ❌ NEVER use id ?? '' fallback
const value = c.publicId ?? c.id ?? ''; // Wrong!
// ✅ CORRECT — Use publicId only
const value = c.publicId; // "019505a1-7c3e-7000-8000-abc123def456"
```
Read `specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md` before any UUID-related work.
+28 -4
View File
@@ -85,8 +85,29 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
| ---------------- | ------------------------- | ------------------------------------------- |
| Internal / DB FK | `INT AUTO_INCREMENT` | Never exposed in API |
| Public API / URL | `UUIDv7` (MariaDB native) | Stored as BINARY(16), no transformer needed |
| Entity Property | `publicId: string` | Exposed via `@Expose({ name: 'id' })` |
| API Response | `id: string` (UUID) | INT `id` has `@Exclude()` — never appears |
| Entity Property | `publicId: string` | Exposed directly in API (no transformation) |
| API Response | `publicId: string` (UUID) | INT `id` has `@Exclude()` — never appears |
### ✅ Updated Pattern (March 2026)
**Backend:** `UuidBaseEntity` exposes `publicId` directly — no `@Expose({ name: 'id' })` transformation
**Frontend:** Use `publicId` only — no `uuid` or `id` fallbacks:
```typescript
// ✅ CORRECT — Use publicId only
type ProjectOption = {
publicId?: string; // No uuid, no id fallback
projectName?: string;
};
// ❌ WRONG — Multiple identifiers cause confusion
type ProjectOption = {
publicId?: string;
uuid?: string; // Don't do this
id?: number; // Don't do this
};
```
### ❌ Forbidden UUID Patterns
@@ -94,8 +115,11 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
// ❌ NEVER use parseInt on UUID
parseInt(projectId); // "0195..." → 19 (WRONG!)
// ✅ CORRECT — Use UUID string directly
const id = projectId; // "019505a1-7c3e-7000-8000-abc123def456"
// ❌ NEVER use id ?? '' fallback
const value = c.publicId ?? c.id ?? ''; // Wrong!
// ✅ CORRECT — Use publicId only
const value = c.publicId; // "019505a1-7c3e-7000-8000-abc123def456"
```
Read `specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md` before any UUID-related work.
+2 -1
View File
@@ -1,3 +1,4 @@
{
"npm.packageManager": "pnpm"
"npm.packageManager": "pnpm",
"editor.fontSize": 16
}
+4 -14
View File
@@ -76,7 +76,7 @@
"socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.27",
"uuid": "^11.0.0",
"uuid": "^9.0.0",
"winston": "^3.18.3",
"zod": "^4.1.13"
},
@@ -115,24 +115,14 @@
"typescript-eslint": "^8.57.1"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"preset": "ts-jest",
"testEnvironment": "node",
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"transformIgnorePatterns": [
"node_modules/(?!(uuid)/)"
]
"coverageDirectory": "../coverage"
},
"main": "index.js",
"directories": {
@@ -17,7 +17,7 @@ import { Organization } from '../../organization/entities/organization.entity';
import { UserAssignment } from './user-assignment.entity';
import { UserPreference } from './user-preference.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude, Expose } from 'class-transformer';
import { Exclude } from 'class-transformer';
@Entity('users')
export class User extends UuidBaseEntity {
@@ -65,7 +65,6 @@ export class User extends UuidBaseEntity {
organization?: Organization;
// ADR-019: Expose UUID instead of INT ID
@Expose({ name: 'primaryOrganizationId' })
get primaryOrganizationPublicId(): string | undefined {
return this.organization?.publicId;
}
+2 -1
View File
@@ -1,3 +1,4 @@
{
"npm.packageManager": "pnpm"
"npm.packageManager": "pnpm",
"editor.fontSize": 16
}
+27 -32
View File
@@ -20,7 +20,7 @@ import { useProjects } from '@/hooks/use-projects';
import { CreateRfaDto } from '@/types/dto/rfa/rfa.dto';
import { useState, useEffect, type FormEvent } from 'react';
import { correspondenceService } from '@/lib/services/correspondence.service';
import { Contract, getContractPublicId } from '@/types/contract';
import { Contract } from '@/types/contract';
const rfaSchema = z.object({
projectId: z.string().min(1, 'Project is required'), // ADR-019: UUID
@@ -41,16 +41,12 @@ type RFAFormData = z.infer<typeof rfaSchema>;
type ProjectOption = {
publicId?: string;
uuid?: string; // Legacy alias for publicId
id?: number;
projectName?: string;
projectCode?: string;
};
type ContractOption = {
publicId?: string;
uuid?: string;
id?: string;
contractName?: string;
name?: string;
contractCode?: string;
@@ -78,20 +74,19 @@ type CorrespondenceTypeOption = {
};
type OrganizationOption = {
uuid?: string;
id?: number;
publicId?: string;
organizationCode?: string;
organizationName?: string;
};
type SelectableDrawingOption = {
uuid?: string;
publicId?: string;
drawingNumber?: string;
title?: string;
legacyDrawingNumber?: string;
currentRevisionUuid?: string;
currentRevisionPublicId?: string;
currentRevision?: {
uuid?: string;
publicId?: string;
revisionLabel?: string;
revisionNumber?: number | string;
title?: string;
@@ -145,11 +140,11 @@ export function RFAForm() {
const createMutation = useCreateRFA();
const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
const projects = dedupeByKey(extractArrayData<ProjectOption>(projectsData), (project) => project.publicId ?? project.uuid ?? project.id);
const projects = dedupeByKey(extractArrayData<ProjectOption>(projectsData), (project) => project.publicId);
const { data: organizationsData, isLoading: isLoadingOrganizations } = useOrganizations({ isActive: true });
const organizations = dedupeByKey(
extractArrayData<OrganizationOption>(organizationsData),
(organization) => organization.uuid ?? organization.id
(organization) => organization.publicId
);
const { data: correspondenceTypesData } = useCorrespondenceTypes();
const correspondenceTypes = extractArrayData<CorrespondenceTypeOption>(correspondenceTypesData);
@@ -185,7 +180,7 @@ export function RFAForm() {
const { data: contractsData, isLoading: isLoadingContracts } = useContracts(selectedProjectId);
const contracts = dedupeByKey(
extractArrayData<ContractOption & Contract>(contractsData),
(contract) => contract.publicId ?? contract.uuid ?? contract.id
(contract) => contract.publicId
);
const selectedContractId = watch('contractId');
@@ -196,27 +191,27 @@ export function RFAForm() {
const [shopDrawingSearch, setShopDrawingSearch] = useState('');
const [shopDrawingPage, setShopDrawingPage] = useState(1);
const { data: shopDrawingsData, isLoading: isLoadingShopDrawings } = useDrawings('SHOP', {
projectUuid: selectedProjectId || '',
projectPublicId: selectedProjectId || '',
search: shopDrawingSearch,
page: shopDrawingPage,
limit: 10,
});
const shopDrawings = dedupeByKey(
extractArrayData<SelectableDrawingOption>(shopDrawingsData),
(drawing) => drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid ?? drawing.uuid
(drawing) => drawing.currentRevisionPublicId ?? drawing.currentRevision?.publicId ?? drawing.publicId
);
const [asBuiltDrawingSearch, setAsBuiltDrawingSearch] = useState('');
const [asBuiltDrawingPage, setAsBuiltDrawingPage] = useState(1);
const { data: asBuiltDrawingsData, isLoading: isLoadingAsBuiltDrawings } = useDrawings('AS_BUILT', {
projectUuid: selectedProjectId || '',
projectPublicId: selectedProjectId || '',
search: asBuiltDrawingSearch,
page: asBuiltDrawingPage,
limit: 10,
});
const asBuiltDrawings = dedupeByKey(
extractArrayData<SelectableDrawingOption>(asBuiltDrawingsData),
(drawing) => drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid ?? drawing.uuid
(drawing) => drawing.currentRevisionPublicId ?? drawing.currentRevision?.publicId ?? drawing.publicId
);
const selectedDisciplineId = watch('disciplineId');
@@ -381,7 +376,7 @@ export function RFAForm() {
</SelectTrigger>
<SelectContent>
{projects.map((p) => {
const projectValue = getOptionValue(p.publicId ?? p.uuid ?? p.id);
const projectValue = getOptionValue(p.publicId);
if (!projectValue) {
return null;
@@ -417,7 +412,7 @@ export function RFAForm() {
</SelectTrigger>
<SelectContent>
{contracts.map((c) => {
const contractValue = getOptionValue(getContractPublicId(c) || c.uuid);
const contractValue = getOptionValue(c.publicId);
if (!contractValue) {
return null;
@@ -499,7 +494,7 @@ export function RFAForm() {
</SelectTrigger>
<SelectContent>
{organizations.map((organization) => {
const organizationValue = getOptionValue(organization.uuid ?? organization.id);
const organizationValue = getOptionValue(organization.publicId);
if (!organizationValue) {
return null;
@@ -551,24 +546,24 @@ export function RFAForm() {
)}
<div className="grid grid-cols-1 gap-3">
{shopDrawings.map((drawing) => {
const revisionUuid = drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid;
const revisionPublicId = drawing.currentRevisionPublicId ?? drawing.currentRevision?.publicId;
if (!revisionUuid) {
if (!revisionPublicId) {
return null;
}
return (
<label
key={revisionUuid}
key={revisionPublicId}
className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
>
<Checkbox
checked={selectedShopDrawingRevisionIds.includes(revisionUuid)}
checked={selectedShopDrawingRevisionIds.includes(revisionPublicId)}
onCheckedChange={(checked) => {
const nextValues =
checked === true
? [...selectedShopDrawingRevisionIds, revisionUuid]
: selectedShopDrawingRevisionIds.filter((value) => value !== revisionUuid);
? [...selectedShopDrawingRevisionIds, revisionPublicId]
: selectedShopDrawingRevisionIds.filter((value) => value !== revisionPublicId);
setValue('shopDrawingRevisionIds', nextValues, { shouldDirty: true, shouldValidate: true });
clearErrors('shopDrawingRevisionIds');
}}
@@ -647,24 +642,24 @@ export function RFAForm() {
)}
<div className="grid grid-cols-1 gap-3">
{asBuiltDrawings.map((drawing) => {
const revisionUuid = drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid;
const revisionPublicId = drawing.currentRevisionPublicId ?? drawing.currentRevision?.publicId;
if (!revisionUuid) {
if (!revisionPublicId) {
return null;
}
return (
<label
key={revisionUuid}
key={revisionPublicId}
className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
>
<Checkbox
checked={selectedAsBuiltDrawingRevisionIds.includes(revisionUuid)}
checked={selectedAsBuiltDrawingRevisionIds.includes(revisionPublicId)}
onCheckedChange={(checked) => {
const nextValues =
checked === true
? [...selectedAsBuiltDrawingRevisionIds, revisionUuid]
: selectedAsBuiltDrawingRevisionIds.filter((value) => value !== revisionUuid);
? [...selectedAsBuiltDrawingRevisionIds, revisionPublicId]
: selectedAsBuiltDrawingRevisionIds.filter((value) => value !== revisionPublicId);
setValue('asBuiltDrawingRevisionIds', nextValues, {
shouldDirty: true,
shouldValidate: true,
-9
View File
@@ -1,13 +1,11 @@
export interface ContractProjectReference {
publicId?: string;
id?: string;
projectCode: string;
projectName: string;
}
export interface Contract {
publicId?: string;
id?: string;
contractCode: string;
contractName: string;
projectId?: number | string;
@@ -16,10 +14,3 @@ export interface Contract {
endDate?: string;
project?: ContractProjectReference;
}
export const getContractPublicId = (contract?: Pick<Contract, 'publicId' | 'id'>): string =>
String(contract?.publicId ?? contract?.id ?? '');
export const getProjectPublicId = (
project?: Pick<ContractProjectReference, 'publicId' | 'id'>
): string => String(project?.publicId ?? project?.id ?? '');
+7 -12
View File
@@ -1,13 +1,11 @@
export interface Organization {
publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019)
publicId: string; // ADR-019: public identifier
organizationName: string;
organizationCode: string;
}
export interface Attachment {
publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019)
publicId: string; // ADR-019: public identifier
name: string;
url: string;
size?: number;
@@ -17,8 +15,7 @@ export interface Attachment {
// Used in List View mainly
export interface CorrespondenceRevision {
publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019)
publicId: string; // ADR-019: public identifier
revisionNumber: number;
revisionLabel?: string; // e.g. "A", "00"
subject: string;
@@ -42,22 +39,20 @@ export interface CorrespondenceRevision {
// Nested Relation from Backend Refactor
correspondence: {
publicId: string;
id?: number; // Excluded from API responses (ADR-019)
publicId: string; // ADR-019: public identifier
correspondenceNumber: string;
projectId: number;
originatorId?: number;
isInternal: boolean;
originator?: Organization;
project?: { publicId: string; id?: number; projectName: string; projectCode: string };
project?: { publicId: string; projectName: string; projectCode: string };
type?: { id: number; typeName: string; typeCode: string };
};
}
// Keep explicit Correspondence for Detail View if needed, or merge concepts
export interface Correspondence {
publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019)
publicId: string; // ADR-019: public identifier
correspondenceNumber: string;
projectId: number;
originatorId?: number;
@@ -67,7 +62,7 @@ export interface Correspondence {
// Relations
originator?: Organization;
project?: { publicId: string; id?: number; projectName: string; projectCode: string };
project?: { publicId: string; projectName: string; projectCode: string };
type?: { id: number; typeName: string; typeCode: string };
revisions?: CorrespondenceRevision[]; // Nested revisions
recipients?: {
+5 -10
View File
@@ -1,7 +1,6 @@
// Entity Interfaces
export interface DrawingRevision {
publicId: string; // ADR-019: exposed as 'id' in API responses
revisionId?: number; // Excluded from API responses (ADR-019)
publicId: string; // ADR-019: public identifier
revisionNumber: string;
title?: string; // Added
legacyDrawingNumber?: string; // Added
@@ -15,8 +14,7 @@ export interface DrawingRevision {
}
export interface ContractDrawing {
publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019)
publicId: string; // ADR-019: public identifier
contractDrawingNo: string;
title: string;
projectId: number;
@@ -28,8 +26,7 @@ export interface ContractDrawing {
}
export interface ShopDrawing {
publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019)
publicId: string; // ADR-019: public identifier
drawingNumber: string;
projectId: number;
mainCategoryId: number;
@@ -41,8 +38,7 @@ export interface ShopDrawing {
}
export interface AsBuiltDrawing {
publicId: string; // ADR-019: exposed as 'id' in API responses
id?: number; // Excluded from API responses (ADR-019)
publicId: string; // ADR-019: public identifier
drawingNumber: string;
projectId: number;
mainCategoryId: number;
@@ -54,8 +50,7 @@ export interface AsBuiltDrawing {
// Unified Type for List
export interface Drawing {
publicId?: string; // ADR-019: exposed as 'id' in API responses
drawingId?: number; // Excluded from API responses (ADR-019)
publicId?: string; // ADR-019: public identifier
drawingNumber: string;
title: string; // Display title (from current revision for Shop/AsBuilt)
discipline?: string | { disciplineCode: string; disciplineName: string };
-2
View File
@@ -17,7 +17,6 @@ export interface Discipline {
codeNameTh?: string;
isActive: boolean;
contract?: {
id?: number;
publicId?: string;
contractCode: string;
contractName: string;
@@ -33,7 +32,6 @@ export interface RfaType {
remark?: string;
isActive: boolean;
contract?: {
id?: number;
publicId?: string;
contractCode: string;
contractName: string;
+2 -4
View File
@@ -28,8 +28,7 @@ export interface RFAItem {
}
export interface RFA {
publicId: string; // ADR-019: from correspondence.publicId
id?: number; // Excluded from API responses (ADR-019)
publicId: string; // ADR-019: public identifier (from correspondence.publicId)
rfaTypeId: number;
createdBy: number;
disciplineId?: number;
@@ -56,8 +55,7 @@ export interface RFA {
};
// Shared Correspondence Relation
correspondence?: {
publicId: string;
id?: number; // Excluded from API responses (ADR-019)
publicId: string; // ADR-019: public identifier
correspondenceNumber: string;
projectId: number;
originatorId?: number;
+1 -1
View File
@@ -22,7 +22,7 @@
// EDITOR SETTINGS
// ========================================
"editor.fontSize": 20,
"editor.fontSize": 16,
"editor.tabSize": 2,
"editor.lineHeight": 1.6,
"editor.rulers": [80, 120],
+15 -9
View File
@@ -211,8 +211,8 @@ importers:
specifier: ^0.3.27
version: 0.3.27(ioredis@5.8.2)(mysql2@3.15.3)(redis@4.7.1)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3))
uuid:
specifier: ^11.0.0
version: 11.1.0
specifier: ^9.0.0
version: 9.0.0
winston:
specifier: ^3.18.3
version: 3.18.3
@@ -222,7 +222,7 @@ importers:
devDependencies:
'@compodoc/compodoc':
specifier: ^1.1.32
version: 1.1.32(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)(keycharm@0.4.0)(typescript@5.9.3)(vis-data@8.0.3(uuid@11.1.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)))(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1))
version: 1.1.32(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)(keycharm@0.4.0)(typescript@5.9.3)(vis-data@8.0.3(uuid@9.0.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)))(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1))
'@eslint/eslintrc':
specifier: ^3.2.0
version: 3.3.1
@@ -8251,6 +8251,10 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
uuid@9.0.0:
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
hasBin: true
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -9973,7 +9977,7 @@ snapshots:
'@colors/colors@1.6.0': {}
'@compodoc/compodoc@1.1.32(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)(keycharm@0.4.0)(typescript@5.9.3)(vis-data@8.0.3(uuid@11.1.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)))(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1))':
'@compodoc/compodoc@1.1.32(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)(keycharm@0.4.0)(typescript@5.9.3)(vis-data@8.0.3(uuid@9.0.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)))(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1))':
dependencies:
'@angular-devkit/schematics': 20.3.4(chokidar@4.0.3)
'@babel/core': 7.28.4
@@ -10017,7 +10021,7 @@ snapshots:
tablesort: 5.6.0
ts-morph: 27.0.2
uuid: 11.1.0
vis-network: 10.0.2(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)(keycharm@0.4.0)(uuid@11.1.0)(vis-data@8.0.3(uuid@11.1.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)))(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1))
vis-network: 10.0.2(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)(keycharm@0.4.0)(uuid@11.1.0)(vis-data@8.0.3(uuid@9.0.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)))(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1))
transitivePeerDependencies:
- '@egjs/hammerjs'
- component-emitter
@@ -17498,6 +17502,8 @@ snapshots:
uuid@8.3.2: {}
uuid@9.0.0: {}
v8-compile-cache-lib@3.0.1: {}
v8-to-istanbul@9.3.0:
@@ -17510,18 +17516,18 @@ snapshots:
vary@1.1.2: {}
vis-data@8.0.3(uuid@11.1.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)):
vis-data@8.0.3(uuid@9.0.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)):
dependencies:
uuid: 11.1.0
uuid: 9.0.0
vis-util: 6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)
vis-network@10.0.2(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)(keycharm@0.4.0)(uuid@11.1.0)(vis-data@8.0.3(uuid@11.1.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)))(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)):
vis-network@10.0.2(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)(keycharm@0.4.0)(uuid@11.1.0)(vis-data@8.0.3(uuid@9.0.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)))(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)):
dependencies:
'@egjs/hammerjs': 2.0.17
component-emitter: 1.3.1
keycharm: 0.4.0
uuid: 11.1.0
vis-data: 8.0.3(uuid@11.1.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1))
vis-data: 8.0.3(uuid@9.0.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1))
vis-util: 6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)
vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1):
+3
View File
@@ -0,0 +1,3 @@
{
"editor.fontSize": 16
}
@@ -610,63 +610,80 @@ test.describe('Correspondence Workflow', () => {
**Backend ใช้ Hybrid ID Strategy:** INT PK (internal) + UUID (public API)
- **Database:** `id` = INT AI (Primary Key), `uuid` = UUID (MariaDB native type)
- **Backend Entity:** `id` = INT (@Exclude), `publicId` = UUID (exposed as `id` in API via @Expose)
- **API Response:** ส่ง `id` (ซึ่งจริงๆ คือ `publicId` UUID string) ไม่มี INT id
- **Backend Entity:** `id` = INT (@Exclude), `publicId` = UUID (exposed directly in API)
- **API Response:** ส่ง `publicId` (UUID string) โดยตรง — ไม่มีการ Transform เป็น `id`
### ✅ Updated Pattern (March 2026)
ใช้ `publicId` เป็น Standard อย่างเดียว — ไม่มี fallback เป็น `uuid` หรือ `id`:
```typescript
// ✅ CORRECT — Use publicId only
type ProjectOption = {
publicId?: string;
projectName?: string;
projectCode?: string;
};
// ใช้ใน dropdown
projects.map((p) => ({
label: p.projectName,
value: p.publicId, // ไม่ต้อง fallback
}));
```
### ⚠️ Common Pitfalls (และวิธีแก้)
#### 1. ใช้ `.id` กับ Entity ที่ควรใช้ `.publicId`
```tsx
// ❌ WRONG - entity.id อาจเป็น undefined หรือ INT ที่ถูก @Exclude
// ❌ WRONG - entity.id ถูก @Exclude จาก API response
contracts.map((c) => <SelectItem key={c.id} value={String(c.id)}>)
// ✅ CORRECT - ใช้ publicId (UUID) ที่ API ส่งมา
// ✅ CORRECT - ใช้ publicId ที่ API ส่งมาโดยตรง
contracts.map((c) => <SelectItem key={c.publicId} value={c.publicId}>)
// หรือ fallback สำหรับ backward compatibility
contracts.map((c) => <SelectItem key={c.publicId ?? c.id} value={String(c.publicId ?? c.id)}>)
```
#### 2. parseInt() บน UUID string
#### 2. Fallback หลายชั้น (uuid ?? id ?? '')
```tsx
// ❌ WRONG - สับสน ไม่ maintainable
value: String(c.publicId ?? c.uuid ?? c.id ?? '')
// ✅ CORRECT - publicId อย่างเดียว
value: c.publicId
```
#### 3. parseInt() บน UUID string
```tsx
// ❌ WRONG - parseInt บน UUID จะได้ค่า garbage
const id = parseInt(projectId); // "0195..." → 19 (wrong!)
// ✅ CORRECT - ส่ง UUID string ตรงๆ ไป backend
const id = projectId; // "019505a1-7c3e-7000-8000-abc123def456"
// ✅ CORRECT - ถ้าต้องการ INT ให้ backend resolve เองผ่าน UuidResolver
// Backend DTO รับ `projectUuid: string` แล้ว resolve เป็น `projectId: number` เอง
```
#### 3. Field Name Mismatch (snake_case vs camelCase)
#### 4. Select Option ไม่มีข้อมูล (Type Mismatch)
```tsx
// ❌ WRONG - ใช้ชื่อ field ไม่ตรงกับ TypeScript interface
fields={[{ name: 'type_code', label: 'Code' }]}
// interface มี typeCode (camelCase) แต่ form ส่ง type_code (snake_case)
// ❌ WRONG - สมมติว่า API ส่ง { uuid: string } แต่จริงๆ ส่ง { publicId: string }
type OrganizationOption = {
uuid?: string; // ผิด!
organizationName?: string;
};
const value = org.uuid ?? org.id; // undefined → dropdown ว่าง
// ✅ CORRECT - ใช้ชื่อ field ตรงกับ interface
fields={[{ name: 'typeCode', label: 'Code' }]}
// ✅ CORRECT - Type ต้องตรงกับ API Response
type OrganizationOption = {
publicId?: string; // ตรงกับ API
organizationName?: string;
};
const value = org.publicId; // ได้ค่าถูกต้อง
```
#### 4. Contract/Project Select ไม่มีข้อมูล
```tsx
// ❌ WRONG - สมมติว่า API ส่ง { id: number }
const options = contracts.map((c) => ({ value: String(c.id), label: c.contractName }))
// ✅ CORRECT - API ส่ง { publicId: string } ตาม ADR-019
const options = contracts.map((c) => ({
value: String(c.publicId ?? c.id ?? ''), // fallback รองรับทั้ง 2 กรณี
label: c.contractName
}))
```
### 📝 Pattern: Contract/Project Select Options
### 📝 Pattern: Select Options with publicId
```typescript
// types/master-data.ts - Entity interfaces
export interface Contract {
id?: number; // Internal INT (อาจถูก @Exclude)
publicId?: string; // UUID ที่ API ส่ง (ต้องใช้ตัวนี้)
publicId?: string; // UUID ที่ API ส่ง — ใช้ตัวนี้
contractCode: string;
contractName: string;
}
@@ -674,17 +691,17 @@ export interface Contract {
// page.tsx - Select options
const contractOptions = contracts.map((c) => ({
label: `${c.contractName} (${c.contractCode})`,
value: String(c.publicId ?? c.id ?? ''), // ADR-019: publicId เป็น UUID
value: c.publicId, // publicId only — no fallback
}));
// GenericCrudTable fields
fields={[
{
name: 'contractId',
name: 'contractPublicId', // DTO field name
label: 'Contract',
type: 'select',
required: true,
options: contractOptions, // ใช้ UUID string เป็น value
options: contractOptions,
},
]}
```
@@ -716,11 +733,11 @@ const columns: ColumnDef<Discipline>[] = [
### ✅ Checklist ก่อน Commit
- [ ] ใช้ `publicId ?? id` pattern สำหรับ entity identifiers
- [ ] ใช้ `publicId` อย่างเดียว (ไม่มี `uuid` หรือ `id` fallback)
- [ ] ไม่ใช้ `parseInt()` บน UUID values
- [ ] Field names ตรงกับ TypeScript interfaces (camelCase)
- [ ] Select options ใช้ UUID string เป็น value
- [ ] แสดง relation columns (Contract/Project) ในตาราง
- [ ] Type Definition ตรงกับ API Response field names
- [ ] Select options ใช้ `publicId` เป็น value
- [ ] DTO field names ใช้ `publicId` suffix (e.g., `projectPublicId`)
---
@@ -243,21 +243,31 @@ async findByUuidOrId(identifier: string): Promise<Entity> {
#### Pattern: Drawing Search (✅ FIXED — reference implementation)
- Backend DTO accepts `projectUuid: string` instead of `projectId: number`
- Controller resolves: `projectService.findOneByUuid(dto.projectUuid)``dto.projectId = project.id`
- 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
#### Remaining Issues (Updated Naming Convention)
| 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 |
| `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)
@@ -300,16 +310,6 @@ For each affected backend DTO:
| 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
@@ -266,12 +266,11 @@ async findByUuid(publicId: string): Promise<CorrespondenceDto> {
}
```
### DTO Pattern — Never Expose INT ID
### DTO Pattern — Expose publicId Directly
```typescript
export class CorrespondenceResponseDto {
// ✅ Expose publicId as 'id' in API response
@Expose({ name: 'id' })
// ✅ Expose publicId directly in API response
publicId!: string;
// ❌ Never expose internal INT id
@@ -279,11 +278,12 @@ export class CorrespondenceResponseDto {
// ... other fields
// For FK references, also use publicId
@Expose({ name: 'project_id' })
projectPublicId!: string;
}
```
> **Note:** We use `publicId` directly in API responses (not transformed to `id`) to maintain naming consistency between Backend Entity property and Frontend Type property. This prevents confusion when mapping data.
---
## Migration SQL Script
@@ -473,11 +473,29 @@ WHERE c.uuid = '019505a1-7c3e-7000-8000-abc123def456';
- API รับได้ทั้ง INT และ UUID ผ่าน `FindByIdOrUuid` pattern
- API Response รวม UUID เป็น `id` field (via @Expose)
### Phase 3: Frontend (Gradual Migration)
### Phase 3: Frontend (Consistent publicId Usage)
- Frontend เปลี่ยนจากใช้ `id` (INT) เป็น `id` (UUID) ใน API response
- URL parameters เปลี่ยนเป็น UUID
- ไม่ต้อง Big-Bang migration — ค่อยๆ เปลี่ยนทีละ Module
- Frontend ใช้ `publicId` เป็น Standard ทุก Type (ไม่ใช้ `uuid` หรือ `id` ที่เป็น number)
- URL parameters ใช้ `publicId` (UUID string)
- ทุก Type Definition ใช้ `publicId?: string` อย่างเดียว — ไม่มี fallback เป็น `uuid` หรือ `id`
**Example:**
```typescript
// ✅ Correct — Consistent publicId usage
type ProjectOption = {
publicId?: string;
projectName?: string;
projectCode?: string;
};
// ❌ Wrong — Multiple identifier fields cause confusion
type ProjectOption = {
publicId?: string;
uuid?: string; // Don't do this
id?: number; // Don't do this
projectName?: string;
};
```
### Phase 4: Cleanup
@@ -492,7 +510,7 @@ WHERE c.uuid = '019505a1-7c3e-7000-8000-abc123def456';
| Area | Status |
| ---------------------- | ------------------------------------ |
| Security | ✅ Eliminates ID enumeration |
| Performance | ✅ No impact on internal JOINs |
| Performance | ✅ No impact on internal JOINs |
| Migration Risk | ✅ Low — ADD COLUMN only |
| Storage Impact | ✅ Negligible (~3.8 MB) |
| Backward Compatibility | ✅ Dual-mode transition |
@@ -500,4 +518,16 @@ WHERE c.uuid = '019505a1-7c3e-7000-8000-abc123def456';
---
## Naming Convention Summary
| Context | Backend (TypeORM) | Frontend (TypeScript) | API Response |
|---------|-------------------|----------------------|--------------|
| **Entity Property** | `publicId: string` | `publicId?: string` | `publicId: string` |
| **DB Column** | `uuid UUID` | — | — |
| **Internal PK** | `id: number` (excluded) | — | — |
**Rule:** ใช้ `publicId` เป็น Identifier เดียวใน API — ไม่มีการ Transform เป็น `id` เพื่อป้องกัน confusion ระหว่าง Backend ↔ Frontend
---
_สำหรับรายละเอียดการ Implement ดูที่ Implementation Plan ใน `05-07-hybrid-uuid-implementation-plan.md`_