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-hybrid-identifier` - **CRITICAL** ADR-019: INT PK + UUID public API
- `db-avoid-n-plus-one` - HIGH N+1 query prevention - `db-avoid-n-plus-one` - HIGH N+1 query prevention
- `db-use-transactions` - HIGH Transaction management - `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) ### 8. API Design (MEDIUM)
@@ -110,7 +110,86 @@ Reference these guidelines when:
- `devops-use-logging` - Structured logging - `devops-use-logging` - Structured logging
- `devops-graceful-shutdown` - Zero-downtime deployments - `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: 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 - Cache handlers for multi-instance ISR
- What works vs needs extra setup - 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: 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 | | Internal / DB FK | `INT AUTO_INCREMENT` | Never exposed in API |
| Public API / URL | `UUIDv7` (MariaDB native) | Stored as BINARY(16), no transformer needed | | Public API / URL | `UUIDv7` (MariaDB native) | Stored as BINARY(16), no transformer needed |
| Entity Property | `publicId: string` | Exposed via `@Expose({ name: 'id' })` | | Entity Property | `publicId: string` | Exposed directly in API (no transformation) |
| API Response | `id: string` (UUID) | INT `id` has `@Exclude()` — never appears | | 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 ### ❌ Forbidden UUID Patterns
@@ -97,8 +118,11 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
// ❌ NEVER use parseInt on UUID // ❌ NEVER use parseInt on UUID
parseInt(projectId); // "0195..." → 19 (WRONG!) parseInt(projectId); // "0195..." → 19 (WRONG!)
// ✅ CORRECT — Use UUID string directly // ❌ NEVER use id ?? '' fallback
const id = projectId; // "019505a1-7c3e-7000-8000-abc123def456" 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. 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" "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 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 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: Make sure to:
1. If exploring the codebase, call multiple tools in parallel for increased efficiency. Do not spend too much time exploring. 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 # NAP-DMS Project Context & Rules
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Amazon Q, AGENTS.md tools) - 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 - 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) - 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 | | Internal / DB FK | `INT AUTO_INCREMENT` | Never exposed in API |
| Public API / URL | `UUIDv7` (MariaDB native) | Stored as BINARY(16), no transformer needed | | Public API / URL | `UUIDv7` (MariaDB native) | Stored as BINARY(16), no transformer needed |
| Entity Property | `publicId: string` | Exposed via `@Expose({ name: 'id' })` | | Entity Property | `publicId: string` | Exposed directly in API (no transformation) |
| API Response | `id: string` (UUID) | INT `id` has `@Exclude()` — never appears | | 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 ### ❌ Forbidden UUID Patterns
@@ -93,8 +114,11 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
// ❌ NEVER use parseInt on UUID // ❌ NEVER use parseInt on UUID
parseInt(projectId); // "0195..." → 19 (WRONG!) parseInt(projectId); // "0195..." → 19 (WRONG!)
// ✅ CORRECT — Use UUID string directly // ❌ NEVER use id ?? '' fallback
const id = projectId; // "019505a1-7c3e-7000-8000-abc123def456" 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. 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 | | Internal / DB FK | `INT AUTO_INCREMENT` | Never exposed in API |
| Public API / URL | `UUIDv7` (MariaDB native) | Stored as BINARY(16), no transformer needed | | Public API / URL | `UUIDv7` (MariaDB native) | Stored as BINARY(16), no transformer needed |
| Entity Property | `publicId: string` | Exposed via `@Expose({ name: 'id' })` | | Entity Property | `publicId: string` | Exposed directly in API (no transformation) |
| API Response | `id: string` (UUID) | INT `id` has `@Exclude()` — never appears | | 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 ### ❌ Forbidden UUID Patterns
@@ -94,8 +115,11 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
// ❌ NEVER use parseInt on UUID // ❌ NEVER use parseInt on UUID
parseInt(projectId); // "0195..." → 19 (WRONG!) parseInt(projectId); // "0195..." → 19 (WRONG!)
// ✅ CORRECT — Use UUID string directly // ❌ NEVER use id ?? '' fallback
const id = projectId; // "019505a1-7c3e-7000-8000-abc123def456" 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. 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", "socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.27", "typeorm": "^0.3.27",
"uuid": "^11.0.0", "uuid": "^9.0.0",
"winston": "^3.18.3", "winston": "^3.18.3",
"zod": "^4.1.13" "zod": "^4.1.13"
}, },
@@ -115,24 +115,14 @@
"typescript-eslint": "^8.57.1" "typescript-eslint": "^8.57.1"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "preset": "ts-jest",
"js", "testEnvironment": "node",
"json",
"ts"
],
"rootDir": "src", "rootDir": "src",
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.(t|j)s" "**/*.(t|j)s"
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage"
"testEnvironment": "node",
"transformIgnorePatterns": [
"node_modules/(?!(uuid)/)"
]
}, },
"main": "index.js", "main": "index.js",
"directories": { "directories": {
@@ -17,7 +17,7 @@ import { Organization } from '../../organization/entities/organization.entity';
import { UserAssignment } from './user-assignment.entity'; import { UserAssignment } from './user-assignment.entity';
import { UserPreference } from './user-preference.entity'; import { UserPreference } from './user-preference.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude, Expose } from 'class-transformer'; import { Exclude } from 'class-transformer';
@Entity('users') @Entity('users')
export class User extends UuidBaseEntity { export class User extends UuidBaseEntity {
@@ -65,7 +65,6 @@ export class User extends UuidBaseEntity {
organization?: Organization; organization?: Organization;
// ADR-019: Expose UUID instead of INT ID // ADR-019: Expose UUID instead of INT ID
@Expose({ name: 'primaryOrganizationId' })
get primaryOrganizationPublicId(): string | undefined { get primaryOrganizationPublicId(): string | undefined {
return this.organization?.publicId; 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 { CreateRfaDto } from '@/types/dto/rfa/rfa.dto';
import { useState, useEffect, type FormEvent } from 'react'; import { useState, useEffect, type FormEvent } from 'react';
import { correspondenceService } from '@/lib/services/correspondence.service'; import { correspondenceService } from '@/lib/services/correspondence.service';
import { Contract, getContractPublicId } from '@/types/contract'; import { Contract } from '@/types/contract';
const rfaSchema = z.object({ const rfaSchema = z.object({
projectId: z.string().min(1, 'Project is required'), // ADR-019: UUID projectId: z.string().min(1, 'Project is required'), // ADR-019: UUID
@@ -41,16 +41,12 @@ type RFAFormData = z.infer<typeof rfaSchema>;
type ProjectOption = { type ProjectOption = {
publicId?: string; publicId?: string;
uuid?: string; // Legacy alias for publicId
id?: number;
projectName?: string; projectName?: string;
projectCode?: string; projectCode?: string;
}; };
type ContractOption = { type ContractOption = {
publicId?: string; publicId?: string;
uuid?: string;
id?: string;
contractName?: string; contractName?: string;
name?: string; name?: string;
contractCode?: string; contractCode?: string;
@@ -78,20 +74,19 @@ type CorrespondenceTypeOption = {
}; };
type OrganizationOption = { type OrganizationOption = {
uuid?: string; publicId?: string;
id?: number;
organizationCode?: string; organizationCode?: string;
organizationName?: string; organizationName?: string;
}; };
type SelectableDrawingOption = { type SelectableDrawingOption = {
uuid?: string; publicId?: string;
drawingNumber?: string; drawingNumber?: string;
title?: string; title?: string;
legacyDrawingNumber?: string; legacyDrawingNumber?: string;
currentRevisionUuid?: string; currentRevisionPublicId?: string;
currentRevision?: { currentRevision?: {
uuid?: string; publicId?: string;
revisionLabel?: string; revisionLabel?: string;
revisionNumber?: number | string; revisionNumber?: number | string;
title?: string; title?: string;
@@ -145,11 +140,11 @@ export function RFAForm() {
const createMutation = useCreateRFA(); const createMutation = useCreateRFA();
const { data: projectsData, isLoading: isLoadingProjects } = useProjects(); 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 { data: organizationsData, isLoading: isLoadingOrganizations } = useOrganizations({ isActive: true });
const organizations = dedupeByKey( const organizations = dedupeByKey(
extractArrayData<OrganizationOption>(organizationsData), extractArrayData<OrganizationOption>(organizationsData),
(organization) => organization.uuid ?? organization.id (organization) => organization.publicId
); );
const { data: correspondenceTypesData } = useCorrespondenceTypes(); const { data: correspondenceTypesData } = useCorrespondenceTypes();
const correspondenceTypes = extractArrayData<CorrespondenceTypeOption>(correspondenceTypesData); const correspondenceTypes = extractArrayData<CorrespondenceTypeOption>(correspondenceTypesData);
@@ -185,7 +180,7 @@ export function RFAForm() {
const { data: contractsData, isLoading: isLoadingContracts } = useContracts(selectedProjectId); const { data: contractsData, isLoading: isLoadingContracts } = useContracts(selectedProjectId);
const contracts = dedupeByKey( const contracts = dedupeByKey(
extractArrayData<ContractOption & Contract>(contractsData), extractArrayData<ContractOption & Contract>(contractsData),
(contract) => contract.publicId ?? contract.uuid ?? contract.id (contract) => contract.publicId
); );
const selectedContractId = watch('contractId'); const selectedContractId = watch('contractId');
@@ -196,27 +191,27 @@ export function RFAForm() {
const [shopDrawingSearch, setShopDrawingSearch] = useState(''); const [shopDrawingSearch, setShopDrawingSearch] = useState('');
const [shopDrawingPage, setShopDrawingPage] = useState(1); const [shopDrawingPage, setShopDrawingPage] = useState(1);
const { data: shopDrawingsData, isLoading: isLoadingShopDrawings } = useDrawings('SHOP', { const { data: shopDrawingsData, isLoading: isLoadingShopDrawings } = useDrawings('SHOP', {
projectUuid: selectedProjectId || '', projectPublicId: selectedProjectId || '',
search: shopDrawingSearch, search: shopDrawingSearch,
page: shopDrawingPage, page: shopDrawingPage,
limit: 10, limit: 10,
}); });
const shopDrawings = dedupeByKey( const shopDrawings = dedupeByKey(
extractArrayData<SelectableDrawingOption>(shopDrawingsData), extractArrayData<SelectableDrawingOption>(shopDrawingsData),
(drawing) => drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid ?? drawing.uuid (drawing) => drawing.currentRevisionPublicId ?? drawing.currentRevision?.publicId ?? drawing.publicId
); );
const [asBuiltDrawingSearch, setAsBuiltDrawingSearch] = useState(''); const [asBuiltDrawingSearch, setAsBuiltDrawingSearch] = useState('');
const [asBuiltDrawingPage, setAsBuiltDrawingPage] = useState(1); const [asBuiltDrawingPage, setAsBuiltDrawingPage] = useState(1);
const { data: asBuiltDrawingsData, isLoading: isLoadingAsBuiltDrawings } = useDrawings('AS_BUILT', { const { data: asBuiltDrawingsData, isLoading: isLoadingAsBuiltDrawings } = useDrawings('AS_BUILT', {
projectUuid: selectedProjectId || '', projectPublicId: selectedProjectId || '',
search: asBuiltDrawingSearch, search: asBuiltDrawingSearch,
page: asBuiltDrawingPage, page: asBuiltDrawingPage,
limit: 10, limit: 10,
}); });
const asBuiltDrawings = dedupeByKey( const asBuiltDrawings = dedupeByKey(
extractArrayData<SelectableDrawingOption>(asBuiltDrawingsData), extractArrayData<SelectableDrawingOption>(asBuiltDrawingsData),
(drawing) => drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid ?? drawing.uuid (drawing) => drawing.currentRevisionPublicId ?? drawing.currentRevision?.publicId ?? drawing.publicId
); );
const selectedDisciplineId = watch('disciplineId'); const selectedDisciplineId = watch('disciplineId');
@@ -381,7 +376,7 @@ export function RFAForm() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{projects.map((p) => { {projects.map((p) => {
const projectValue = getOptionValue(p.publicId ?? p.uuid ?? p.id); const projectValue = getOptionValue(p.publicId);
if (!projectValue) { if (!projectValue) {
return null; return null;
@@ -417,7 +412,7 @@ export function RFAForm() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{contracts.map((c) => { {contracts.map((c) => {
const contractValue = getOptionValue(getContractPublicId(c) || c.uuid); const contractValue = getOptionValue(c.publicId);
if (!contractValue) { if (!contractValue) {
return null; return null;
@@ -499,7 +494,7 @@ export function RFAForm() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{organizations.map((organization) => { {organizations.map((organization) => {
const organizationValue = getOptionValue(organization.uuid ?? organization.id); const organizationValue = getOptionValue(organization.publicId);
if (!organizationValue) { if (!organizationValue) {
return null; return null;
@@ -551,24 +546,24 @@ export function RFAForm() {
)} )}
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
{shopDrawings.map((drawing) => { {shopDrawings.map((drawing) => {
const revisionUuid = drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid; const revisionPublicId = drawing.currentRevisionPublicId ?? drawing.currentRevision?.publicId;
if (!revisionUuid) { if (!revisionPublicId) {
return null; return null;
} }
return ( return (
<label <label
key={revisionUuid} key={revisionPublicId}
className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors" className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
> >
<Checkbox <Checkbox
checked={selectedShopDrawingRevisionIds.includes(revisionUuid)} checked={selectedShopDrawingRevisionIds.includes(revisionPublicId)}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
const nextValues = const nextValues =
checked === true checked === true
? [...selectedShopDrawingRevisionIds, revisionUuid] ? [...selectedShopDrawingRevisionIds, revisionPublicId]
: selectedShopDrawingRevisionIds.filter((value) => value !== revisionUuid); : selectedShopDrawingRevisionIds.filter((value) => value !== revisionPublicId);
setValue('shopDrawingRevisionIds', nextValues, { shouldDirty: true, shouldValidate: true }); setValue('shopDrawingRevisionIds', nextValues, { shouldDirty: true, shouldValidate: true });
clearErrors('shopDrawingRevisionIds'); clearErrors('shopDrawingRevisionIds');
}} }}
@@ -647,24 +642,24 @@ export function RFAForm() {
)} )}
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
{asBuiltDrawings.map((drawing) => { {asBuiltDrawings.map((drawing) => {
const revisionUuid = drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid; const revisionPublicId = drawing.currentRevisionPublicId ?? drawing.currentRevision?.publicId;
if (!revisionUuid) { if (!revisionPublicId) {
return null; return null;
} }
return ( return (
<label <label
key={revisionUuid} key={revisionPublicId}
className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors" className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
> >
<Checkbox <Checkbox
checked={selectedAsBuiltDrawingRevisionIds.includes(revisionUuid)} checked={selectedAsBuiltDrawingRevisionIds.includes(revisionPublicId)}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
const nextValues = const nextValues =
checked === true checked === true
? [...selectedAsBuiltDrawingRevisionIds, revisionUuid] ? [...selectedAsBuiltDrawingRevisionIds, revisionPublicId]
: selectedAsBuiltDrawingRevisionIds.filter((value) => value !== revisionUuid); : selectedAsBuiltDrawingRevisionIds.filter((value) => value !== revisionPublicId);
setValue('asBuiltDrawingRevisionIds', nextValues, { setValue('asBuiltDrawingRevisionIds', nextValues, {
shouldDirty: true, shouldDirty: true,
shouldValidate: true, shouldValidate: true,
-9
View File
@@ -1,13 +1,11 @@
export interface ContractProjectReference { export interface ContractProjectReference {
publicId?: string; publicId?: string;
id?: string;
projectCode: string; projectCode: string;
projectName: string; projectName: string;
} }
export interface Contract { export interface Contract {
publicId?: string; publicId?: string;
id?: string;
contractCode: string; contractCode: string;
contractName: string; contractName: string;
projectId?: number | string; projectId?: number | string;
@@ -16,10 +14,3 @@ export interface Contract {
endDate?: string; endDate?: string;
project?: ContractProjectReference; 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 { export interface Organization {
publicId: string; // ADR-019: exposed as 'id' in API responses publicId: string; // ADR-019: public identifier
id?: number; // Excluded from API responses (ADR-019)
organizationName: string; organizationName: string;
organizationCode: string; organizationCode: string;
} }
export interface Attachment { export interface Attachment {
publicId: string; // ADR-019: exposed as 'id' in API responses publicId: string; // ADR-019: public identifier
id?: number; // Excluded from API responses (ADR-019)
name: string; name: string;
url: string; url: string;
size?: number; size?: number;
@@ -17,8 +15,7 @@ export interface Attachment {
// Used in List View mainly // Used in List View mainly
export interface CorrespondenceRevision { export interface CorrespondenceRevision {
publicId: string; // ADR-019: exposed as 'id' in API responses publicId: string; // ADR-019: public identifier
id?: number; // Excluded from API responses (ADR-019)
revisionNumber: number; revisionNumber: number;
revisionLabel?: string; // e.g. "A", "00" revisionLabel?: string; // e.g. "A", "00"
subject: string; subject: string;
@@ -42,22 +39,20 @@ export interface CorrespondenceRevision {
// Nested Relation from Backend Refactor // Nested Relation from Backend Refactor
correspondence: { correspondence: {
publicId: string; publicId: string; // ADR-019: public identifier
id?: number; // Excluded from API responses (ADR-019)
correspondenceNumber: string; correspondenceNumber: string;
projectId: number; projectId: number;
originatorId?: number; originatorId?: number;
isInternal: boolean; isInternal: boolean;
originator?: Organization; 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 }; type?: { id: number; typeName: string; typeCode: string };
}; };
} }
// Keep explicit Correspondence for Detail View if needed, or merge concepts // Keep explicit Correspondence for Detail View if needed, or merge concepts
export interface Correspondence { export interface Correspondence {
publicId: string; // ADR-019: exposed as 'id' in API responses publicId: string; // ADR-019: public identifier
id?: number; // Excluded from API responses (ADR-019)
correspondenceNumber: string; correspondenceNumber: string;
projectId: number; projectId: number;
originatorId?: number; originatorId?: number;
@@ -67,7 +62,7 @@ export interface Correspondence {
// Relations // Relations
originator?: Organization; 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 }; type?: { id: number; typeName: string; typeCode: string };
revisions?: CorrespondenceRevision[]; // Nested revisions revisions?: CorrespondenceRevision[]; // Nested revisions
recipients?: { recipients?: {
+5 -10
View File
@@ -1,7 +1,6 @@
// Entity Interfaces // Entity Interfaces
export interface DrawingRevision { export interface DrawingRevision {
publicId: string; // ADR-019: exposed as 'id' in API responses publicId: string; // ADR-019: public identifier
revisionId?: number; // Excluded from API responses (ADR-019)
revisionNumber: string; revisionNumber: string;
title?: string; // Added title?: string; // Added
legacyDrawingNumber?: string; // Added legacyDrawingNumber?: string; // Added
@@ -15,8 +14,7 @@ export interface DrawingRevision {
} }
export interface ContractDrawing { export interface ContractDrawing {
publicId: string; // ADR-019: exposed as 'id' in API responses publicId: string; // ADR-019: public identifier
id?: number; // Excluded from API responses (ADR-019)
contractDrawingNo: string; contractDrawingNo: string;
title: string; title: string;
projectId: number; projectId: number;
@@ -28,8 +26,7 @@ export interface ContractDrawing {
} }
export interface ShopDrawing { export interface ShopDrawing {
publicId: string; // ADR-019: exposed as 'id' in API responses publicId: string; // ADR-019: public identifier
id?: number; // Excluded from API responses (ADR-019)
drawingNumber: string; drawingNumber: string;
projectId: number; projectId: number;
mainCategoryId: number; mainCategoryId: number;
@@ -41,8 +38,7 @@ export interface ShopDrawing {
} }
export interface AsBuiltDrawing { export interface AsBuiltDrawing {
publicId: string; // ADR-019: exposed as 'id' in API responses publicId: string; // ADR-019: public identifier
id?: number; // Excluded from API responses (ADR-019)
drawingNumber: string; drawingNumber: string;
projectId: number; projectId: number;
mainCategoryId: number; mainCategoryId: number;
@@ -54,8 +50,7 @@ export interface AsBuiltDrawing {
// Unified Type for List // Unified Type for List
export interface Drawing { export interface Drawing {
publicId?: string; // ADR-019: exposed as 'id' in API responses publicId?: string; // ADR-019: public identifier
drawingId?: number; // Excluded from API responses (ADR-019)
drawingNumber: string; drawingNumber: string;
title: string; // Display title (from current revision for Shop/AsBuilt) title: string; // Display title (from current revision for Shop/AsBuilt)
discipline?: string | { disciplineCode: string; disciplineName: string }; discipline?: string | { disciplineCode: string; disciplineName: string };
-2
View File
@@ -17,7 +17,6 @@ export interface Discipline {
codeNameTh?: string; codeNameTh?: string;
isActive: boolean; isActive: boolean;
contract?: { contract?: {
id?: number;
publicId?: string; publicId?: string;
contractCode: string; contractCode: string;
contractName: string; contractName: string;
@@ -33,7 +32,6 @@ export interface RfaType {
remark?: string; remark?: string;
isActive: boolean; isActive: boolean;
contract?: { contract?: {
id?: number;
publicId?: string; publicId?: string;
contractCode: string; contractCode: string;
contractName: string; contractName: string;
+2 -4
View File
@@ -28,8 +28,7 @@ export interface RFAItem {
} }
export interface RFA { export interface RFA {
publicId: string; // ADR-019: from correspondence.publicId publicId: string; // ADR-019: public identifier (from correspondence.publicId)
id?: number; // Excluded from API responses (ADR-019)
rfaTypeId: number; rfaTypeId: number;
createdBy: number; createdBy: number;
disciplineId?: number; disciplineId?: number;
@@ -56,8 +55,7 @@ export interface RFA {
}; };
// Shared Correspondence Relation // Shared Correspondence Relation
correspondence?: { correspondence?: {
publicId: string; publicId: string; // ADR-019: public identifier
id?: number; // Excluded from API responses (ADR-019)
correspondenceNumber: string; correspondenceNumber: string;
projectId: number; projectId: number;
originatorId?: number; originatorId?: number;
+1 -1
View File
@@ -22,7 +22,7 @@
// EDITOR SETTINGS // EDITOR SETTINGS
// ======================================== // ========================================
"editor.fontSize": 20, "editor.fontSize": 16,
"editor.tabSize": 2, "editor.tabSize": 2,
"editor.lineHeight": 1.6, "editor.lineHeight": 1.6,
"editor.rulers": [80, 120], "editor.rulers": [80, 120],
+15 -9
View File
@@ -211,8 +211,8 @@ importers:
specifier: ^0.3.27 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)) 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: uuid:
specifier: ^11.0.0 specifier: ^9.0.0
version: 11.1.0 version: 9.0.0
winston: winston:
specifier: ^3.18.3 specifier: ^3.18.3
version: 3.18.3 version: 3.18.3
@@ -222,7 +222,7 @@ importers:
devDependencies: devDependencies:
'@compodoc/compodoc': '@compodoc/compodoc':
specifier: ^1.1.32 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': '@eslint/eslintrc':
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.3.1 version: 3.3.1
@@ -8251,6 +8251,10 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true hasBin: true
uuid@9.0.0:
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
hasBin: true
v8-compile-cache-lib@3.0.1: v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -9973,7 +9977,7 @@ snapshots:
'@colors/colors@1.6.0': {} '@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: dependencies:
'@angular-devkit/schematics': 20.3.4(chokidar@4.0.3) '@angular-devkit/schematics': 20.3.4(chokidar@4.0.3)
'@babel/core': 7.28.4 '@babel/core': 7.28.4
@@ -10017,7 +10021,7 @@ snapshots:
tablesort: 5.6.0 tablesort: 5.6.0
ts-morph: 27.0.2 ts-morph: 27.0.2
uuid: 11.1.0 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: transitivePeerDependencies:
- '@egjs/hammerjs' - '@egjs/hammerjs'
- component-emitter - component-emitter
@@ -17498,6 +17502,8 @@ snapshots:
uuid@8.3.2: {} uuid@8.3.2: {}
uuid@9.0.0: {}
v8-compile-cache-lib@3.0.1: {} v8-compile-cache-lib@3.0.1: {}
v8-to-istanbul@9.3.0: v8-to-istanbul@9.3.0:
@@ -17510,18 +17516,18 @@ snapshots:
vary@1.1.2: {} 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: 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-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: dependencies:
'@egjs/hammerjs': 2.0.17 '@egjs/hammerjs': 2.0.17
component-emitter: 1.3.1 component-emitter: 1.3.1
keycharm: 0.4.0 keycharm: 0.4.0
uuid: 11.1.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)
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) **Backend ใช้ Hybrid ID Strategy:** INT PK (internal) + UUID (public API)
- **Database:** `id` = INT AI (Primary Key), `uuid` = UUID (MariaDB native type) - **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) - **Backend Entity:** `id` = INT (@Exclude), `publicId` = UUID (exposed directly in API)
- **API Response:** ส่ง `id` (ซึ่งจริงๆ คือ `publicId` UUID string) ไม่มี INT id - **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 (และวิธีแก้) ### ⚠️ Common Pitfalls (และวิธีแก้)
#### 1. ใช้ `.id` กับ Entity ที่ควรใช้ `.publicId` #### 1. ใช้ `.id` กับ Entity ที่ควรใช้ `.publicId`
```tsx ```tsx
// ❌ WRONG - entity.id อาจเป็น undefined หรือ INT ที่ถูก @Exclude // ❌ WRONG - entity.id ถูก @Exclude จาก API response
contracts.map((c) => <SelectItem key={c.id} value={String(c.id)}>) 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}>) 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 ```tsx
// ❌ WRONG - parseInt บน UUID จะได้ค่า garbage // ❌ WRONG - parseInt บน UUID จะได้ค่า garbage
const id = parseInt(projectId); // "0195..." → 19 (wrong!) const id = parseInt(projectId); // "0195..." → 19 (wrong!)
// ✅ CORRECT - ส่ง UUID string ตรงๆ ไป backend // ✅ CORRECT - ส่ง UUID string ตรงๆ ไป backend
const id = projectId; // "019505a1-7c3e-7000-8000-abc123def456" 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 ```tsx
// ❌ WRONG - ใช้ชื่อ field ไม่ตรงกับ TypeScript interface // ❌ WRONG - สมมติว่า API ส่ง { uuid: string } แต่จริงๆ ส่ง { publicId: string }
fields={[{ name: 'type_code', label: 'Code' }]} type OrganizationOption = {
// interface มี typeCode (camelCase) แต่ form ส่ง type_code (snake_case) uuid?: string; // ผิด!
organizationName?: string;
};
const value = org.uuid ?? org.id; // undefined → dropdown ว่าง
// ✅ CORRECT - ใช้ชื่อ field ตรงกับ interface // ✅ CORRECT - Type ต้องตรงกับ API Response
fields={[{ name: 'typeCode', label: 'Code' }]} type OrganizationOption = {
publicId?: string; // ตรงกับ API
organizationName?: string;
};
const value = org.publicId; // ได้ค่าถูกต้อง
``` ```
#### 4. Contract/Project Select ไม่มีข้อมูล ### 📝 Pattern: Select Options with publicId
```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
```typescript ```typescript
// types/master-data.ts - Entity interfaces // types/master-data.ts - Entity interfaces
export interface Contract { export interface Contract {
id?: number; // Internal INT (อาจถูก @Exclude) publicId?: string; // UUID ที่ API ส่ง — ใช้ตัวนี้
publicId?: string; // UUID ที่ API ส่ง (ต้องใช้ตัวนี้)
contractCode: string; contractCode: string;
contractName: string; contractName: string;
} }
@@ -674,17 +691,17 @@ export interface Contract {
// page.tsx - Select options // page.tsx - Select options
const contractOptions = contracts.map((c) => ({ const contractOptions = contracts.map((c) => ({
label: `${c.contractName} (${c.contractCode})`, label: `${c.contractName} (${c.contractCode})`,
value: String(c.publicId ?? c.id ?? ''), // ADR-019: publicId เป็น UUID value: c.publicId, // publicId only — no fallback
})); }));
// GenericCrudTable fields // GenericCrudTable fields
fields={[ fields={[
{ {
name: 'contractId', name: 'contractPublicId', // DTO field name
label: 'Contract', label: 'Contract',
type: 'select', type: 'select',
required: true, required: true,
options: contractOptions, // ใช้ UUID string เป็น value options: contractOptions,
}, },
]} ]}
``` ```
@@ -716,11 +733,11 @@ const columns: ColumnDef<Discipline>[] = [
### ✅ Checklist ก่อน Commit ### ✅ Checklist ก่อน Commit
- [ ] ใช้ `publicId ?? id` pattern สำหรับ entity identifiers - [ ] ใช้ `publicId` อย่างเดียว (ไม่มี `uuid` หรือ `id` fallback)
- [ ] ไม่ใช้ `parseInt()` บน UUID values - [ ] ไม่ใช้ `parseInt()` บน UUID values
- [ ] Field names ตรงกับ TypeScript interfaces (camelCase) - [ ] Type Definition ตรงกับ API Response field names
- [ ] Select options ใช้ UUID string เป็น value - [ ] Select options ใช้ `publicId` เป็น value
- [ ] แสดง relation columns (Contract/Project) ในตาราง - [ ] DTO field names ใช้ `publicId` suffix (e.g., `projectPublicId`)
--- ---
@@ -243,21 +243,31 @@ async findByUuidOrId(identifier: string): Promise<Entity> {
#### Pattern: Drawing Search (✅ FIXED — reference implementation) #### Pattern: Drawing Search (✅ FIXED — reference implementation)
- Backend DTO accepts `projectUuid: string` instead of `projectId: number` - Backend DTO accepts `projectPublicId: string` instead of `projectId: number`
- Controller resolves: `projectService.findOneByUuid(dto.projectUuid)``dto.projectId = project.id` - Controller resolves: `projectService.findOneByUuid(dto.projectPublicId)``dto.projectId = project.id`
- Frontend sends UUID string directly (no `parseInt`) - 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 | | File | Field | Entity | Issue |
| ------------------------------------- | ----------------------------------------------------- | ------------ | ------------------------------------------------------------ | | ------------------------------------- | ----------------------------------------------------- | ------------ | ------------------------------------------------------------ |
| `correspondences/form.tsx:212` | `projectId` | Project | `parseInt(p.id)` where `p.id` = UUID string (garbled number) | | `correspondences/form.tsx` | `projectPublicId` | Project | Type uses `id` instead of `publicId` |
| `correspondences/form.tsx:326` | `fromOrganizationId` | Organization | `parseInt(String(org.id))` where `org.id` = undefined (NaN) | | `correspondences/form.tsx` | `fromOrganizationPublicId` | Organization | Type uses `uuid/id` instead of `publicId` |
| `correspondences/form.tsx:349` | `toOrganizationId` | Organization | Same as above | | `correspondences/form.tsx` | `toOrganizationPublicId` | Organization | Type uses `uuid/id` instead of `publicId` |
| `admin/users/page.tsx:47` | `primaryOrganizationId` (filter) | Organization | `parseInt(selectedOrgId)` where value = UUID string | | `admin/users/page.tsx` | `primaryOrganizationPublicId` (filter) | Organization | Type uses `id` instead of `publicId` |
| `admin/user-dialog.tsx:226` | `primaryOrganizationId` | Organization | `parseInt(val)` where `org.id` = undefined → `"0"` fallback | | `admin/user-dialog.tsx` | `primaryOrganizationPublicId` | Organization | Type uses `id` instead of `publicId` |
| `numbering/template-tester.tsx:71-74` | `originatorOrganizationId`, `recipientOrganizationId` | Organization | `parseInt` on org UUID | | `numbering/template-tester.tsx` | `originatorOrganizationPublicId` / `recipientOrganizationPublicId` | Organization | Type uses `id` instead of `publicId` |
| `rfas/page.tsx:17` | `projectId` (URL param) | Project | `parseInt(searchParams.get('projectId'))` — UUID if from URL | | `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) #### Fix Strategy (same pattern as Drawing Search fix)
@@ -300,16 +310,6 @@ For each affected backend DTO:
| Order | Task | Effort | Status | | Order | Task | Effort | Status |
| ----- | ------------------------------------------------------------- | ------ | -------------------------- | | ----- | ------------------------------------------------------------- | ------ | -------------------------- |
| 1 | UuidBaseEntity (no transformer needed — MariaDB native UUID) | S | ✅ Done | | 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 **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 ```typescript
export class CorrespondenceResponseDto { export class CorrespondenceResponseDto {
// ✅ Expose publicId as 'id' in API response // ✅ Expose publicId directly in API response
@Expose({ name: 'id' })
publicId!: string; publicId!: string;
// ❌ Never expose internal INT id // ❌ Never expose internal INT id
@@ -279,11 +278,12 @@ export class CorrespondenceResponseDto {
// ... other fields // ... other fields
// For FK references, also use publicId // For FK references, also use publicId
@Expose({ name: 'project_id' })
projectPublicId!: string; 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 ## Migration SQL Script
@@ -473,11 +473,29 @@ WHERE c.uuid = '019505a1-7c3e-7000-8000-abc123def456';
- API รับได้ทั้ง INT และ UUID ผ่าน `FindByIdOrUuid` pattern - API รับได้ทั้ง INT และ UUID ผ่าน `FindByIdOrUuid` pattern
- API Response รวม UUID เป็น `id` field (via @Expose) - 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 - Frontend ใช้ `publicId` เป็น Standard ทุก Type (ไม่ใช้ `uuid` หรือ `id` ที่เป็น number)
- URL parameters เปลี่ยนเป็น UUID - URL parameters ใช้ `publicId` (UUID string)
- ไม่ต้อง Big-Bang migration — ค่อยๆ เปลี่ยนทีละ Module - ทุก 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 ### Phase 4: Cleanup
@@ -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`_ _สำหรับรายละเอียดการ Implement ดูที่ Implementation Plan ใน `05-07-hybrid-uuid-implementation-plan.md`_