260316:1117 20260316:1100 Refactor UUID
Build and Deploy / deploy (push) Successful in 9m24s

This commit is contained in:
admin
2026-03-16 11:17:15 +07:00
parent b93cd91325
commit c5c3ed9016
92 changed files with 1726 additions and 620 deletions
+7 -31
View File
@@ -17,7 +17,7 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**.
### 📊 Project Status: UAT Ready (2026-03-11) ### 📊 Project Status: UAT Ready (2026-03-11)
| Area | Status | Notes | | Area | Status | Notes |
|------|--------|-------| | ------------- | ------------------------ | ------------------------------------ |
| Backend | ✅ Production Ready | 18 Modules, ADR-018 AI Isolation | | Backend | ✅ Production Ready | 18 Modules, ADR-018 AI Isolation |
| Frontend | ✅ 100% Complete | App Router, TanStack Query, Zustand | | Frontend | ✅ 100% Complete | App Router, TanStack Query, Zustand |
| Database | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration (ADR-009) | | Database | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration (ADR-009) |
@@ -54,18 +54,16 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**.
6. **Rate Limiting:** Apply ThrottlerGuard on auth endpoints. 6. **Rate Limiting:** Apply ThrottlerGuard on auth endpoints.
7. **AI Isolation (ADR-018):** Ollama MUST run on Admin Desktop only (NOT on QNAP/production server). AI has NO direct DB access, NO write access to uploads. Output JSON only. 7. **AI Isolation (ADR-018):** Ollama MUST run on Admin Desktop only (NOT on QNAP/production server). AI has NO direct DB access, NO write access to uploads. Output JSON only.
## 📋 Workflow & Spec Guidelines ## 📋 Spec Guidelines
- Always follow specs in `specs/` (v1.8.1). Priority: `06-Decision-Records` > `05-Engineering-Guidelines` > others. - Always follow specs in `specs/` (v1.8.1). Priority: `06-Decision-Records` > `05-Engineering-Guidelines` > others.
- Always verify database schema against **`specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`** before writing queries. (Schema split: `01-drop`, `02-tables`, `03-views-indexes`) - Always verify database schema against **`specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`** before writing queries. (Schema split: `01-drop`, `02-tables`, `03-views-indexes`)
- Check data dictionary at **`specs/03-Data-and-Storage/03-01-data-dictionary.md`** for field meanings and business rules. - Check data dictionary at **`specs/03-Data-and-Storage/03-01-data-dictionary.md`** for field meanings and business rules.
- Check seed data: **`lcbp3-v1.8.0-seed-basic.sql`** (reference data), **`lcbp3-v1.8.0-seed-permissions.sql`** (CASL permissions).
- For migration context: **`specs/03-Data-and-Storage/03-04-legacy-data-migration.md`** and **`03-05-n8n-migration-setup-guide.md`**.
### 📁 Key Spec Documents (Quick Reference) ### 📁 Key Spec Documents (Quick Reference)
| เอกสาร | Path | ใช้เมื่อ | | เอกสาร | Path | ใช้เมื่อ |
|--------|------|--------| | -------------------- | ----------------------------------------------------------- | ----------------------------------- |
| **Schema Tables** | `03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | ก่อนเขียน Query ทุกครั้ง | | **Schema Tables** | `03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | ก่อนเขียน Query ทุกครั้ง |
| **Data Dictionary** | `03-Data-and-Storage/03-01-data-dictionary.md` | ตรวจ Field Meaning + Business Rules | | **Data Dictionary** | `03-Data-and-Storage/03-01-data-dictionary.md` | ตรวจ Field Meaning + Business Rules |
| **Seed Permissions** | `03-Data-and-Storage/lcbp3-v1.8.0-seed-permissions.sql` | ตรวจ CASL Permission Matrix | | **Seed Permissions** | `03-Data-and-Storage/lcbp3-v1.8.0-seed-permissions.sql` | ตรวจ CASL Permission Matrix |
@@ -75,13 +73,12 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**.
| **UAT Criteria** | `01-Requirements/01-05-acceptance-criteria.md` | ตรวจความสมบูรณ์ Feature | | **UAT Criteria** | `01-Requirements/01-05-acceptance-criteria.md` | ตรวจความสมบูรณ์ Feature |
| **ADR-009** | `06-Decision-Records/ADR-009-db-strategy.md` | Schema Change Process | | **ADR-009** | `06-Decision-Records/ADR-009-db-strategy.md` | Schema Change Process |
| **ADR-018** | `06-Decision-Records/ADR-018-ai-boundary.md` | AI/Ollama Integration Rules | | **ADR-018** | `06-Decision-Records/ADR-018-ai-boundary.md` | AI/Ollama Integration Rules |
| **ADR-019** | `06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | Hybrid ID Strategy (INT + UUIDv7) |
### ADR Reference (All 17 + Patch) ### ADR Reference (All 17 + Patch + ADR-019)
Adhere to all ADRs in `specs/06-Decision-Records/`:
| ADR | Topic | Key Decision | | ADR | Topic | Key Decision |
| ------- | ------------------------- | -------------------------------------------------- | | ------- | -------------------------- | -------------------------------------------------- |
| ADR-001 | Workflow Engine | Unified state machine for document workflows | | ADR-001 | Workflow Engine | Unified state machine for document workflows |
| ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking | | ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking |
| ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis | | ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis |
@@ -97,28 +94,7 @@ Adhere to all ADRs in `specs/06-Decision-Records/`:
| ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV | | ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV |
| ADR-017 | Ollama Migration | Local AI + n8n for legacy data import | | ADR-017 | Ollama Migration | Local AI + n8n for legacy data import |
| ADR-018 | AI Boundary (Patch 1.8.1) | AI isolation — no direct DB/storage access | | ADR-018 | AI Boundary (Patch 1.8.1) | AI isolation — no direct DB/storage access |
| ADR-019 | Hybrid Identifier Strategy | INT PK (internal) + UUIDv7 BINARY(16) (public API) |
## 🎯 Active Skills
- **`nestjs-best-practices`** — Apply when writing/reviewing any NestJS code (modules, services, controllers, guards, interceptors, DTOs)
- **`next-best-practices`** — Apply when writing/reviewing any Next.js code (App Router, RSC boundaries, async patterns, data fetching, error handling)
- **`speckit.security-audit`** — Apply when auditing security (OWASP Top 10, CASL, ClamAV, LCBP3-specific checks)
## 🔄 Speckit Workflow Pipeline
Use `/slash-command` to trigger these workflows. Always prefer spec-driven development for new features.
| Phase | Command | เมื่อใช้ |
| -------------------- | ---------------------------------------------------------- | ----------------------------------------------------- |
| **Full Pipeline** | `/speckit.all` | Feature ใหม่ — รัน Specify→...→Validate (10 steps) |
| **Feature Design** | `/speckit.prepare` | Preparation only — Specify→Clarify→Plan→Tasks→Analyze |
| **Implement** | `/07-speckit.implement` | เขียนโค้ดตาม tasks.md พร้อม anti-regression |
| **QA** | `/08-speckit.checker` | ตรวจ TypeScript + ESLint + Security |
| **Test** | `/09-speckit.tester` | รัน Jest/Vitest + coverage report |
| **Review** | `/10-speckit.reviewer` | Code review — Logic, Performance, Style |
| **Validate** | `/11-speckit.validate` | ยืนยันว่า implementation ตรงกับ spec.md |
| **Schema Change** | `/schema-change` | แก้ schema SQL → data dictionary → notify user |
| **Project-Specific** | `/create-backend-module` `/create-frontend-page` `/deploy` | งานประจำของ LCBP3-DMS |
## 🚫 Forbidden Actions ## 🚫 Forbidden Actions
+6 -4
View File
@@ -15,7 +15,7 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**.
### 📊 Project Status: UAT Ready (2026-03-11) ### 📊 Project Status: UAT Ready (2026-03-11)
| Area | Status | Notes | | Area | Status | Notes |
|------|--------|-------| | ------------- | ------------------------ | ------------------------------------ |
| Backend | ✅ Production Ready | 18 Modules, ADR-018 AI Isolation | | Backend | ✅ Production Ready | 18 Modules, ADR-018 AI Isolation |
| Frontend | ✅ 100% Complete | App Router, TanStack Query, Zustand | | Frontend | ✅ 100% Complete | App Router, TanStack Query, Zustand |
| Database | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration (ADR-009) | | Database | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration (ADR-009) |
@@ -61,7 +61,7 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**.
### 📁 Key Spec Documents (Quick Reference) ### 📁 Key Spec Documents (Quick Reference)
| เอกสาร | Path | ใช้เมื่อ | | เอกสาร | Path | ใช้เมื่อ |
|--------|------|--------| | -------------------- | ----------------------------------------------------------- | ----------------------------------- |
| **Schema Tables** | `03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | ก่อนเขียน Query ทุกครั้ง | | **Schema Tables** | `03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | ก่อนเขียน Query ทุกครั้ง |
| **Data Dictionary** | `03-Data-and-Storage/03-01-data-dictionary.md` | ตรวจ Field Meaning + Business Rules | | **Data Dictionary** | `03-Data-and-Storage/03-01-data-dictionary.md` | ตรวจ Field Meaning + Business Rules |
| **Seed Permissions** | `03-Data-and-Storage/lcbp3-v1.8.0-seed-permissions.sql` | ตรวจ CASL Permission Matrix | | **Seed Permissions** | `03-Data-and-Storage/lcbp3-v1.8.0-seed-permissions.sql` | ตรวจ CASL Permission Matrix |
@@ -71,11 +71,12 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**.
| **UAT Criteria** | `01-Requirements/01-05-acceptance-criteria.md` | ตรวจความสมบูรณ์ Feature | | **UAT Criteria** | `01-Requirements/01-05-acceptance-criteria.md` | ตรวจความสมบูรณ์ Feature |
| **ADR-009** | `06-Decision-Records/ADR-009-db-strategy.md` | Schema Change Process | | **ADR-009** | `06-Decision-Records/ADR-009-db-strategy.md` | Schema Change Process |
| **ADR-018** | `06-Decision-Records/ADR-018-ai-boundary.md` | AI/Ollama Integration Rules | | **ADR-018** | `06-Decision-Records/ADR-018-ai-boundary.md` | AI/Ollama Integration Rules |
| **ADR-019** | `06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | Hybrid ID Strategy (INT + UUIDv7) |
### ADR Reference (All 17 + Patch) ### ADR Reference (All 17 + Patch + ADR-019)
| ADR | Topic | Key Decision | | ADR | Topic | Key Decision |
| ------- | ------------------------- | -------------------------------------------------- | | ------- | -------------------------- | -------------------------------------------------- |
| ADR-001 | Workflow Engine | Unified state machine for document workflows | | ADR-001 | Workflow Engine | Unified state machine for document workflows |
| ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking | | ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking |
| ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis | | ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis |
@@ -91,6 +92,7 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**.
| ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV | | ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV |
| ADR-017 | Ollama Migration | Local AI + n8n for legacy data import | | ADR-017 | Ollama Migration | Local AI + n8n for legacy data import |
| ADR-018 | AI Boundary (Patch 1.8.1) | AI isolation — no direct DB/storage access | | ADR-018 | AI Boundary (Patch 1.8.1) | AI isolation — no direct DB/storage access |
| ADR-019 | Hybrid Identifier Strategy | INT PK (internal) + UUIDv7 BINARY(16) (public API) |
## 🚫 Forbidden Actions ## 🚫 Forbidden Actions
+38 -10
View File
@@ -8,7 +8,19 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**.
## 🏗️ Project Overview ## 🏗️ Project Overview
**LCBP3-DMS (Laem Chabang Port Phase 3 - Document Management System)** — Version 1.8.0 (Patch 1.8.1) **LCBP3-DMS (Laem Chabang Port Phase 3 - Document Management System)** — Version 1.8.1 (Patch)
### 📊 Project Status: UAT Ready (2026-03-11)
| Area | Status | Notes |
| ------------- | ------------------------ | ------------------------------------ |
| Backend | ✅ Production Ready | 18 Modules, ADR-018 AI Isolation |
| Frontend | ✅ 100% Complete | App Router, TanStack Query, Zustand |
| Database | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration (ADR-009) |
| Documentation | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy |
| AI Migration | 🔄 Pre-migration Setup | n8n + Ollama (ADR-017/018) |
| Testing | 🔄 UAT In Progress | Per `01-05-acceptance-criteria.md` |
| Deployment | 📋 Pending Go-Live | Blue-Green, QNAP Container Station |
- **Goal:** Manage construction documents (Correspondence, RFA, Contract Drawings, Shop Drawings) - **Goal:** Manage construction documents (Correspondence, RFA, Contract Drawings, Shop Drawings)
with complex multi-level approval workflows. with complex multi-level approval workflows.
@@ -38,20 +50,31 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**.
6. **Rate Limiting:** Apply ThrottlerGuard on auth endpoints. 6. **Rate Limiting:** Apply ThrottlerGuard on auth endpoints.
7. **AI Isolation (ADR-018):** Ollama MUST run on Admin Desktop only (NOT on QNAP/production server). AI has NO direct DB access, NO write access to uploads. Output JSON only. 7. **AI Isolation (ADR-018):** Ollama MUST run on Admin Desktop only (NOT on QNAP/production server). AI has NO direct DB access, NO write access to uploads. Output JSON only.
## 📋 Workflow & Spec Guidelines ## 📋 Spec Guidelines
- Always follow specs in `specs/` (v1.8.0). Priority: `06-Decision-Records` > `05-Engineering-Guidelines` > others. - Always follow specs in `specs/` (v1.8.1). Priority: `06-Decision-Records` > `05-Engineering-Guidelines` > others.
- Always verify database schema against **`specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`** before writing queries. (Schema split: `01-drop`, `02-tables`, `03-views-indexes`) - Always verify database schema against **`specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`** before writing queries. (Schema split: `01-drop`, `02-tables`, `03-views-indexes`)
- Check data dictionary at **`specs/03-Data-and-Storage/03-01-data-dictionary.md`** for field meanings and business rules. - Check data dictionary at **`specs/03-Data-and-Storage/03-01-data-dictionary.md`** for field meanings and business rules.
- Check seed data: **`lcbp3-v1.8.0-seed-basic.sql`** (reference data), **`lcbp3-v1.8.0-seed-permissions.sql`** (CASL permissions).
- For migration context: **`specs/03-Data-and-Storage/03-04-legacy-data-migration.md`** and **`03-05-n8n-migration-setup-guide.md`**.
### ADR Reference (All 17 + Patch) ### 📁 Key Spec Documents (Quick Reference)
Adhere to all ADRs in `specs/06-Decision-Records/`: | เอกสาร | Path | ใช้เมื่อ |
| -------------------- | ----------------------------------------------------------- | ----------------------------------- |
| **Schema Tables** | `03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | ก่อนเขียน Query ทุกครั้ง |
| **Data Dictionary** | `03-Data-and-Storage/03-01-data-dictionary.md` | ตรวจ Field Meaning + Business Rules |
| **Seed Permissions** | `03-Data-and-Storage/lcbp3-v1.8.0-seed-permissions.sql` | ตรวจ CASL Permission Matrix |
| **Edge Cases** | `01-Requirements/01-06-edge-cases-and-rules.md` | 37 Rules ป้องกัน Bug |
| **Migration Scope** | `03-Data-and-Storage/03-06-migration-business-scope.md` | งาน Migration Bot |
| **Release Policy** | `04-Infrastructure-OPS/04-08-release-management-policy.md` | ก่อน Deploy / Hotfix |
| **UAT Criteria** | `01-Requirements/01-05-acceptance-criteria.md` | ตรวจความสมบูรณ์ Feature |
| **ADR-009** | `06-Decision-Records/ADR-009-db-strategy.md` | Schema Change Process |
| **ADR-018** | `06-Decision-Records/ADR-018-ai-boundary.md` | AI/Ollama Integration Rules |
| **ADR-019** | `06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | Hybrid ID Strategy (INT + UUIDv7) |
### ADR Reference (All 17 + Patch + ADR-019)
| ADR | Topic | Key Decision | | ADR | Topic | Key Decision |
| ------- | ------------------------- | -------------------------------------------------- | | ------- | -------------------------- | -------------------------------------------------- |
| ADR-001 | Workflow Engine | Unified state machine for document workflows | | ADR-001 | Workflow Engine | Unified state machine for document workflows |
| ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking | | ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking |
| ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis | | ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis |
@@ -67,14 +90,19 @@ Adhere to all ADRs in `specs/06-Decision-Records/`:
| ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV | | ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV |
| ADR-017 | Ollama Migration | Local AI + n8n for legacy data import | | ADR-017 | Ollama Migration | Local AI + n8n for legacy data import |
| ADR-018 | AI Boundary (Patch 1.8.1) | AI isolation — no direct DB/storage access | | ADR-018 | AI Boundary (Patch 1.8.1) | AI isolation — no direct DB/storage access |
| ADR-019 | Hybrid Identifier Strategy | INT PK (internal) + UUIDv7 BINARY(16) (public API) |
## 🚫 Forbidden Actions ## 🚫 Forbidden Actions
- DO NOT use SQL Triggers (Business logic must be in NestJS services). - DO NOT use SQL Triggers (Business logic must be in NestJS services).
- DO NOT use `.env` files for production deployment — QNAP Container Station requires secrets directly in `docker-compose.yml` environment section. - DO NOT use `.env` files for production deployment — QNAP Container Station requires secrets directly in `docker-compose.yml` environment section.
- DO NOT run database migrations — modify the schema SQL file directly. - DO NOT run database migrations — modify the schema SQL file directly (ADR-009).
- DO NOT invent table names or columns — use ONLY what is defined in the schema SQL file. - DO NOT invent table names or columns — use ONLY what is defined in the schema SQL file.
- DO NOT generate code that violates OWASP Top 10 security practices. - DO NOT generate code that violates OWASP Top 10 security practices.
- DO NOT use `any` TypeScript type anywhere. - DO NOT use `any` TypeScript type anywhere.
- DO NOT let AI (Ollama) access production database directly — all writes go through DMS API. - DO NOT let AI (Ollama) access production database directly — all writes go through DMS API (ADR-018).
- DO NOT bypass StorageService for file operations — all file moves must go through the API. - DO NOT bypass StorageService for file operations — all file moves must go through the API.
- DO NOT deploy to Production without completing Release Gates — see `04-08-release-management-policy.md`.
- DO NOT start Legacy Migration without Go/No-Go Gate #1 approval — see `03-06-migration-business-scope.md`.
- DO NOT modify Migration Bot Token scope — IP Whitelist + 7-day Expiry + REVOKE after migration.
- DO NOT close UAT sign-off without all Acceptance Criteria ✅ — see `01-05-acceptance-criteria.md`.
+2 -2
View File
@@ -75,7 +75,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": "^9.0.1", "uuid": "^11.1.0",
"winston": "^3.18.3", "winston": "^3.18.3",
"zod": "^4.1.13" "zod": "^4.1.13"
}, },
@@ -98,7 +98,7 @@
"@types/opossum": "^8.1.9", "@types/opossum": "^8.1.9",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8", "@types/uuid": "^10.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
@@ -0,0 +1,28 @@
import { Column, BeforeInsert } from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
/**
* Abstract base entity providing a UUID public identifier column.
* Uses MariaDB native UUID type (stored as BINARY(16) internally,
* auto-converts to string format — no transformer needed).
*
* App generates UUIDv7 via @BeforeInsert(); DB DEFAULT UUID() is fallback.
*
* @see ADR-019 Hybrid Identifier Strategy
*/
export abstract class UuidBaseEntity {
@Column({
type: 'uuid',
unique: true,
nullable: false,
comment: 'UUID Public Identifier (ADR-019)',
})
uuid!: string;
@BeforeInsert()
generateUuid(): void {
if (!this.uuid) {
this.uuid = uuidv7();
}
}
}
@@ -7,10 +7,13 @@ import {
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { User } from '../../../modules/user/entities/user.entity'; import { User } from '../../../modules/user/entities/user.entity';
import { UuidBaseEntity } from '../../entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('attachments') @Entity('attachments')
export class Attachment { export class Attachment extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; id!: number;
@Column({ name: 'original_filename', length: 255 }) @Column({ name: 'original_filename', length: 255 })
@@ -9,6 +9,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { instanceToPlain } from 'class-transformer';
/** Metadata สำหรับ Paginated Response */ /** Metadata สำหรับ Paginated Response */
export interface ResponseMeta { export interface ResponseMeta {
@@ -53,24 +54,31 @@ export class TransformInterceptor<T>
): Observable<ApiResponse<T>> { ): Observable<ApiResponse<T>> {
return next.handle().pipe( return next.handle().pipe(
map((data: T) => { map((data: T) => {
const response = context.switchToHttp().getResponse<{ statusCode: number }>(); const response = context
.switchToHttp()
.getResponse<{ statusCode: number }>();
// ADR-019: Serialize entities via class-transformer
// This applies @Exclude() decorators to strip internal INT ids from responses
const serialized = instanceToPlain(data) as T;
// Handle Pagination Response (Standardize) // Handle Pagination Response (Standardize)
// ถ้า data มี structure { data: [], meta: {} } ให้ unzip ออกมา // ถ้า data มี structure { data: [], meta: {} } ให้ unzip ออกมา
if (isPaginatedPayload(data)) { if (isPaginatedPayload(serialized)) {
return { return {
statusCode: response.statusCode, statusCode: response.statusCode,
message: data.message ?? 'Success', message: serialized.message ?? 'Success',
data: data.data as unknown as T, data: serialized.data as unknown as T,
meta: data.meta, meta: serialized.meta,
}; };
} }
const dataAsRecord = data as Record<string, unknown>; const dataAsRecord = serialized as Record<string, unknown>;
return { return {
statusCode: response.statusCode, statusCode: response.statusCode,
message: (dataAsRecord?.['message'] as string | undefined) ?? 'Success', message:
data: (dataAsRecord?.['result'] as T | undefined) ?? data, (dataAsRecord?.['message'] as string | undefined) ?? 'Success',
data: (dataAsRecord?.['result'] as T | undefined) ?? serialized,
}; };
}) })
); );
@@ -0,0 +1,20 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { validate as uuidValidate } from 'uuid';
/**
* Validates that a route parameter is a valid UUID string.
* Accepts any UUID version (v1 from DB DEFAULT, v7 from app generation).
*
* Usage: @Param('uuid', ParseUuidPipe) uuid: string
*
* @see ADR-019 Hybrid Identifier Strategy
*/
@Injectable()
export class ParseUuidPipe implements PipeTransform<string> {
transform(value: string): string {
if (!uuidValidate(value)) {
throw new BadRequestException(`Invalid UUID format: ${value}`);
}
return value.toLowerCase();
}
}
@@ -22,6 +22,7 @@ import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Audit } from '../../common/decorators/audit.decorator'; // Import import { Audit } from '../../common/decorators/audit.decorator'; // Import
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
@ApiTags('Circulations') @ApiTags('Circulations')
@ApiBearerAuth() @ApiBearerAuth()
@@ -45,11 +46,11 @@ export class CirculationController {
return this.circulationService.findAll(searchDto, user); return this.circulationService.findAll(searchDto, user);
} }
@Get(':id') @Get(':uuid')
@ApiOperation({ summary: 'Get circulation details' }) @ApiOperation({ summary: 'Get circulation details' })
@RequirePermission('document.view') @RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.circulationService.findOne(id); return this.circulationService.findOneByUuid(uuid);
} }
@Patch('routings/:id') @Patch('routings/:id')
@@ -58,7 +59,7 @@ export class CirculationController {
updateRouting( updateRouting(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateCirculationRoutingDto, @Body() updateDto: UpdateCirculationRoutingDto,
@CurrentUser() user: User, @CurrentUser() user: User
) { ) {
return this.circulationService.updateRoutingStatus(id, updateDto, user); return this.circulationService.updateRoutingStatus(id, updateDto, user);
} }
@@ -113,6 +113,17 @@ export class CirculationService {
return circulation; return circulation;
} }
async findOneByUuid(uuid: string) {
const circulation = await this.circulationRepo.findOne({
where: { uuid },
relations: ['routings', 'routings.assignee', 'correspondence', 'creator'],
order: { routings: { stepNumber: 'ASC' } },
});
if (!circulation)
throw new NotFoundException(`Circulation UUID ${uuid} not found`);
return circulation;
}
// ✅ Logic อัปเดตสถานะและปิดงาน // ✅ Logic อัปเดตสถานะและปิดงาน
async updateRoutingStatus( async updateRoutingStatus(
routingId: number, routingId: number,
@@ -13,10 +13,13 @@ import { Organization } from '../../organization/entities/organization.entity';
import { User } from '../../user/entities/user.entity'; import { User } from '../../user/entities/user.entity';
import { CirculationStatusCode } from './circulation-status-code.entity'; import { CirculationStatusCode } from './circulation-status-code.entity';
import { CirculationRouting } from './circulation-routing.entity'; import { CirculationRouting } from './circulation-routing.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('circulations') @Entity('circulations')
export class Circulation { export class Circulation extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; id!: number;
@Column({ name: 'correspondence_id', nullable: true }) @Column({ name: 'correspondence_id', nullable: true })
@@ -7,7 +7,6 @@ import {
Param, Param,
Delete, Delete,
UseGuards, UseGuards,
ParseIntPipe,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
@@ -22,6 +21,7 @@ import { UpdateContractDto } from './dto/update-contract.dto.js';
import { SearchContractDto } from './dto/search-contract.dto.js'; import { SearchContractDto } from './dto/search-contract.dto.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js'; import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
@ApiTags('Contracts') @ApiTags('Contracts')
@ApiBearerAuth() @ApiBearerAuth()
@@ -45,26 +45,26 @@ export class ContractController {
return this.contractService.findAll(query); return this.contractService.findAll(query);
} }
@Get(':id') @Get(':uuid')
@ApiOperation({ summary: 'Get Contract by ID' }) @ApiOperation({ summary: 'Get Contract by UUID' })
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.contractService.findOne(id); return this.contractService.findOneByUuid(uuid);
} }
@Patch(':id') @Patch(':uuid')
@RequirePermission('master_data.manage') @RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Update Contract' }) @ApiOperation({ summary: 'Update Contract' })
update( update(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() dto: UpdateContractDto @Body() dto: UpdateContractDto
) { ) {
return this.contractService.update(id, dto); return this.contractService.update(uuid, dto);
} }
@Delete(':id') @Delete(':uuid')
@RequirePermission('master_data.manage') @RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Delete Contract' }) @ApiOperation({ summary: 'Delete Contract' })
remove(@Param('id', ParseIntPipe) id: number) { remove(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.contractService.remove(id); return this.contractService.remove(uuid);
} }
} }
@@ -87,15 +87,24 @@ export class ContractService {
return contract; return contract;
} }
async update(id: number, dto: UpdateContractDto) { async findOneByUuid(uuid: string) {
const contract = await this.findOne(id); const contract = await this.contractRepo.findOne({
where: { uuid },
relations: ['project'],
});
if (!contract)
throw new NotFoundException(`Contract UUID ${uuid} not found`);
return contract;
}
async update(uuid: string, dto: UpdateContractDto) {
const contract = await this.findOneByUuid(uuid);
Object.assign(contract, dto); Object.assign(contract, dto);
return this.contractRepo.save(contract); return this.contractRepo.save(contract);
} }
async remove(id: number) { async remove(uuid: string) {
const contract = await this.findOne(id); const contract = await this.findOneByUuid(uuid);
// Schema doesn't have deleted_at for Contract either.
return this.contractRepo.remove(contract); return this.contractRepo.remove(contract);
} }
} }
@@ -4,15 +4,34 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
BeforeInsert,
} from 'typeorm'; } from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
import { Exclude } from 'class-transformer';
import { BaseEntity } from '../../../common/entities/base.entity'; import { BaseEntity } from '../../../common/entities/base.entity';
import { Project } from '../../project/entities/project.entity'; import { Project } from '../../project/entities/project.entity';
@Entity('contracts') @Entity('contracts')
export class Contract extends BaseEntity { export class Contract extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; id!: number;
@Column({
type: 'uuid',
unique: true,
nullable: false,
comment: 'UUID Public Identifier (ADR-019)',
})
uuid!: string;
@BeforeInsert()
generateUuid(): void {
if (!this.uuid) {
this.uuid = uuidv7();
}
}
@Column({ name: 'project_id' }) @Column({ name: 'project_id' })
projectId!: number; projectId!: number;
@@ -30,6 +30,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard'; import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Audit } from '../../common/decorators/audit.decorator'; import { Audit } from '../../common/decorators/audit.decorator';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
@ApiTags('Correspondences') @ApiTags('Correspondences')
@Controller('correspondences') @Controller('correspondences')
@@ -119,7 +120,7 @@ export class CorrespondenceController {
return this.correspondenceService.findAll(searchDto); return this.correspondenceService.findAll(searchDto);
} }
@Post(':id/submit') @Post(':uuid/submit')
@ApiOperation({ summary: 'Submit correspondence to Unified Workflow Engine' }) @ApiOperation({ summary: 'Submit correspondence to Unified Workflow Engine' })
@ApiResponse({ @ApiResponse({
status: 201, status: 201,
@@ -127,8 +128,8 @@ export class CorrespondenceController {
}) })
@RequirePermission('correspondence.create') @RequirePermission('correspondence.create')
@Audit('correspondence.submit', 'correspondence') @Audit('correspondence.submit', 'correspondence')
submit( async submit(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() submitDto: SubmitCorrespondenceDto, @Body() submitDto: SubmitCorrespondenceDto,
@Request() @Request()
req: Request & { req: Request & {
@@ -138,28 +139,29 @@ export class CorrespondenceController {
}; };
} }
) { ) {
const corr = await this.correspondenceService.findOneByUuid(uuid);
// Extract roles from user assignments // Extract roles from user assignments
const userRoles = const userRoles =
req.user.assignments?.map((a) => a.role?.roleName).filter(Boolean) || []; req.user.assignments?.map((a) => a.role?.roleName).filter(Boolean) || [];
// Use Unified Workflow Engine - pass user roles for DSL requirements check // Use Unified Workflow Engine - pass user roles for DSL requirements check
return this.workflowService.submitWorkflow( return this.workflowService.submitWorkflow(
id, corr.id,
req.user.user_id, req.user.user_id,
userRoles, userRoles,
submitDto.note submitDto.note
); );
} }
@Get(':id') @Get(':uuid')
@ApiOperation({ summary: 'Get correspondence by ID' }) @ApiOperation({ summary: 'Get correspondence by UUID' })
@ApiResponse({ status: 200, description: 'Return correspondence details.' }) @ApiResponse({ status: 200, description: 'Return correspondence details.' })
@RequirePermission('document.view') @RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.correspondenceService.findOne(id); return this.correspondenceService.findOneByUuid(uuid);
} }
@Put(':id') @Put(':uuid')
@ApiOperation({ summary: 'Update correspondence (Draft only)' }) @ApiOperation({ summary: 'Update correspondence (Draft only)' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -167,48 +169,52 @@ export class CorrespondenceController {
}) })
@RequirePermission('correspondence.create') // Assuming create permission is enough for draft update, or add 'correspondence.edit' @RequirePermission('correspondence.create') // Assuming create permission is enough for draft update, or add 'correspondence.edit'
@Audit('correspondence.update', 'correspondence') @Audit('correspondence.update', 'correspondence')
update( async update(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() updateDto: UpdateCorrespondenceDto, @Body() updateDto: UpdateCorrespondenceDto,
@Request() req: Request & { user: unknown } @Request() req: Request & { user: unknown }
) { ) {
const corr = await this.correspondenceService.findOneByUuid(uuid);
return this.correspondenceService.update( return this.correspondenceService.update(
id, corr.id,
updateDto, updateDto,
req.user as Parameters<typeof this.correspondenceService.create>[1] req.user as Parameters<typeof this.correspondenceService.create>[1]
); );
} }
@Get(':id/references') @Get(':uuid/references')
@ApiOperation({ summary: 'Get referenced documents' }) @ApiOperation({ summary: 'Get referenced documents' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Return list of referenced documents.', description: 'Return list of referenced documents.',
}) })
@RequirePermission('document.view') @RequirePermission('document.view')
getReferences(@Param('id', ParseIntPipe) id: number) { async getReferences(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.correspondenceService.getReferences(id); const corr = await this.correspondenceService.findOneByUuid(uuid);
return this.correspondenceService.getReferences(corr.id);
} }
@Post(':id/references') @Post(':uuid/references')
@ApiOperation({ summary: 'Add reference to another document' }) @ApiOperation({ summary: 'Add reference to another document' })
@ApiResponse({ status: 201, description: 'Reference added successfully.' }) @ApiResponse({ status: 201, description: 'Reference added successfully.' })
@RequirePermission('document.edit') @RequirePermission('document.edit')
addReference( async addReference(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() dto: AddReferenceDto @Body() dto: AddReferenceDto
) { ) {
return this.correspondenceService.addReference(id, dto); const corr = await this.correspondenceService.findOneByUuid(uuid);
return this.correspondenceService.addReference(corr.id, dto);
} }
@Delete(':id/references/:targetId') @Delete(':uuid/references/:targetId')
@ApiOperation({ summary: 'Remove reference' }) @ApiOperation({ summary: 'Remove reference' })
@ApiResponse({ status: 200, description: 'Reference removed successfully.' }) @ApiResponse({ status: 200, description: 'Reference removed successfully.' })
@RequirePermission('document.edit') @RequirePermission('document.edit')
removeReference( async removeReference(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Param('targetId', ParseIntPipe) targetId: number @Param('targetId', ParseIntPipe) targetId: number
) { ) {
return this.correspondenceService.removeReference(id, targetId); const corr = await this.correspondenceService.findOneByUuid(uuid);
return this.correspondenceService.removeReference(corr.id, targetId);
} }
} }
@@ -333,6 +333,26 @@ export class CorrespondenceService {
return correspondence; return correspondence;
} }
async findOneByUuid(uuid: string) {
const correspondence = await this.correspondenceRepo.findOne({
where: { uuid },
relations: [
'revisions',
'revisions.status',
'type',
'project',
'originator',
'recipients',
'recipients.recipientOrganization',
],
});
if (!correspondence) {
throw new NotFoundException(`Correspondence with UUID ${uuid} not found`);
}
return correspondence;
}
async addReference(id: number, dto: AddReferenceDto) { async addReference(id: number, dto: AddReferenceDto) {
const source = await this.correspondenceRepo.findOne({ where: { id } }); const source = await this.correspondenceRepo.findOne({ where: { id } });
const target = await this.correspondenceRepo.findOne({ const target = await this.correspondenceRepo.findOne({
@@ -13,13 +13,16 @@ import { RfaRevision } from '../../rfa/entities/rfa-revision.entity';
import { Correspondence } from './correspondence.entity'; import { Correspondence } from './correspondence.entity';
import { CorrespondenceStatus } from './correspondence-status.entity'; import { CorrespondenceStatus } from './correspondence-status.entity';
import { User } from '../../user/entities/user.entity'; import { User } from '../../user/entities/user.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('correspondence_revisions') @Entity('correspondence_revisions')
// ✅ เพิ่ม Index สำหรับ Virtual Columns เพื่อให้ Search เร็วขึ้น // ✅ เพิ่ม Index สำหรับ Virtual Columns เพื่อให้ Search เร็วขึ้น
@Index('idx_corr_rev_v_project', ['vRefProjectId']) @Index('idx_corr_rev_v_project', ['vRefProjectId'])
@Index('idx_corr_rev_v_type', ['vRefType']) @Index('idx_corr_rev_v_type', ['vRefType'])
export class CorrespondenceRevision { export class CorrespondenceRevision extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; id!: number;
@Column({ name: 'correspondence_id' }) @Column({ name: 'correspondence_id' })
@@ -15,10 +15,13 @@ import { User } from '../../user/entities/user.entity';
import { CorrespondenceRecipient } from './correspondence-recipient.entity'; import { CorrespondenceRecipient } from './correspondence-recipient.entity';
import { CorrespondenceRevision } from './correspondence-revision.entity'; import { CorrespondenceRevision } from './correspondence-revision.entity';
import { Discipline } from '../../master/entities/discipline.entity'; import { Discipline } from '../../master/entities/discipline.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('correspondences') @Entity('correspondences')
export class Correspondence { export class Correspondence extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; id!: number;
@Column({ name: 'correspondence_number', length: 100 }) @Column({ name: 'correspondence_number', length: 100 })
@@ -6,7 +6,6 @@ import {
Body, Body,
Param, Param,
Query, Query,
ParseIntPipe,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
UseGuards, UseGuards,
@@ -34,6 +33,7 @@ import { SearchAsBuiltDrawingDto } from './dto/search-asbuilt-drawing.dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Audit } from '../../common/decorators/audit.decorator'; import { Audit } from '../../common/decorators/audit.decorator';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity';
@ApiTags('Drawings - AS Built') @ApiTags('Drawings - AS Built')
@@ -56,16 +56,17 @@ export class AsBuiltDrawingController {
return this.asBuiltDrawingService.create(createDto, user); return this.asBuiltDrawingService.create(createDto, user);
} }
@Post(':id/revisions') @Post(':uuid/revisions')
@ApiOperation({ summary: 'Create new revision for AS Built Drawing' }) @ApiOperation({ summary: 'Create new revision for AS Built Drawing' })
@ApiResponse({ status: 201, description: 'Revision created' }) @ApiResponse({ status: 201, description: 'Revision created' })
@ApiResponse({ status: 404, description: 'AS Built Drawing not found' }) @ApiResponse({ status: 404, description: 'AS Built Drawing not found' })
@ApiResponse({ status: 409, description: 'Revision label already exists' }) @ApiResponse({ status: 409, description: 'Revision label already exists' })
async createRevision( async createRevision(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() createDto: CreateAsBuiltDrawingRevisionDto @Body() createDto: CreateAsBuiltDrawingRevisionDto
) { ) {
return this.asBuiltDrawingService.createRevision(id, createDto); const drawing = await this.asBuiltDrawingService.findOneByUuid(uuid);
return this.asBuiltDrawingService.createRevision(drawing.id, createDto);
} }
@Get() @Get()
@@ -76,16 +77,16 @@ export class AsBuiltDrawingController {
return this.asBuiltDrawingService.findAll(searchDto); return this.asBuiltDrawingService.findAll(searchDto);
} }
@Get(':id') @Get(':uuid')
@ApiOperation({ summary: 'Get AS Built Drawing by ID' }) @ApiOperation({ summary: 'Get AS Built Drawing by UUID' })
@ApiResponse({ status: 200, description: 'AS Built Drawing details' }) @ApiResponse({ status: 200, description: 'AS Built Drawing details' })
@ApiResponse({ status: 404, description: 'AS Built Drawing not found' }) @ApiResponse({ status: 404, description: 'AS Built Drawing not found' })
@RequirePermission('drawing.view') @RequirePermission('drawing.view')
async findOne(@Param('id', ParseIntPipe) id: number) { async findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.asBuiltDrawingService.findOne(id); return this.asBuiltDrawingService.findOneByUuid(uuid);
} }
@Delete(':id') @Delete(':uuid')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft delete AS Built Drawing' }) @ApiOperation({ summary: 'Soft delete AS Built Drawing' })
@ApiResponse({ status: 204, description: 'AS Built Drawing deleted' }) @ApiResponse({ status: 204, description: 'AS Built Drawing deleted' })
@@ -93,9 +94,10 @@ export class AsBuiltDrawingController {
@RequirePermission('drawing.delete') @RequirePermission('drawing.delete')
@Audit('drawing.delete', 'asbuilt_drawing') @Audit('drawing.delete', 'asbuilt_drawing')
async remove( async remove(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@CurrentUser() user: User @CurrentUser() user: User
) { ) {
return this.asBuiltDrawingService.remove(id, user); const drawing = await this.asBuiltDrawingService.findOneByUuid(uuid);
return this.asBuiltDrawingService.remove(drawing.id, user);
} }
} }
@@ -295,6 +295,28 @@ export class AsBuiltDrawingService {
return asBuiltDrawing; return asBuiltDrawing;
} }
async findOneByUuid(uuid: string) {
const asBuiltDrawing = await this.asBuiltDrawingRepo.findOne({
where: { uuid },
relations: [
'mainCategory',
'subCategory',
'revisions',
'revisions.attachments',
'revisions.shopDrawingRevisions',
],
order: {
revisions: { revisionNumber: 'DESC' },
},
});
if (!asBuiltDrawing) {
throw new NotFoundException(`AS Built Drawing UUID ${uuid} not found`);
}
return asBuiltDrawing;
}
/** /**
* ลบ AS Built Drawing * ลบ AS Built Drawing
*/ */
@@ -8,7 +8,6 @@ import {
Put, Put,
Query, Query,
UseGuards, UseGuards,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@@ -20,6 +19,7 @@ import { SearchContractDrawingDto } from './dto/search-contract-drawing.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard'; import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity';
@@ -51,28 +51,33 @@ export class ContractDrawingController {
return this.contractDrawingService.findAll(searchDto); return this.contractDrawingService.findAll(searchDto);
} }
@Get(':id') @Get(':uuid')
@ApiOperation({ summary: 'Get Contract Drawing details' }) @ApiOperation({ summary: 'Get Contract Drawing details' })
@RequirePermission('document.view') @RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.contractDrawingService.findOne(id); return this.contractDrawingService.findOneByUuid(uuid);
} }
@Put(':id') @Put(':uuid')
@ApiOperation({ summary: 'Update Contract Drawing' }) @ApiOperation({ summary: 'Update Contract Drawing' })
@RequirePermission('drawing.create') // สิทธิ์ ID 39 ครอบคลุมการแก้ไขด้วย @RequirePermission('drawing.create') // สิทธิ์ ID 39 ครอบคลุมการแก้ไขด้วย
update( async update(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() updateDto: UpdateContractDrawingDto, @Body() updateDto: UpdateContractDrawingDto,
@CurrentUser() user: User @CurrentUser() user: User
) { ) {
return this.contractDrawingService.update(id, updateDto, user); const drawing = await this.contractDrawingService.findOneByUuid(uuid);
return this.contractDrawingService.update(drawing.id, updateDto, user);
} }
@Delete(':id') @Delete(':uuid')
@ApiOperation({ summary: 'Delete Contract Drawing (Soft Delete)' }) @ApiOperation({ summary: 'Delete Contract Drawing (Soft Delete)' })
@RequirePermission('document.delete') // สิทธิ์ ID 34: ลบเอกสาร @RequirePermission('document.delete') // สิทธิ์ ID 34: ลบเอกสาร
remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) { async remove(
return this.contractDrawingService.remove(id, user); @Param('uuid', ParseUuidPipe) uuid: string,
@CurrentUser() user: User
) {
const drawing = await this.contractDrawingService.findOneByUuid(uuid);
return this.contractDrawingService.remove(drawing.id, user);
} }
} }
@@ -180,6 +180,19 @@ export class ContractDrawingService {
return drawing; return drawing;
} }
async findOneByUuid(uuid: string) {
const drawing = await this.drawingRepo.findOne({
where: { uuid },
relations: ['attachments'],
});
if (!drawing) {
throw new NotFoundException(`Contract Drawing UUID ${uuid} not found`);
}
return drawing;
}
/** /**
* แก้ไขข้อมูลแบบ (Update) * แก้ไขข้อมูลแบบ (Update)
*/ */
@@ -13,11 +13,14 @@ import { AsBuiltDrawing } from './asbuilt-drawing.entity';
import { ShopDrawingRevision } from './shop-drawing-revision.entity'; import { ShopDrawingRevision } from './shop-drawing-revision.entity';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity'; import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { User } from '../../user/entities/user.entity'; import { User } from '../../user/entities/user.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('asbuilt_drawing_revisions') @Entity('asbuilt_drawing_revisions')
@Unique(['asBuiltDrawingId', 'isCurrent']) @Unique(['asBuiltDrawingId', 'isCurrent'])
export class AsBuiltDrawingRevision { export class AsBuiltDrawingRevision extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; id!: number;
@Column({ name: 'asbuilt_drawing_id' }) @Column({ name: 'asbuilt_drawing_id' })
@@ -14,10 +14,13 @@ import { AsBuiltDrawingRevision } from './asbuilt-drawing-revision.entity';
import { User } from '../../user/entities/user.entity'; import { User } from '../../user/entities/user.entity';
import { ShopDrawingMainCategory } from './shop-drawing-main-category.entity'; import { ShopDrawingMainCategory } from './shop-drawing-main-category.entity';
import { ShopDrawingSubCategory } from './shop-drawing-sub-category.entity'; import { ShopDrawingSubCategory } from './shop-drawing-sub-category.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('asbuilt_drawings') @Entity('asbuilt_drawings')
export class AsBuiltDrawing { export class AsBuiltDrawing extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; id!: number;
@Column({ name: 'project_id' }) @Column({ name: 'project_id' })
@@ -15,10 +15,13 @@ import { User } from '../../user/entities/user.entity';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity'; import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { ContractDrawingSubcatCatMap } from './contract-drawing-subcat-cat-map.entity'; import { ContractDrawingSubcatCatMap } from './contract-drawing-subcat-cat-map.entity';
import { ContractDrawingVolume } from './contract-drawing-volume.entity'; import { ContractDrawingVolume } from './contract-drawing-volume.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('contract_drawings') @Entity('contract_drawings')
export class ContractDrawing { export class ContractDrawing extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; // ! ห้ามว่าง id!: number; // ! ห้ามว่าง
@Column({ name: 'project_id' }) @Column({ name: 'project_id' })
@@ -13,11 +13,14 @@ import { ShopDrawing } from './shop-drawing.entity';
import { ContractDrawing } from './contract-drawing.entity'; import { ContractDrawing } from './contract-drawing.entity';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity'; import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { User } from '../../user/entities/user.entity'; import { User } from '../../user/entities/user.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('shop_drawing_revisions') @Entity('shop_drawing_revisions')
@Unique(['shopDrawingId', 'isCurrent']) @Unique(['shopDrawingId', 'isCurrent'])
export class ShopDrawingRevision { export class ShopDrawingRevision extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; // เติม ! id!: number; // เติม !
@Column({ name: 'shop_drawing_id' }) @Column({ name: 'shop_drawing_id' })
@@ -13,10 +13,13 @@ import { ShopDrawingRevision } from './shop-drawing-revision.entity';
import { Project } from '../../project/entities/project.entity'; import { Project } from '../../project/entities/project.entity';
import { ShopDrawingMainCategory } from './shop-drawing-main-category.entity'; import { ShopDrawingMainCategory } from './shop-drawing-main-category.entity';
import { ShopDrawingSubCategory } from './shop-drawing-sub-category.entity'; import { ShopDrawingSubCategory } from './shop-drawing-sub-category.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('shop_drawings') @Entity('shop_drawings')
export class ShopDrawing { export class ShopDrawing extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; // เติม ! id!: number; // เติม !
@Column({ name: 'project_id' }) @Column({ name: 'project_id' })
@@ -6,7 +6,6 @@ import {
Param, Param,
Query, Query,
UseGuards, UseGuards,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@@ -18,6 +17,7 @@ import { CreateShopDrawingRevisionDto } from './dto/create-shop-drawing-revision
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard'; import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity';
import { Audit } from '../../common/decorators/audit.decorator'; // Import import { Audit } from '../../common/decorators/audit.decorator'; // Import
@@ -44,21 +44,22 @@ export class ShopDrawingController {
return this.shopDrawingService.findAll(searchDto); return this.shopDrawingService.findAll(searchDto);
} }
@Get(':id') @Get(':uuid')
@ApiOperation({ summary: 'Get Shop Drawing details with revisions' }) @ApiOperation({ summary: 'Get Shop Drawing details with revisions' })
@RequirePermission('drawing.view') @RequirePermission('drawing.view')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.shopDrawingService.findOne(id); return this.shopDrawingService.findOneByUuid(uuid);
} }
@Post(':id/revisions') @Post(':uuid/revisions')
@ApiOperation({ summary: 'Add new revision to existing Shop Drawing' }) @ApiOperation({ summary: 'Add new revision to existing Shop Drawing' })
@RequirePermission('drawing.create') // หรือ drawing.edit ตาม Logic องค์กร @RequirePermission('drawing.create') // หรือ drawing.edit ตาม Logic องค์กร
@Audit('drawing.create', 'shop_drawing') // ✅ แปะตรงนี้ @Audit('drawing.create', 'shop_drawing') // ✅ แปะตรงนี้
createRevision( async createRevision(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() createRevisionDto: CreateShopDrawingRevisionDto, @Body() createRevisionDto: CreateShopDrawingRevisionDto
) { ) {
return this.shopDrawingService.createRevision(id, createRevisionDto); const sd = await this.shopDrawingService.findOneByUuid(uuid);
return this.shopDrawingService.createRevision(sd.id, createRevisionDto);
} }
} }
@@ -289,6 +289,28 @@ export class ShopDrawingService {
return shopDrawing; return shopDrawing;
} }
async findOneByUuid(uuid: string) {
const shopDrawing = await this.shopDrawingRepo.findOne({
where: { uuid },
relations: [
'mainCategory',
'subCategory',
'revisions',
'revisions.attachments',
'revisions.contractDrawings',
],
order: {
revisions: { revisionNumber: 'DESC' },
},
});
if (!shopDrawing) {
throw new NotFoundException(`Shop Drawing UUID ${uuid} not found`);
}
return shopDrawing;
}
/** /**
* ลบ Shop Drawing * ลบ Shop Drawing
*/ */
@@ -8,6 +8,8 @@ import {
PrimaryColumn, // ✅ [Fix] เพิ่ม Import นี้ PrimaryColumn, // ✅ [Fix] เพิ่ม Import นี้
} from 'typeorm'; } from 'typeorm';
import { User } from '../../user/entities/user.entity'; import { User } from '../../user/entities/user.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
export enum NotificationType { export enum NotificationType {
EMAIL = 'EMAIL', EMAIL = 'EMAIL',
@@ -16,8 +18,9 @@ export enum NotificationType {
} }
@Entity('notifications') @Entity('notifications')
export class Notification { export class Notification extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; id!: number;
@Column({ name: 'user_id' }) @Column({ name: 'user_id' })
@@ -1,12 +1,4 @@
import { import { Controller, Get, Put, Param, UseGuards, Query } from '@nestjs/common';
Controller,
Get,
Put,
Param,
UseGuards,
ParseIntPipe,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -17,6 +9,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity';
import { SearchNotificationDto } from './dto/search-notification.dto'; // ✅ Import import { SearchNotificationDto } from './dto/search-notification.dto'; // ✅ Import
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
@ApiTags('Notifications') @ApiTags('Notifications')
@ApiBearerAuth() @ApiBearerAuth()
@@ -26,14 +19,14 @@ export class NotificationController {
constructor( constructor(
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
@InjectRepository(Notification) @InjectRepository(Notification)
private notificationRepo: Repository<Notification>, private notificationRepo: Repository<Notification>
) {} ) {}
@Get() @Get()
@ApiOperation({ summary: 'Get my notifications' }) @ApiOperation({ summary: 'Get my notifications' })
async getMyNotifications( async getMyNotifications(
@CurrentUser() user: User, @CurrentUser() user: User,
@Query() searchDto: SearchNotificationDto, // ✅ ใช้ DTO แทน @Query() searchDto: SearchNotificationDto // ✅ ใช้ DTO แทน
) { ) {
const { page = 1, limit = 20, isRead } = searchDto; const { page = 1, limit = 20, isRead } = searchDto;
@@ -65,13 +58,13 @@ export class NotificationController {
return { unreadCount: count }; return { unreadCount: count };
} }
@Put(':id/read') @Put(':uuid/read')
@ApiOperation({ summary: 'Mark notification as read' }) @ApiOperation({ summary: 'Mark notification as read' })
async markAsRead( async markAsRead(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@CurrentUser() user: User, @CurrentUser() user: User
) { ) {
return this.notificationService.markAsRead(id, user.user_id); return this.notificationService.markAsReadByUuid(uuid, user.user_id);
} }
@Put('read-all') @Put('read-all')
@@ -39,7 +39,7 @@ export class NotificationService {
@InjectRepository(User) @InjectRepository(User)
private userRepo: Repository<User>, private userRepo: Repository<User>,
// ไม่ต้อง Inject UserPrefRepo แล้ว เพราะ Processor จะจัดการเอง // ไม่ต้อง Inject UserPrefRepo แล้ว เพราะ Processor จะจัดการเอง
private notificationGateway: NotificationGateway, private notificationGateway: NotificationGateway
) {} ) {}
/** /**
@@ -84,14 +84,14 @@ export class NotificationService {
delay: 5000, delay: 5000,
}, },
removeOnComplete: true, removeOnComplete: true,
}, }
); );
this.logger.debug(`Dispatched notification job for user ${data.userId}`); this.logger.debug(`Dispatched notification job for user ${data.userId}`);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to process notification for user ${data.userId}`, `Failed to process notification for user ${data.userId}`,
(error as Error).stack, (error as Error).stack
); );
} }
} }
@@ -154,10 +154,25 @@ export class NotificationService {
} }
} }
async markAsReadByUuid(uuid: string, userId: number): Promise<void> {
const notification = await this.notificationRepo.findOne({
where: { uuid, userId },
});
if (!notification) {
throw new NotFoundException(`Notification UUID ${uuid} not found`);
}
if (!notification.isRead) {
notification.isRead = true;
await this.notificationRepo.save(notification);
}
}
async markAllAsRead(userId: number): Promise<void> { async markAllAsRead(userId: number): Promise<void> {
await this.notificationRepo.update( await this.notificationRepo.update(
{ userId, isRead: false }, { userId, isRead: false },
{ isRead: true }, { isRead: true }
); );
} }
@@ -10,10 +10,13 @@ import {
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { OrganizationRole } from './organization-role.entity'; import { OrganizationRole } from './organization-role.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { Exclude } from 'class-transformer';
@Entity('organizations') @Entity('organizations')
export class Organization { export class Organization extends UuidBaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; id!: number;
@Column({ name: 'organization_code', length: 20, unique: true }) @Column({ name: 'organization_code', length: 20, unique: true })
@@ -8,7 +8,6 @@ import {
Delete, Delete,
Query, Query,
UseGuards, UseGuards,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationService } from './organization.service.js'; import { OrganizationService } from './organization.service.js';
@@ -17,6 +16,7 @@ import { UpdateOrganizationDto } from './dto/update-organization.dto.js';
import { SearchOrganizationDto } from './dto/search-organization.dto.js'; import { SearchOrganizationDto } from './dto/search-organization.dto.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js'; import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
@ApiTags('Organizations') @ApiTags('Organizations')
@ApiBearerAuth() @ApiBearerAuth()
@@ -38,26 +38,26 @@ export class OrganizationController {
return this.orgService.findAll(query); return this.orgService.findAll(query);
} }
@Get(':id') @Get(':uuid')
@ApiOperation({ summary: 'Get Organization by ID' }) @ApiOperation({ summary: 'Get Organization by UUID' })
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.orgService.findOne(id); return this.orgService.findOneByUuid(uuid);
} }
@Patch(':id') @Patch(':uuid')
@RequirePermission('master_data.manage') @RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Update Organization' }) @ApiOperation({ summary: 'Update Organization' })
update( update(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() dto: UpdateOrganizationDto @Body() dto: UpdateOrganizationDto
) { ) {
return this.orgService.update(id, dto); return this.orgService.update(uuid, dto);
} }
@Delete(':id') @Delete(':uuid')
@RequirePermission('master_data.manage') @RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Delete Organization' }) @ApiOperation({ summary: 'Delete Organization' })
remove(@Param('id', ParseIntPipe) id: number) { remove(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.orgService.remove(id); return this.orgService.remove(uuid);
} }
} }
@@ -86,17 +86,21 @@ export class OrganizationService {
return org; return org;
} }
async update(id: number, dto: UpdateOrganizationDto) { async findOneByUuid(uuid: string) {
const org = await this.findOne(id); const org = await this.orgRepo.findOne({ where: { uuid } });
if (!org)
throw new NotFoundException(`Organization UUID ${uuid} not found`);
return org;
}
async update(uuid: string, dto: UpdateOrganizationDto) {
const org = await this.findOneByUuid(uuid);
Object.assign(org, dto); Object.assign(org, dto);
return this.orgRepo.save(org); return this.orgRepo.save(org);
} }
async remove(id: number) { async remove(uuid: string) {
const org = await this.findOne(id); const org = await this.findOneByUuid(uuid);
// Hard delete or Soft delete? Schema doesn't have deleted_at for Organization, but let's check.
// Schema says: created_at, updated_at. No deleted_at.
// So hard delete.
return this.orgRepo.remove(org); return this.orgRepo.remove(org);
} }
@@ -1,12 +1,36 @@
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; import {
Entity,
Column,
PrimaryGeneratedColumn,
OneToMany,
BeforeInsert,
} from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
import { Exclude } from 'class-transformer';
import { BaseEntity } from '../../../common/entities/base.entity'; import { BaseEntity } from '../../../common/entities/base.entity';
import { Contract } from '../../contract/entities/contract.entity'; import { Contract } from '../../contract/entities/contract.entity';
@Entity('projects') @Entity('projects')
export class Project extends BaseEntity { export class Project extends BaseEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
@Exclude()
id!: number; id!: number;
@Column({
type: 'uuid',
unique: true,
nullable: false,
comment: 'UUID Public Identifier (ADR-019)',
})
uuid!: string;
@BeforeInsert()
generateUuid(): void {
if (!this.uuid) {
this.uuid = uuidv7();
}
}
@Column({ name: 'project_code', unique: true, length: 50 }) @Column({ name: 'project_code', unique: true, length: 50 })
projectCode!: string; projectCode!: string;
@@ -8,7 +8,6 @@ import {
Delete, Delete,
Query, Query,
UseGuards, UseGuards,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@@ -19,6 +18,7 @@ import { SearchProjectDto } from './dto/search-project.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard'; import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('Projects') @ApiTags('Projects')
@@ -49,34 +49,34 @@ export class ProjectController {
return this.projectService.findAllOrganizations(); return this.projectService.findAllOrganizations();
} }
@Get(':id/contracts') @Get(':uuid/contracts')
@ApiOperation({ summary: 'List All Contracts in Project' }) @ApiOperation({ summary: 'List All Contracts in Project' })
@RequirePermission('project.view') @RequirePermission('project.view')
findContracts(@Param('id', ParseIntPipe) id: number) { findContracts(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.projectService.findContracts(id); return this.projectService.findContracts(uuid);
} }
@Get(':id') @Get(':uuid')
@ApiOperation({ summary: 'Get Project Details' }) @ApiOperation({ summary: 'Get Project Details' })
@RequirePermission('project.view') @RequirePermission('project.view')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.projectService.findOne(id); return this.projectService.findOneByUuid(uuid);
} }
@Patch(':id') @Patch(':uuid')
@ApiOperation({ summary: 'Update Project' }) @ApiOperation({ summary: 'Update Project' })
@RequirePermission('project.edit') @RequirePermission('project.edit')
update( update(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() updateDto: UpdateProjectDto @Body() updateDto: UpdateProjectDto
) { ) {
return this.projectService.update(id, updateDto); return this.projectService.update(uuid, updateDto);
} }
@Delete(':id') @Delete(':uuid')
@ApiOperation({ summary: 'Delete Project (Soft Delete)' }) @ApiOperation({ summary: 'Delete Project (Soft Delete)' })
@RequirePermission('project.delete') @RequirePermission('project.delete')
remove(@Param('id', ParseIntPipe) id: number) { remove(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.projectService.remove(id); return this.projectService.remove(uuid);
} }
} }
+19 -14
View File
@@ -91,8 +91,21 @@ export class ProjectService {
return project; return project;
} }
async update(id: number, updateDto: UpdateProjectDto) { async findOneByUuid(uuid: string) {
const project = await this.findOne(id); const project = await this.projectRepository.findOne({
where: { uuid },
relations: ['contracts'],
});
if (!project) {
throw new NotFoundException(`Project UUID ${uuid} not found`);
}
return project;
}
async update(uuid: string, updateDto: UpdateProjectDto) {
const project = await this.findOneByUuid(uuid);
// Merge ข้อมูลใหม่ใส่ข้อมูลเดิม // Merge ข้อมูลใหม่ใส่ข้อมูลเดิม
this.projectRepository.merge(project, updateDto); this.projectRepository.merge(project, updateDto);
@@ -100,22 +113,14 @@ export class ProjectService {
return this.projectRepository.save(project); return this.projectRepository.save(project);
} }
async remove(id: number) { async remove(uuid: string) {
const project = await this.findOne(id); const project = await this.findOneByUuid(uuid);
// ใช้ Soft Delete // ใช้ Soft Delete
return this.projectRepository.softRemove(project); return this.projectRepository.softRemove(project);
} }
async findContracts(projectId: number) { async findContracts(uuid: string) {
const project = await this.projectRepository.findOne({ const project = await this.findOneByUuid(uuid);
where: { id: projectId },
relations: ['contracts'],
});
if (!project) {
throw new NotFoundException(`Project ID ${projectId} not found`);
}
return project.contracts; return project.contracts;
} }
@@ -16,10 +16,13 @@ import {
import { Organization } from '../../organization/entities/organization.entity'; // Adjust path as needed import { Organization } from '../../organization/entities/organization.entity'; // Adjust path as needed
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 { Exclude } from 'class-transformer';
@Entity('users') @Entity('users')
export class User { export class User extends UuidBaseEntity {
@PrimaryGeneratedColumn({ name: 'user_id' }) @PrimaryGeneratedColumn({ name: 'user_id' })
@Exclude()
user_id!: number; user_id!: number;
@Column({ unique: true, length: 50 }) @Column({ unique: true, length: 50 })
+13 -12
View File
@@ -33,6 +33,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard'; import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { User } from './entities/user.entity'; import { User } from './entities/user.entity';
@ApiTags('Users') @ApiTags('Users')
@@ -123,35 +124,35 @@ export class UserController {
return this.userService.findAll(query); return this.userService.findAll(query);
} }
@Get(':id') @Get(':uuid')
@ApiOperation({ summary: 'Get user details' }) @ApiOperation({ summary: 'Get user details' })
@ApiParam({ name: 'id', description: 'User ID' }) @ApiParam({ name: 'uuid', description: 'User UUID' })
@ApiResponse({ status: 200, description: 'User details' }) @ApiResponse({ status: 200, description: 'User details' })
@RequirePermission('user.view') @RequirePermission('user.view')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.userService.findOne(id); return this.userService.findOneByUuid(uuid);
} }
@Patch(':id') @Patch(':uuid')
@ApiOperation({ summary: 'Update user' }) @ApiOperation({ summary: 'Update user' })
@ApiParam({ name: 'id', description: 'User ID' }) @ApiParam({ name: 'uuid', description: 'User UUID' })
@ApiBody({ type: UpdateUserDto }) @ApiBody({ type: UpdateUserDto })
@ApiResponse({ status: 200, description: 'User updated' }) @ApiResponse({ status: 200, description: 'User updated' })
@RequirePermission('user.edit') @RequirePermission('user.edit')
update( update(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() updateUserDto: UpdateUserDto @Body() updateUserDto: UpdateUserDto
) { ) {
return this.userService.update(id, updateUserDto); return this.userService.update(uuid, updateUserDto);
} }
@Delete(':id') @Delete(':uuid')
@ApiOperation({ summary: 'Delete user (Soft delete)' }) @ApiOperation({ summary: 'Delete user (Soft delete)' })
@ApiParam({ name: 'id', description: 'User ID' }) @ApiParam({ name: 'uuid', description: 'User UUID' })
@ApiResponse({ status: 200, description: 'User deleted' }) @ApiResponse({ status: 200, description: 'User deleted' })
@RequirePermission('user.delete') @RequirePermission('user.delete')
remove(@Param('id', ParseIntPipe) id: number) { remove(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.userService.remove(id); return this.userService.remove(uuid);
} }
// --- Role Assignment --- // --- Role Assignment ---
+26 -7
View File
@@ -133,13 +133,31 @@ export class UserService {
return user; return user;
} }
async findOneByUuid(uuid: string): Promise<User> {
const user = await this.usersRepository.findOne({
where: { uuid },
relations: [
'preference',
'assignments',
'assignments.role',
'assignments.role.permissions',
],
});
if (!user) {
throw new NotFoundException(`User with UUID ${uuid} not found`);
}
return user;
}
async findOneByUsername(username: string): Promise<User | null> { async findOneByUsername(username: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { username } }); return this.usersRepository.findOne({ where: { username } });
} }
// 4. แก้ไขข้อมูล // 4. แก้ไขข้อมูล
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> { async update(uuid: string, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id); const user = await this.findOneByUuid(uuid);
if (updateUserDto.password) { if (updateUserDto.password) {
const salt = await bcrypt.genSalt(); const salt = await bcrypt.genSalt();
@@ -150,20 +168,21 @@ export class UserService {
const savedUser = await this.usersRepository.save(updatedUser); const savedUser = await this.usersRepository.save(updatedUser);
// ⚠️ สำคัญ: เมื่อมีการแก้ไขข้อมูล User ต้องเคลียร์ Cache สิทธิ์เสมอ // ⚠️ สำคัญ: เมื่อมีการแก้ไขข้อมูล User ต้องเคลียร์ Cache สิทธิ์เสมอ
await this.clearUserCache(id); await this.clearUserCache(user.user_id);
return savedUser; return savedUser;
} }
// 5. ลบผู้ใช้ (Soft Delete) // 5. ลบผู้ใช้ (Soft Delete)
async remove(id: number): Promise<void> { async remove(uuid: string): Promise<void> {
const result = await this.usersRepository.softDelete(id); const user = await this.findOneByUuid(uuid);
const result = await this.usersRepository.softDelete(user.user_id);
if (result.affected === 0) { if (result.affected === 0) {
throw new NotFoundException(`User with ID ${id} not found`); throw new NotFoundException(`User with UUID ${uuid} not found`);
} }
// เคลียร์ Cache เมื่อลบ // เคลียร์ Cache เมื่อลบ
await this.clearUserCache(id); await this.clearUserCache(user.user_id);
} }
async findDocControlIdByOrg(organizationId: number): Promise<number | null> { async findDocControlIdByOrg(organizationId: number): Promise<number | null> {
@@ -63,7 +63,7 @@ export default function OrganizationsPage() {
const confirmDelete = () => { const confirmDelete = () => {
if (orgToDelete) { if (orgToDelete) {
deleteOrg.mutate(orgToDelete.id, { deleteOrg.mutate(orgToDelete.uuid, {
onSuccess: () => { onSuccess: () => {
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setOrgToDelete(null); setOrgToDelete(null);
@@ -64,7 +64,7 @@ export default function UsersPage() {
const confirmDelete = () => { const confirmDelete = () => {
if (userToDelete) { if (userToDelete) {
deleteMutation.mutate(userToDelete.userId, { deleteMutation.mutate(userToDelete.uuid, {
onSuccess: () => { onSuccess: () => {
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setUserToDelete(null); setUserToDelete(null);
@@ -186,7 +186,7 @@ export default function UsersPage() {
<SelectContent> <SelectContent>
<SelectItem value="all">All Organizations</SelectItem> <SelectItem value="all">All Organizations</SelectItem>
{Array.isArray(organizations) && (organizations as Organization[]).map((org) => ( {Array.isArray(organizations) && (organizations as Organization[]).map((org) => (
<SelectItem key={org.id} value={org.id.toString()}> <SelectItem key={org.uuid} value={(org.id ?? org.uuid).toString()}>
{org.organizationCode} - {org.organizationName} {org.organizationCode} - {org.organizationName}
</SelectItem> </SelectItem>
))} ))}
@@ -56,7 +56,8 @@ interface Project {
} }
interface Contract { interface Contract {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
contractCode: string; contractCode: string;
contractName: string; contractName: string;
projectId: number; projectId: number;
@@ -112,7 +113,7 @@ export default function ContractsPage() {
}); });
const updateContract = useMutation({ const updateContract = useMutation({
mutationFn: ({ id, data }: { id: number, data: UpdateContractDto }) => apiClient.patch(`/contracts/${id}`, data).then(res => res.data), mutationFn: ({ uuid, data }: { uuid: string, data: UpdateContractDto }) => apiClient.patch(`/contracts/${uuid}`, data).then(res => res.data),
onSuccess: () => { onSuccess: () => {
toast.success("Contract updated successfully"); toast.success("Contract updated successfully");
queryClient.invalidateQueries({ queryKey: ['contracts'] }); queryClient.invalidateQueries({ queryKey: ['contracts'] });
@@ -122,7 +123,7 @@ export default function ContractsPage() {
}); });
const deleteContract = useMutation({ const deleteContract = useMutation({
mutationFn: (id: number) => apiClient.delete(`/contracts/${id}`).then(res => res.data), mutationFn: (uuid: string) => apiClient.delete(`/contracts/${uuid}`).then(res => res.data),
onSuccess: () => { onSuccess: () => {
toast.success("Contract deleted successfully"); toast.success("Contract deleted successfully");
queryClient.invalidateQueries({ queryKey: ['contracts'] }); queryClient.invalidateQueries({ queryKey: ['contracts'] });
@@ -131,7 +132,7 @@ export default function ContractsPage() {
}); });
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingUuid, setEditingUuid] = useState<string | null>(null);
// Stats for Delete Dialog // Stats for Delete Dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -144,7 +145,7 @@ export default function ContractsPage() {
const confirmDelete = () => { const confirmDelete = () => {
if (contractToDelete) { if (contractToDelete) {
deleteContract.mutate(contractToDelete.id, { deleteContract.mutate(contractToDelete.uuid, {
onSuccess: () => { onSuccess: () => {
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setContractToDelete(null); setContractToDelete(null);
@@ -212,7 +213,7 @@ export default function ContractsPage() {
]; ];
const handleEdit = (contract: Contract) => { const handleEdit = (contract: Contract) => {
setEditingId(contract.id); setEditingUuid(contract.uuid);
reset({ reset({
contractCode: contract.contractCode, contractCode: contract.contractCode,
contractName: contract.contractName, contractName: contract.contractName,
@@ -225,7 +226,7 @@ export default function ContractsPage() {
}; };
const handleCreate = () => { const handleCreate = () => {
setEditingId(null); setEditingUuid(null);
reset({ reset({
contractCode: "", contractCode: "",
contractName: "", contractName: "",
@@ -243,8 +244,8 @@ export default function ContractsPage() {
projectId: parseInt(data.projectId), projectId: parseInt(data.projectId),
}; };
if (editingId) { if (editingUuid) {
updateContract.mutate({ id: editingId, data: submitData }); updateContract.mutate({ uuid: editingUuid, data: submitData });
} else { } else {
createContract.mutate(submitData); createContract.mutate(submitData);
} }
@@ -289,7 +290,7 @@ export default function ContractsPage() {
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{editingId ? "Edit Contract" : "New Contract"}</DialogTitle> <DialogTitle>{editingUuid ? "Edit Contract" : "New Contract"}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
@@ -363,7 +364,7 @@ export default function ContractsPage() {
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={createContract.isPending || updateContract.isPending}> <Button type="submit" disabled={createContract.isPending || updateContract.isPending}>
{editingId ? "Save Changes" : "Create Contract"} {editingUuid ? "Save Changes" : "Create Contract"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -44,7 +44,8 @@ import {
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
interface Project { interface Project {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
projectCode: string; projectCode: string;
projectName: string; projectName: string;
isActive: boolean; isActive: boolean;
@@ -69,7 +70,7 @@ export default function ProjectsPage() {
const deleteProject = useDeleteProject(); const deleteProject = useDeleteProject();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingUuid, setEditingUuid] = useState<string | null>(null);
// Stats for Delete Dialog // Stats for Delete Dialog
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -82,7 +83,7 @@ export default function ProjectsPage() {
const confirmDelete = () => { const confirmDelete = () => {
if (projectToDelete) { if (projectToDelete) {
deleteProject.mutate(projectToDelete.id, { deleteProject.mutate(projectToDelete.uuid, {
onSuccess: () => { onSuccess: () => {
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setProjectToDelete(null); setProjectToDelete(null);
@@ -156,7 +157,7 @@ export default function ProjectsPage() {
]; ];
const handleEdit = (project: Project) => { const handleEdit = (project: Project) => {
setEditingId(project.id); setEditingUuid(project.uuid);
reset({ reset({
projectCode: project.projectCode, projectCode: project.projectCode,
projectName: project.projectName, projectName: project.projectName,
@@ -166,7 +167,7 @@ export default function ProjectsPage() {
}; };
const handleCreate = () => { const handleCreate = () => {
setEditingId(null); setEditingUuid(null);
reset({ reset({
projectCode: "", projectCode: "",
projectName: "", projectName: "",
@@ -176,9 +177,9 @@ export default function ProjectsPage() {
}; };
const onSubmit = (data: ProjectFormData) => { const onSubmit = (data: ProjectFormData) => {
if (editingId) { if (editingUuid) {
updateProject.mutate( updateProject.mutate(
{ id: editingId, data }, { uuid: editingUuid, data },
{ {
onSuccess: () => setDialogOpen(false), onSuccess: () => setDialogOpen(false),
} }
@@ -232,7 +233,7 @@ export default function ProjectsPage() {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{editingId ? "Edit Project" : "New Project"} {editingUuid ? "Edit Project" : "New Project"}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
@@ -241,7 +242,7 @@ export default function ProjectsPage() {
<Input <Input
placeholder="e.g. LCBP3" placeholder="e.g. LCBP3"
{...register("projectCode")} {...register("projectCode")}
disabled={!!editingId} // Code is immutable after creation usually disabled={!!editingUuid} // Code is immutable after creation usually
/> />
{errors.projectCode && ( {errors.projectCode && (
<p className="text-sm text-red-500">{errors.projectCode.message}</p> <p className="text-sm text-red-500">{errors.projectCode.message}</p>
@@ -280,7 +281,7 @@ export default function ProjectsPage() {
type="submit" type="submit"
disabled={createProject.isPending || updateProject.isPending} disabled={createProject.isPending || updateProject.isPending}
> >
{editingId ? "Save Changes" : "Create Project"} {editingUuid ? "Save Changes" : "Create Project"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useParams, useRouter } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { circulationService } from "@/lib/services/circulation.service"; import { circulationService } from "@/lib/services/circulation.service";
import { Circulation, UpdateCirculationRoutingDto } from "@/types/circulation"; import { Circulation, UpdateCirculationRoutingDto } from "@/types/circulation";
@@ -42,14 +42,13 @@ function getStatusVariant(status: string): "default" | "secondary" | "destructiv
export default function CirculationDetailPage() { export default function CirculationDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const id = params.id as string; const uuid = params.uuid as string;
const { data: circulation, isLoading, error } = useQuery<Circulation>({ const { data: circulation, isLoading, error } = useQuery<Circulation>({
queryKey: ["circulation", id], queryKey: ["circulation", uuid],
queryFn: () => circulationService.getById(id), queryFn: () => circulationService.getByUuid(uuid),
enabled: !!id, enabled: !!uuid,
}); });
const completeMutation = useMutation({ const completeMutation = useMutation({
@@ -57,7 +56,7 @@ export default function CirculationDetailPage() {
circulationService.updateRouting(routingId, data), circulationService.updateRouting(routingId, data),
onSuccess: () => { onSuccess: () => {
toast.success("Task completed successfully"); toast.success("Task completed successfully");
queryClient.invalidateQueries({ queryKey: ["circulation", id] }); queryClient.invalidateQueries({ queryKey: ["circulation", uuid] });
}, },
onError: () => { onError: () => {
toast.error("Failed to update task status"); toast.error("Failed to update task status");
@@ -146,7 +145,7 @@ export default function CirculationDetailPage() {
<div> <div>
<p className="text-sm text-muted-foreground">Linked Document</p> <p className="text-sm text-muted-foreground">Linked Document</p>
<Link <Link
href={`/correspondences/${circulation.correspondenceId}`} href={`/correspondences/${circulation.correspondence.uuid}`}
className="font-medium text-primary hover:underline" className="font-medium text-primary hover:underline"
> >
{circulation.correspondence.correspondence_number} {circulation.correspondence.correspondence_number}
@@ -83,7 +83,7 @@ export default function CreateCirculationPage() {
mutationFn: (data: CreateCirculationDto) => circulationService.create(data), mutationFn: (data: CreateCirculationDto) => circulationService.create(data),
onSuccess: (result) => { onSuccess: (result) => {
toast.success("Circulation created successfully"); toast.success("Circulation created successfully");
router.push(`/circulation/${result.id}`); router.push(`/circulation/${result.uuid}`);
}, },
onError: () => { onError: () => {
toast.error("Failed to create circulation"); toast.error("Failed to create circulation");
@@ -232,7 +232,7 @@ export default function CreateCirculationPage() {
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{selectedAssignees.map((userId) => { {selectedAssignees.map((userId) => {
const user = users.find( const user = users.find(
(u: { userId: number }) => u.userId === userId (u) => u.userId === userId
); );
return user ? ( return user ? (
<Badge <Badge
@@ -267,16 +267,16 @@ export default function CreateCirculationPage() {
<CommandList> <CommandList>
<CommandEmpty>No user found.</CommandEmpty> <CommandEmpty>No user found.</CommandEmpty>
<CommandGroup> <CommandGroup>
{users.map((user: { userId: number; username: string; firstName?: string; lastName?: string }) => ( {users.map((user) => (
<CommandItem <CommandItem
key={user.userId} key={user.userId ?? user.uuid}
value={user.username} value={user.username}
onSelect={() => toggleAssignee(user.userId)} onSelect={() => user.userId && toggleAssignee(user.userId)}
> >
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
selectedAssignees.includes(user.userId) user.userId != null && selectedAssignees.includes(user.userId)
? "opacity-100" ? "opacity-100"
: "opacity-0" : "opacity-0"
)} )}
@@ -1,44 +0,0 @@
"use client";
import { CorrespondenceForm } from "@/components/correspondences/form";
import { useCorrespondence } from "@/hooks/use-correspondence";
import { Loader2 } from "lucide-react";
import { useParams } from "next/navigation";
export default function EditCorrespondencePage() {
const params = useParams();
const id = Number(params?.id);
const { data: correspondence, isLoading, isError } = useCorrespondence(id);
if (isLoading) {
return (
<div className="flex bg-muted/20 min-h-screen justify-center items-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
if (isError || !correspondence) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-xl font-bold text-red-500">Failed to load correspondence</h1>
</div>
);
}
return (
<div className="max-w-4xl mx-auto py-6">
<div className="mb-8">
<h1 className="text-3xl font-bold">Edit Correspondence</h1>
<p className="text-muted-foreground mt-1">
{correspondence.correspondenceNumber}
</p>
</div>
<div className="bg-card border rounded-lg p-6 shadow-sm">
<CorrespondenceForm initialData={correspondence} id={id} />
</div>
</div>
);
}
@@ -3,27 +3,22 @@
import { CorrespondenceDetail } from "@/components/correspondences/detail"; import { CorrespondenceDetail } from "@/components/correspondences/detail";
import { useCorrespondence } from "@/hooks/use-correspondence"; import { useCorrespondence } from "@/hooks/use-correspondence";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { notFound, useParams } from "next/navigation"; import { useParams } from "next/navigation";
export default function CorrespondenceDetailPage() { export default function CorrespondenceDetailPage() {
const params = useParams(); const params = useParams();
const id = Number(params?.id); // useParams returns string | string[] const uuid = (params?.uuid as string) ?? '';
if (isNaN(id)) { const { data: correspondence, isLoading, isError } = useCorrespondence(uuid);
// We can't use notFound() directly in client component render without breaking sometimes,
// but typically it works. Better to handle gracefully or redirect. if (!uuid) {
// For now, let's keep it or return 404 UI.
// Actually notFound() is for server components mostly.
// Let's just return our error UI if ID is invalid.
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-xl font-bold text-red-500">Invalid Correspondence ID</h1> <h1 className="text-xl font-bold text-red-500">Invalid Correspondence UUID</h1>
</div> </div>
); );
} }
const { data: correspondence, isLoading, isError } = useCorrespondence(id);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex bg-muted/20 min-h-screen justify-center items-center"> <div className="flex bg-muted/20 min-h-screen justify-center items-center">
@@ -33,11 +28,10 @@ export default function CorrespondenceDetailPage() {
} }
if (isError || !correspondence) { if (isError || !correspondence) {
// Optionally handle 404 vs other errors differently, but for now simple handling
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-xl font-bold text-red-500">Failed to load correspondence</h1> <h1 className="text-xl font-bold text-red-500">Failed to load correspondence</h1>
<p>Please try again later or verify the ID.</p> <p>Please try again later or verify the UUID.</p>
</div> </div>
); );
} }
@@ -1,4 +1,3 @@
import { drawingApi } from "@/lib/api/drawings";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft, Download, FileText, GitCompare } from "lucide-react"; import { ArrowLeft, Download, FileText, GitCompare } from "lucide-react";
@@ -8,19 +7,22 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { RevisionHistory } from "@/components/drawings/revision-history"; import { RevisionHistory } from "@/components/drawings/revision-history";
import { format } from "date-fns"; import { format } from "date-fns";
import { drawingApi } from "@/lib/api/drawings";
export default async function DrawingDetailPage({ export default async function DrawingDetailPage({
params, params,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ uuid: string }>;
}) { }) {
const { id: rawId } = await params; const { uuid } = await params;
const id = parseInt(rawId); if (!uuid) {
if (isNaN(id)) {
notFound(); notFound();
} }
const drawing = await drawingApi.getById(id); // TODO: Replace mock drawingApi with real service call using UUID
// For now, keep using the mock API with a numeric fallback
const drawingId = parseInt(uuid);
const drawing = !isNaN(drawingId) ? await drawingApi.getById(drawingId) : undefined;
if (!drawing) { if (!drawing) {
notFound(); notFound();
@@ -104,7 +104,7 @@ export function OrganizationDialog({
if (organization) { if (organization) {
updateOrg.mutate( updateOrg.mutate(
{ id: organization.id, data: submitData }, { uuid: organization.uuid, data: submitData },
{ onSuccess: () => onOpenChange(false) } { onSuccess: () => onOpenChange(false) }
); );
} else { } else {
+6 -3
View File
@@ -151,7 +151,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
if (user) { if (user) {
updateUser.mutate( updateUser.mutate(
{ id: user.userId, data: payload }, { uuid: user.uuid, data: payload },
{ onSuccess: () => onOpenChange(false) } { onSuccess: () => onOpenChange(false) }
); );
} else { } else {
@@ -230,10 +230,13 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
<SelectValue placeholder="Select Organization" /> <SelectValue placeholder="Select Organization" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{/* TODO: ADR-019 — Backend DTO needs to accept UUID for primaryOrganization.
Currently using org.id which is excluded from API responses.
Temporary: org.id may still exist in some query responses. */}
{organizations?.map((org: any) => ( {organizations?.map((org: any) => (
<SelectItem <SelectItem
key={org.id} key={org.uuid ?? org.id}
value={org.id.toString()} value={(org.id ?? 0).toString()}
> >
{org.organizationCode} - {org.organizationName} {org.organizationCode} - {org.organizationName}
</SelectItem> </SelectItem>
@@ -113,7 +113,7 @@ export function CirculationList({ data }: CirculationListProps) {
const item = row.original; const item = row.original;
return ( return (
<div className="flex gap-1"> <div className="flex gap-1">
<Link href={`/circulation/${item.id}`}> <Link href={`/circulation/${item.uuid}`}>
<Button variant="ghost" size="icon" title="View Details"> <Button variant="ghost" size="icon" title="View Details">
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
@@ -39,7 +39,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
const handleSubmit = () => { const handleSubmit = () => {
if (confirm("Are you sure you want to submit this correspondence?")) { if (confirm("Are you sure you want to submit this correspondence?")) {
submitMutation.mutate({ submitMutation.mutate({
id: data.id, uuid: data.uuid,
data: {} data: {}
}); });
} }
@@ -50,7 +50,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
const action = actionState === "approve" ? "APPROVE" : "REJECT"; const action = actionState === "approve" ? "APPROVE" : "REJECT";
processMutation.mutate({ processMutation.mutate({
id: data.id, uuid: data.uuid,
data: { data: {
action, action,
comments comments
@@ -83,7 +83,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
<div className="flex gap-2"> <div className="flex gap-2">
{/* EDIT BUTTON LOGIC: Show if DRAFT */} {/* EDIT BUTTON LOGIC: Show if DRAFT */}
{status === "DRAFT" && ( {status === "DRAFT" && (
<Link href={`/correspondences/${data.id}/edit`}> <Link href={`/correspondences/${data.uuid}/edit`}>
<Button variant="outline"> <Button variant="outline">
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit
+5 -5
View File
@@ -42,7 +42,7 @@ const correspondenceSchema = z.object({
type FormData = z.infer<typeof correspondenceSchema>; type FormData = z.infer<typeof correspondenceSchema>;
export function CorrespondenceForm({ initialData, id }: { initialData?: any, id?: number }) { export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, uuid?: string }) {
const router = useRouter(); const router = useRouter();
const createMutation = useCreateCorrespondence(); const createMutation = useCreateCorrespondence();
const updateMutation = useUpdateCorrespondence(); const updateMutation = useUpdateCorrespondence();
@@ -107,10 +107,10 @@ export function CorrespondenceForm({ initialData, id }: { initialData?: any, id?
}, },
}; };
if (id && initialData) { if (uuid && initialData) {
// UPDATE Mode // UPDATE Mode
updateMutation.mutate({ id, data: payload }, { updateMutation.mutate({ uuid, data: payload }, {
onSuccess: () => router.push(`/correspondences/${id}`) onSuccess: () => router.push(`/correspondences/${uuid}`)
}); });
} else { } else {
// CREATE Mode // CREATE Mode
@@ -420,7 +420,7 @@ export function CorrespondenceForm({ initialData, id }: { initialData?: any, id?
</Button> </Button>
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{id ? "Update Correspondence" : "Create Correspondence"} {uuid ? "Update Correspondence" : "Create Correspondence"}
</Button> </Button>
</div> </div>
</form> </form>
+5 -5
View File
@@ -59,15 +59,15 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original; const item = row.original;
// Edit/View link goes to the DOCUMENT detail (correspondence.id) // Edit/View link goes to the DOCUMENT detail (correspondence.uuid)
// Ideally we might pass ?revId=item.id to view specific revision, but detail page defaults to latest. // Ideally we might pass ?revId=item.uuid to view specific revision, but detail page defaults to latest.
// For editing, we edit the document. // For editing, we edit the document.
const docId = item.correspondence.id; const docUuid = item.correspondence.uuid;
const statusCode = item.status?.statusCode; const statusCode = item.status?.statusCode;
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<Link href={`/correspondences/${docId}`}> <Link href={`/correspondences/${docUuid}`}>
<Button variant="ghost" size="icon" title="View Details"> <Button variant="ghost" size="icon" title="View Details">
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
@@ -89,7 +89,7 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) {
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
</Button> </Button>
{statusCode === "DRAFT" && ( {statusCode === "DRAFT" && (
<Link href={`/correspondences/${docId}/edit`}> <Link href={`/correspondences/${docUuid}/edit`}>
<Button variant="ghost" size="icon" title="Edit"> <Button variant="ghost" size="icon" title="Edit">
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
+1 -1
View File
@@ -58,7 +58,7 @@ export function DrawingCard({ drawing }: { drawing: Drawing }) {
</div> </div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<Link href={`/drawings/${drawing.drawingId}`}> <Link href={`/drawings/${drawing.uuid}`}>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
View View
+5 -5
View File
@@ -16,7 +16,8 @@ import { useSearchSuggestions } from "@/hooks/use-search";
/** Search suggestion item returned from the API */ /** Search suggestion item returned from the API */
interface SearchSuggestion { interface SearchSuggestion {
id: string | number; uuid: string;
id?: string | number; // Excluded from API responses (ADR-019)
type: string; type: string;
title: string; title: string;
documentNumber?: string; documentNumber?: string;
@@ -97,12 +98,11 @@ export function GlobalSearch() {
<CommandGroup heading="Suggestions"> <CommandGroup heading="Suggestions">
{(suggestions as SearchSuggestion[]).map((item) => ( {(suggestions as SearchSuggestion[]).map((item) => (
<CommandItem <CommandItem
key={`${item.type}-${item.id}`} key={`${item.type}-${item.uuid}`}
onSelect={() => { onSelect={() => {
setQuery(item.title); setQuery(item.title);
// Assumption: item has type and id. // ADR-019: Use UUID for public routes
// If type is missing, we might need a map or check usage in backend response router.push(`/${item.type}s/${item.uuid}`);
router.push(`/${item.type}s/${item.id}`);
setOpen(false); setOpen(false);
}} }}
> >
@@ -26,7 +26,7 @@ export function NotificationsDropdown() {
const handleNotificationClick = (notification: Notification) => { const handleNotificationClick = (notification: Notification) => {
if (!notification.isRead) { if (!notification.isRead) {
markAsRead.mutate(notification.notificationId); markAsRead.mutate(notification.uuid);
} }
if (notification.link) { if (notification.link) {
router.push(notification.link); router.push(notification.link);
@@ -117,7 +117,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(organizations as Organization[])?.map((org) => ( {(organizations as Organization[])?.map((org) => (
<SelectItem key={org.id} value={org.id.toString()}> <SelectItem key={org.uuid} value={(org.id ?? org.uuid).toString()}>
{org.organizationCode} - {org.organizationName} {org.organizationCode} - {org.organizationName}
</SelectItem> </SelectItem>
))} ))}
@@ -137,7 +137,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(organizations as Organization[])?.map((org) => ( {(organizations as Organization[])?.map((org) => (
<SelectItem key={org.id} value={org.id.toString()}> <SelectItem key={org.uuid} value={(org.id ?? org.uuid).toString()}>
{org.organizationCode} - {org.organizationName} {org.organizationCode} - {org.organizationName}
</SelectItem> </SelectItem>
))} ))}
+2 -2
View File
@@ -44,7 +44,7 @@ export function SearchResults({ results, query, loading }: SearchResultsProps) {
}; };
const getLink = (result: SearchResult) => { const getLink = (result: SearchResult) => {
return `/${result.type}s/${result.id}`; // Assuming routes are plural (correspondences, rfas, drawings) return `/${result.type}s/${result.uuid}`; // ADR-019: Use UUID for public routes
}; };
return ( return (
@@ -54,7 +54,7 @@ export function SearchResults({ results, query, loading }: SearchResultsProps) {
return ( return (
<Card <Card
key={`${result.type}-${result.id}-${index}`} key={`${result.type}-${result.uuid}-${index}`}
className="p-6 hover:shadow-md transition-shadow group" className="p-6 hover:shadow-md transition-shadow group"
> >
<Link href={getLink(result)}> <Link href={getLink(result)}>
+18 -18
View File
@@ -15,7 +15,7 @@ export const correspondenceKeys = {
lists: () => [...correspondenceKeys.all, 'list'] as const, lists: () => [...correspondenceKeys.all, 'list'] as const,
list: (params: SearchCorrespondenceDto) => [...correspondenceKeys.lists(), params] as const, list: (params: SearchCorrespondenceDto) => [...correspondenceKeys.lists(), params] as const,
details: () => [...correspondenceKeys.all, 'detail'] as const, details: () => [...correspondenceKeys.all, 'detail'] as const,
detail: (id: number | string) => [...correspondenceKeys.details(), id] as const, detail: (uuid: string) => [...correspondenceKeys.details(), uuid] as const,
}; };
// --- Queries --- // --- Queries ---
@@ -28,11 +28,11 @@ export function useCorrespondences(params: SearchCorrespondenceDto) {
}); });
} }
export function useCorrespondence(id: number | string) { export function useCorrespondence(uuid: string) {
return useQuery({ return useQuery({
queryKey: correspondenceKeys.detail(id), queryKey: correspondenceKeys.detail(uuid),
queryFn: () => correspondenceService.getById(id), queryFn: () => correspondenceService.getByUuid(uuid),
enabled: !!id, enabled: !!uuid,
}); });
} }
@@ -59,11 +59,11 @@ export function useUpdateCorrespondence() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: number | string; data: Partial<CreateCorrespondenceDto> }) => mutationFn: ({ uuid, data }: { uuid: string; data: Partial<CreateCorrespondenceDto> }) =>
correspondenceService.update(id, data), correspondenceService.update(uuid, data),
onSuccess: (_, { id }) => { onSuccess: (_, { uuid }) => {
toast.success('Correspondence updated successfully'); toast.success('Correspondence updated successfully');
queryClient.invalidateQueries({ queryKey: correspondenceKeys.detail(id) }); queryClient.invalidateQueries({ queryKey: correspondenceKeys.detail(uuid) });
queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() }); queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() });
}, },
onError: (error: ApiError) => { onError: (error: ApiError) => {
@@ -78,7 +78,7 @@ export function useDeleteCorrespondence() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: number | string) => correspondenceService.delete(id), mutationFn: (uuid: string) => correspondenceService.delete(uuid),
onSuccess: () => { onSuccess: () => {
toast.success('Correspondence deleted successfully'); toast.success('Correspondence deleted successfully');
queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() }); queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() });
@@ -95,11 +95,11 @@ export function useSubmitCorrespondence() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: number; data: SubmitCorrespondenceDto }) => mutationFn: ({ uuid, data }: { uuid: string; data: SubmitCorrespondenceDto }) =>
correspondenceService.submit(id, data), correspondenceService.submit(uuid, data),
onSuccess: (_, { id }) => { onSuccess: (_, { uuid }) => {
toast.success('Correspondence submitted successfully'); toast.success('Correspondence submitted successfully');
queryClient.invalidateQueries({ queryKey: correspondenceKeys.detail(id) }); queryClient.invalidateQueries({ queryKey: correspondenceKeys.detail(uuid) });
queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() }); queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() });
}, },
onError: (error: ApiError) => { onError: (error: ApiError) => {
@@ -114,11 +114,11 @@ export function useProcessWorkflow() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: number | string; data: WorkflowActionDto }) => mutationFn: ({ uuid, data }: { uuid: string; data: WorkflowActionDto }) =>
correspondenceService.processWorkflow(id, data), correspondenceService.processWorkflow(uuid, data),
onSuccess: (_, { id }) => { onSuccess: (_, { uuid }) => {
toast.success('Action completed successfully'); toast.success('Action completed successfully');
queryClient.invalidateQueries({ queryKey: correspondenceKeys.detail(id) }); queryClient.invalidateQueries({ queryKey: correspondenceKeys.detail(uuid) });
queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() }); queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() });
}, },
onError: (error: ApiError) => { onError: (error: ApiError) => {
+10 -10
View File
@@ -17,7 +17,7 @@ export const drawingKeys = {
lists: () => [...drawingKeys.all, 'list'] as const, lists: () => [...drawingKeys.all, 'list'] as const,
list: (type: DrawingType, params: DrawingSearchParams) => [...drawingKeys.lists(), type, params] as const, list: (type: DrawingType, params: DrawingSearchParams) => [...drawingKeys.lists(), type, params] as const,
details: () => [...drawingKeys.all, 'detail'] as const, details: () => [...drawingKeys.all, 'detail'] as const,
detail: (type: DrawingType, id: number | string) => [...drawingKeys.details(), type, id] as const, detail: (type: DrawingType, uuid: string) => [...drawingKeys.details(), type, uuid] as const,
}; };
// --- Queries --- // --- Queries ---
@@ -33,7 +33,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
if (response && response.data) { if (response && response.data) {
const mappedData = response.data.map((d: ContractDrawing) => ({ const mappedData = response.data.map((d: ContractDrawing) => ({
...d, ...d,
drawingId: d.id, uuid: d.uuid,
drawingNumber: d.contractDrawingNo, drawingNumber: d.contractDrawingNo,
type: 'CONTRACT', type: 'CONTRACT',
})); }));
@@ -46,7 +46,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
if (response && response.data) { if (response && response.data) {
const mappedData = response.data.map((d: ShopDrawing) => ({ const mappedData = response.data.map((d: ShopDrawing) => ({
...d, ...d,
drawingId: d.id, uuid: d.uuid,
type: 'SHOP', type: 'SHOP',
title: d.currentRevision?.title || 'Untitled', title: d.currentRevision?.title || 'Untitled',
revision: d.currentRevision?.revisionNumber, revision: d.currentRevision?.revisionNumber,
@@ -61,7 +61,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
if (response && response.data) { if (response && response.data) {
const mappedData = response.data.map((d: AsBuiltDrawing) => ({ const mappedData = response.data.map((d: AsBuiltDrawing) => ({
...d, ...d,
drawingId: d.id, uuid: d.uuid,
type: 'AS_BUILT', type: 'AS_BUILT',
title: d.currentRevision?.title || 'Untitled', title: d.currentRevision?.title || 'Untitled',
revision: d.currentRevision?.revisionNumber, revision: d.currentRevision?.revisionNumber,
@@ -76,19 +76,19 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
}); });
} }
export function useDrawing(type: DrawingType, id: number | string) { export function useDrawing(type: DrawingType, uuid: string) {
return useQuery({ return useQuery({
queryKey: drawingKeys.detail(type, id), queryKey: drawingKeys.detail(type, uuid),
queryFn: async () => { queryFn: async () => {
if (type === 'CONTRACT') { if (type === 'CONTRACT') {
return contractDrawingService.getById(id); return contractDrawingService.getByUuid(uuid);
} else if (type === 'SHOP') { } else if (type === 'SHOP') {
return shopDrawingService.getById(id); return shopDrawingService.getByUuid(uuid);
} else { } else {
return asBuiltDrawingService.getById(id); return asBuiltDrawingService.getByUuid(uuid);
} }
}, },
enabled: !!id, enabled: !!uuid,
}); });
} }
+3 -3
View File
@@ -44,8 +44,8 @@ export function useCreateOrganization() {
export function useUpdateOrganization() { export function useUpdateOrganization() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateOrganizationDto }) => mutationFn: ({ uuid, data }: { uuid: string; data: UpdateOrganizationDto }) =>
masterDataService.updateOrganization(id, data), masterDataService.updateOrganization(uuid, data),
onSuccess: () => { onSuccess: () => {
toast.success('Organization updated successfully'); toast.success('Organization updated successfully');
queryClient.invalidateQueries({ queryKey: masterDataKeys.organizations() }); queryClient.invalidateQueries({ queryKey: masterDataKeys.organizations() });
@@ -61,7 +61,7 @@ export function useUpdateOrganization() {
export function useDeleteOrganization() { export function useDeleteOrganization() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: number) => masterDataService.deleteOrganization(id), mutationFn: (uuid: string) => masterDataService.deleteOrganization(uuid),
onSuccess: () => { onSuccess: () => {
toast.success('Organization deleted successfully'); toast.success('Organization deleted successfully');
queryClient.invalidateQueries({ queryKey: masterDataKeys.organizations() }); queryClient.invalidateQueries({ queryKey: masterDataKeys.organizations() });
+3 -3
View File
@@ -7,7 +7,7 @@ import { getApiErrorMessage } from '@/types/api-error';
export const projectKeys = { export const projectKeys = {
all: ['projects'] as const, all: ['projects'] as const,
list: (params: SearchProjectDto) => [...projectKeys.all, 'list', params] as const, list: (params: SearchProjectDto) => [...projectKeys.all, 'list', params] as const,
detail: (id: number) => [...projectKeys.all, 'detail', id] as const, detail: (uuid: string) => [...projectKeys.all, 'detail', uuid] as const,
}; };
export function useProjects(params?: SearchProjectDto) { export function useProjects(params?: SearchProjectDto) {
@@ -36,7 +36,7 @@ export function useCreateProject() {
export function useUpdateProject() { export function useUpdateProject() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateProjectDto }) => projectService.update(id, data), mutationFn: ({ uuid, data }: { uuid: string; data: UpdateProjectDto }) => projectService.update(uuid, data),
onSuccess: () => { onSuccess: () => {
toast.success("Project updated successfully"); toast.success("Project updated successfully");
queryClient.invalidateQueries({ queryKey: projectKeys.all }); queryClient.invalidateQueries({ queryKey: projectKeys.all });
@@ -52,7 +52,7 @@ export function useUpdateProject() {
export function useDeleteProject() { export function useDeleteProject() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: number) => projectService.delete(id), mutationFn: (uuid: string) => projectService.delete(uuid),
onSuccess: () => { onSuccess: () => {
toast.success("Project deleted successfully"); toast.success("Project deleted successfully");
queryClient.invalidateQueries({ queryKey: projectKeys.all }); queryClient.invalidateQueries({ queryKey: projectKeys.all });
+3 -3
View File
@@ -7,7 +7,7 @@ import { getApiErrorMessage } from '@/types/api-error';
export const userKeys = { export const userKeys = {
all: ['users'] as const, all: ['users'] as const,
list: (params?: SearchUserDto) => [...userKeys.all, 'list', params] as const, list: (params?: SearchUserDto) => [...userKeys.all, 'list', params] as const,
detail: (id: number) => [...userKeys.all, 'detail', id] as const, detail: (uuid: string) => [...userKeys.all, 'detail', uuid] as const,
}; };
export function useUsers(params?: SearchUserDto) { export function useUsers(params?: SearchUserDto) {
@@ -43,7 +43,7 @@ export function useCreateUser() {
export function useUpdateUser() { export function useUpdateUser() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateUserDto }) => userService.update(id, data), mutationFn: ({ uuid, data }: { uuid: string; data: UpdateUserDto }) => userService.update(uuid, data),
onSuccess: () => { onSuccess: () => {
toast.success("User updated successfully"); toast.success("User updated successfully");
queryClient.invalidateQueries({ queryKey: userKeys.all }); queryClient.invalidateQueries({ queryKey: userKeys.all });
@@ -59,7 +59,7 @@ export function useUpdateUser() {
export function useDeleteUser() { export function useDeleteUser() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: number) => userService.delete(id), mutationFn: (uuid: string) => userService.delete(uuid),
onSuccess: () => { onSuccess: () => {
toast.success("User deleted successfully"); toast.success("User deleted successfully");
queryClient.invalidateQueries({ queryKey: userKeys.all }); queryClient.invalidateQueries({ queryKey: userKeys.all });
+3
View File
@@ -3,6 +3,7 @@ import { NotificationResponse } from "@/types/notification";
// Mock Data // Mock Data
let mockNotifications = [ let mockNotifications = [
{ {
uuid: "019575a0-0001-7000-8000-000000000001",
notificationId: 1, notificationId: 1,
title: "RFA Approved", title: "RFA Approved",
message: "RFA-001 has been approved by the Project Manager.", message: "RFA-001 has been approved by the Project Manager.",
@@ -12,6 +13,7 @@ let mockNotifications = [
link: "/rfas/1", link: "/rfas/1",
}, },
{ {
uuid: "019575a0-0002-7000-8000-000000000002",
notificationId: 2, notificationId: 2,
title: "New Correspondence", title: "New Correspondence",
message: "You have received a new correspondence from Contractor A.", message: "You have received a new correspondence from Contractor A.",
@@ -21,6 +23,7 @@ let mockNotifications = [
link: "/correspondences/3", link: "/correspondences/3",
}, },
{ {
uuid: "019575a0-0003-7000-8000-000000000003",
notificationId: 3, notificationId: 3,
title: "Drawing Revision Required", title: "Drawing Revision Required",
message: "Drawing S-201 requires revision based on recent comments.", message: "Drawing S-201 requires revision based on recent comments.",
@@ -18,8 +18,8 @@ export const asBuiltDrawingService = {
/** /**
* Get details by ID * Get details by ID
*/ */
getById: async (id: string | number) => { getByUuid: async (uuid: string) => {
const response = await apiClient.get(`/drawings/asbuilt/${id}`); const response = await apiClient.get(`/drawings/asbuilt/${uuid}`);
return response.data; return response.data;
}, },
@@ -34,8 +34,8 @@ export const asBuiltDrawingService = {
/** /**
* Create New Revision * Create New Revision
*/ */
createRevision: async (id: string | number, data: CreateAsBuiltDrawingRevisionDto) => { createRevision: async (uuid: string, data: CreateAsBuiltDrawingRevisionDto) => {
const response = await apiClient.post(`/drawings/asbuilt/${id}/revisions`, data); const response = await apiClient.post(`/drawings/asbuilt/${uuid}/revisions`, data);
return response.data; return response.data;
}, },
}; };
+5 -5
View File
@@ -19,9 +19,9 @@ export const circulationService = {
/** /**
* ID * ID
*/ */
getById: async (id: string | number) => { getByUuid: async (uuid: string) => {
// GET /circulations/:id // GET /circulations/:uuid
const response = await apiClient.get(`/circulations/${id}`); const response = await apiClient.get(`/circulations/${uuid}`);
return response.data; return response.data;
}, },
@@ -47,8 +47,8 @@ export const circulationService = {
/** /**
* / * /
*/ */
delete: async (id: string | number) => { delete: async (uuid: string) => {
const response = await apiClient.delete(`/circulations/${id}`); const response = await apiClient.delete(`/circulations/${uuid}`);
return response.data; return response.data;
} }
}; };
@@ -17,8 +17,8 @@ export const contractDrawingService = {
/** /**
* ID * ID
*/ */
getById: async (id: string | number) => { getByUuid: async (uuid: string) => {
const response = await apiClient.get(`/drawings/contract/${id}`); const response = await apiClient.get(`/drawings/contract/${uuid}`);
return response.data; return response.data;
}, },
@@ -33,16 +33,16 @@ export const contractDrawingService = {
/** /**
* *
*/ */
update: async (id: string | number, data: UpdateContractDrawingDto) => { update: async (uuid: string, data: UpdateContractDrawingDto) => {
const response = await apiClient.put(`/drawings/contract/${id}`, data); const response = await apiClient.put(`/drawings/contract/${uuid}`, data);
return response.data; return response.data;
}, },
/** /**
* (Soft Delete) * (Soft Delete)
*/ */
delete: async (id: string | number) => { delete: async (uuid: string) => {
const response = await apiClient.delete(`/drawings/contract/${id}`); const response = await apiClient.delete(`/drawings/contract/${uuid}`);
return response.data; return response.data;
}, },
}; };
+10 -10
View File
@@ -19,11 +19,11 @@ export const contractService = {
}, },
/** /**
* Get contract by ID * Get contract by UUID
* GET /contracts/:id * GET /contracts/:uuid
*/ */
getById: async (id: number) => { getByUuid: async (uuid: string) => {
const response = await apiClient.get(`/contracts/${id}`); const response = await apiClient.get(`/contracts/${uuid}`);
return response.data; return response.data;
}, },
@@ -38,19 +38,19 @@ export const contractService = {
/** /**
* Update contract * Update contract
* PATCH /contracts/:id * PATCH /contracts/:uuid
*/ */
update: async (id: number, data: UpdateContractDto) => { update: async (uuid: string, data: UpdateContractDto) => {
const response = await apiClient.patch(`/contracts/${id}`, data); const response = await apiClient.patch(`/contracts/${uuid}`, data);
return response.data; return response.data;
}, },
/** /**
* Delete contract * Delete contract
* DELETE /contracts/:id * DELETE /contracts/:uuid
*/ */
delete: async (id: number) => { delete: async (uuid: string) => {
const response = await apiClient.delete(`/contracts/${id}`); const response = await apiClient.delete(`/contracts/${uuid}`);
return response.data; return response.data;
}, },
}; };
+14 -14
View File
@@ -15,8 +15,8 @@ export const correspondenceService = {
return response.data; return response.data;
}, },
getById: async (id: string | number) => { getByUuid: async (uuid: string) => {
const response = await apiClient.get(`/correspondences/${id}`); const response = await apiClient.get(`/correspondences/${uuid}`);
return response.data.data; // Unwrap NestJS Interceptor 'data' wrapper return response.data.data; // Unwrap NestJS Interceptor 'data' wrapper
}, },
@@ -25,13 +25,13 @@ export const correspondenceService = {
return response.data; return response.data;
}, },
update: async (id: string | number, data: Partial<CreateCorrespondenceDto>) => { update: async (uuid: string, data: Partial<CreateCorrespondenceDto>) => {
const response = await apiClient.put(`/correspondences/${id}`, data); const response = await apiClient.put(`/correspondences/${uuid}`, data);
return response.data; return response.data;
}, },
delete: async (id: string | number) => { delete: async (uuid: string) => {
const response = await apiClient.delete(`/correspondences/${id}`); const response = await apiClient.delete(`/correspondences/${uuid}`);
return response.data; return response.data;
}, },
@@ -40,33 +40,33 @@ export const correspondenceService = {
/** /**
* (Submit) Workflow * (Submit) Workflow
*/ */
submit: async (id: string | number, data: SubmitCorrespondenceDto) => { submit: async (uuid: string, data: SubmitCorrespondenceDto) => {
const response = await apiClient.post(`/correspondences/${id}/submit`, data); const response = await apiClient.post(`/correspondences/${uuid}/submit`, data);
return response.data; return response.data;
}, },
/** /**
* Workflow ( Approve, Reject) * Workflow ( Approve, Reject)
*/ */
processWorkflow: async (id: string | number, data: WorkflowActionDto) => { processWorkflow: async (uuid: string, data: WorkflowActionDto) => {
const response = await apiClient.post(`/correspondences/${id}/workflow`, data); const response = await apiClient.post(`/correspondences/${uuid}/workflow`, data);
return response.data; return response.data;
}, },
/** /**
* *
*/ */
addReference: async (id: string | number, data: AddReferenceDto) => { addReference: async (uuid: string, data: AddReferenceDto) => {
const response = await apiClient.post(`/correspondences/${id}/references`, data); const response = await apiClient.post(`/correspondences/${uuid}/references`, data);
return response.data; return response.data;
}, },
/** /**
* *
*/ */
removeReference: async (id: string | number, data: RemoveReferenceDto) => { removeReference: async (uuid: string, data: RemoveReferenceDto) => {
// ใช้ DELETE method โดยส่ง body ไปด้วย (axios รองรับผ่าน config.data) // ใช้ DELETE method โดยส่ง body ไปด้วย (axios รองรับผ่าน config.data)
const response = await apiClient.delete(`/correspondences/${id}/references`, { const response = await apiClient.delete(`/correspondences/${uuid}/references`, {
data: data data: data
}); });
return response.data; return response.data;
+4 -4
View File
@@ -78,14 +78,14 @@ export const masterDataService = {
}, },
/** แก้ไของค์กร */ /** แก้ไของค์กร */
updateOrganization: async (id: number, data: UpdateOrganizationDto) => { updateOrganization: async (uuid: string, data: UpdateOrganizationDto) => {
const response = await apiClient.put(`/organizations/${id}`, data); const response = await apiClient.put(`/organizations/${uuid}`, data);
return response.data; return response.data;
}, },
/** ลบองค์กร */ /** ลบองค์กร */
deleteOrganization: async (id: number) => { deleteOrganization: async (uuid: string) => {
const response = await apiClient.delete(`/organizations/${id}`); const response = await apiClient.delete(`/organizations/${uuid}`);
return response.data; return response.data;
}, },
@@ -9,8 +9,8 @@ export const notificationService = {
return response.data; return response.data;
}, },
markAsRead: async (id: number) => { markAsRead: async (uuid: string) => {
const response = await apiClient.patch(`/notifications/${id}/read`); const response = await apiClient.put(`/notifications/${uuid}/read`);
return response.data; return response.data;
}, },
+10 -10
View File
@@ -20,11 +20,11 @@ export const organizationService = {
}, },
/** /**
* Get organization by ID * Get organization by UUID
* GET /organizations/:id * GET /organizations/:uuid
*/ */
getById: async (id: number) => { getByUuid: async (uuid: string) => {
const response = await apiClient.get(`/organizations/${id}`); const response = await apiClient.get(`/organizations/${uuid}`);
return response.data; return response.data;
}, },
@@ -39,19 +39,19 @@ export const organizationService = {
/** /**
* Update organization * Update organization
* PATCH /organizations/:id * PATCH /organizations/:uuid
*/ */
update: async (id: number, data: UpdateOrganizationDto) => { update: async (uuid: string, data: UpdateOrganizationDto) => {
const response = await apiClient.patch(`/organizations/${id}`, data); const response = await apiClient.patch(`/organizations/${uuid}`, data);
return response.data; return response.data;
}, },
/** /**
* Delete organization * Delete organization
* DELETE /organizations/:id * DELETE /organizations/:uuid
*/ */
delete: async (id: number) => { delete: async (uuid: string) => {
const response = await apiClient.delete(`/organizations/${id}`); const response = await apiClient.delete(`/organizations/${uuid}`);
return response.data; return response.data;
}, },
}; };
+7 -7
View File
@@ -23,9 +23,9 @@ export const projectService = {
return response.data; return response.data;
}, },
/** ดึงรายละเอียดโครงการตาม ID */ /** ดึงรายละเอียดโครงการตาม UUID */
getById: async (id: string | number) => { getByUuid: async (uuid: string) => {
const response = await apiClient.get(`/projects/${id}`); const response = await apiClient.get(`/projects/${uuid}`);
return response.data; return response.data;
}, },
@@ -36,14 +36,14 @@ export const projectService = {
}, },
/** แก้ไขโครงการ */ /** แก้ไขโครงการ */
update: async (id: string | number, data: UpdateProjectDto) => { update: async (uuid: string, data: UpdateProjectDto) => {
const response = await apiClient.put(`/projects/${id}`, data); const response = await apiClient.put(`/projects/${uuid}`, data);
return response.data; return response.data;
}, },
/** ลบโครงการ (Soft Delete) */ /** ลบโครงการ (Soft Delete) */
delete: async (id: string | number) => { delete: async (uuid: string) => {
const response = await apiClient.delete(`/projects/${id}`); const response = await apiClient.delete(`/projects/${uuid}`);
return response.data; return response.data;
}, },
@@ -18,8 +18,8 @@ export const shopDrawingService = {
/** /**
* ID ( Revision History ) * ID ( Revision History )
*/ */
getById: async (id: string | number) => { getByUuid: async (uuid: string) => {
const response = await apiClient.get(`/drawings/shop/${id}`); const response = await apiClient.get(`/drawings/shop/${uuid}`);
return response.data; return response.data;
}, },
@@ -34,8 +34,8 @@ export const shopDrawingService = {
/** /**
* Revision Shop Drawing * Revision Shop Drawing
*/ */
createRevision: async (id: string | number, data: CreateShopDrawingRevisionDto) => { createRevision: async (uuid: string, data: CreateShopDrawingRevisionDto) => {
const response = await apiClient.post(`/drawings/shop/${id}/revisions`, data); const response = await apiClient.post(`/drawings/shop/${uuid}/revisions`, data);
return response.data; return response.data;
}, },
}; };
+8 -7
View File
@@ -12,7 +12,8 @@ interface RawUser {
const transformUser = (user: RawUser): User => { const transformUser = (user: RawUser): User => {
return { return {
...(user as unknown as User), ...(user as unknown as User),
userId: (user.user_id ?? user.userId) as number, uuid: (user.uuid as string) ?? '',
userId: (user.user_id ?? user.userId) as number | undefined,
roles: (user.assignments?.map((a) => a.role) ?? []) as User['roles'], roles: (user.assignments?.map((a) => a.role) ?? []) as User['roles'],
}; };
}; };
@@ -45,8 +46,8 @@ export const userService = {
return response.data; return response.data;
}, },
getById: async (id: number) => { getByUuid: async (uuid: string) => {
const response = await apiClient.get<RawUser>(`/users/${id}`); const response = await apiClient.get<RawUser>(`/users/${uuid}`);
return transformUser(response.data); return transformUser(response.data);
}, },
@@ -55,13 +56,13 @@ export const userService = {
return transformUser(response.data); return transformUser(response.data);
}, },
update: async (id: number, data: UpdateUserDto) => { update: async (uuid: string, data: UpdateUserDto) => {
const response = await apiClient.put<RawUser>(`/users/${id}`, data); const response = await apiClient.put<RawUser>(`/users/${uuid}`, data);
return transformUser(response.data); return transformUser(response.data);
}, },
delete: async (id: number) => { delete: async (uuid: string) => {
const response = await apiClient.delete(`/users/${id}`); const response = await apiClient.delete(`/users/${uuid}`);
return response.data; return response.data;
}, },
+8 -4
View File
@@ -38,7 +38,8 @@ export interface CirculationRouting {
* Main Circulation entity * Main Circulation entity
*/ */
export interface Circulation { export interface Circulation {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
correspondenceId?: number; correspondenceId?: number;
organizationId: number; organizationId: number;
circulationNo: string; circulationNo: string;
@@ -52,16 +53,19 @@ export interface Circulation {
// Joined relations from API // Joined relations from API
routings?: CirculationRouting[]; routings?: CirculationRouting[];
correspondence?: { correspondence?: {
id: number; uuid: string;
id?: number;
correspondence_number: string; correspondence_number: string;
}; };
organization?: { organization?: {
id: number; uuid: string;
id?: number;
organization_code: string; organization_code: string;
organization_name: string; organization_name: string;
}; };
creator?: { creator?: {
user_id: number; uuid: string;
user_id?: number;
username: string; username: string;
first_name?: string; first_name?: string;
last_name?: string; last_name?: string;
+12 -7
View File
@@ -1,11 +1,13 @@
export interface Organization { export interface Organization {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
organizationName: string; organizationName: string;
organizationCode: string; organizationCode: string;
} }
export interface Attachment { export interface Attachment {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
name: string; name: string;
url: string; url: string;
size?: number; size?: number;
@@ -15,7 +17,8 @@ export interface Attachment {
// Used in List View mainly // Used in List View mainly
export interface CorrespondenceRevision { export interface CorrespondenceRevision {
id: number; uuid: string;
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;
@@ -36,20 +39,22 @@ export interface CorrespondenceRevision {
// Nested Relation from Backend Refactor // Nested Relation from Backend Refactor
correspondence: { correspondence: {
id: number; uuid: string;
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?: { id: number; projectName: string; projectCode: string }; project?: { uuid: string; id?: number; 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 {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
correspondenceNumber: string; correspondenceNumber: string;
projectId: number; projectId: number;
originatorId?: number; originatorId?: number;
@@ -59,7 +64,7 @@ export interface Correspondence {
// Relations // Relations
originator?: Organization; originator?: Organization;
project?: { id: number; projectName: string; projectCode: string }; project?: { uuid: string; id?: number; 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?: {
+10 -5
View File
@@ -1,6 +1,7 @@
// Entity Interfaces // Entity Interfaces
export interface DrawingRevision { export interface DrawingRevision {
revisionId: number; uuid: string;
revisionId?: number; // Excluded from API responses (ADR-019)
revisionNumber: string; revisionNumber: string;
title?: string; // Added title?: string; // Added
legacyDrawingNumber?: string; // Added legacyDrawingNumber?: string; // Added
@@ -14,7 +15,8 @@ export interface DrawingRevision {
} }
export interface ContractDrawing { export interface ContractDrawing {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
contractDrawingNo: string; contractDrawingNo: string;
title: string; title: string;
projectId: number; projectId: number;
@@ -26,7 +28,8 @@ export interface ContractDrawing {
} }
export interface ShopDrawing { export interface ShopDrawing {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
drawingNumber: string; drawingNumber: string;
projectId: number; projectId: number;
mainCategoryId: number; mainCategoryId: number;
@@ -38,7 +41,8 @@ export interface ShopDrawing {
} }
export interface AsBuiltDrawing { export interface AsBuiltDrawing {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
drawingNumber: string; drawingNumber: string;
projectId: number; projectId: number;
mainCategoryId: number; mainCategoryId: number;
@@ -50,7 +54,8 @@ export interface AsBuiltDrawing {
// Unified Type for List // Unified Type for List
export interface Drawing { export interface Drawing {
drawingId: number; uuid?: string;
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 -1
View File
@@ -1,5 +1,6 @@
export interface Notification { export interface Notification {
notificationId: number; uuid: string;
notificationId?: number; // Excluded from API responses (ADR-019)
title: string; title: string;
message: string; message: string;
type: "INFO" | "SUCCESS" | "WARNING" | "ERROR"; type: "INFO" | "SUCCESS" | "WARNING" | "ERROR";
+2 -1
View File
@@ -1,5 +1,6 @@
export interface Organization { export interface Organization {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
organizationCode: string; organizationCode: string;
organizationName: string; organizationName: string;
roleId?: number; // NEW - organization role (OWNER, DESIGNER, CONSULTANT, CONTRACTOR, THIRD_PARTY) roleId?: number; // NEW - organization role (OWNER, DESIGNER, CONSULTANT, CONTRACTOR, THIRD_PARTY)
+2 -1
View File
@@ -1,5 +1,6 @@
export interface SearchResult { export interface SearchResult {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
type: "correspondence" | "rfa" | "drawing"; type: "correspondence" | "rfa" | "drawing";
title: string; title: string;
description?: string; description?: string;
+2 -1
View File
@@ -12,7 +12,8 @@ export interface UserOrganization {
} }
export interface User { export interface User {
userId: number; uuid: string;
userId?: number; // Excluded from API responses (ADR-019)
username: string; username: string;
email: string; email: string;
firstName: string; firstName: string;
+7 -2
View File
@@ -22,7 +22,7 @@
// EDITOR SETTINGS // EDITOR SETTINGS
// ======================================== // ========================================
"editor.fontSize": 18, "editor.fontSize": 20,
"editor.tabSize": 2, "editor.tabSize": 2,
"editor.lineHeight": 1.6, "editor.lineHeight": 1.6,
"editor.rulers": [80, 120], "editor.rulers": [80, 120],
@@ -636,7 +636,7 @@
// DEBUGGING // DEBUGGING
// ======================================== // ========================================
"debug.console.fontSize": 14, "debug.console.fontSize": 16,
"debug.console.fontFamily": "Consolas, 'Courier New', monospace", "debug.console.fontFamily": "Consolas, 'Courier New', monospace",
"debug.console.lineHeight": 20, "debug.console.lineHeight": 20,
"debug.console.wordWrap": false, "debug.console.wordWrap": false,
@@ -670,6 +670,11 @@
"vitest.enable": true, "vitest.enable": true,
"yaml.maxItemsComputed": 6000, "yaml.maxItemsComputed": 6000,
"powershell.cwd": "🎯 Root", "powershell.cwd": "🎯 Root",
"files.autoSave": "onFocusChange",
"diffEditor.codeLens": false,
"workbench.colorTheme": "Default Dark Modern",
"workbench.preferredDarkColorTheme": "Default Dark Modern",
"scm.alwaysShowActions": false,
}, },
// ======================================== // ========================================
// LAUNCH CONFIGURATIONS // LAUNCH CONFIGURATIONS
+24 -30
View File
@@ -167,8 +167,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@22.19.1)(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@22.19.1)(typescript@5.9.3))
uuid: uuid:
specifier: ^9.0.1 specifier: ^11.1.0
version: 9.0.1 version: 11.1.0
winston: winston:
specifier: ^3.18.3 specifier: ^3.18.3
version: 3.18.3 version: 3.18.3
@@ -178,7 +178,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@9.0.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)) 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))
'@eslint/eslintrc': '@eslint/eslintrc':
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.3.1 version: 3.3.1
@@ -231,8 +231,8 @@ importers:
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.3 version: 6.0.3
'@types/uuid': '@types/uuid':
specifier: ^9.0.8 specifier: ^10.0.0
version: 9.0.8 version: 10.0.0
eslint: eslint:
specifier: ^9.18.0 specifier: ^9.18.0
version: 9.39.1(jiti@1.21.7) version: 9.39.1(jiti@1.21.7)
@@ -3797,13 +3797,13 @@ packages:
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/uuid@11.0.0': '@types/uuid@11.0.0':
resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==}
deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed. deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.
'@types/uuid@9.0.8':
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
'@types/validator@13.15.10': '@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
@@ -8125,10 +8125,6 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
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==}
@@ -9777,7 +9773,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@9.0.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))': '@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))':
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
@@ -9821,7 +9817,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@9.0.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-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))
transitivePeerDependencies: transitivePeerDependencies:
- '@egjs/hammerjs' - '@egjs/hammerjs'
- component-emitter - component-emitter
@@ -12411,12 +12407,12 @@ snapshots:
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
optional: true optional: true
'@types/uuid@10.0.0': {}
'@types/uuid@11.0.0': '@types/uuid@11.0.0':
dependencies: dependencies:
uuid: 13.0.0 uuid: 13.0.0
'@types/uuid@9.0.8': {}
'@types/validator@13.15.10': {} '@types/validator@13.15.10': {}
'@types/yargs-parser@21.0.3': {} '@types/yargs-parser@21.0.3': {}
@@ -14000,8 +13996,8 @@ snapshots:
'@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
@@ -14024,7 +14020,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3 debug: 4.4.3
@@ -14035,22 +14031,22 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -14061,7 +14057,7 @@ snapshots:
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
@@ -17389,8 +17385,6 @@ snapshots:
uuid@8.3.2: {} uuid@8.3.2: {}
uuid@9.0.1: {}
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:
@@ -17403,18 +17397,18 @@ snapshots:
vary@1.1.2: {} vary@1.1.2: {}
vis-data@8.0.3(uuid@9.0.1)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)): vis-data@8.0.3(uuid@11.1.0)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)):
dependencies: dependencies:
uuid: 9.0.1 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.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-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)):
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@9.0.1)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@1.3.1)) 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-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):
@@ -104,7 +104,7 @@ UNIQUE | Role name (
* * Purpose **: MASTER TABLE storing ALL organizations involved IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ----------------- | ------------ | ----------------------------------- | ---------------------------------------- | * * Purpose **: MASTER TABLE storing ALL organizations involved IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ----------------- | ------------ | ----------------------------------- | ---------------------------------------- |
| id | INT | PRIMARY KEY, | id | INT | PRIMARY KEY,
AUTO_INCREMENT | UNIQUE identifier FOR organization | | organization_code | VARCHAR(20) | NOT NULL, AUTO_INCREMENT | UNIQUE identifier FOR organization | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | organization_code | VARCHAR(20) | NOT NULL,
UNIQUE | Organization code (e.g., 'กทท.', 'TEAM') | | organization_name | VARCHAR(255) | NOT NULL | FULL organization name | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS (1 = active, 0 = inactive) | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last UNIQUE | Organization code (e.g., 'กทท.', 'TEAM') | | organization_name | VARCHAR(255) | NOT NULL | FULL organization name | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS (1 = active, 0 = inactive) | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last
UPDATE timestamp | UPDATE timestamp |
| deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (organization_code) - INDEX (is_active) ** Relationships **: - Referenced by: users, | deleted_at | DATETIME | NULL | Soft delete timestamp | ** INDEXES **: - PRIMARY KEY (id) - UNIQUE (organization_code) - INDEX (is_active) ** Relationships **: - Referenced by: users,
@@ -117,7 +117,7 @@ UPDATE timestamp |
* * Purpose **: MASTER TABLE FOR ALL projects IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ------------ | ------------ | --------------------------- | ----------------------------- | * * Purpose **: MASTER TABLE FOR ALL projects IN the system | COLUMN Name | Data TYPE | Constraints | Description | | ------------ | ------------ | --------------------------- | ----------------------------- |
| id | INT | PRIMARY KEY, | id | INT | PRIMARY KEY,
AUTO_INCREMENT | UNIQUE identifier FOR project | | project_code | VARCHAR(50) | NOT NULL, AUTO_INCREMENT | UNIQUE identifier FOR project | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | project_code | VARCHAR(50) | NOT NULL,
UNIQUE | Project code (e.g., 'LCBP3') | | project_name | VARCHAR(255) | NOT NULL | FULL project name | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS | UNIQUE | Project code (e.g., 'LCBP3') | | project_name | VARCHAR(255) | NOT NULL | FULL project name | | is_active | TINYINT(1) | DEFAULT 1 | Active STATUS |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last update timestamp |
@@ -131,7 +131,7 @@ UPDATE timestamp |
* * Purpose **: MASTER TABLE FOR contracts within projects | COLUMN Name | Data TYPE | Constraints | Description | | ------------- | ------------ | ----------------------------------- | ------------------------------ | * * Purpose **: MASTER TABLE FOR contracts within projects | COLUMN Name | Data TYPE | Constraints | Description | | ------------- | ------------ | ----------------------------------- | ------------------------------ |
| id | INT | PRIMARY KEY, | id | INT | PRIMARY KEY,
AUTO_INCREMENT | UNIQUE identifier FOR contract | | project_id | INT | NOT NULL, AUTO_INCREMENT | UNIQUE identifier FOR contract | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | project_id | INT | NOT NULL,
FK | Reference TO projects TABLE | | contract_code | VARCHAR(50) | NOT NULL, FK | Reference TO projects TABLE | | contract_code | VARCHAR(50) | NOT NULL,
UNIQUE | Contract code | | contract_name | VARCHAR(255) | NOT NULL | FULL contract name | | description | TEXT | NULL | Contract description | | start_date | DATE | NULL | Contract START date | | end_date | DATE | NULL | Contract UNIQUE | Contract code | | contract_name | VARCHAR(255) | NOT NULL | FULL contract name | | description | TEXT | NULL | Contract description | | start_date | DATE | NULL | Contract START date | | end_date | DATE | NULL | Contract
END date | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last END date | | is_active | BOOLEAN | DEFAULT TRUE | Active STATUS | | created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | Record creation timestamp | | updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE | Last
@@ -152,7 +152,7 @@ UPDATE timestamp |
* * Purpose **: MASTER TABLE storing ALL system users | COLUMN Name | Data TYPE | Constraints | Description | | ----------------------- | ------------ | ----------------------------------- | -------------------------------- | * * Purpose **: MASTER TABLE storing ALL system users | COLUMN Name | Data TYPE | Constraints | Description | | ----------------------- | ------------ | ----------------------------------- | -------------------------------- |
| user_id | INT | PRIMARY KEY, | user_id | INT | PRIMARY KEY,
AUTO_INCREMENT | UNIQUE identifier FOR user | | username | VARCHAR(50) | NOT NULL, AUTO_INCREMENT | UNIQUE identifier FOR user | | uuid | UUID | NOT NULL, UNIQUE, DEFAULT UUID() | UUID Public Identifier (ADR-019) | | username | VARCHAR(50) | NOT NULL,
UNIQUE | Login username | | password_hash | VARCHAR(255) | NOT NULL | Hashed PASSWORD (bcrypt) | | first_name | VARCHAR(50) | NULL | User 's first name | UNIQUE | Login username | | password_hash | VARCHAR(255) | NOT NULL | Hashed PASSWORD (bcrypt) | | first_name | VARCHAR(50) | NULL | User 's first name |
| last_name | VARCHAR(50) | NULL | User' s last name | | email | VARCHAR(100) | NOT NULL, | last_name | VARCHAR(50) | NULL | User' s last name | | email | VARCHAR(100) | NOT NULL,
UNIQUE | Email address | | line_id | VARCHAR(100) | NULL | LINE messenger ID | | primary_organization_id | INT | NULL, UNIQUE | Email address | | line_id | VARCHAR(100) | NULL | LINE messenger ID | | primary_organization_id | INT | NULL,
@@ -335,6 +335,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| Column Name | Data Type | Constraints | Description | | Column Name | Data Type | Constraints | Description |
| ------------------------- | ------------ | --------------------------- | ------------------------------------------ | | ------------------------- | ------------ | --------------------------- | ------------------------------------------ |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Master correspondence ID | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Master correspondence ID |
| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) |
| correspondence_number | VARCHAR(100) | NOT NULL | Document number (from numbering system) | | correspondence_number | VARCHAR(100) | NOT NULL | Document number (from numbering system) |
| correspondence_type_id | INT | NOT NULL, FK | Reference to correspondence_types | | correspondence_type_id | INT | NOT NULL, FK | Reference to correspondence_types |
| **discipline_id** | **INT** | **NULL, FK** | **[NEW] สาขางาน (ถ้ามี)** | | **discipline_id** | **INT** | **NULL, FK** | **[NEW] สาขางาน (ถ้ามี)** |
@@ -354,6 +355,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
* FOREIGN KEY (originator_id) REFERENCES organizations(id) ON DELETE SET NULL * FOREIGN KEY (originator_id) REFERENCES organizations(id) ON DELETE SET NULL
* FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL * FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL
* UNIQUE KEY (project_id, correspondence_number) * UNIQUE KEY (project_id, correspondence_number)
* UNIQUE INDEX idx_correspondences_uuid (uuid)
* INDEX (correspondence_type_id) * INDEX (correspondence_type_id)
* INDEX (originator_id) * INDEX (originator_id)
* INDEX (deleted_at) * INDEX (deleted_at)
@@ -372,6 +374,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| Column Name | Data Type | Constraints | Description | | Column Name | Data Type | Constraints | Description |
| ------------------------ | ------------ | --------------------------------- | -------------------------------------------------------- | | ------------------------ | ------------ | --------------------------------- | -------------------------------------------------------- |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID |
| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) |
| correspondence_id | INT | NOT NULL, FK | Master correspondence ID | | correspondence_id | INT | NOT NULL, FK | Master correspondence ID |
| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | | revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) |
| revision_label | VARCHAR(10) | NULL | Display revision (A, B, 1.1...) | | revision_label | VARCHAR(10) | NULL | Display revision (A, B, 1.1...) |
@@ -824,6 +827,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| Column Name | Data Type | Constraints | Description | | Column Name | Data Type | Constraints | Description |
| --------------- | ------------ | ----------------------------------- | ---------------------------------------- | | --------------- | ------------ | ----------------------------------- | ---------------------------------------- |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID |
| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) |
| project_id | INT | NOT NULL, FK | Reference to projects | | project_id | INT | NOT NULL, FK | Reference to projects |
| condwg_no | VARCHAR(255) | NOT NULL | Contract drawing number | | condwg_no | VARCHAR(255) | NOT NULL | Contract drawing number |
| title | VARCHAR(255) | NOT NULL | Drawing title | | title | VARCHAR(255) | NOT NULL | Drawing title |
@@ -843,6 +847,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
* FOREIGN KEY (volume_id) REFERENCES contract_drawing_volumes(id) ON DELETE RESTRICT * FOREIGN KEY (volume_id) REFERENCES contract_drawing_volumes(id) ON DELETE RESTRICT
* FOREIGN KEY (updated_by) REFERENCES users(user_id) * FOREIGN KEY (updated_by) REFERENCES users(user_id)
* UNIQUE KEY (project_id, condwg_no) * UNIQUE KEY (project_id, condwg_no)
* UNIQUE INDEX idx_contract_drawings_uuid (uuid)
* INDEX (map_cat_id) * INDEX (map_cat_id)
* INDEX (volume_id) * INDEX (volume_id)
* INDEX (deleted_at) * INDEX (deleted_at)
@@ -942,6 +947,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| Column Name | Data Type | Constraints | Description | | Column Name | Data Type | Constraints | Description |
| ---------------- | ------------ | ----------------------------------- | -------------------------- | | ---------------- | ------------ | ----------------------------------- | -------------------------- |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID |
| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) |
| project_id | INT | NOT NULL, FK | Reference to projects | | project_id | INT | NOT NULL, FK | Reference to projects |
| drawing_number | VARCHAR(100) | NOT NULL, UNIQUE | Shop drawing number | | drawing_number | VARCHAR(100) | NOT NULL, UNIQUE | Shop drawing number |
| main_category_id | INT | NOT NULL, FK | Reference to main category | | main_category_id | INT | NOT NULL, FK | Reference to main category |
@@ -955,6 +961,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
* PRIMARY KEY (id) * PRIMARY KEY (id)
* UNIQUE (drawing_number) * UNIQUE (drawing_number)
* UNIQUE INDEX idx_shop_drawings_uuid (uuid)
* FOREIGN KEY (project_id) REFERENCES projects(id) * FOREIGN KEY (project_id) REFERENCES projects(id)
* FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id) * FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id)
* FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id) * FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id)
@@ -986,6 +993,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| Column Name | Data Type | Constraints | Description | | Column Name | Data Type | Constraints | Description |
| ------------------------- | ---------------- | --------------------------- | ---------------------------------------- | | ------------------------- | ---------------- | --------------------------- | ---------------------------------------- |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID |
| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) |
| shop_drawing_id | INT | NOT NULL, FK | Master shop drawing ID | | shop_drawing_id | INT | NOT NULL, FK | Master shop drawing ID |
| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | | revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) |
| revision_label | VARCHAR(10) | NULL | Display revision (A, B, C...) | | revision_label | VARCHAR(10) | NULL | Display revision (A, B, C...) |
@@ -1000,6 +1008,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
* PRIMARY KEY (id) * PRIMARY KEY (id)
* FOREIGN KEY (shop_drawing_id) REFERENCES shop_drawings(id) ON DELETE CASCADE * FOREIGN KEY (shop_drawing_id) REFERENCES shop_drawings(id) ON DELETE CASCADE
* UNIQUE KEY (shop_drawing_id, revision_number) * UNIQUE KEY (shop_drawing_id, revision_number)
* UNIQUE INDEX idx_shop_drawing_revisions_uuid (uuid)
* INDEX (revision_date) * INDEX (revision_date)
**Relationships**: **Relationships**:
@@ -1053,6 +1062,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| Column Name | Data Type | Constraints | Description | | Column Name | Data Type | Constraints | Description |
| ---------------- | ------------ | ----------------------------------- | -------------------------- | | ---------------- | ------------ | ----------------------------------- | -------------------------- |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique drawing ID |
| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) |
| project_id | INT | NOT NULL, FK | Reference to projects | | project_id | INT | NOT NULL, FK | Reference to projects |
| drawing_number | VARCHAR(100) | NOT NULL, UNIQUE | AS Built drawing number | | drawing_number | VARCHAR(100) | NOT NULL, UNIQUE | AS Built drawing number |
| main_category_id | INT | NOT NULL, FK | Reference to main category | | main_category_id | INT | NOT NULL, FK | Reference to main category |
@@ -1066,6 +1076,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
* PRIMARY KEY (id) * PRIMARY KEY (id)
* UNIQUE (drawing_number) * UNIQUE (drawing_number)
* UNIQUE INDEX idx_asbuilt_drawings_uuid (uuid)
* FOREIGN KEY (project_id) REFERENCES projects(id) * FOREIGN KEY (project_id) REFERENCES projects(id)
* FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id) * FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories(id)
* FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id) * FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories(id)
@@ -1097,6 +1108,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| Column Name | Data Type | Constraints | Description | | Column Name | Data Type | Constraints | Description |
| --------------------- | ------------ | --------------------------- | ------------------------------ | | --------------------- | ------------ | --------------------------- | ------------------------------ |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique revision ID |
| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) |
| asbuilt_drawing_id | INT | NOT NULL, FK | Master AS Built drawing ID | | asbuilt_drawing_id | INT | NOT NULL, FK | Master AS Built drawing ID |
| revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) | | revision_number | INT | NOT NULL | Revision sequence (0, 1, 2...) |
| revision_label | VARCHAR(10) | NULL | Display revision (A, B, C...) | | revision_label | VARCHAR(10) | NULL | Display revision (A, B, C...) |
@@ -1111,6 +1123,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
* PRIMARY KEY (id) * PRIMARY KEY (id)
* FOREIGN KEY (asbuilt_drawing_id) REFERENCES asbuilt_drawings(id) ON DELETE CASCADE * FOREIGN KEY (asbuilt_drawing_id) REFERENCES asbuilt_drawings(id) ON DELETE CASCADE
* UNIQUE KEY (asbuilt_drawing_id, revision_number) * UNIQUE KEY (asbuilt_drawing_id, revision_number)
* UNIQUE INDEX idx_asbuilt_drawing_revisions_uuid (uuid)
* INDEX (revision_date) * INDEX (revision_date)
**Relationships**: **Relationships**:
@@ -1229,6 +1242,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| Column Name | Data Type | Constraints | Description | | Column Name | Data Type | Constraints | Description |
| ----------------------- | ------------ | ----------------------------------- | ----------------------------------------- | | ----------------------- | ------------ | ----------------------------------- | ----------------------------------------- |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique circulation ID | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique circulation ID |
| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) |
| correspondence_id | INT | UNIQUE, FK | Link to correspondence (1:1 relationship) | | correspondence_id | INT | UNIQUE, FK | Link to correspondence (1:1 relationship) |
| organization_id | INT | NOT NULL, FK | Organization that owns this circulation | | organization_id | INT | NOT NULL, FK | Organization that owns this circulation |
| circulation_no | VARCHAR(100) | NOT NULL | Circulation sheet number | | circulation_no | VARCHAR(100) | NOT NULL | Circulation sheet number |
@@ -1249,6 +1263,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
* FOREIGN KEY (circulation_status_code) REFERENCES circulation_status_codes(code) * FOREIGN KEY (circulation_status_code) REFERENCES circulation_status_codes(code)
* FOREIGN KEY (created_by_user_id) REFERENCES users(user_id) * FOREIGN KEY (created_by_user_id) REFERENCES users(user_id)
* INDEX (organization_id) * INDEX (organization_id)
* UNIQUE INDEX idx_circulations_uuid (uuid)
* INDEX (circulation_status_code) * INDEX (circulation_status_code)
* INDEX (created_by_user_id) * INDEX (created_by_user_id)
@@ -1338,6 +1353,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| Column Name | Data Type | Constraints | Description | | Column Name | Data Type | Constraints | Description |
| ------------------- | ------------ | --------------------------- | ------------------------------------------------------------------------ | | ------------------- | ------------ | --------------------------- | ------------------------------------------------------------------------ |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique attachment ID | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique attachment ID |
| uuid | UUID | NOT NULL, UNIQUE, DEFAULT | UUID Public Identifier (ADR-019) |
| original_filename | VARCHAR(255) | NOT NULL | Original filename from upload | | original_filename | VARCHAR(255) | NOT NULL | Original filename from upload |
| stored_filename | VARCHAR(255) | NOT NULL | System-generated unique filename | | stored_filename | VARCHAR(255) | NOT NULL | System-generated unique filename |
| file_path | VARCHAR(500) | NOT NULL | Full file path on server (/share/dms-data/) | | file_path | VARCHAR(500) | NOT NULL | Full file path on server (/share/dms-data/) |
@@ -1358,6 +1374,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
* INDEX (stored_filename) * INDEX (stored_filename)
* INDEX (mime_type) * INDEX (mime_type)
* INDEX (uploaded_by_user_id) * INDEX (uploaded_by_user_id)
* UNIQUE INDEX idx_attachments_uuid (uuid)
* INDEX (created_at) * INDEX (created_at)
* INDEX (reference_date) * INDEX (reference_date)
@@ -1820,6 +1837,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
| Column Name | Data Type | Constraints | Description | | Column Name | Data Type | Constraints | Description |
| :---------------- | :----------- | :-------------------------- | :------------------------ | | :---------------- | :----------- | :-------------------------- | :------------------------ |
| id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique notification ID | | id | INT | PRIMARY KEY, AUTO_INCREMENT | Unique notification ID |
| uuid | UUID | NOT NULL, DEFAULT | UUID Public Identifier (ADR-019) |
| user_id | INT | NOT NULL, FK | Recipient user ID | | user_id | INT | NOT NULL, FK | Recipient user ID |
| title | VARCHAR(255) | NOT NULL | Notification title | | title | VARCHAR(255) | NOT NULL | Notification title |
| message | TEXT | NOT NULL | Notification body | | message | TEXT | NOT NULL | Notification body |
@@ -1836,6 +1854,7 @@ SET NULL - INDEX (is_active) - INDEX (email) ** Relationships **: - Parent: orga
* INDEX idx_notif_type (notification_type) * INDEX idx_notif_type (notification_type)
* INDEX idx_notif_read (is_read) * INDEX idx_notif_read (is_read)
* INDEX idx_notif_created (created_at) * INDEX idx_notif_created (created_at)
* INDEX idx_notifications_uuid (uuid)
**Partitioning**: **Partitioning**:
* **PARTITION BY RANGE (YEAR(created_at))**: แบ่ง Partition รายปี * **PARTITION BY RANGE (YEAR(created_at))**: แบ่ง Partition รายปี
@@ -23,6 +23,7 @@ CREATE TABLE organization_roles (
-- ตาราง Master เก็บข้อมูลองค์กรทั้งหมดที่เกี่ยวข้องในระบบ -- ตาราง Master เก็บข้อมูลองค์กรทั้งหมดที่เกี่ยวข้องในระบบ
CREATE TABLE organizations ( CREATE TABLE organizations (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
organization_code VARCHAR(20) NOT NULL UNIQUE COMMENT 'รหัสองค์กร', organization_code VARCHAR(20) NOT NULL UNIQUE COMMENT 'รหัสองค์กร',
organization_name VARCHAR(255) NOT NULL COMMENT 'ชื่อองค์กร', organization_name VARCHAR(255) NOT NULL COMMENT 'ชื่อองค์กร',
role_id INT COMMENT 'บทบาทขององค์กร', role_id INT COMMENT 'บทบาทขององค์กร',
@@ -31,12 +32,14 @@ CREATE TABLE organizations (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)', deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)',
FOREIGN KEY (role_id) REFERENCES organization_roles (id) ON DELETE FOREIGN KEY (role_id) REFERENCES organization_roles (id) ON DELETE
SET NULL SET NULL,
UNIQUE INDEX idx_organizations_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลองค์กรทั้งหมดที่เกี่ยวข้องในระบบ'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลองค์กรทั้งหมดที่เกี่ยวข้องในระบบ';
-- ตาราง Master เก็บข้อมูลโครงการ -- ตาราง Master เก็บข้อมูลโครงการ
CREATE TABLE projects ( CREATE TABLE projects (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
project_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสโครงการ', project_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสโครงการ',
project_name VARCHAR(255) NOT NULL COMMENT 'ชื่อโครงการ', project_name VARCHAR(255) NOT NULL COMMENT 'ชื่อโครงการ',
-- parent_project_id INT COMMENT 'รหัสโครงการหลัก (ถ้ามี)', -- parent_project_id INT COMMENT 'รหัสโครงการหลัก (ถ้ามี)',
@@ -46,12 +49,14 @@ CREATE TABLE projects (
-- FOREIGN KEY (contractor_organization_id) REFERENCES organizations(id) ON DELETE SET NULL -- FOREIGN KEY (contractor_organization_id) REFERENCES organizations(id) ON DELETE SET NULL
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)' deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)',
UNIQUE INDEX idx_projects_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลโครงการ'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลโครงการ';
-- ตาราง Master เก็บข้อมูลสัญญา -- ตาราง Master เก็บข้อมูลสัญญา
CREATE TABLE contracts ( CREATE TABLE contracts (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
project_id INT NOT NULL, project_id INT NOT NULL,
contract_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสสัญญา', contract_code VARCHAR(50) NOT NULL UNIQUE COMMENT 'รหัสสัญญา',
contract_name VARCHAR(255) NOT NULL COMMENT 'ชื่อสัญญา', contract_name VARCHAR(255) NOT NULL COMMENT 'ชื่อสัญญา',
@@ -62,7 +67,8 @@ CREATE TABLE contracts (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)', deleted_at DATETIME NULL COMMENT 'วันที่ลบ (Soft Delete)',
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
UNIQUE INDEX idx_contracts_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลสัญญา'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลสัญญา';
-- ===================================================== -- =====================================================
@@ -71,6 +77,7 @@ CREATE TABLE contracts (
-- ตาราง Master เก็บข้อมูลผู้ใช้งาน (User) -- ตาราง Master เก็บข้อมูลผู้ใช้งาน (User)
CREATE TABLE users ( CREATE TABLE users (
user_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', user_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
username VARCHAR(50) NOT NULL UNIQUE COMMENT 'ชื่อผู้ใช้งาน', username VARCHAR(50) NOT NULL UNIQUE COMMENT 'ชื่อผู้ใช้งาน',
password_hash VARCHAR(255) NOT NULL COMMENT 'รหัสผ่าน (Hashed)', password_hash VARCHAR(255) NOT NULL COMMENT 'รหัสผ่าน (Hashed)',
first_name VARCHAR(50) COMMENT 'ชื่อจริง', first_name VARCHAR(50) COMMENT 'ชื่อจริง',
@@ -86,7 +93,8 @@ CREATE TABLE users (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
deleted_at DATETIME NULL DEFAULT NULL COMMENT 'วันที่ลบ', deleted_at DATETIME NULL DEFAULT NULL COMMENT 'วันที่ลบ',
FOREIGN KEY (primary_organization_id) REFERENCES organizations (id) ON DELETE FOREIGN KEY (primary_organization_id) REFERENCES organizations (id) ON DELETE
SET NULL SET NULL,
UNIQUE INDEX idx_users_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลผู้ใช้งาน (User)'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูลผู้ใช้งาน (User)';
-- ตารางเก็บ Refresh Tokens สำหรับ Authentication -- ตารางเก็บ Refresh Tokens สำหรับ Authentication
@@ -258,6 +266,7 @@ CREATE TABLE correspondence_status (
-- ตาราง "แม่" ของเอกสารโต้ตอบ เก็บข้อมูลที่ไม่เปลี่ยนตาม Revision -- ตาราง "แม่" ของเอกสารโต้ตอบ เก็บข้อมูลที่ไม่เปลี่ยนตาม Revision
CREATE TABLE correspondences ( CREATE TABLE correspondences (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง (นี่คือ "Master ID" ที่ใช้เชื่อมโยง)', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง (นี่คือ "Master ID" ที่ใช้เชื่อมโยง)',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
correspondence_type_id INT NOT NULL COMMENT 'ประเภทเอกสาร ใช้แบ่งแยกว่าเป็น RFA หรือ อื่นๆ', correspondence_type_id INT NOT NULL COMMENT 'ประเภทเอกสาร ใช้แบ่งแยกว่าเป็น RFA หรือ อื่นๆ',
correspondence_number VARCHAR(100) NOT NULL COMMENT 'เลขที่เอกสาร (สร้างจาก DocumentNumberingModule)', correspondence_number VARCHAR(100) NOT NULL COMMENT 'เลขที่เอกสาร (สร้างจาก DocumentNumberingModule)',
discipline_id INT NULL COMMENT 'สาขางาน (ถ้ามี)', discipline_id INT NULL COMMENT 'สาขางาน (ถ้ามี)',
@@ -279,7 +288,8 @@ CREATE TABLE correspondences (
-- Foreign Key ที่รวมเข้ามาจาก ALTER (ระบุชื่อ Constraint ตามที่ต้องการ) -- Foreign Key ที่รวมเข้ามาจาก ALTER (ระบุชื่อ Constraint ตามที่ต้องการ)
CONSTRAINT fk_corr_discipline FOREIGN KEY (discipline_id) REFERENCES disciplines (id) ON DELETE CONSTRAINT fk_corr_discipline FOREIGN KEY (discipline_id) REFERENCES disciplines (id) ON DELETE
SET NULL, SET NULL,
UNIQUE KEY uq_corr_no_per_project (project_id, correspondence_number) UNIQUE KEY uq_corr_no_per_project (project_id, correspondence_number),
UNIQUE INDEX idx_correspondences_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "แม่" ของเอกสารโต้ตอบ เก็บข้อมูลที่ไม่เปลี่ยนตาม Revision'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "แม่" ของเอกสารโต้ตอบ เก็บข้อมูลที่ไม่เปลี่ยนตาม Revision';
-- ตารางเชื่อมผู้รับ (TO/CC) สำหรับเอกสารแต่ละฉบับ (M:N) -- ตารางเชื่อมผู้รับ (TO/CC) สำหรับเอกสารแต่ละฉบับ (M:N)
@@ -299,6 +309,7 @@ CREATE TABLE correspondence_recipients (
-- ตาราง "ลูก" เก็บประวัติการแก้ไข (Revisions) ของ correspondences (1:N) -- ตาราง "ลูก" เก็บประวัติการแก้ไข (Revisions) ของ correspondences (1:N)
CREATE TABLE correspondence_revisions ( CREATE TABLE correspondence_revisions (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
correspondence_id INT NOT NULL COMMENT 'Master ID', correspondence_id INT NOT NULL COMMENT 'Master ID',
revision_number INT NOT NULL COMMENT 'หมายเลข Revision (0, 1, 2...)', revision_number INT NOT NULL COMMENT 'หมายเลข Revision (0, 1, 2...)',
revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)', revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)',
@@ -338,7 +349,8 @@ CREATE TABLE correspondence_revisions (
UNIQUE KEY uq_master_revision_number (correspondence_id, revision_number), UNIQUE KEY uq_master_revision_number (correspondence_id, revision_number),
UNIQUE KEY uq_master_current (correspondence_id, is_current), UNIQUE KEY uq_master_current (correspondence_id, is_current),
INDEX idx_corr_rev_v_project (v_ref_project_id), INDEX idx_corr_rev_v_project (v_ref_project_id),
INDEX idx_corr_rev_v_subtype (v_doc_subtype) INDEX idx_corr_rev_v_subtype (v_doc_subtype),
UNIQUE INDEX idx_correspondence_revisions_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติการแก้ไข (Revisions) ของ correspondences (1 :N)'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติการแก้ไข (Revisions) ของ correspondences (1 :N)';
-- ตาราง Master เก็บ Tags ทั้งหมดที่ใช้ในระบบ -- ตาราง Master เก็บ Tags ทั้งหมดที่ใช้ในระบบ
@@ -529,6 +541,7 @@ CREATE TABLE contract_drawing_subcat_cat_maps (
-- ตาราง Master เก็บข้อมูล "แบบคู่สัญญา" -- ตาราง Master เก็บข้อมูล "แบบคู่สัญญา"
CREATE TABLE contract_drawings ( CREATE TABLE contract_drawings (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
project_id INT NOT NULL COMMENT 'โครงการ', project_id INT NOT NULL COMMENT 'โครงการ',
condwg_no VARCHAR(255) NOT NULL COMMENT 'เลขที่แบบสัญญา', condwg_no VARCHAR(255) NOT NULL COMMENT 'เลขที่แบบสัญญา',
title VARCHAR(255) NOT NULL COMMENT 'ชื่อแบบสัญญา', title VARCHAR(255) NOT NULL COMMENT 'ชื่อแบบสัญญา',
@@ -542,7 +555,8 @@ CREATE TABLE contract_drawings (
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
FOREIGN KEY (map_cat_id) REFERENCES contract_drawing_subcat_cat_maps (id) ON DELETE RESTRICT, FOREIGN KEY (map_cat_id) REFERENCES contract_drawing_subcat_cat_maps (id) ON DELETE RESTRICT,
FOREIGN KEY (volume_id) REFERENCES contract_drawing_volumes (id) ON DELETE RESTRICT, FOREIGN KEY (volume_id) REFERENCES contract_drawing_volumes (id) ON DELETE RESTRICT,
UNIQUE KEY ux_condwg_no_project (project_id, condwg_no) UNIQUE KEY ux_condwg_no_project (project_id, condwg_no),
UNIQUE INDEX idx_contract_drawings_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบคู่สัญญา"'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบคู่สัญญา"';
-- ตาราง Master สำหรับ "หมวดหมู่หลัก" ของแบบก่อสร้าง -- ตาราง Master สำหรับ "หมวดหมู่หลัก" ของแบบก่อสร้าง
@@ -576,6 +590,7 @@ CREATE TABLE shop_drawing_sub_categories (
-- ตาราง Master เก็บข้อมูล "แบบก่อสร้าง" -- ตาราง Master เก็บข้อมูล "แบบก่อสร้าง"
CREATE TABLE shop_drawings ( CREATE TABLE shop_drawings (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
project_id INT NOT NULL COMMENT 'โครงการ', project_id INT NOT NULL COMMENT 'โครงการ',
drawing_number VARCHAR(100) NOT NULL COMMENT 'เลขที่ Shop Drawing', drawing_number VARCHAR(100) NOT NULL COMMENT 'เลขที่ Shop Drawing',
main_category_id INT NOT NULL COMMENT 'หมวดหมู่หลัก', main_category_id INT NOT NULL COMMENT 'หมวดหมู่หลัก',
@@ -587,12 +602,14 @@ CREATE TABLE shop_drawings (
FOREIGN KEY (project_id) REFERENCES projects (id), FOREIGN KEY (project_id) REFERENCES projects (id),
FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories (id), FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories (id),
FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories (id), FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories (id),
UNIQUE KEY ux_shop_dwg_no_project (project_id, drawing_number) UNIQUE KEY ux_shop_dwg_no_project (project_id, drawing_number),
UNIQUE INDEX idx_shop_drawings_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบก่อสร้าง"'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบก่อสร้าง"';
-- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N) -- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N)
CREATE TABLE shop_drawing_revisions ( CREATE TABLE shop_drawing_revisions (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
shop_drawing_id INT NOT NULL COMMENT 'Master ID', shop_drawing_id INT NOT NULL COMMENT 'Master ID',
revision_number INT NOT NULL COMMENT 'หมายเลข Revision (เช่น 0, 1, 2...)', revision_number INT NOT NULL COMMENT 'หมายเลข Revision (เช่น 0, 1, 2...)',
revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)', revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)',
@@ -610,7 +627,8 @@ CREATE TABLE shop_drawing_revisions (
FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE
SET NULL, SET NULL,
UNIQUE KEY ux_sd_rev_drawing_revision (shop_drawing_id, revision_number), UNIQUE KEY ux_sd_rev_drawing_revision (shop_drawing_id, revision_number),
UNIQUE KEY uq_sd_current (shop_drawing_id, is_current) UNIQUE KEY uq_sd_current (shop_drawing_id, is_current),
UNIQUE INDEX idx_shop_drawing_revisions_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N)'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ shop_drawings (1:N)';
-- ตารางเชื่อมระหว่าง shop_drawing_revisions กับ contract_drawings (M:N) -- ตารางเชื่อมระหว่าง shop_drawing_revisions กับ contract_drawings (M:N)
@@ -628,6 +646,7 @@ CREATE TABLE shop_drawing_revision_contract_refs (
-- ตาราง Master เก็บข้อมูล "AS Built" -- ตาราง Master เก็บข้อมูล "AS Built"
CREATE TABLE asbuilt_drawings ( CREATE TABLE asbuilt_drawings (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
project_id INT NOT NULL COMMENT 'โครงการ', project_id INT NOT NULL COMMENT 'โครงการ',
drawing_number VARCHAR(100) NOT NULL COMMENT 'เลขที่ AS Built Drawing', drawing_number VARCHAR(100) NOT NULL COMMENT 'เลขที่ AS Built Drawing',
main_category_id INT NOT NULL COMMENT 'หมวดหมู่หลัก', main_category_id INT NOT NULL COMMENT 'หมวดหมู่หลัก',
@@ -639,12 +658,14 @@ CREATE TABLE asbuilt_drawings (
FOREIGN KEY (project_id) REFERENCES projects (id), FOREIGN KEY (project_id) REFERENCES projects (id),
FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories (id), FOREIGN KEY (main_category_id) REFERENCES shop_drawing_main_categories (id),
FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories (id), FOREIGN KEY (sub_category_id) REFERENCES shop_drawing_sub_categories (id),
UNIQUE KEY ux_asbuilt_no_project (project_id, drawing_number) UNIQUE KEY ux_asbuilt_no_project (project_id, drawing_number),
UNIQUE INDEX idx_asbuilt_drawings_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบ AS Built"'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง Master เก็บข้อมูล "แบบ AS Built"';
-- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ AS Built (1:N) -- ตาราง "ลูก" เก็บประวัติ (Revisions) ของ AS Built (1:N)
CREATE TABLE asbuilt_drawing_revisions ( CREATE TABLE asbuilt_drawing_revisions (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของ Revision',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
asbuilt_drawing_id INT NOT NULL COMMENT 'Master ID', asbuilt_drawing_id INT NOT NULL COMMENT 'Master ID',
revision_number INT NOT NULL COMMENT 'หมายเลข Revision (เช่น 0, 1, 2...)', revision_number INT NOT NULL COMMENT 'หมายเลข Revision (เช่น 0, 1, 2...)',
revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)', revision_label VARCHAR(10) COMMENT 'Revision ที่แสดง (เช่น A, B, 1.1)',
@@ -662,7 +683,8 @@ CREATE TABLE asbuilt_drawing_revisions (
FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE
SET NULL, SET NULL,
UNIQUE KEY ux_asbuilt_rev_drawing_revision (asbuilt_drawing_id, revision_number), UNIQUE KEY ux_asbuilt_rev_drawing_revision (asbuilt_drawing_id, revision_number),
UNIQUE KEY uq_asbuilt_current (asbuilt_drawing_id, is_current) UNIQUE KEY uq_asbuilt_current (asbuilt_drawing_id, is_current),
UNIQUE INDEX idx_asbuilt_drawing_revisions_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ asbuilt_drawings (1:N)'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "ลูก" เก็บประวัติ (Revisions) ของ asbuilt_drawings (1:N)';
-- ตารางเชื่อมระหว่าง asbuilt_drawing_revisions กับ shop_drawing_revisions (M:N) -- ตารางเชื่อมระหว่าง asbuilt_drawing_revisions กับ shop_drawing_revisions (M:N)
@@ -744,6 +766,7 @@ CREATE TABLE circulation_status_codes (
-- ตาราง "แม่" ของใบเวียนเอกสารภายใน -- ตาราง "แม่" ของใบเวียนเอกสารภายใน
CREATE TABLE circulations ( CREATE TABLE circulations (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตารางใบเวียน', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตารางใบเวียน',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
correspondence_id INT UNIQUE COMMENT 'ID ของเอกสาร (จากตาราง correspondences)', correspondence_id INT UNIQUE COMMENT 'ID ของเอกสาร (จากตาราง correspondences)',
organization_id INT NOT NULL COMMENT 'ID ขององค์กรณ์ที่เป็นเจ้าของใบเวียนนี้', organization_id INT NOT NULL COMMENT 'ID ขององค์กรณ์ที่เป็นเจ้าของใบเวียนนี้',
circulation_no VARCHAR(100) NOT NULL COMMENT 'เลขที่ใบเวียน', circulation_no VARCHAR(100) NOT NULL COMMENT 'เลขที่ใบเวียน',
@@ -757,7 +780,8 @@ CREATE TABLE circulations (
FOREIGN KEY (correspondence_id) REFERENCES correspondences (id), FOREIGN KEY (correspondence_id) REFERENCES correspondences (id),
FOREIGN KEY (organization_id) REFERENCES organizations (id), FOREIGN KEY (organization_id) REFERENCES organizations (id),
FOREIGN KEY (circulation_status_code) REFERENCES circulation_status_codes (code), FOREIGN KEY (circulation_status_code) REFERENCES circulation_status_codes (code),
FOREIGN KEY (created_by_user_id) REFERENCES users (user_id) FOREIGN KEY (created_by_user_id) REFERENCES users (user_id),
UNIQUE INDEX idx_circulations_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "แม่" ของใบเวียนเอกสารภายใน'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "แม่" ของใบเวียนเอกสารภายใน';
-- ===================================================== -- =====================================================
@@ -800,6 +824,7 @@ CREATE TABLE transmittal_items (
-- เหตุผล: จัดการไฟล์ขยะ (Orphan Files) และตรวจสอบความถูกต้องไฟล์ -- เหตุผล: จัดการไฟล์ขยะ (Orphan Files) และตรวจสอบความถูกต้องไฟล์
CREATE TABLE attachments ( CREATE TABLE attachments (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของไฟล์แนบ', id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของไฟล์แนบ',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
original_filename VARCHAR(255) NOT NULL COMMENT 'ชื่อไฟล์ดั้งเดิมตอนอัปโหลด', original_filename VARCHAR(255) NOT NULL COMMENT 'ชื่อไฟล์ดั้งเดิมตอนอัปโหลด',
stored_filename VARCHAR(255) NOT NULL COMMENT 'ชื่อไฟล์ที่เก็บจริงบน Server (ป้องกันชื่อซ้ำ)', stored_filename VARCHAR(255) NOT NULL COMMENT 'ชื่อไฟล์ที่เก็บจริงบน Server (ป้องกันชื่อซ้ำ)',
file_path VARCHAR(500) NOT NULL COMMENT 'Path ที่เก็บไฟล์ (บน QNAP / share / dms - data /)', file_path VARCHAR(500) NOT NULL COMMENT 'Path ที่เก็บไฟล์ (บน QNAP / share / dms - data /)',
@@ -813,7 +838,8 @@ CREATE TABLE attachments (
CHECKSUM VARCHAR(64) NULL COMMENT 'SHA-256 Checksum', CHECKSUM VARCHAR(64) NULL COMMENT 'SHA-256 Checksum',
reference_date DATE NULL COMMENT 'Date used for folder structure (e.g. Issue Date) to prevent broken paths', reference_date DATE NULL COMMENT 'Date used for folder structure (e.g. Issue Date) to prevent broken paths',
FOREIGN KEY (uploaded_by_user_id) REFERENCES users (user_id) ON DELETE CASCADE, FOREIGN KEY (uploaded_by_user_id) REFERENCES users (user_id) ON DELETE CASCADE,
INDEX idx_attachments_reference_date (reference_date) INDEX idx_attachments_reference_date (reference_date),
UNIQUE INDEX idx_attachments_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "กลาง" เก็บไฟล์แนบทั้งหมดของระบบ'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตาราง "กลาง" เก็บไฟล์แนบทั้งหมดของระบบ';
-- ตารางเชื่อม correspondences กับ attachments (M:N) -- ตารางเชื่อม correspondences กับ attachments (M:N)
@@ -1198,6 +1224,7 @@ PARTITION BY RANGE (YEAR(created_at)) (
-- ตารางสำหรับจัดการการแจ้งเตือน (Email/Line/System) -- ตารางสำหรับจัดการการแจ้งเตือน (Email/Line/System)
CREATE TABLE notifications ( CREATE TABLE notifications (
id INT NOT NULL AUTO_INCREMENT COMMENT 'ID ของการแจ้งเตือน', id INT NOT NULL AUTO_INCREMENT COMMENT 'ID ของการแจ้งเตือน',
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
user_id INT NOT NULL COMMENT 'ID ผู้ใช้', user_id INT NOT NULL COMMENT 'ID ผู้ใช้',
title VARCHAR(255) NOT NULL COMMENT 'หัวข้อการแจ้งเตือน', title VARCHAR(255) NOT NULL COMMENT 'หัวข้อการแจ้งเตือน',
message TEXT NOT NULL COMMENT 'รายละเอียดการแจ้งเตือน', message TEXT NOT NULL COMMENT 'รายละเอียดการแจ้งเตือน',
@@ -1213,7 +1240,8 @@ CREATE TABLE notifications (
INDEX idx_notif_user (user_id), INDEX idx_notif_user (user_id),
INDEX idx_notif_type (notification_type), INDEX idx_notif_type (notification_type),
INDEX idx_notif_read (is_read), INDEX idx_notif_read (is_read),
INDEX idx_notif_created (created_at) INDEX idx_notif_created (created_at),
INDEX idx_notifications_uuid (uuid)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางสำหรับจัดการการแจ้งเตือน (Email / Line / System)' -- [เพิ่ม] คำสั่ง Partition ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'ตารางสำหรับจัดการการแจ้งเตือน (Email / Line / System)' -- [เพิ่ม] คำสั่ง Partition
PARTITION BY RANGE (YEAR(created_at)) ( PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p_old PARTITION p_old
@@ -120,17 +120,21 @@ CREATE INDEX idx_audit_request_id ON audit_logs (request_id);
-- View แสดง Revision "ปัจจุบัน" ของ correspondences ทั้งหมด (ที่ไม่ใช่ RFA) -- View แสดง Revision "ปัจจุบัน" ของ correspondences ทั้งหมด (ที่ไม่ใช่ RFA)
CREATE VIEW v_current_correspondences AS CREATE VIEW v_current_correspondences AS
SELECT c.id AS correspondence_id, SELECT c.id AS correspondence_id,
c.uuid AS correspondence_uuid,
c.correspondence_number, c.correspondence_number,
c.correspondence_type_id, c.correspondence_type_id,
ct.type_code AS correspondence_type_code, ct.type_code AS correspondence_type_code,
ct.type_name AS correspondence_type_name, ct.type_name AS correspondence_type_name,
c.project_id, c.project_id,
p.uuid AS project_uuid,
p.project_code, p.project_code,
p.project_name, p.project_name,
c.originator_id, c.originator_id,
org.uuid AS originator_uuid,
org.organization_code AS originator_code, org.organization_code AS originator_code,
org.organization_name AS originator_name, org.organization_name AS originator_name,
cr.id AS revision_id, cr.id AS revision_id,
cr.uuid AS revision_uuid,
cr.revision_number, cr.revision_number,
cr.revision_label, cr.revision_label,
cr.subject, cr.subject,
@@ -162,6 +166,7 @@ WHERE cr.is_current = TRUE
-- View แสดง Revision "ปัจจุบัน" ของ rfa_revisions ทั้งหมด -- View แสดง Revision "ปัจจุบัน" ของ rfa_revisions ทั้งหมด
CREATE VIEW v_current_rfas AS CREATE VIEW v_current_rfas AS
SELECT r.id AS rfa_id, SELECT r.id AS rfa_id,
c.uuid AS correspondence_uuid,
r.rfa_type_id, r.rfa_type_id,
rt.type_code AS rfa_type_code, rt.type_code AS rfa_type_code,
rt.type_name_th AS rfa_type_name_th, rt.type_name_th AS rfa_type_name_th,
@@ -172,11 +177,14 @@ SELECT r.id AS rfa_id,
d.discipline_code, d.discipline_code,
-- ✅ Join เพิ่มเพื่อแสดง code -- ✅ Join เพิ่มเพื่อแสดง code
c.project_id, c.project_id,
p.uuid AS project_uuid,
p.project_code, p.project_code,
p.project_name, p.project_name,
c.originator_id, c.originator_id,
org.uuid AS originator_uuid,
org.organization_name AS originator_name, org.organization_name AS originator_name,
rr.id AS revision_id, rr.id AS revision_id,
cr.uuid AS revision_uuid,
cr.revision_number, cr.revision_number,
cr.revision_label, cr.revision_label,
cr.subject, cr.subject,
@@ -211,12 +219,15 @@ WHERE cr.is_current = TRUE
-- View แสดงความสัมพันธ์ทั้งหมดระหว่าง Contract, Project, และ Organization -- View แสดงความสัมพันธ์ทั้งหมดระหว่าง Contract, Project, และ Organization
CREATE VIEW v_contract_parties_all AS CREATE VIEW v_contract_parties_all AS
SELECT c.id AS contract_id, SELECT c.id AS contract_id,
c.uuid AS contract_uuid,
c.contract_code, c.contract_code,
c.contract_name, c.contract_name,
p.id AS project_id, p.id AS project_id,
p.uuid AS project_uuid,
p.project_code, p.project_code,
p.project_name, p.project_name,
o.id AS organization_id, o.id AS organization_id,
o.uuid AS organization_uuid,
o.organization_code, o.organization_code,
o.organization_name, o.organization_name,
co.role_in_contract co.role_in_contract
@@ -380,8 +391,10 @@ WHERE p.is_active = 1
CREATE VIEW v_documents_with_attachments AS CREATE VIEW v_documents_with_attachments AS
SELECT 'CORRESPONDENCE' AS document_type, SELECT 'CORRESPONDENCE' AS document_type,
c.id AS document_id, c.id AS document_id,
c.uuid AS document_uuid,
c.correspondence_number AS document_number, c.correspondence_number AS document_number,
c.project_id, c.project_id,
p.uuid AS project_uuid,
p.project_code, p.project_code,
p.project_name, p.project_name,
COUNT(ca.attachment_id) AS attachment_count, COUNT(ca.attachment_id) AS attachment_count,
@@ -399,8 +412,10 @@ GROUP BY c.id,
UNION ALL UNION ALL
SELECT 'CIRCULATION' AS document_type, SELECT 'CIRCULATION' AS document_type,
circ.id AS document_id, circ.id AS document_id,
circ.uuid AS document_uuid,
circ.circulation_no AS document_number, circ.circulation_no AS document_number,
corr.project_id, corr.project_id,
p.uuid AS project_uuid,
p.project_code, p.project_code,
p.project_name, p.project_name,
COUNT(ca.attachment_id) AS attachment_count, COUNT(ca.attachment_id) AS attachment_count,
@@ -418,8 +433,10 @@ GROUP BY circ.id,
UNION ALL UNION ALL
SELECT 'SHOP_DRAWING' AS document_type, SELECT 'SHOP_DRAWING' AS document_type,
sdr.id AS document_id, sdr.id AS document_id,
sdr.uuid AS document_uuid,
sd.drawing_number AS document_number, sd.drawing_number AS document_number,
sd.project_id, sd.project_id,
p.uuid AS project_uuid,
p.project_code, p.project_code,
p.project_name, p.project_name,
COUNT(sdra.attachment_id) AS attachment_count, COUNT(sdra.attachment_id) AS attachment_count,
@@ -438,8 +455,10 @@ GROUP BY sdr.id,
UNION ALL UNION ALL
SELECT 'CONTRACT_DRAWING' AS document_type, SELECT 'CONTRACT_DRAWING' AS document_type,
cd.id AS document_id, cd.id AS document_id,
cd.uuid AS document_uuid,
cd.condwg_no AS document_number, cd.condwg_no AS document_number,
cd.project_id, cd.project_id,
p.uuid AS project_uuid,
p.project_code, p.project_code,
p.project_name, p.project_name,
COUNT(cda.attachment_id) AS attachment_count, COUNT(cda.attachment_id) AS attachment_count,
@@ -0,0 +1,295 @@
# Implementation Plan: Hybrid UUID Strategy (ADR-019)
**Version:** 1.8.1
**Created:** 2026-03-16
**Related ADR:** [ADR-019](../06-Decision-Records/ADR-019-hybrid-identifier-strategy.md)
---
## Overview
This document outlines the step-by-step implementation plan to integrate UUIDv7 public identifiers into the LCBP3-DMS backend, following the hybrid strategy defined in ADR-019.
**Scope:** 14 public-facing tables now have `uuid UUID` columns (MariaDB native type, stored as BINARY(16) internally) in the schema. This plan covers backend code changes to expose UUIDs through the API while keeping INT PKs for internal operations.
---
## Phase 1: Database Foundation (✅ COMPLETED)
- [x] Create ADR-019 document
- [x] Add `uuid UUID` columns (MariaDB native type) to 14 public-facing tables in schema SQL
- [x] Add UNIQUE INDEX on each uuid column
- [x] Update data dictionary with uuid column documentation
- [x] Update AGENTS.md with ADR-019 reference
### Affected Tables (14)
| # | Table | PK Column | UUID Index |
|---|-------|-----------|------------|
| 1 | organizations | id | idx_organizations_uuid |
| 2 | projects | id | idx_projects_uuid |
| 3 | contracts | id | idx_contracts_uuid |
| 4 | users | user_id | idx_users_uuid |
| 5 | correspondences | id | idx_correspondences_uuid |
| 6 | correspondence_revisions | id | idx_correspondence_revisions_uuid |
| 7 | circulations | id | idx_circulations_uuid |
| 8 | shop_drawings | id | idx_shop_drawings_uuid |
| 9 | shop_drawing_revisions | id | idx_shop_drawing_revisions_uuid |
| 10 | contract_drawings | id | idx_contract_drawings_uuid |
| 11 | asbuilt_drawings | id | idx_asbuilt_drawings_uuid |
| 12 | asbuilt_drawing_revisions | id | idx_asbuilt_drawing_revisions_uuid |
| 13 | attachments | id | idx_attachments_uuid |
| 14 | notifications | id | idx_notifications_uuid |
### Excluded Tables (Shared-PK / Junction — inherit UUID from parent)
- `rfas` — shared PK with `correspondences`
- `rfa_revisions` — shared PK with `correspondence_revisions`
- `transmittals` — shared PK with `correspondences`
- `rfa_items` — junction table (composite PK, no own identity)
---
## Phase 2: Backend — TypeORM Base Entity & UUID Utilities
> **Simplified by MariaDB Native UUID Type:** MariaDB 10.7+ stores UUID as `BINARY(16)` internally but auto-converts to/from string format. No manual binary conversion utilities or TypeORM transformers needed.
### 2.1 Create Base Entity with UUID
**File:** `backend/src/common/entities/uuid-base.entity.ts`
```typescript
import { Column, BeforeInsert } from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
export abstract class UuidBaseEntity {
@Column({
type: 'uuid',
unique: true,
nullable: false,
comment: 'UUID Public Identifier (ADR-019)',
})
uuid: string;
@BeforeInsert()
generateUuid(): void {
if (!this.uuid) {
this.uuid = uuidv7();
}
}
}
```
> **Note:** MariaDB native `UUID` type handles string ↔ binary conversion automatically.
> TypeORM reads/writes UUID as standard string format (8-4-4-4-12) — no transformer required.
> DB `DEFAULT UUID()` generates UUID v1 as fallback; app generates UUIDv7 via `@BeforeInsert()`.
### 2.2 Install uuid Package
```bash
cd backend
npm install uuid
npm install -D @types/uuid
```
---
## Phase 3: Backend — Update Existing Entities
For each of the 14 public-facing entities, extend or mix in the UUID column:
### Pattern: Extend UuidBaseEntity
```typescript
// Example: correspondence.entity.ts
import { Entity, PrimaryGeneratedColumn } from 'typeorm';
import { UuidBaseEntity } from '../../common/entities/uuid-base.entity';
@Entity('correspondences')
export class Correspondence extends UuidBaseEntity {
@PrimaryGeneratedColumn()
id: number;
// ... existing columns (uuid + @BeforeInsert inherited from UuidBaseEntity)
}
```
### Entities to Update
| Entity File | Table |
|-------------|-------|
| `organization.entity.ts` | organizations |
| `project.entity.ts` | projects |
| `contract.entity.ts` | contracts |
| `user.entity.ts` | users |
| `correspondence.entity.ts` | correspondences |
| `correspondence-revision.entity.ts` | correspondence_revisions |
| `circulation.entity.ts` | circulations |
| `shop-drawing.entity.ts` | shop_drawings |
| `shop-drawing-revision.entity.ts` | shop_drawing_revisions |
| `contract-drawing.entity.ts` | contract_drawings |
| `asbuilt-drawing.entity.ts` | asbuilt_drawings |
| `asbuilt-drawing-revision.entity.ts` | asbuilt_drawing_revisions |
| `attachment.entity.ts` | attachments |
| `notification.entity.ts` | notifications |
---
## Phase 4: Backend — API Layer Changes
### 4.1 UUID Pipe (Parameter Validation)
**File:** `backend/src/common/pipes/parse-uuid.pipe.ts`
```typescript
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { validate as uuidValidate, version as uuidVersion } from 'uuid';
@Injectable()
export class ParseUuidPipe implements PipeTransform<string> {
transform(value: string): string {
if (!uuidValidate(value) || uuidVersion(value) !== 7) {
throw new BadRequestException(`Invalid UUID: ${value}`);
}
return value;
}
}
```
### 4.2 Controller Pattern — UUID in URLs
```typescript
// BEFORE (INT): GET /api/correspondences/123
// AFTER (UUID): GET /api/correspondences/01912345-6789-7abc-...
@Get(':uuid')
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.service.findByUuid(uuid);
}
```
### 4.3 Service Pattern — Internal UUID Lookup
```typescript
async findByUuid(uuid: string): Promise<CorrespondenceDto> {
const entity = await this.repository.findOne({
where: { uuid },
relations: ['revisions', 'recipients'],
});
if (!entity) throw new NotFoundException();
return this.mapToDto(entity);
}
```
### 4.4 DTO Pattern — UUID Exposure
```typescript
// Response DTO exposes uuid, hides id
export class CorrespondenceResponseDto {
uuid: string; // ✅ Public identifier
correspondenceNumber: string;
// id: number; // ❌ Never expose INT id
}
```
### 4.5 Migration Helper — findByUuidOrId
During transition, support both identifiers:
```typescript
async findByUuidOrId(identifier: string): Promise<Entity> {
const isUuid = uuidValidate(identifier);
if (isUuid) {
return this.repository.findOne({ where: { uuid: identifier } });
}
// Fallback to INT (internal/admin use only)
const id = parseInt(identifier, 10);
if (isNaN(id)) throw new BadRequestException();
return this.repository.findOne({ where: { id } });
}
```
---
## Phase 5: Frontend — UUID Integration
### 5.1 API Client Updates
- Update all API calls to use UUID in URL paths instead of INT id
- Update TanStack Query cache keys to use UUID
- Update Zustand stores to key by UUID
### 5.2 Route Parameters
```typescript
// BEFORE: /correspondences/[id]
// AFTER: /correspondences/[uuid]
```
### 5.3 Form Handling
- Hidden `uuid` field in forms for edit operations
- No changes needed for create operations (UUID generated server-side)
---
## Phase 6: Testing & Verification
### 6.1 Unit Tests
- UUID generation produces valid UUIDv7
- UuidBaseEntity `@BeforeInsert()` auto-generates UUID when not provided
- ParseUuidPipe rejects invalid UUIDs
- MariaDB native UUID column stores and retrieves string format correctly
### 6.2 Integration Tests
- Entity creation auto-generates UUID
- API endpoints accept UUID parameters
- UUID lookup returns correct records
- Duplicate UUID detection (unique constraint)
### 6.3 Performance Verification
- Benchmark: UUID lookup via UNIQUE INDEX vs INT PK lookup
- Acceptable threshold: < 2x overhead on single-row lookups
- Verify B-tree ordering with time-sorted UUIDv7
---
## Implementation Order (Priority)
| Order | Task | Effort | Depends On |
|-------|------|--------|------------|
| 1 | UuidBaseEntity (no transformer needed — MariaDB native UUID) | S | Phase 1 |
| 2 | Install `uuid` package | XS | — |
| 3 | Update 14 entity files with uuid column | M | Task 1 |
| 4 | Create ParseUuidPipe | S | — |
| 5 | Update controllers to use UUID params | L | Tasks 3, 4 |
| 6 | Update services with findByUuid methods | L | Task 3 |
| 7 | Update DTOs to expose uuid, hide id | M | Task 3 |
| 8 | Update frontend API calls | L | Tasks 5, 6, 7 |
| 9 | Update frontend routes | M | Task 8 |
| 10 | Write unit + integration tests | M | Tasks 1-7 |
**Estimated Total Effort:** ~3-5 days for backend, ~2-3 days for frontend
---
## Rollback Strategy
If issues arise:
1. **Schema:** UUID columns have `DEFAULT` — existing inserts still work without app changes
2. **API:** INT-based endpoints can be restored by reverting controller/service changes
3. **Data:** No data loss — UUID column is additive (no existing columns modified)
4. **Frontend:** Route parameter changes are reversible
---
## Notes
- **Seed files** do not need UUID values — the `DEFAULT UUID()` clause auto-generates UUIDs at INSERT time
- **Notifications table** uses a non-unique INDEX (not UNIQUE) for uuid because of its partitioned composite PK `(id, created_at)`
- **Workflow engine tables** (`workflow_instances`, `workflow_tasks`) already use `CHAR(36)` UUIDs — no changes needed
- **Shared-PK tables** (`rfas`, `rfa_revisions`, `transmittals`) inherit their parent's UUID via the correspondence relationship
@@ -0,0 +1,478 @@
# ADR-019: Hybrid Identifier Strategy (INT + UUIDv7)
**Status:** Accepted
**Date:** 2026-03-12
**Version:** 1.8.1
**Decision Makers:** Development Team, Database Architect
**Related Documents:**
- [Data Dictionary](../03-Data-and-Storage/03-01-data-dictionary.md)
- [Database Schema](../03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql)
- [ADR-005: Technology Stack](ADR-005-technology-stack.md)
- [ADR-009: Database Migration Strategy](ADR-009-database-migration-strategy.md)
- [ADR-016: Security & Authentication](ADR-016-security-authentication.md)
---
## Context and Problem Statement
ระบบ LCBP3-DMS ใช้ `INT AUTO_INCREMENT` เป็น Primary Key ทุกตาราง ซึ่งทำงานได้ดีสำหรับ Internal JOIN/FK แต่มีปัญหาด้านความปลอดภัยและ Scalability:
1. **ID Enumeration Attack:** Sequential INT IDs ถูกเดาได้ง่าย (เช่น `/api/correspondences/1`, `/api/correspondences/2`) ทำให้ผู้ไม่ประสงค์ดีสามารถ Enumerate ข้อมูลได้
2. **Information Leakage:** INT IDs เปิดเผยจำนวนข้อมูลในระบบ (เช่น `user_id=5` แปลว่ามีผู้ใช้ 5 คน)
3. **Cross-System Integration:** หากในอนาคตต้องการ Sync ข้อมูลข้ามระบบ INT ID จะชนกัน
4. **API Security:** OWASP BOLA (Broken Object Level Authorization) แนะนำให้ใช้ Opaque Identifier แทน Sequential ID
ทั้งนี้ ระบบมีข้อจำกัดด้าน Hardware (QNAP NAS) ที่ต้องพิจารณาเรื่อง Performance
---
## Decision Drivers
- **Security:** ป้องกัน ID Enumeration และลดความเสี่ยง OWASP BOLA
- **Performance:** INT PK ยังคงเป็น Primary Key เพื่อ JOIN/FK Performance บน InnoDB
- **Backward Compatibility:** ไม่ต้อง Migrate ข้อมูลหรือเปลี่ยน FK Relationships ที่มีอยู่
- **Simplicity:** เปลี่ยนเฉพาะ Public-Facing Tables (ไม่ใช่ทุกตาราง)
- **Standards:** UUIDv7 (RFC 9562) เป็น Time-ordered UUID ที่ B-tree friendly
---
## Considered Options
### Option 1: Replace INT with UUID as Primary Key
**Pros:**
- ✅ Opaque identifier ทุกที่
**Cons:**
- ❌ FK ทั้งหมดต้องเปลี่ยนเป็น BINARY(16) — Migration ซับซ้อนมาก
- ❌ JOIN Performance แย่ลง (16 bytes vs 4 bytes)
- ❌ InnoDB Clustered Index ไม่เรียงลำดับตาม INSERT Time (UUIDv4)
- ❌ ต้อง Rewrite Backend ทั้งหมด (Entity, DTO, Controller, Service)
- ❌ Breaking Change กับ Frontend ที่ใช้ INT ID อยู่
### Option 2: UUID as String Column (CHAR(36))
**Pros:**
- ✅ Human-readable
**Cons:**
- ❌ ใช้พื้นที่ 36 bytes ต่อ row (vs 16 bytes สำหรับ BINARY)
- ❌ Index ใหญ่ ช้ากว่า BINARY(16) อย่างมีนัยสำคัญ
- ❌ Collation issues กับ case-sensitivity
### Option 3: Hybrid INT + UUID (MariaDB Native) ⭐ (Selected)
**Pros:**
- ✅ INT PK ยังเป็น Internal ID → Performance ไม่เปลี่ยน
- ✅ UUID เป็น External ID → ปลอดภัย + Space-efficient (BINARY(16) ภายใน)
- ✅ ไม่ต้อง Migrate FK Relationships
- ✅ UUIDv7 Time-ordered → B-tree friendly, Index Performance ดี
- ✅ Backward Compatible — Frontend ค่อยๆ Migrate ได้
- ✅ ไม่กระทบ Migration Tables (Temporary)
**Cons:**
- ❌ ต้องเพิ่ม Column ใหม่ + UNIQUE INDEX ทุก Public-Facing Table
- ❌ Application Layer ต้อง Generate UUIDv7 ตอน INSERT
- ❌ API Layer ต้อง Resolve UUID → INT สำหรับ Internal Queries
---
## Decision Outcome
**Chosen Option:** Option 3 — Hybrid INT + UUID (MariaDB Native Type)
**Rationale:** เป็นแนวทางที่ Balance ระหว่าง Security, Performance และ Migration Effort ดีที่สุด ไม่ต้อง Rewrite FK ทั้งหมด ไม่ต้อง Migrate ข้อมูล และเพิ่มความปลอดภัยของ API
---
## Technical Specification
### 1. UUID Format
| Property | Value |
|----------|-------|
| **Type** | MariaDB Native `UUID` (available since 10.7) |
| **Storage** | `BINARY(16)` internally (automatic) |
| **DB Default** | `UUID()` — generates UUID v1 (time-based, fallback for seed data) |
| **App Generation** | NestJS `@BeforeInsert()` generates UUIDv7 (RFC 9562) for time-ordering |
| **Display** | Auto-converts to string format (8-4-4-4-12) — no conversion function needed |
| **Index** | `UNIQUE INDEX` on `uuid` column |
### 2. Column Specification
```sql
-- Column definition for all public-facing tables (MariaDB 10.7+)
uuid UUID NOT NULL DEFAULT UUID() COMMENT 'UUID Public Identifier (ADR-019)',
UNIQUE INDEX idx_{table}_uuid (uuid)
```
> **Note:** MariaDB native `UUID` type เก็บเป็น `BINARY(16)` ภายใน แต่แสดงผลเป็น String format อัตโนมัติ ไม่ต้องใช้ `BIN_TO_UUID()` / `UUID_TO_BIN()`
>
> **DB Default:** `UUID()` สร้าง UUID v1 (สำหรับ Seed Data และ Fallback)
>
> **Application Override:** NestJS Entity จะ Generate UUIDv7 เองก่อน INSERT เพื่อให้ได้ True UUIDv7 (Time-ordered, B-tree friendly)
### 3. Tables Requiring UUID Column
#### Tier 1 — Core Entity Tables (Own UUID Column)
| # | Table Name | Current PK | UUID Column | Notes |
|---|-----------|-----------|-------------|-------|
| 1 | `users` | `user_id INT AI` | `uuid UUID` | User profiles |
| 2 | `organizations` | `id INT AI` | `uuid UUID` | Organization data |
| 3 | `projects` | `id INT AI` | `uuid UUID` | Project data |
| 4 | `contracts` | `id INT AI` | `uuid UUID` | Contract data |
| 5 | `correspondences` | `id INT AI` | `uuid UUID` | Main document entity |
| 6 | `correspondence_revisions` | `id INT AI` | `uuid UUID` | Document versions |
| 7 | `circulations` | `id INT AI` | `uuid UUID` | Internal circulations |
| 8 | `shop_drawings` | `id INT AI` | `uuid UUID` | Shop drawing master |
| 9 | `shop_drawing_revisions` | `id INT AI` | `uuid UUID` | Shop drawing versions |
| 10 | `contract_drawings` | `id INT AI` | `uuid UUID` | Contract drawing master |
| 11 | `asbuilt_drawings` | `id INT AI` | `uuid UUID` | As-built drawing master |
| 12 | `asbuilt_drawing_revisions` | `id INT AI` | `uuid UUID` | As-built drawing versions |
| 13 | `attachments` | `id INT AI` | `uuid UUID` | File attachments |
| 14 | `notifications` | `id INT AI` (partitioned) | `uuid UUID` | User notifications |
#### Tier 2 — Shared-PK Tables (Inherit UUID from Parent)
| # | Table Name | Shared PK Source | UUID Resolution |
|---|-----------|-----------------|-----------------|
| 1 | `rfas` | `correspondences.id` | Use `correspondences.uuid` |
| 2 | `rfa_revisions` | `correspondence_revisions.id` | Use `correspondence_revisions.uuid` |
| 3 | `transmittals` | `correspondences.id` | Use `correspondences.uuid` |
#### Already Using UUID — No Changes Needed
| Table Name | Current PK |
|-----------|-----------|
| `workflow_definitions` | `CHAR(36) UUID` |
| `workflow_instances` | `CHAR(36) UUID` |
| `workflow_histories` | `CHAR(36) UUID` |
#### Excluded Tables (Internal/Master/Junction)
ตารางต่อไปนี้ **ไม่ต้อง** เพิ่ม UUID Column เพราะเป็น Internal-use only:
- **Master/Lookup:** `organization_roles`, `disciplines`, `correspondence_types`, `correspondence_sub_types`, `correspondence_status`, `rfa_types`, `rfa_status_codes`, `rfa_approve_codes`, `circulation_status_codes`, `tags`
- **RBAC:** `roles`, `permissions`, `user_assignments`
- **Junction/Mapping:** `project_organizations`, `contract_organizations`, `correspondence_recipients`, `correspondence_tags`, `correspondence_references`, `contract_drawing_subcat_cat_maps`, `shop_drawing_revision_contract_refs`, `asbuilt_revision_shop_revisions_refs`, all `*_attachments` junction tables
- **Drawing Categories:** `contract_drawing_volumes`, `contract_drawing_cats`, `contract_drawing_sub_cats`, `shop_drawing_main_categories`, `shop_drawing_sub_categories`
- **Document Numbering:** `document_number_formats`, `document_number_counters`, `document_number_audit`, `document_number_errors`, `document_number_reservations`
- **System/Logs:** `json_schemas`, `user_preferences`, `audit_logs`, `search_indices`, `backup_logs`, `refresh_tokens`
- **Migration (Temporary):** `migration_progress`, `migration_review_queue`, `migration_errors`, `migration_fallback_state`, `import_transactions`, `migration_daily_summary`
---
## TypeORM Entity Pattern
### Base Entity with UUID
```typescript
// src/common/entities/base-uuid.entity.ts
import { Column, BeforeInsert } from 'typeorm';
import { v7 as uuidv7 } from 'uuid';
export abstract class BaseUuidEntity {
@Column({
type: 'uuid',
unique: true,
nullable: false,
comment: 'UUID Public Identifier (ADR-019)',
})
uuid!: string;
@BeforeInsert()
generateUuid(): void {
if (!this.uuid) {
this.uuid = uuidv7();
}
}
}
```
> **Note:** MariaDB native `UUID` type ทำให้ TypeORM ไม่ต้องใช้ transformer อีกต่อไป — ค่าเข้า/ออกเป็น string format เสมอ
### Entity Usage Example
```typescript
// Example: correspondence.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { BaseUuidEntity } from '../../common/entities/base-uuid.entity';
@Entity('correspondences')
export class Correspondence extends BaseUuidEntity {
@PrimaryGeneratedColumn()
id!: number;
// ... existing columns
}
```
---
## API Layer Changes
### URL Pattern
```
// Before (INT — vulnerable to enumeration)
GET /api/correspondences/42
GET /api/users/5
// After (UUID — opaque identifier)
GET /api/correspondences/019505a1-7c3e-7000-8000-abc123def456
GET /api/users/019505a1-8b2f-7000-8000-abc123def456
```
### Controller Pattern
```typescript
@Get(':uuid')
async findOne(@Param('uuid', ParseUUIDPipe) uuid: string) {
return this.service.findByUuid(uuid);
}
```
### Service Pattern
```typescript
async findByUuid(uuid: string): Promise<CorrespondenceDto> {
const entity = await this.repository.findOne({
where: { uuid },
});
if (!entity) throw new NotFoundException();
return this.toDto(entity);
}
```
### DTO Pattern — Never Expose INT ID
```typescript
export class CorrespondenceResponseDto {
// ✅ Expose UUID as 'id' in API response
@Expose({ name: 'id' })
uuid!: string;
// ❌ Never expose internal INT id
// id: number; — REMOVED from response
// ... other fields
// For FK references, also use UUID
@Expose({ name: 'project_id' })
projectUuid!: string;
}
```
---
## Migration SQL Script
```sql
-- =====================================================
-- ADR-019: Add UUIDv7 columns to public-facing tables
-- Strategy: Non-destructive — ADD COLUMN only
-- Rollback: DROP COLUMN uuid
-- =====================================================
-- Tier 1: Core Entity Tables
ALTER TABLE users
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_users_uuid (uuid);
ALTER TABLE organizations
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_organizations_uuid (uuid);
ALTER TABLE projects
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_projects_uuid (uuid);
ALTER TABLE contracts
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_contracts_uuid (uuid);
ALTER TABLE correspondences
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_correspondences_uuid (uuid);
ALTER TABLE correspondence_revisions
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_correspondence_revisions_uuid (uuid);
ALTER TABLE circulations
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_circulations_uuid (uuid);
ALTER TABLE shop_drawings
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_shop_drawings_uuid (uuid);
ALTER TABLE shop_drawing_revisions
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_shop_drawing_revisions_uuid (uuid);
ALTER TABLE contract_drawings
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_contract_drawings_uuid (uuid);
ALTER TABLE asbuilt_drawings
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_asbuilt_drawings_uuid (uuid);
ALTER TABLE asbuilt_drawing_revisions
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_asbuilt_drawing_revisions_uuid (uuid);
ALTER TABLE attachments
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD UNIQUE INDEX idx_attachments_uuid (uuid);
ALTER TABLE notifications
ADD COLUMN uuid UUID NOT NULL DEFAULT UUID()
COMMENT 'UUID Public Identifier (ADR-019)',
ADD INDEX idx_notifications_uuid (uuid);
-- Note: UNIQUE constraint on partitioned table requires uuid in partition key
-- Using regular INDEX instead
```
### Rollback SQL
```sql
-- Rollback: Remove UUID columns (Non-destructive reverse)
ALTER TABLE users DROP INDEX idx_users_uuid, DROP COLUMN uuid;
ALTER TABLE organizations DROP INDEX idx_organizations_uuid, DROP COLUMN uuid;
ALTER TABLE projects DROP INDEX idx_projects_uuid, DROP COLUMN uuid;
ALTER TABLE contracts DROP INDEX idx_contracts_uuid, DROP COLUMN uuid;
ALTER TABLE correspondences DROP INDEX idx_correspondences_uuid, DROP COLUMN uuid;
ALTER TABLE correspondence_revisions DROP INDEX idx_correspondence_revisions_uuid, DROP COLUMN uuid;
ALTER TABLE circulations DROP INDEX idx_circulations_uuid, DROP COLUMN uuid;
ALTER TABLE shop_drawings DROP INDEX idx_shop_drawings_uuid, DROP COLUMN uuid;
ALTER TABLE shop_drawing_revisions DROP INDEX idx_shop_drawing_revisions_uuid, DROP COLUMN uuid;
ALTER TABLE contract_drawings DROP INDEX idx_contract_drawings_uuid, DROP COLUMN uuid;
ALTER TABLE asbuilt_drawings DROP INDEX idx_asbuilt_drawings_uuid, DROP COLUMN uuid;
ALTER TABLE asbuilt_drawing_revisions DROP INDEX idx_asbuilt_drawing_revisions_uuid, DROP COLUMN uuid;
ALTER TABLE attachments DROP INDEX idx_attachments_uuid, DROP COLUMN uuid;
ALTER TABLE notifications DROP INDEX idx_notifications_uuid, DROP COLUMN uuid;
```
---
## Storage Impact Analysis
| Item | Size |
|------|------|
| UUID (BINARY(16) internal) per row | 16 bytes |
| UNIQUE INDEX per row | ~16 bytes (key) + ~6 bytes (pointer) ≈ 22 bytes |
| **Total per row** | **~38 bytes** |
| Estimated rows (all 14 tables combined) | ~100,000 (Year 1) |
| **Total additional storage** | **~3.8 MB** |
| Impact on QNAP NAS | **Negligible** |
---
## Performance Considerations
### UUIDv7 vs UUIDv4 for B-tree Index
| Property | UUIDv4 | UUIDv7 |
|----------|--------|--------|
| Ordering | Random | Time-ordered |
| B-tree insert | Random page splits | Sequential append |
| Index fragmentation | High | Low |
| Cache efficiency | Poor | Good |
**UUIDv7 ถูกเลือกเพราะ Time-ordering** ทำให้ INSERT ไม่ทำให้เกิด Random Page Split บน InnoDB B-tree ซึ่งสำคัญมากสำหรับ QNAP NAS ที่มี I/O จำกัด
### Query Pattern
```sql
-- Internal query (JOINs still use INT — no performance change)
SELECT c.*, cr.*
FROM correspondences c
JOIN correspondence_revisions cr ON c.id = cr.correspondence_id
WHERE c.id = 42;
-- API query (UUID lookup via UNIQUE INDEX — O(log n))
SELECT c.*, cr.*
FROM correspondences c
JOIN correspondence_revisions cr ON c.id = cr.correspondence_id
WHERE c.uuid = '019505a1-7c3e-7000-8000-abc123def456';
```
---
## Security Benefits
| Threat | Before (INT) | After (Hybrid) |
|--------|-------------|----------------|
| ID Enumeration | ❌ Vulnerable (`/api/users/1,2,3...`) | ✅ Opaque UUID |
| Record Count Leak | ❌ `id=500` reveals ~500 records | ✅ UUID reveals nothing |
| BOLA Attack Surface | ❌ Predictable IDs | ✅ 2^122 possible values |
| Cross-System Collision | ❌ Possible | ✅ Globally unique |
---
## Compatibility with Existing ADRs
| ADR | Impact | Notes |
|-----|--------|-------|
| ADR-002 (Doc Numbering) | ✅ None | Document numbers (VARCHAR) are business identifiers, unaffected |
| ADR-005 (Tech Stack) | ✅ Compatible | uuid npm package + MariaDB native UUID type |
| ADR-006 (Redis Caching) | ⚠️ Minor | Cache keys should use UUID instead of INT for public-facing data |
| ADR-009 (DB Migration) | ✅ Compatible | ADD COLUMN is a safe, non-destructive migration |
| ADR-016 (Security) | ✅ Enhanced | Strengthens OWASP BOLA defense |
| ADR-017 (Ollama Migration) | ✅ None | Migration tables are temporary, excluded from UUID scope |
---
## Transition Strategy
### Phase 1: Database (Schema Change)
- เพิ่ม `uuid UUID` column (MariaDB native type) กับ UNIQUE INDEX ใน 14 ตาราง
- Existing rows ได้รับ UUID อัตโนมัติจาก DB DEFAULT
### Phase 2: Backend (Dual-Mode)
- เพิ่ม `uuid` field ใน TypeORM Entities
- สร้าง `BaseUuidEntity` class
- API รับได้ทั้ง INT และ UUID ผ่าน `FindByIdOrUuid` pattern
- API Response รวม UUID เป็น `id` field
### Phase 3: Frontend (Gradual Migration)
- Frontend เปลี่ยนจากใช้ `id` (INT) เป็น `id` (UUID) ใน API response
- URL parameters เปลี่ยนเป็น UUID
- ไม่ต้อง Big-Bang migration — ค่อยๆ เปลี่ยนทีละ Module
### Phase 4: Cleanup
- ลบ INT ID จาก API Response (DTO)
- ลบ INT-based route handlers
- Update API Documentation
---
## Final Assessment
| Area | Status |
|------|--------|
| Security | ✅ Eliminates ID enumeration |
| Performance | ✅ No impact on internal JOINs |
| Migration Risk | ✅ Low — ADD COLUMN only |
| Storage Impact | ✅ Negligible (~3.8 MB) |
| Backward Compatibility | ✅ Dual-mode transition |
| ADR Compliance | ✅ Compatible with all existing ADRs |
---
*สำหรับรายละเอียดการ Implement ดูที่ Implementation Plan ใน ADR-019-implementation-plan.md*