diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md index 7820eaa..058c59e 100644 --- a/.gemini/GEMINI.md +++ b/.gemini/GEMINI.md @@ -16,15 +16,15 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**. ### 📊 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 | +| 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) with complex multi-level approval workflows. @@ -54,71 +54,47 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**. 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. -## 📋 Workflow & Spec Guidelines +## 📋 Spec Guidelines - 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`) - 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) -| เอกสาร | 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 | +| เอกสาร | 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 Reference (All 17 + Patch + ADR-019) -Adhere to all ADRs in `specs/06-Decision-Records/`: - -| ADR | Topic | Key Decision | -| ------- | ------------------------- | -------------------------------------------------- | -| ADR-001 | Workflow Engine | Unified state machine for document workflows | -| ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking | -| ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis | -| ADR-006 | Redis Caching | Cache strategy and invalidation patterns | -| ADR-008 | Email Notification | BullMQ queue-based email/LINE/in-app | -| ADR-009 | DB Strategy | No TypeORM migrations — modify schema SQL directly | -| ADR-010 | Logging/Monitoring | Prometheus + Loki + Grafana stack | -| ADR-011 | App Router | Next.js App Router with RSC patterns | -| ADR-012 | UI Components | Shadcn/UI component library | -| ADR-013 | Form Handling | React Hook Form + Zod validation | -| ADR-014 | State Management | TanStack Query (server) + Zustand (client) | -| ADR-015 | Deployment | Docker Compose + Gitea CI/CD | -| ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV | -| 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 | - -## 🎯 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 | +| ADR | Topic | Key Decision | +| ------- | -------------------------- | -------------------------------------------------- | +| ADR-001 | Workflow Engine | Unified state machine for document workflows | +| ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking | +| ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis | +| ADR-006 | Redis Caching | Cache strategy and invalidation patterns | +| ADR-008 | Email Notification | BullMQ queue-based email/LINE/in-app | +| ADR-009 | DB Strategy | No TypeORM migrations — modify schema SQL directly | +| ADR-010 | Logging/Monitoring | Prometheus + Loki + Grafana stack | +| ADR-011 | App Router | Next.js App Router with RSC patterns | +| ADR-012 | UI Components | Shadcn/UI component library | +| ADR-013 | Form Handling | React Hook Form + Zod validation | +| ADR-014 | State Management | TanStack Query (server) + Zustand (client) | +| ADR-015 | Deployment | Docker Compose + Gitea CI/CD | +| ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV | +| 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-019 | Hybrid Identifier Strategy | INT PK (internal) + UUIDv7 BINARY(16) (public API) | ## 🚫 Forbidden Actions diff --git a/AGENTS.md b/AGENTS.md index 12c8eb2..6f10fec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,15 +14,15 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**. ### 📊 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 | +| 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) with complex multi-level approval workflows. @@ -60,37 +60,39 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**. ### 📁 Key Spec Documents (Quick Reference) -| เอกสาร | 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 | +| เอกสาร | 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 Reference (All 17 + Patch + ADR-019) -| ADR | Topic | Key Decision | -| ------- | ------------------------- | -------------------------------------------------- | -| ADR-001 | Workflow Engine | Unified state machine for document workflows | -| ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking | -| ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis | -| ADR-006 | Redis Caching | Cache strategy and invalidation patterns | -| ADR-008 | Email Notification | BullMQ queue-based email/LINE/in-app | -| ADR-009 | DB Strategy | No TypeORM migrations — modify schema SQL directly | -| ADR-010 | Logging/Monitoring | Prometheus + Loki + Grafana stack | -| ADR-011 | App Router | Next.js App Router with RSC patterns | -| ADR-012 | UI Components | Shadcn/UI component library | -| ADR-013 | Form Handling | React Hook Form + Zod validation | -| ADR-014 | State Management | TanStack Query (server) + Zustand (client) | -| ADR-015 | Deployment | Docker Compose + Gitea CI/CD | -| ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV | -| 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 | Topic | Key Decision | +| ------- | -------------------------- | -------------------------------------------------- | +| ADR-001 | Workflow Engine | Unified state machine for document workflows | +| ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking | +| ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis | +| ADR-006 | Redis Caching | Cache strategy and invalidation patterns | +| ADR-008 | Email Notification | BullMQ queue-based email/LINE/in-app | +| ADR-009 | DB Strategy | No TypeORM migrations — modify schema SQL directly | +| ADR-010 | Logging/Monitoring | Prometheus + Loki + Grafana stack | +| ADR-011 | App Router | Next.js App Router with RSC patterns | +| ADR-012 | UI Components | Shadcn/UI component library | +| ADR-013 | Form Handling | React Hook Form + Zod validation | +| ADR-014 | State Management | TanStack Query (server) + Zustand (client) | +| ADR-015 | Deployment | Docker Compose + Gitea CI/CD | +| ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV | +| 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-019 | Hybrid Identifier Strategy | INT PK (internal) + UUIDv7 BINARY(16) (public API) | ## 🚫 Forbidden Actions diff --git a/CLAUDE.md b/CLAUDE.md index 5bc32aa..e9bf9a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,19 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**. ## 🏗️ 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) with complex multi-level approval workflows. @@ -38,43 +50,59 @@ You value **Data Integrity**, **Security**, and **Clean Architecture**. 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. -## 📋 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`) - 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 | Topic | Key Decision | -| ------- | ------------------------- | -------------------------------------------------- | -| ADR-001 | Workflow Engine | Unified state machine for document workflows | -| ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking | -| ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis | -| ADR-006 | Redis Caching | Cache strategy and invalidation patterns | -| ADR-008 | Email Notification | BullMQ queue-based email/LINE/in-app | -| ADR-009 | DB Strategy | No TypeORM migrations — modify schema SQL directly | -| ADR-010 | Logging/Monitoring | Prometheus + Loki + Grafana stack | -| ADR-011 | App Router | Next.js App Router with RSC patterns | -| ADR-012 | UI Components | Shadcn/UI component library | -| ADR-013 | Form Handling | React Hook Form + Zod validation | -| ADR-014 | State Management | TanStack Query (server) + Zustand (client) | -| ADR-015 | Deployment | Docker Compose + Gitea CI/CD | -| ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV | -| 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 Reference (All 17 + Patch + ADR-019) + +| ADR | Topic | Key Decision | +| ------- | -------------------------- | -------------------------------------------------- | +| ADR-001 | Workflow Engine | Unified state machine for document workflows | +| ADR-002 | Doc Numbering | Redis Redlock + DB optimistic locking | +| ADR-005 | Technology Stack | NestJS + Next.js + MariaDB + Redis | +| ADR-006 | Redis Caching | Cache strategy and invalidation patterns | +| ADR-008 | Email Notification | BullMQ queue-based email/LINE/in-app | +| ADR-009 | DB Strategy | No TypeORM migrations — modify schema SQL directly | +| ADR-010 | Logging/Monitoring | Prometheus + Loki + Grafana stack | +| ADR-011 | App Router | Next.js App Router with RSC patterns | +| ADR-012 | UI Components | Shadcn/UI component library | +| ADR-013 | Form Handling | React Hook Form + Zod validation | +| ADR-014 | State Management | TanStack Query (server) + Zustand (client) | +| ADR-015 | Deployment | Docker Compose + Gitea CI/CD | +| ADR-016 | Security | JWT + CASL RBAC + Helmet.js + ClamAV | +| 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-019 | Hybrid Identifier Strategy | INT PK (internal) + UUIDv7 BINARY(16) (public API) | ## 🚫 Forbidden Actions - 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 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 generate code that violates OWASP Top 10 security practices. - 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 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`. diff --git a/backend/package.json b/backend/package.json index 9636729..33d7f4f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -75,7 +75,7 @@ "socket.io": "^4.8.1", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.27", - "uuid": "^9.0.1", + "uuid": "^11.1.0", "winston": "^3.18.3", "zod": "^4.1.13" }, @@ -98,7 +98,7 @@ "@types/opossum": "^8.1.9", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", - "@types/uuid": "^9.0.8", + "@types/uuid": "^10.0.0", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", diff --git a/backend/src/common/entities/uuid-base.entity.ts b/backend/src/common/entities/uuid-base.entity.ts new file mode 100644 index 0000000..c629b2b --- /dev/null +++ b/backend/src/common/entities/uuid-base.entity.ts @@ -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(); + } + } +} diff --git a/backend/src/common/file-storage/entities/attachment.entity.ts b/backend/src/common/file-storage/entities/attachment.entity.ts index 3a3586a..3168ae0 100644 --- a/backend/src/common/file-storage/entities/attachment.entity.ts +++ b/backend/src/common/file-storage/entities/attachment.entity.ts @@ -7,10 +7,13 @@ import { JoinColumn, } from 'typeorm'; import { User } from '../../../modules/user/entities/user.entity'; +import { UuidBaseEntity } from '../../entities/uuid-base.entity'; +import { Exclude } from 'class-transformer'; @Entity('attachments') -export class Attachment { +export class Attachment extends UuidBaseEntity { @PrimaryGeneratedColumn() + @Exclude() id!: number; @Column({ name: 'original_filename', length: 255 }) diff --git a/backend/src/common/interceptors/transform.interceptor.ts b/backend/src/common/interceptors/transform.interceptor.ts index 9611db5..a982e2d 100644 --- a/backend/src/common/interceptors/transform.interceptor.ts +++ b/backend/src/common/interceptors/transform.interceptor.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { instanceToPlain } from 'class-transformer'; /** Metadata สำหรับ Paginated Response */ export interface ResponseMeta { @@ -53,24 +54,31 @@ export class TransformInterceptor ): Observable> { return next.handle().pipe( 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) // ถ้า data มี structure { data: [], meta: {} } ให้ unzip ออกมา - if (isPaginatedPayload(data)) { + if (isPaginatedPayload(serialized)) { return { statusCode: response.statusCode, - message: data.message ?? 'Success', - data: data.data as unknown as T, - meta: data.meta, + message: serialized.message ?? 'Success', + data: serialized.data as unknown as T, + meta: serialized.meta, }; } - const dataAsRecord = data as Record; + const dataAsRecord = serialized as Record; return { statusCode: response.statusCode, - message: (dataAsRecord?.['message'] as string | undefined) ?? 'Success', - data: (dataAsRecord?.['result'] as T | undefined) ?? data, + message: + (dataAsRecord?.['message'] as string | undefined) ?? 'Success', + data: (dataAsRecord?.['result'] as T | undefined) ?? serialized, }; }) ); diff --git a/backend/src/common/pipes/parse-uuid.pipe.ts b/backend/src/common/pipes/parse-uuid.pipe.ts new file mode 100644 index 0000000..e3050fe --- /dev/null +++ b/backend/src/common/pipes/parse-uuid.pipe.ts @@ -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 { + transform(value: string): string { + if (!uuidValidate(value)) { + throw new BadRequestException(`Invalid UUID format: ${value}`); + } + return value.toLowerCase(); + } +} diff --git a/backend/src/modules/circulation/circulation.controller.ts b/backend/src/modules/circulation/circulation.controller.ts index 85839c2..97f4e69 100644 --- a/backend/src/modules/circulation/circulation.controller.ts +++ b/backend/src/modules/circulation/circulation.controller.ts @@ -22,6 +22,7 @@ import { RbacGuard } from '../../common/guards/rbac.guard'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { Audit } from '../../common/decorators/audit.decorator'; // Import +import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; @ApiTags('Circulations') @ApiBearerAuth() @@ -45,11 +46,11 @@ export class CirculationController { return this.circulationService.findAll(searchDto, user); } - @Get(':id') + @Get(':uuid') @ApiOperation({ summary: 'Get circulation details' }) @RequirePermission('document.view') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.circulationService.findOne(id); + findOne(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.circulationService.findOneByUuid(uuid); } @Patch('routings/:id') @@ -58,7 +59,7 @@ export class CirculationController { updateRouting( @Param('id', ParseIntPipe) id: number, @Body() updateDto: UpdateCirculationRoutingDto, - @CurrentUser() user: User, + @CurrentUser() user: User ) { return this.circulationService.updateRoutingStatus(id, updateDto, user); } diff --git a/backend/src/modules/circulation/circulation.service.ts b/backend/src/modules/circulation/circulation.service.ts index 5113e1c..6c3f585 100644 --- a/backend/src/modules/circulation/circulation.service.ts +++ b/backend/src/modules/circulation/circulation.service.ts @@ -113,6 +113,17 @@ export class CirculationService { 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 อัปเดตสถานะและปิดงาน async updateRoutingStatus( routingId: number, diff --git a/backend/src/modules/circulation/entities/circulation.entity.ts b/backend/src/modules/circulation/entities/circulation.entity.ts index b9de144..f2c019e 100644 --- a/backend/src/modules/circulation/entities/circulation.entity.ts +++ b/backend/src/modules/circulation/entities/circulation.entity.ts @@ -13,10 +13,13 @@ import { Organization } from '../../organization/entities/organization.entity'; import { User } from '../../user/entities/user.entity'; import { CirculationStatusCode } from './circulation-status-code.entity'; import { CirculationRouting } from './circulation-routing.entity'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { Exclude } from 'class-transformer'; @Entity('circulations') -export class Circulation { +export class Circulation extends UuidBaseEntity { @PrimaryGeneratedColumn() + @Exclude() id!: number; @Column({ name: 'correspondence_id', nullable: true }) diff --git a/backend/src/modules/contract/contract.controller.ts b/backend/src/modules/contract/contract.controller.ts index 7ce524e..ea52a0e 100644 --- a/backend/src/modules/contract/contract.controller.ts +++ b/backend/src/modules/contract/contract.controller.ts @@ -7,7 +7,6 @@ import { Param, Delete, UseGuards, - ParseIntPipe, Query, } from '@nestjs/common'; import { @@ -22,6 +21,7 @@ import { UpdateContractDto } from './dto/update-contract.dto.js'; import { SearchContractDto } from './dto/search-contract.dto.js'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js'; import { RequirePermission } from '../../common/decorators/require-permission.decorator.js'; +import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; @ApiTags('Contracts') @ApiBearerAuth() @@ -45,26 +45,26 @@ export class ContractController { return this.contractService.findAll(query); } - @Get(':id') - @ApiOperation({ summary: 'Get Contract by ID' }) - findOne(@Param('id', ParseIntPipe) id: number) { - return this.contractService.findOne(id); + @Get(':uuid') + @ApiOperation({ summary: 'Get Contract by UUID' }) + findOne(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.contractService.findOneByUuid(uuid); } - @Patch(':id') + @Patch(':uuid') @RequirePermission('master_data.manage') @ApiOperation({ summary: 'Update Contract' }) update( - @Param('id', ParseIntPipe) id: number, + @Param('uuid', ParseUuidPipe) uuid: string, @Body() dto: UpdateContractDto ) { - return this.contractService.update(id, dto); + return this.contractService.update(uuid, dto); } - @Delete(':id') + @Delete(':uuid') @RequirePermission('master_data.manage') @ApiOperation({ summary: 'Delete Contract' }) - remove(@Param('id', ParseIntPipe) id: number) { - return this.contractService.remove(id); + remove(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.contractService.remove(uuid); } } diff --git a/backend/src/modules/contract/contract.service.ts b/backend/src/modules/contract/contract.service.ts index aaf1094..8637ff3 100644 --- a/backend/src/modules/contract/contract.service.ts +++ b/backend/src/modules/contract/contract.service.ts @@ -87,15 +87,24 @@ export class ContractService { return contract; } - async update(id: number, dto: UpdateContractDto) { - const contract = await this.findOne(id); + async findOneByUuid(uuid: string) { + 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); return this.contractRepo.save(contract); } - async remove(id: number) { - const contract = await this.findOne(id); - // Schema doesn't have deleted_at for Contract either. + async remove(uuid: string) { + const contract = await this.findOneByUuid(uuid); return this.contractRepo.remove(contract); } } diff --git a/backend/src/modules/contract/entities/contract.entity.ts b/backend/src/modules/contract/entities/contract.entity.ts index 239fa8d..9fcb162 100644 --- a/backend/src/modules/contract/entities/contract.entity.ts +++ b/backend/src/modules/contract/entities/contract.entity.ts @@ -4,15 +4,34 @@ import { PrimaryGeneratedColumn, ManyToOne, JoinColumn, + BeforeInsert, } from 'typeorm'; +import { v7 as uuidv7 } from 'uuid'; +import { Exclude } from 'class-transformer'; import { BaseEntity } from '../../../common/entities/base.entity'; import { Project } from '../../project/entities/project.entity'; @Entity('contracts') export class Contract extends BaseEntity { @PrimaryGeneratedColumn() + @Exclude() 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' }) projectId!: number; diff --git a/backend/src/modules/correspondence/correspondence.controller.ts b/backend/src/modules/correspondence/correspondence.controller.ts index b82f2e2..ac085a6 100644 --- a/backend/src/modules/correspondence/correspondence.controller.ts +++ b/backend/src/modules/correspondence/correspondence.controller.ts @@ -30,6 +30,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { RbacGuard } from '../../common/guards/rbac.guard'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { Audit } from '../../common/decorators/audit.decorator'; +import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; @ApiTags('Correspondences') @Controller('correspondences') @@ -119,7 +120,7 @@ export class CorrespondenceController { return this.correspondenceService.findAll(searchDto); } - @Post(':id/submit') + @Post(':uuid/submit') @ApiOperation({ summary: 'Submit correspondence to Unified Workflow Engine' }) @ApiResponse({ status: 201, @@ -127,8 +128,8 @@ export class CorrespondenceController { }) @RequirePermission('correspondence.create') @Audit('correspondence.submit', 'correspondence') - submit( - @Param('id', ParseIntPipe) id: number, + async submit( + @Param('uuid', ParseUuidPipe) uuid: string, @Body() submitDto: SubmitCorrespondenceDto, @Request() req: Request & { @@ -138,28 +139,29 @@ export class CorrespondenceController { }; } ) { + const corr = await this.correspondenceService.findOneByUuid(uuid); // Extract roles from user assignments const userRoles = req.user.assignments?.map((a) => a.role?.roleName).filter(Boolean) || []; // Use Unified Workflow Engine - pass user roles for DSL requirements check return this.workflowService.submitWorkflow( - id, + corr.id, req.user.user_id, userRoles, submitDto.note ); } - @Get(':id') - @ApiOperation({ summary: 'Get correspondence by ID' }) + @Get(':uuid') + @ApiOperation({ summary: 'Get correspondence by UUID' }) @ApiResponse({ status: 200, description: 'Return correspondence details.' }) @RequirePermission('document.view') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.correspondenceService.findOne(id); + findOne(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.correspondenceService.findOneByUuid(uuid); } - @Put(':id') + @Put(':uuid') @ApiOperation({ summary: 'Update correspondence (Draft only)' }) @ApiResponse({ status: 200, @@ -167,48 +169,52 @@ export class CorrespondenceController { }) @RequirePermission('correspondence.create') // Assuming create permission is enough for draft update, or add 'correspondence.edit' @Audit('correspondence.update', 'correspondence') - update( - @Param('id', ParseIntPipe) id: number, + async update( + @Param('uuid', ParseUuidPipe) uuid: string, @Body() updateDto: UpdateCorrespondenceDto, @Request() req: Request & { user: unknown } ) { + const corr = await this.correspondenceService.findOneByUuid(uuid); return this.correspondenceService.update( - id, + corr.id, updateDto, req.user as Parameters[1] ); } - @Get(':id/references') + @Get(':uuid/references') @ApiOperation({ summary: 'Get referenced documents' }) @ApiResponse({ status: 200, description: 'Return list of referenced documents.', }) @RequirePermission('document.view') - getReferences(@Param('id', ParseIntPipe) id: number) { - return this.correspondenceService.getReferences(id); + async getReferences(@Param('uuid', ParseUuidPipe) uuid: string) { + 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' }) @ApiResponse({ status: 201, description: 'Reference added successfully.' }) @RequirePermission('document.edit') - addReference( - @Param('id', ParseIntPipe) id: number, + async addReference( + @Param('uuid', ParseUuidPipe) uuid: string, @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' }) @ApiResponse({ status: 200, description: 'Reference removed successfully.' }) @RequirePermission('document.edit') - removeReference( - @Param('id', ParseIntPipe) id: number, + async removeReference( + @Param('uuid', ParseUuidPipe) uuid: string, @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); } } diff --git a/backend/src/modules/correspondence/correspondence.service.ts b/backend/src/modules/correspondence/correspondence.service.ts index 595c346..4d38b8f 100644 --- a/backend/src/modules/correspondence/correspondence.service.ts +++ b/backend/src/modules/correspondence/correspondence.service.ts @@ -333,6 +333,26 @@ export class CorrespondenceService { 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) { const source = await this.correspondenceRepo.findOne({ where: { id } }); const target = await this.correspondenceRepo.findOne({ diff --git a/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts b/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts index a4f6132..dec129d 100644 --- a/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts +++ b/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts @@ -13,13 +13,16 @@ import { RfaRevision } from '../../rfa/entities/rfa-revision.entity'; import { Correspondence } from './correspondence.entity'; import { CorrespondenceStatus } from './correspondence-status.entity'; import { User } from '../../user/entities/user.entity'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { Exclude } from 'class-transformer'; @Entity('correspondence_revisions') // ✅ เพิ่ม Index สำหรับ Virtual Columns เพื่อให้ Search เร็วขึ้น @Index('idx_corr_rev_v_project', ['vRefProjectId']) @Index('idx_corr_rev_v_type', ['vRefType']) -export class CorrespondenceRevision { +export class CorrespondenceRevision extends UuidBaseEntity { @PrimaryGeneratedColumn() + @Exclude() id!: number; @Column({ name: 'correspondence_id' }) diff --git a/backend/src/modules/correspondence/entities/correspondence.entity.ts b/backend/src/modules/correspondence/entities/correspondence.entity.ts index 96c404b..07c73f0 100644 --- a/backend/src/modules/correspondence/entities/correspondence.entity.ts +++ b/backend/src/modules/correspondence/entities/correspondence.entity.ts @@ -15,10 +15,13 @@ import { User } from '../../user/entities/user.entity'; import { CorrespondenceRecipient } from './correspondence-recipient.entity'; import { CorrespondenceRevision } from './correspondence-revision.entity'; import { Discipline } from '../../master/entities/discipline.entity'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { Exclude } from 'class-transformer'; @Entity('correspondences') -export class Correspondence { +export class Correspondence extends UuidBaseEntity { @PrimaryGeneratedColumn() + @Exclude() id!: number; @Column({ name: 'correspondence_number', length: 100 }) diff --git a/backend/src/modules/drawing/asbuilt-drawing.controller.ts b/backend/src/modules/drawing/asbuilt-drawing.controller.ts index be5b112..fa2b161 100644 --- a/backend/src/modules/drawing/asbuilt-drawing.controller.ts +++ b/backend/src/modules/drawing/asbuilt-drawing.controller.ts @@ -6,7 +6,6 @@ import { Body, Param, Query, - ParseIntPipe, HttpCode, HttpStatus, UseGuards, @@ -34,6 +33,7 @@ import { SearchAsBuiltDrawingDto } from './dto/search-asbuilt-drawing.dto'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { Audit } from '../../common/decorators/audit.decorator'; +import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; import { User } from '../user/entities/user.entity'; @ApiTags('Drawings - AS Built') @@ -56,16 +56,17 @@ export class AsBuiltDrawingController { return this.asBuiltDrawingService.create(createDto, user); } - @Post(':id/revisions') + @Post(':uuid/revisions') @ApiOperation({ summary: 'Create new revision for AS Built Drawing' }) @ApiResponse({ status: 201, description: 'Revision created' }) @ApiResponse({ status: 404, description: 'AS Built Drawing not found' }) @ApiResponse({ status: 409, description: 'Revision label already exists' }) async createRevision( - @Param('id', ParseIntPipe) id: number, + @Param('uuid', ParseUuidPipe) uuid: string, @Body() createDto: CreateAsBuiltDrawingRevisionDto ) { - return this.asBuiltDrawingService.createRevision(id, createDto); + const drawing = await this.asBuiltDrawingService.findOneByUuid(uuid); + return this.asBuiltDrawingService.createRevision(drawing.id, createDto); } @Get() @@ -76,16 +77,16 @@ export class AsBuiltDrawingController { return this.asBuiltDrawingService.findAll(searchDto); } - @Get(':id') - @ApiOperation({ summary: 'Get AS Built Drawing by ID' }) + @Get(':uuid') + @ApiOperation({ summary: 'Get AS Built Drawing by UUID' }) @ApiResponse({ status: 200, description: 'AS Built Drawing details' }) @ApiResponse({ status: 404, description: 'AS Built Drawing not found' }) @RequirePermission('drawing.view') - async findOne(@Param('id', ParseIntPipe) id: number) { - return this.asBuiltDrawingService.findOne(id); + async findOne(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.asBuiltDrawingService.findOneByUuid(uuid); } - @Delete(':id') + @Delete(':uuid') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft delete AS Built Drawing' }) @ApiResponse({ status: 204, description: 'AS Built Drawing deleted' }) @@ -93,9 +94,10 @@ export class AsBuiltDrawingController { @RequirePermission('drawing.delete') @Audit('drawing.delete', 'asbuilt_drawing') async remove( - @Param('id', ParseIntPipe) id: number, + @Param('uuid', ParseUuidPipe) uuid: string, @CurrentUser() user: User ) { - return this.asBuiltDrawingService.remove(id, user); + const drawing = await this.asBuiltDrawingService.findOneByUuid(uuid); + return this.asBuiltDrawingService.remove(drawing.id, user); } } diff --git a/backend/src/modules/drawing/asbuilt-drawing.service.ts b/backend/src/modules/drawing/asbuilt-drawing.service.ts index b956480..83a3cf7 100644 --- a/backend/src/modules/drawing/asbuilt-drawing.service.ts +++ b/backend/src/modules/drawing/asbuilt-drawing.service.ts @@ -295,6 +295,28 @@ export class AsBuiltDrawingService { 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 */ diff --git a/backend/src/modules/drawing/contract-drawing.controller.ts b/backend/src/modules/drawing/contract-drawing.controller.ts index 2e5a752..d701e3c 100644 --- a/backend/src/modules/drawing/contract-drawing.controller.ts +++ b/backend/src/modules/drawing/contract-drawing.controller.ts @@ -8,7 +8,6 @@ import { Put, Query, UseGuards, - ParseIntPipe, } from '@nestjs/common'; 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 { RbacGuard } from '../../common/guards/rbac.guard'; 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 { User } from '../user/entities/user.entity'; @@ -51,28 +51,33 @@ export class ContractDrawingController { return this.contractDrawingService.findAll(searchDto); } - @Get(':id') + @Get(':uuid') @ApiOperation({ summary: 'Get Contract Drawing details' }) @RequirePermission('document.view') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.contractDrawingService.findOne(id); + findOne(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.contractDrawingService.findOneByUuid(uuid); } - @Put(':id') + @Put(':uuid') @ApiOperation({ summary: 'Update Contract Drawing' }) @RequirePermission('drawing.create') // สิทธิ์ ID 39 ครอบคลุมการแก้ไขด้วย - update( - @Param('id', ParseIntPipe) id: number, + async update( + @Param('uuid', ParseUuidPipe) uuid: string, @Body() updateDto: UpdateContractDrawingDto, @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)' }) @RequirePermission('document.delete') // สิทธิ์ ID 34: ลบเอกสาร - remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) { - return this.contractDrawingService.remove(id, user); + async remove( + @Param('uuid', ParseUuidPipe) uuid: string, + @CurrentUser() user: User + ) { + const drawing = await this.contractDrawingService.findOneByUuid(uuid); + return this.contractDrawingService.remove(drawing.id, user); } } diff --git a/backend/src/modules/drawing/contract-drawing.service.ts b/backend/src/modules/drawing/contract-drawing.service.ts index 9bd6a40..8c993da 100644 --- a/backend/src/modules/drawing/contract-drawing.service.ts +++ b/backend/src/modules/drawing/contract-drawing.service.ts @@ -180,6 +180,19 @@ export class ContractDrawingService { 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) */ diff --git a/backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts b/backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts index 96831ee..759702f 100644 --- a/backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts +++ b/backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts @@ -13,11 +13,14 @@ import { AsBuiltDrawing } from './asbuilt-drawing.entity'; import { ShopDrawingRevision } from './shop-drawing-revision.entity'; import { Attachment } from '../../../common/file-storage/entities/attachment.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') @Unique(['asBuiltDrawingId', 'isCurrent']) -export class AsBuiltDrawingRevision { +export class AsBuiltDrawingRevision extends UuidBaseEntity { @PrimaryGeneratedColumn() + @Exclude() id!: number; @Column({ name: 'asbuilt_drawing_id' }) diff --git a/backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts b/backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts index fb0b823..cc30743 100644 --- a/backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts +++ b/backend/src/modules/drawing/entities/asbuilt-drawing.entity.ts @@ -14,10 +14,13 @@ import { AsBuiltDrawingRevision } from './asbuilt-drawing-revision.entity'; import { User } from '../../user/entities/user.entity'; import { ShopDrawingMainCategory } from './shop-drawing-main-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') -export class AsBuiltDrawing { +export class AsBuiltDrawing extends UuidBaseEntity { @PrimaryGeneratedColumn() + @Exclude() id!: number; @Column({ name: 'project_id' }) diff --git a/backend/src/modules/drawing/entities/contract-drawing.entity.ts b/backend/src/modules/drawing/entities/contract-drawing.entity.ts index a1ebec2..53ed824 100644 --- a/backend/src/modules/drawing/entities/contract-drawing.entity.ts +++ b/backend/src/modules/drawing/entities/contract-drawing.entity.ts @@ -15,10 +15,13 @@ import { User } from '../../user/entities/user.entity'; import { Attachment } from '../../../common/file-storage/entities/attachment.entity'; import { ContractDrawingSubcatCatMap } from './contract-drawing-subcat-cat-map.entity'; import { ContractDrawingVolume } from './contract-drawing-volume.entity'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { Exclude } from 'class-transformer'; @Entity('contract_drawings') -export class ContractDrawing { +export class ContractDrawing extends UuidBaseEntity { @PrimaryGeneratedColumn() + @Exclude() id!: number; // ! ห้ามว่าง @Column({ name: 'project_id' }) diff --git a/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts b/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts index f9c9972..5fc1676 100644 --- a/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts +++ b/backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts @@ -13,11 +13,14 @@ import { ShopDrawing } from './shop-drawing.entity'; import { ContractDrawing } from './contract-drawing.entity'; import { Attachment } from '../../../common/file-storage/entities/attachment.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') @Unique(['shopDrawingId', 'isCurrent']) -export class ShopDrawingRevision { +export class ShopDrawingRevision extends UuidBaseEntity { @PrimaryGeneratedColumn() + @Exclude() id!: number; // เติม ! @Column({ name: 'shop_drawing_id' }) diff --git a/backend/src/modules/drawing/entities/shop-drawing.entity.ts b/backend/src/modules/drawing/entities/shop-drawing.entity.ts index 4e3b503..3def7c2 100644 --- a/backend/src/modules/drawing/entities/shop-drawing.entity.ts +++ b/backend/src/modules/drawing/entities/shop-drawing.entity.ts @@ -13,10 +13,13 @@ import { ShopDrawingRevision } from './shop-drawing-revision.entity'; import { Project } from '../../project/entities/project.entity'; import { ShopDrawingMainCategory } from './shop-drawing-main-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') -export class ShopDrawing { +export class ShopDrawing extends UuidBaseEntity { @PrimaryGeneratedColumn() + @Exclude() id!: number; // เติม ! @Column({ name: 'project_id' }) diff --git a/backend/src/modules/drawing/shop-drawing.controller.ts b/backend/src/modules/drawing/shop-drawing.controller.ts index 82f3538..2422889 100644 --- a/backend/src/modules/drawing/shop-drawing.controller.ts +++ b/backend/src/modules/drawing/shop-drawing.controller.ts @@ -6,7 +6,6 @@ import { Param, Query, UseGuards, - ParseIntPipe, } from '@nestjs/common'; 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 { RbacGuard } from '../../common/guards/rbac.guard'; 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 { User } from '../user/entities/user.entity'; import { Audit } from '../../common/decorators/audit.decorator'; // Import @@ -44,21 +44,22 @@ export class ShopDrawingController { return this.shopDrawingService.findAll(searchDto); } - @Get(':id') + @Get(':uuid') @ApiOperation({ summary: 'Get Shop Drawing details with revisions' }) @RequirePermission('drawing.view') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.shopDrawingService.findOne(id); + findOne(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.shopDrawingService.findOneByUuid(uuid); } - @Post(':id/revisions') + @Post(':uuid/revisions') @ApiOperation({ summary: 'Add new revision to existing Shop Drawing' }) @RequirePermission('drawing.create') // หรือ drawing.edit ตาม Logic องค์กร @Audit('drawing.create', 'shop_drawing') // ✅ แปะตรงนี้ - createRevision( - @Param('id', ParseIntPipe) id: number, - @Body() createRevisionDto: CreateShopDrawingRevisionDto, + async createRevision( + @Param('uuid', ParseUuidPipe) uuid: string, + @Body() createRevisionDto: CreateShopDrawingRevisionDto ) { - return this.shopDrawingService.createRevision(id, createRevisionDto); + const sd = await this.shopDrawingService.findOneByUuid(uuid); + return this.shopDrawingService.createRevision(sd.id, createRevisionDto); } } diff --git a/backend/src/modules/drawing/shop-drawing.service.ts b/backend/src/modules/drawing/shop-drawing.service.ts index 64fcbe8..27a03e8 100644 --- a/backend/src/modules/drawing/shop-drawing.service.ts +++ b/backend/src/modules/drawing/shop-drawing.service.ts @@ -289,6 +289,28 @@ export class ShopDrawingService { 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 */ diff --git a/backend/src/modules/notification/entities/notification.entity.ts b/backend/src/modules/notification/entities/notification.entity.ts index 2cb8dbe..27420f4 100644 --- a/backend/src/modules/notification/entities/notification.entity.ts +++ b/backend/src/modules/notification/entities/notification.entity.ts @@ -8,6 +8,8 @@ import { PrimaryColumn, // ✅ [Fix] เพิ่ม Import นี้ } from 'typeorm'; import { User } from '../../user/entities/user.entity'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { Exclude } from 'class-transformer'; export enum NotificationType { EMAIL = 'EMAIL', @@ -16,8 +18,9 @@ export enum NotificationType { } @Entity('notifications') -export class Notification { +export class Notification extends UuidBaseEntity { @PrimaryGeneratedColumn() + @Exclude() id!: number; @Column({ name: 'user_id' }) diff --git a/backend/src/modules/notification/notification.controller.ts b/backend/src/modules/notification/notification.controller.ts index c9e7546..e85da9b 100644 --- a/backend/src/modules/notification/notification.controller.ts +++ b/backend/src/modules/notification/notification.controller.ts @@ -1,12 +1,4 @@ -import { - Controller, - Get, - Put, - Param, - UseGuards, - ParseIntPipe, - Query, -} from '@nestjs/common'; +import { Controller, Get, Put, Param, UseGuards, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { InjectRepository } from '@nestjs/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 { User } from '../user/entities/user.entity'; import { SearchNotificationDto } from './dto/search-notification.dto'; // ✅ Import +import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; @ApiTags('Notifications') @ApiBearerAuth() @@ -26,14 +19,14 @@ export class NotificationController { constructor( private readonly notificationService: NotificationService, @InjectRepository(Notification) - private notificationRepo: Repository, + private notificationRepo: Repository ) {} @Get() @ApiOperation({ summary: 'Get my notifications' }) async getMyNotifications( @CurrentUser() user: User, - @Query() searchDto: SearchNotificationDto, // ✅ ใช้ DTO แทน + @Query() searchDto: SearchNotificationDto // ✅ ใช้ DTO แทน ) { const { page = 1, limit = 20, isRead } = searchDto; @@ -65,13 +58,13 @@ export class NotificationController { return { unreadCount: count }; } - @Put(':id/read') + @Put(':uuid/read') @ApiOperation({ summary: 'Mark notification as read' }) async markAsRead( - @Param('id', ParseIntPipe) id: number, - @CurrentUser() user: User, + @Param('uuid', ParseUuidPipe) uuid: string, + @CurrentUser() user: User ) { - return this.notificationService.markAsRead(id, user.user_id); + return this.notificationService.markAsReadByUuid(uuid, user.user_id); } @Put('read-all') diff --git a/backend/src/modules/notification/notification.service.ts b/backend/src/modules/notification/notification.service.ts index e34530c..7aefdc4 100644 --- a/backend/src/modules/notification/notification.service.ts +++ b/backend/src/modules/notification/notification.service.ts @@ -39,7 +39,7 @@ export class NotificationService { @InjectRepository(User) private userRepo: Repository, // ไม่ต้อง Inject UserPrefRepo แล้ว เพราะ Processor จะจัดการเอง - private notificationGateway: NotificationGateway, + private notificationGateway: NotificationGateway ) {} /** @@ -84,14 +84,14 @@ export class NotificationService { delay: 5000, }, removeOnComplete: true, - }, + } ); this.logger.debug(`Dispatched notification job for user ${data.userId}`); } catch (error) { this.logger.error( `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 { + 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 { await this.notificationRepo.update( { userId, isRead: false }, - { isRead: true }, + { isRead: true } ); } diff --git a/backend/src/modules/organization/entities/organization.entity.ts b/backend/src/modules/organization/entities/organization.entity.ts index d87d925..fd89b4b 100644 --- a/backend/src/modules/organization/entities/organization.entity.ts +++ b/backend/src/modules/organization/entities/organization.entity.ts @@ -10,10 +10,13 @@ import { JoinColumn, } from 'typeorm'; import { OrganizationRole } from './organization-role.entity'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { Exclude } from 'class-transformer'; @Entity('organizations') -export class Organization { +export class Organization extends UuidBaseEntity { @PrimaryGeneratedColumn() + @Exclude() id!: number; @Column({ name: 'organization_code', length: 20, unique: true }) diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts index bccaeb5..dcc633e 100644 --- a/backend/src/modules/organization/organization.controller.ts +++ b/backend/src/modules/organization/organization.controller.ts @@ -8,7 +8,6 @@ import { Delete, Query, UseGuards, - ParseIntPipe, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; 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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js'; import { RequirePermission } from '../../common/decorators/require-permission.decorator.js'; +import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; @ApiTags('Organizations') @ApiBearerAuth() @@ -38,26 +38,26 @@ export class OrganizationController { return this.orgService.findAll(query); } - @Get(':id') - @ApiOperation({ summary: 'Get Organization by ID' }) - findOne(@Param('id', ParseIntPipe) id: number) { - return this.orgService.findOne(id); + @Get(':uuid') + @ApiOperation({ summary: 'Get Organization by UUID' }) + findOne(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.orgService.findOneByUuid(uuid); } - @Patch(':id') + @Patch(':uuid') @RequirePermission('master_data.manage') @ApiOperation({ summary: 'Update Organization' }) update( - @Param('id', ParseIntPipe) id: number, + @Param('uuid', ParseUuidPipe) uuid: string, @Body() dto: UpdateOrganizationDto ) { - return this.orgService.update(id, dto); + return this.orgService.update(uuid, dto); } - @Delete(':id') + @Delete(':uuid') @RequirePermission('master_data.manage') @ApiOperation({ summary: 'Delete Organization' }) - remove(@Param('id', ParseIntPipe) id: number) { - return this.orgService.remove(id); + remove(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.orgService.remove(uuid); } } diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index 6452ed0..d183b2d 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -86,17 +86,21 @@ export class OrganizationService { return org; } - async update(id: number, dto: UpdateOrganizationDto) { - const org = await this.findOne(id); + async findOneByUuid(uuid: string) { + 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); return this.orgRepo.save(org); } - async remove(id: number) { - const org = await this.findOne(id); - // 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. + async remove(uuid: string) { + const org = await this.findOneByUuid(uuid); return this.orgRepo.remove(org); } diff --git a/backend/src/modules/project/entities/project.entity.ts b/backend/src/modules/project/entities/project.entity.ts index 8a87b2d..7a9beb6 100644 --- a/backend/src/modules/project/entities/project.entity.ts +++ b/backend/src/modules/project/entities/project.entity.ts @@ -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 { Contract } from '../../contract/entities/contract.entity'; @Entity('projects') export class Project extends BaseEntity { @PrimaryGeneratedColumn() + @Exclude() 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 }) projectCode!: string; diff --git a/backend/src/modules/project/project.controller.ts b/backend/src/modules/project/project.controller.ts index d2321dc..80299b2 100644 --- a/backend/src/modules/project/project.controller.ts +++ b/backend/src/modules/project/project.controller.ts @@ -8,7 +8,6 @@ import { Delete, Query, UseGuards, - ParseIntPipe, } from '@nestjs/common'; 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 { RbacGuard } from '../../common/guards/rbac.guard'; +import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; @ApiTags('Projects') @@ -49,34 +49,34 @@ export class ProjectController { return this.projectService.findAllOrganizations(); } - @Get(':id/contracts') + @Get(':uuid/contracts') @ApiOperation({ summary: 'List All Contracts in Project' }) @RequirePermission('project.view') - findContracts(@Param('id', ParseIntPipe) id: number) { - return this.projectService.findContracts(id); + findContracts(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.projectService.findContracts(uuid); } - @Get(':id') + @Get(':uuid') @ApiOperation({ summary: 'Get Project Details' }) @RequirePermission('project.view') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.projectService.findOne(id); + findOne(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.projectService.findOneByUuid(uuid); } - @Patch(':id') + @Patch(':uuid') @ApiOperation({ summary: 'Update Project' }) @RequirePermission('project.edit') update( - @Param('id', ParseIntPipe) id: number, + @Param('uuid', ParseUuidPipe) uuid: string, @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)' }) @RequirePermission('project.delete') - remove(@Param('id', ParseIntPipe) id: number) { - return this.projectService.remove(id); + remove(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.projectService.remove(uuid); } } diff --git a/backend/src/modules/project/project.service.ts b/backend/src/modules/project/project.service.ts index 9fdc01b..4decc8f 100644 --- a/backend/src/modules/project/project.service.ts +++ b/backend/src/modules/project/project.service.ts @@ -91,8 +91,21 @@ export class ProjectService { return project; } - async update(id: number, updateDto: UpdateProjectDto) { - const project = await this.findOne(id); + async findOneByUuid(uuid: string) { + 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 ข้อมูลใหม่ใส่ข้อมูลเดิม this.projectRepository.merge(project, updateDto); @@ -100,22 +113,14 @@ export class ProjectService { return this.projectRepository.save(project); } - async remove(id: number) { - const project = await this.findOne(id); + async remove(uuid: string) { + const project = await this.findOneByUuid(uuid); // ใช้ Soft Delete return this.projectRepository.softRemove(project); } - async findContracts(projectId: number) { - const project = await this.projectRepository.findOne({ - where: { id: projectId }, - relations: ['contracts'], - }); - - if (!project) { - throw new NotFoundException(`Project ID ${projectId} not found`); - } - + async findContracts(uuid: string) { + const project = await this.findOneByUuid(uuid); return project.contracts; } diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index b7e4f67..655dde7 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -16,10 +16,13 @@ import { import { Organization } from '../../organization/entities/organization.entity'; // Adjust path as needed import { UserAssignment } from './user-assignment.entity'; import { UserPreference } from './user-preference.entity'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +import { Exclude } from 'class-transformer'; @Entity('users') -export class User { +export class User extends UuidBaseEntity { @PrimaryGeneratedColumn({ name: 'user_id' }) + @Exclude() user_id!: number; @Column({ unique: true, length: 50 }) diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index 68c9981..ee93785 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -33,6 +33,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { RbacGuard } from '../../common/guards/rbac.guard'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; import { User } from './entities/user.entity'; @ApiTags('Users') @@ -123,35 +124,35 @@ export class UserController { return this.userService.findAll(query); } - @Get(':id') + @Get(':uuid') @ApiOperation({ summary: 'Get user details' }) - @ApiParam({ name: 'id', description: 'User ID' }) + @ApiParam({ name: 'uuid', description: 'User UUID' }) @ApiResponse({ status: 200, description: 'User details' }) @RequirePermission('user.view') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.userService.findOne(id); + findOne(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.userService.findOneByUuid(uuid); } - @Patch(':id') + @Patch(':uuid') @ApiOperation({ summary: 'Update user' }) - @ApiParam({ name: 'id', description: 'User ID' }) + @ApiParam({ name: 'uuid', description: 'User UUID' }) @ApiBody({ type: UpdateUserDto }) @ApiResponse({ status: 200, description: 'User updated' }) @RequirePermission('user.edit') update( - @Param('id', ParseIntPipe) id: number, + @Param('uuid', ParseUuidPipe) uuid: string, @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)' }) - @ApiParam({ name: 'id', description: 'User ID' }) + @ApiParam({ name: 'uuid', description: 'User UUID' }) @ApiResponse({ status: 200, description: 'User deleted' }) @RequirePermission('user.delete') - remove(@Param('id', ParseIntPipe) id: number) { - return this.userService.remove(id); + remove(@Param('uuid', ParseUuidPipe) uuid: string) { + return this.userService.remove(uuid); } // --- Role Assignment --- diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 11c6eb1..edc8558 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -133,13 +133,31 @@ export class UserService { return user; } + async findOneByUuid(uuid: string): Promise { + 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 { return this.usersRepository.findOne({ where: { username } }); } // 4. แก้ไขข้อมูล - async update(id: number, updateUserDto: UpdateUserDto): Promise { - const user = await this.findOne(id); + async update(uuid: string, updateUserDto: UpdateUserDto): Promise { + const user = await this.findOneByUuid(uuid); if (updateUserDto.password) { const salt = await bcrypt.genSalt(); @@ -150,20 +168,21 @@ export class UserService { const savedUser = await this.usersRepository.save(updatedUser); // ⚠️ สำคัญ: เมื่อมีการแก้ไขข้อมูล User ต้องเคลียร์ Cache สิทธิ์เสมอ - await this.clearUserCache(id); + await this.clearUserCache(user.user_id); return savedUser; } // 5. ลบผู้ใช้ (Soft Delete) - async remove(id: number): Promise { - const result = await this.usersRepository.softDelete(id); + async remove(uuid: string): Promise { + const user = await this.findOneByUuid(uuid); + const result = await this.usersRepository.softDelete(user.user_id); if (result.affected === 0) { - throw new NotFoundException(`User with ID ${id} not found`); + throw new NotFoundException(`User with UUID ${uuid} not found`); } // เคลียร์ Cache เมื่อลบ - await this.clearUserCache(id); + await this.clearUserCache(user.user_id); } async findDocControlIdByOrg(organizationId: number): Promise { diff --git a/frontend/app/(admin)/admin/access-control/organizations/page.tsx b/frontend/app/(admin)/admin/access-control/organizations/page.tsx index 8cd48d5..4724c53 100644 --- a/frontend/app/(admin)/admin/access-control/organizations/page.tsx +++ b/frontend/app/(admin)/admin/access-control/organizations/page.tsx @@ -63,7 +63,7 @@ export default function OrganizationsPage() { const confirmDelete = () => { if (orgToDelete) { - deleteOrg.mutate(orgToDelete.id, { + deleteOrg.mutate(orgToDelete.uuid, { onSuccess: () => { setDeleteDialogOpen(false); setOrgToDelete(null); diff --git a/frontend/app/(admin)/admin/access-control/users/page.tsx b/frontend/app/(admin)/admin/access-control/users/page.tsx index 5fa0f4c..bba65ef 100644 --- a/frontend/app/(admin)/admin/access-control/users/page.tsx +++ b/frontend/app/(admin)/admin/access-control/users/page.tsx @@ -64,7 +64,7 @@ export default function UsersPage() { const confirmDelete = () => { if (userToDelete) { - deleteMutation.mutate(userToDelete.userId, { + deleteMutation.mutate(userToDelete.uuid, { onSuccess: () => { setDeleteDialogOpen(false); setUserToDelete(null); @@ -186,7 +186,7 @@ export default function UsersPage() { All Organizations {Array.isArray(organizations) && (organizations as Organization[]).map((org) => ( - + {org.organizationCode} - {org.organizationName} ))} diff --git a/frontend/app/(admin)/admin/doc-control/contracts/page.tsx b/frontend/app/(admin)/admin/doc-control/contracts/page.tsx index 7cbf705..01c453e 100644 --- a/frontend/app/(admin)/admin/doc-control/contracts/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/contracts/page.tsx @@ -56,7 +56,8 @@ interface Project { } interface Contract { - id: number; + uuid: string; + id?: number; // Excluded from API responses (ADR-019) contractCode: string; contractName: string; projectId: number; @@ -112,7 +113,7 @@ export default function ContractsPage() { }); 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: () => { toast.success("Contract updated successfully"); queryClient.invalidateQueries({ queryKey: ['contracts'] }); @@ -122,7 +123,7 @@ export default function ContractsPage() { }); 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: () => { toast.success("Contract deleted successfully"); queryClient.invalidateQueries({ queryKey: ['contracts'] }); @@ -131,7 +132,7 @@ export default function ContractsPage() { }); const [dialogOpen, setDialogOpen] = useState(false); - const [editingId, setEditingId] = useState(null); + const [editingUuid, setEditingUuid] = useState(null); // Stats for Delete Dialog const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -144,7 +145,7 @@ export default function ContractsPage() { const confirmDelete = () => { if (contractToDelete) { - deleteContract.mutate(contractToDelete.id, { + deleteContract.mutate(contractToDelete.uuid, { onSuccess: () => { setDeleteDialogOpen(false); setContractToDelete(null); @@ -212,7 +213,7 @@ export default function ContractsPage() { ]; const handleEdit = (contract: Contract) => { - setEditingId(contract.id); + setEditingUuid(contract.uuid); reset({ contractCode: contract.contractCode, contractName: contract.contractName, @@ -225,7 +226,7 @@ export default function ContractsPage() { }; const handleCreate = () => { - setEditingId(null); + setEditingUuid(null); reset({ contractCode: "", contractName: "", @@ -243,8 +244,8 @@ export default function ContractsPage() { projectId: parseInt(data.projectId), }; - if (editingId) { - updateContract.mutate({ id: editingId, data: submitData }); + if (editingUuid) { + updateContract.mutate({ uuid: editingUuid, data: submitData }); } else { createContract.mutate(submitData); } @@ -289,7 +290,7 @@ export default function ContractsPage() { - {editingId ? "Edit Contract" : "New Contract"} + {editingUuid ? "Edit Contract" : "New Contract"}
@@ -363,7 +364,7 @@ export default function ContractsPage() { Cancel
diff --git a/frontend/app/(admin)/admin/doc-control/projects/page.tsx b/frontend/app/(admin)/admin/doc-control/projects/page.tsx index 074a4f8..9ece38d 100644 --- a/frontend/app/(admin)/admin/doc-control/projects/page.tsx +++ b/frontend/app/(admin)/admin/doc-control/projects/page.tsx @@ -44,7 +44,8 @@ import { import { Skeleton } from "@/components/ui/skeleton"; interface Project { - id: number; + uuid: string; + id?: number; // Excluded from API responses (ADR-019) projectCode: string; projectName: string; isActive: boolean; @@ -69,7 +70,7 @@ export default function ProjectsPage() { const deleteProject = useDeleteProject(); const [dialogOpen, setDialogOpen] = useState(false); - const [editingId, setEditingId] = useState(null); + const [editingUuid, setEditingUuid] = useState(null); // Stats for Delete Dialog const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -82,7 +83,7 @@ export default function ProjectsPage() { const confirmDelete = () => { if (projectToDelete) { - deleteProject.mutate(projectToDelete.id, { + deleteProject.mutate(projectToDelete.uuid, { onSuccess: () => { setDeleteDialogOpen(false); setProjectToDelete(null); @@ -156,7 +157,7 @@ export default function ProjectsPage() { ]; const handleEdit = (project: Project) => { - setEditingId(project.id); + setEditingUuid(project.uuid); reset({ projectCode: project.projectCode, projectName: project.projectName, @@ -166,7 +167,7 @@ export default function ProjectsPage() { }; const handleCreate = () => { - setEditingId(null); + setEditingUuid(null); reset({ projectCode: "", projectName: "", @@ -176,9 +177,9 @@ export default function ProjectsPage() { }; const onSubmit = (data: ProjectFormData) => { - if (editingId) { + if (editingUuid) { updateProject.mutate( - { id: editingId, data }, + { uuid: editingUuid, data }, { onSuccess: () => setDialogOpen(false), } @@ -232,7 +233,7 @@ export default function ProjectsPage() { - {editingId ? "Edit Project" : "New Project"} + {editingUuid ? "Edit Project" : "New Project"}
@@ -241,7 +242,7 @@ export default function ProjectsPage() { {errors.projectCode && (

{errors.projectCode.message}

@@ -280,7 +281,7 @@ export default function ProjectsPage() { type="submit" disabled={createProject.isPending || updateProject.isPending} > - {editingId ? "Save Changes" : "Create Project"} + {editingUuid ? "Save Changes" : "Create Project"}
diff --git a/frontend/app/(dashboard)/circulation/[id]/page.tsx b/frontend/app/(dashboard)/circulation/[uuid]/page.tsx similarity index 96% rename from frontend/app/(dashboard)/circulation/[id]/page.tsx rename to frontend/app/(dashboard)/circulation/[uuid]/page.tsx index dfd8e89..65a4a1f 100644 --- a/frontend/app/(dashboard)/circulation/[id]/page.tsx +++ b/frontend/app/(dashboard)/circulation/[uuid]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { circulationService } from "@/lib/services/circulation.service"; import { Circulation, UpdateCirculationRoutingDto } from "@/types/circulation"; @@ -42,14 +42,13 @@ function getStatusVariant(status: string): "default" | "secondary" | "destructiv export default function CirculationDetailPage() { const params = useParams(); - const router = useRouter(); const queryClient = useQueryClient(); - const id = params.id as string; + const uuid = params.uuid as string; const { data: circulation, isLoading, error } = useQuery({ - queryKey: ["circulation", id], - queryFn: () => circulationService.getById(id), - enabled: !!id, + queryKey: ["circulation", uuid], + queryFn: () => circulationService.getByUuid(uuid), + enabled: !!uuid, }); const completeMutation = useMutation({ @@ -57,7 +56,7 @@ export default function CirculationDetailPage() { circulationService.updateRouting(routingId, data), onSuccess: () => { toast.success("Task completed successfully"); - queryClient.invalidateQueries({ queryKey: ["circulation", id] }); + queryClient.invalidateQueries({ queryKey: ["circulation", uuid] }); }, onError: () => { toast.error("Failed to update task status"); @@ -146,7 +145,7 @@ export default function CirculationDetailPage() {

Linked Document

{circulation.correspondence.correspondence_number} diff --git a/frontend/app/(dashboard)/circulation/new/page.tsx b/frontend/app/(dashboard)/circulation/new/page.tsx index 6f69e55..7e512dc 100644 --- a/frontend/app/(dashboard)/circulation/new/page.tsx +++ b/frontend/app/(dashboard)/circulation/new/page.tsx @@ -83,7 +83,7 @@ export default function CreateCirculationPage() { mutationFn: (data: CreateCirculationDto) => circulationService.create(data), onSuccess: (result) => { toast.success("Circulation created successfully"); - router.push(`/circulation/${result.id}`); + router.push(`/circulation/${result.uuid}`); }, onError: () => { toast.error("Failed to create circulation"); @@ -232,7 +232,7 @@ export default function CreateCirculationPage() {
{selectedAssignees.map((userId) => { const user = users.find( - (u: { userId: number }) => u.userId === userId + (u) => u.userId === userId ); return user ? ( No user found. - {users.map((user: { userId: number; username: string; firstName?: string; lastName?: string }) => ( + {users.map((user) => ( toggleAssignee(user.userId)} + onSelect={() => user.userId && toggleAssignee(user.userId)} > - -
- ); - } - - if (isError || !correspondence) { - return ( -
-

Failed to load correspondence

-
- ); - } - - return ( -
-
-

Edit Correspondence

-

- {correspondence.correspondenceNumber} -

-
- -
- -
-
- ); -} diff --git a/frontend/app/(dashboard)/correspondences/[id]/page.tsx b/frontend/app/(dashboard)/correspondences/[uuid]/page.tsx similarity index 60% rename from frontend/app/(dashboard)/correspondences/[id]/page.tsx rename to frontend/app/(dashboard)/correspondences/[uuid]/page.tsx index c7bb32a..e10a768 100644 --- a/frontend/app/(dashboard)/correspondences/[id]/page.tsx +++ b/frontend/app/(dashboard)/correspondences/[uuid]/page.tsx @@ -3,27 +3,22 @@ import { CorrespondenceDetail } from "@/components/correspondences/detail"; import { useCorrespondence } from "@/hooks/use-correspondence"; import { Loader2 } from "lucide-react"; -import { notFound, useParams } from "next/navigation"; +import { useParams } from "next/navigation"; export default function CorrespondenceDetailPage() { const params = useParams(); - const id = Number(params?.id); // useParams returns string | string[] + const uuid = (params?.uuid as string) ?? ''; - if (isNaN(id)) { - // We can't use notFound() directly in client component render without breaking sometimes, - // but typically it works. Better to handle gracefully or redirect. - // 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. + const { data: correspondence, isLoading, isError } = useCorrespondence(uuid); + + if (!uuid) { return (
-

Invalid Correspondence ID

+

Invalid Correspondence UUID

); } - const { data: correspondence, isLoading, isError } = useCorrespondence(id); - if (isLoading) { return (
@@ -33,11 +28,10 @@ export default function CorrespondenceDetailPage() { } if (isError || !correspondence) { - // Optionally handle 404 vs other errors differently, but for now simple handling return (

Failed to load correspondence

-

Please try again later or verify the ID.

+

Please try again later or verify the UUID.

); } diff --git a/frontend/app/(dashboard)/drawings/[id]/page.tsx b/frontend/app/(dashboard)/drawings/[uuid]/page.tsx similarity index 92% rename from frontend/app/(dashboard)/drawings/[id]/page.tsx rename to frontend/app/(dashboard)/drawings/[uuid]/page.tsx index f682ae4..e21afec 100644 --- a/frontend/app/(dashboard)/drawings/[id]/page.tsx +++ b/frontend/app/(dashboard)/drawings/[uuid]/page.tsx @@ -1,4 +1,3 @@ -import { drawingApi } from "@/lib/api/drawings"; import { notFound } from "next/navigation"; import { Button } from "@/components/ui/button"; 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 { RevisionHistory } from "@/components/drawings/revision-history"; import { format } from "date-fns"; +import { drawingApi } from "@/lib/api/drawings"; export default async function DrawingDetailPage({ params, }: { - params: Promise<{ id: string }>; + params: Promise<{ uuid: string }>; }) { - const { id: rawId } = await params; - const id = parseInt(rawId); - if (isNaN(id)) { + const { uuid } = await params; + if (!uuid) { 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) { notFound(); diff --git a/frontend/components/admin/organization-dialog.tsx b/frontend/components/admin/organization-dialog.tsx index fcfc6dc..0264302 100644 --- a/frontend/components/admin/organization-dialog.tsx +++ b/frontend/components/admin/organization-dialog.tsx @@ -104,7 +104,7 @@ export function OrganizationDialog({ if (organization) { updateOrg.mutate( - { id: organization.id, data: submitData }, + { uuid: organization.uuid, data: submitData }, { onSuccess: () => onOpenChange(false) } ); } else { diff --git a/frontend/components/admin/user-dialog.tsx b/frontend/components/admin/user-dialog.tsx index 4662484..d71ce4f 100644 --- a/frontend/components/admin/user-dialog.tsx +++ b/frontend/components/admin/user-dialog.tsx @@ -151,7 +151,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) { if (user) { updateUser.mutate( - { id: user.userId, data: payload }, + { uuid: user.uuid, data: payload }, { onSuccess: () => onOpenChange(false) } ); } else { @@ -230,10 +230,13 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) { + {/* 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) => ( {org.organizationCode} - {org.organizationName} diff --git a/frontend/components/circulation/circulation-list.tsx b/frontend/components/circulation/circulation-list.tsx index 0bbf94f..be7019d 100644 --- a/frontend/components/circulation/circulation-list.tsx +++ b/frontend/components/circulation/circulation-list.tsx @@ -113,7 +113,7 @@ export function CirculationList({ data }: CirculationListProps) { const item = row.original; return (
- + diff --git a/frontend/components/correspondences/detail.tsx b/frontend/components/correspondences/detail.tsx index 700e635..636f02b 100644 --- a/frontend/components/correspondences/detail.tsx +++ b/frontend/components/correspondences/detail.tsx @@ -39,7 +39,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) { const handleSubmit = () => { if (confirm("Are you sure you want to submit this correspondence?")) { submitMutation.mutate({ - id: data.id, + uuid: data.uuid, data: {} }); } @@ -50,7 +50,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) { const action = actionState === "approve" ? "APPROVE" : "REJECT"; processMutation.mutate({ - id: data.id, + uuid: data.uuid, data: { action, comments @@ -83,7 +83,7 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
{/* EDIT BUTTON LOGIC: Show if DRAFT */} {status === "DRAFT" && ( - +
diff --git a/frontend/components/correspondences/list.tsx b/frontend/components/correspondences/list.tsx index 1d17f6b..981dde6 100644 --- a/frontend/components/correspondences/list.tsx +++ b/frontend/components/correspondences/list.tsx @@ -59,15 +59,15 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) { id: "actions", cell: ({ row }) => { const item = row.original; - // Edit/View link goes to the DOCUMENT detail (correspondence.id) - // Ideally we might pass ?revId=item.id to view specific revision, but detail page defaults to latest. + // Edit/View link goes to the DOCUMENT detail (correspondence.uuid) + // Ideally we might pass ?revId=item.uuid to view specific revision, but detail page defaults to latest. // For editing, we edit the document. - const docId = item.correspondence.id; + const docUuid = item.correspondence.uuid; const statusCode = item.status?.statusCode; return (
- + @@ -89,7 +89,7 @@ export function CorrespondenceList({ data }: CorrespondenceListProps) { {statusCode === "DRAFT" && ( - + diff --git a/frontend/components/drawings/card.tsx b/frontend/components/drawings/card.tsx index d894b3c..39dff14 100644 --- a/frontend/components/drawings/card.tsx +++ b/frontend/components/drawings/card.tsx @@ -58,7 +58,7 @@ export function DrawingCard({ drawing }: { drawing: Drawing }) {
- +