690514:2019 204-rfa-approval-refactor #01
CI / CD Pipeline / build (push) Successful in 6m1s
CI / CD Pipeline / deploy (push) Failing after 6m42s

This commit is contained in:
2026-05-14 20:19:21 +07:00
parent 07cc6d47b1
commit 0240d80da5
183 changed files with 20050 additions and 1017 deletions
+3
View File
@@ -32,6 +32,7 @@ examples
# Test artifacts # Test artifacts
coverage coverage
**/coverage
**/*.spec.ts **/*.spec.ts
**/*.test.ts **/*.test.ts
**/*.test.tsx **/*.test.tsx
@@ -60,4 +61,6 @@ scripts
Thumbs.db Thumbs.db
# Environment files # Environment files
**/.env
**/.env.*
**/.env.local **/.env.local
+3
View File
@@ -3,4 +3,7 @@ backend/node_modules/
frontend/node_modules/ frontend/node_modules/
dist dist
build build
coverage
.next
out
*.min.js *.min.js
+2 -1
View File
@@ -33,6 +33,7 @@
"github.copilot", "github.copilot",
"bierner.markdown-mermaid", "bierner.markdown-mermaid",
"vitest.explorer", "vitest.explorer",
"google.geminicodeassist" "google.geminicodeassist",
"openai.chatgpt"
] ]
} }
+2 -1
View File
@@ -1,4 +1,5 @@
{ {
"editor.fontSize": 16, "editor.fontSize": 16,
"npm.packageManager": "pnpm" "npm.packageManager": "pnpm",
"chatgpt.runCodexInWindowsSubsystemForLinux": true
} }
+20 -20
View File
@@ -1,9 +1,9 @@
# NAP-DMS Project Context & Rules # NAP-DMS Project Context & Rules
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools) - For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
- Version: 1.9.1 | Last synced from repo: 2026-05-13 - Version: 1.9.2 | Last synced from repo: 2026-05-14
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3) - Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
- Skill pack: `.agents/skills/` (v1.9.1, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md) - Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
--- ---
@@ -66,7 +66,7 @@ If significant logic changes are made, summarize what was done for the user afte
- **UUID Validation:** ทุกครั้งที่มีการรับค่า ID จาก API หรือ URL ต้องตรวจสอบว่าเป็น **UUIDv7** และห้ามใช้ `parseInt()` หรือตัวดำเนินการทางคณิตศาสตร์กับค่านี้เด็ดขาด (ADR-019) - **UUID Validation:** ทุกครั้งที่มีการรับค่า ID จาก API หรือ URL ต้องตรวจสอบว่าเป็น **UUIDv7** และห้ามใช้ `parseInt()` หรือตัวดำเนินการทางคณิตศาสตร์กับค่านี้เด็ดขาด (ADR-019)
- **RBAC Check:** การสร้าง API ใหม่ต้องมี **CASL Guard** และตรวจสอบสิทธิ์แบบ 4-Level RBAC Matrix เสมอ (ADR-016) - **RBAC Check:** การสร้าง API ใหม่ต้องมี **CASL Guard** และตรวจสอบสิทธิ์แบบ 4-Level RBAC Matrix เสมอ (ADR-016)
- **Data Isolation:** หากมีการใช้ฟีเจอร์ AI ต้องมั่นใจว่ารันผ่าน **Ollama บน Admin Desktop** เท่านั้น และห้ามให้ AI เข้าถึง Database หรือ Storage โดยตรง (ต้องผ่าน DMS API เท่านั้น) (ADR-018) - **Data Isolation:** หากมีการใช้ฟีเจอร์ AI ต้องมั่นใจว่ารันผ่าน **Ollama บน Admin Desktop** เท่านั้น และห้ามให้ AI เข้าถึง Database หรือ Storage โดยตรง (ต้องผ่าน DMS API เท่านั้น) (ADR-023)
- **Input Sanitization:** ไฟล์อัปโหลดต้องผ่านการตรวจสอบแบบ **Two-Phase** (Temp → Commit) และต้องสแกนด้วย **ClamAV** ก่อนย้ายเข้า Permanent Storage (ADR-016) - **Input Sanitization:** ไฟล์อัปโหลดต้องผ่านการตรวจสอบแบบ **Two-Phase** (Temp → Commit) และต้องสแกนด้วย **ClamAV** ก่อนย้ายเข้า Permanent Storage (ADR-016)
--- ---
@@ -81,7 +81,7 @@ Build fails immediately if violated:
- UUID Strategy (ADR-019) — no `parseInt` / `Number` / `+` on UUID - UUID Strategy (ADR-019) — no `parseInt` / `Number` / `+` on UUID
- Database correctness — verify schema before writing queries - Database correctness — verify schema before writing queries
- File upload security (ClamAV + whitelist) - File upload security (ClamAV + whitelist)
- AI validation boundary (ADR-018) - AI validation boundary (ADR-023)
- Error handling strategy (ADR-007) - Error handling strategy (ADR-007)
- Forbidden patterns: `any`, `console.log`, UUID misuse, `id ?? ''` fallback - Forbidden patterns: `any`, `console.log`, UUID misuse, `id ?? ''` fallback
@@ -122,10 +122,9 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
| **ADR-008 Notifications** | `specs/06-Decision-Records/ADR-008-email-notification-strategy.md` | ✅ Active | BullMQ + multi-channel notification | | **ADR-008 Notifications** | `specs/06-Decision-Records/ADR-008-email-notification-strategy.md` | ✅ Active | BullMQ + multi-channel notification |
| **ADR-009 DB Migration** | `specs/06-Decision-Records/ADR-009-database-migration-strategy.md` | ✅ Active | Schema changes — edit SQL directly | | **ADR-009 DB Migration** | `specs/06-Decision-Records/ADR-009-database-migration-strategy.md` | ✅ Active | Schema changes — edit SQL directly |
| **ADR-016 Security** | `specs/06-Decision-Records/ADR-016-security-authentication.md` | ✅ Active | Auth, RBAC, file upload security | | **ADR-016 Security** | `specs/06-Decision-Records/ADR-016-security-authentication.md` | ✅ Active | Auth, RBAC, file upload security |
| **ADR-018 AI Boundary** | `specs/06-Decision-Records/ADR-018-ai-boundary.md` | ✅ Active | AI isolation rules |
| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work | | **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work |
| **ADR-020 AI Integration** | `specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md` | ✅ Active | AI architecture patterns |
| **ADR-021 Workflow Context** | `specs/06-Decision-Records/ADR-021-workflow-context.md` | ✅ Active | Integrated workflow & step attachments | | **ADR-021 Workflow Context** | `specs/06-Decision-Records/ADR-021-workflow-context.md` | ✅ Active | Integrated workflow & step attachments |
| **ADR-023 AI Architecture** | `specs/06-Decision-Records/ADR-023-unified-ai-architecture.md` | ✅ Active | Unified AI boundaries and pipeline |
| **Backend Guidelines** | `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` | — | NestJS patterns | | **Backend Guidelines** | `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` | — | NestJS patterns |
| **Frontend Guidelines** | `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` | — | Next.js patterns | | **Frontend Guidelines** | `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` | — | Next.js patterns |
| **Testing Strategy** | `specs/05-Engineering-Guidelines/05-04-testing-strategy.md` | — | Coverage goals | | **Testing Strategy** | `specs/05-Engineering-Guidelines/05-04-testing-strategy.md` | — | Coverage goals |
@@ -248,9 +247,9 @@ Read `specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md`
5. **Password:** bcrypt 12 salt rounds, min 8 chars, rotate every 90 days 5. **Password:** bcrypt 12 salt rounds, min 8 chars, rotate every 90 days
6. **Rate Limiting:** `ThrottlerGuard` on all auth endpoints 6. **Rate Limiting:** `ThrottlerGuard` on all auth endpoints
7. **File Upload:** Whitelist PDF/DWG/DOCX/XLSX/ZIP, max 50MB, ClamAV scan 7. **File Upload:** Whitelist PDF/DWG/DOCX/XLSX/ZIP, max 50MB, ClamAV scan
8. **AI Isolation (ADR-018):** Ollama on Admin Desktop ONLY — NO direct DB/storage access 8. **AI Isolation (ADR-023):** Ollama on Admin Desktop ONLY — NO direct DB/storage access
9. **Error Handling (ADR-007):** Use layered error classification with user-friendly messages 9. **Error Handling (ADR-007):** Use layered error classification with user-friendly messages
10. **AI Integration (ADR-020):** RFA-First approach with unified pipeline architecture 10. **AI Integration (ADR-023):** RFA-First approach with unified pipeline architecture
Full details: `specs/06-Decision-Records/ADR-016-security-authentication.md` Full details: `specs/06-Decision-Records/ADR-016-security-authentication.md`
@@ -308,12 +307,12 @@ Full glossary: `specs/00-overview/00-02-glossary.md`
| `req: any` in controllers | `RequestWithUser` typed interface | Type safety lost; auth context unreachable | | `req: any` in controllers | `RequestWithUser` typed interface | Type safety lost; auth context unreachable |
| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | `"019505…"` parsed to integer `19` — silently wrong | | `parseInt()` on UUID values | Use UUID string directly (ADR-019) | `"019505…"` parsed to integer `19` — silently wrong |
| Exposing INT PK in API responses | UUIDv7 `publicId` (ADR-019) | Leaks row count; enables DB enumeration attacks | | Exposing INT PK in API responses | UUIDv7 `publicId` (ADR-019) | Leaks row count; enables DB enumeration attacks |
| AI accessing DB/storage directly | AI → DMS API → DB (ADR-018) | Bypasses RBAC, audit trail, and validation layer | | AI accessing DB/storage directly | AI → DMS API → DB (ADR-023) | Bypasses RBAC, audit trail, and validation layer |
| Direct file operations bypassing StorageService | `StorageService` for all file moves | Orphaned files; broken ClamAV scan; no audit trail | | Direct file operations bypassing StorageService | `StorageService` for all file moves | Orphaned files; broken ClamAV scan; no audit trail |
| Inline email/notification sending | BullMQ queue job (ADR-008) | Blocks request thread; no retry on transient failure | | Inline email/notification sending | BullMQ queue job (ADR-008) | Blocks request thread; no retry on transient failure |
| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | Unverified deploy risks data loss in production | | Deploying without Release Gates | Complete `04-08-release-management-policy.md` | Unverified deploy risks data loss in production |
| AI direct cloud API calls | On-premises Ollama only (ADR-018) | Data privacy violation; no audit control | | AI direct cloud API calls | On-premises Ollama only (ADR-023) | Data privacy violation; no audit control |
| AI outputs without human validation | Human-in-the-loop validation required (ADR-020) | Unvalidated AI metadata corrupts document records | | AI outputs without human validation | Human-in-the-loop validation required (ADR-023) | Unvalidated AI metadata corrupts document records |
--- ---
@@ -347,7 +346,7 @@ The following actions MUST NOT be performed autonomously. **Stop and ask for con
3. **Check schema** — verify table/column in `schema-02-tables.sql` 3. **Check schema** — verify table/column in `schema-02-tables.sql`
4. **Check data dictionary** — confirm field meanings + business rules 4. **Check data dictionary** — confirm field meanings + business rules
5. **Scan edge cases**`01-06-edge-cases-and-rules.md` 5. **Scan edge cases**`01-06-edge-cases-and-rules.md`
6. **Check ADRs** — verify decisions align (ADR-009, ADR-018, ADR-019) 6. **Check ADRs** — verify decisions align (ADR-009, ADR-019, ADR-023)
7. **Write code** — TypeScript strict, no `any`, no `console.log`, follow headers/JSDoc rules 7. **Write code** — TypeScript strict, no `any`, no `console.log`, follow headers/JSDoc rules
### 🟡 Normal Work — UI / Feature / Integration ### 🟡 Normal Work — UI / Feature / Integration
@@ -408,25 +407,25 @@ When user asks about... check these files:
| Request | Files to Check | Expected Response | | Request | Files to Check | Expected Response |
| ----------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------- | | ----------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| "สร้าง API ใหม่" | `05-02-backend-guidelines.md`, `schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard | | "สร้าง API ใหม่" | `05-02-backend-guidelines.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard |
| "แก้ฟอร์ม frontend" | `05-03-frontend-guidelines.md`, `01-06-edge-cases.md` | RHF+Zod + TanStack Query + Thai comments | | "แก้ฟอร์ม frontend" | `05-03-frontend-guidelines.md`, `01-06-edge-cases-and-rules.md` | RHF+Zod + TanStack Query + Thai comments |
| "เพิ่ม field ใหม่" | `ADR-009`, `data-dictionary.md`, `schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity | | "เพิ่ม field ใหม่" | `ADR-009`, `03-01-data-dictionary.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity |
| "ตรวจสอบ UUID" | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor | | "ตรวจสอบ UUID" | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor |
| "สร้าง migration" | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow | | "สร้าง migration" | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow |
| "ตรวจสอบ permission" | `seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix | | "ตรวจสอบ permission" | `lcbp3-v1.9.0-seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix |
| "deploy production" | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy | | "deploy production" | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy |
| "เพิ่ม test" | `05-04-testing-strategy.md` | Coverage goals + test patterns | | "เพิ่ม test" | `05-04-testing-strategy.md` | Coverage goals + test patterns |
| "AI integration" | `ADR-018`, `ADR-020` | AI boundary + unified pipeline | | "AI integration" | `ADR-023` | AI boundary + unified pipeline |
| "Error handling" | `ADR-007` | Layered error classification + recovery | | "Error handling" | `ADR-007` | Layered error classification + recovery |
| "File upload" | `ADR-016`, `05-02-backend-guidelines.md`, `03-Data-and-Storage/03-03-file-storage.md` | Two-phase upload → temp → commit; ClamAV + whitelist | | "File upload" | `ADR-016`, `05-02-backend-guidelines.md`, `03-Data-and-Storage/03-03-file-storage.md` | Two-phase upload → temp → commit; ClamAV + whitelist |
| "Notifications / Queue" | `ADR-008`, `05-02-backend-guidelines.md` | BullMQ job — never inline; check retry + dead-letter | | "Notifications / Queue" | `ADR-008`, `05-02-backend-guidelines.md` | BullMQ job — never inline; check retry + dead-letter |
| "Add i18n / translate" | `05-08-i18n-guidelines.md` | i18n keys only — no hardcoded text | | "Add i18n / translate" | `05-08-i18n-guidelines.md` | i18n keys only — no hardcoded text |
| "Workflow / DSL" | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService | | "Workflow / DSL" | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService |
| "Document numbering" | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) | | "Document numbering" | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) |
| "ตรวจสอบ Workflow" | `01-06-edge-cases.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร | | "ตรวจสอบ Workflow" | `01-06-edge-cases-and-rules.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร |
| "Transmittal submit" | ADR-021, TransmittalService | submit() with EC-RFA-004 validation | | "Transmittal submit" | ADR-021, TransmittalService | submit() with EC-RFA-004 validation |
| "Circulation reassign" | ADR-021, CirculationService | reassignRouting() with EC-CIRC-001 | | "Circulation reassign" | ADR-021, CirculationService | reassignRouting() with EC-CIRC-001 |
| "Audit ความปลอดภัย" | `ADR-016`, `ADR-018`, `ADR-019` | ตรวจสอบ UUID pattern, CASL Guard และ AI Boundary | | "Audit ความปลอดภัย" | `ADR-016`, `ADR-019`, `ADR-023` | ตรวจสอบ UUID pattern, CASL Guard และ AI Boundary |
| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน | | "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
## 🛠️ Final Checklist (Tier 1 & Tier 2) ## 🛠️ Final Checklist (Tier 1 & Tier 2)
@@ -441,7 +440,7 @@ When user asks about... check these files:
- [ ] **One main export per file** - [ ] **One main export per file**
- [ ] Schema changes via SQL directly (not migration) - [ ] Schema changes via SQL directly (not migration)
- [ ] Test coverage meets targets (Backend 70%+, Business Logic 80%+) - [ ] Test coverage meets targets (Backend 70%+, Business Logic 80%+)
- [ ] Relevant ADRs checked (ADR-007, ADR-009, ADR-018, ADR-019, ADR-020) - [ ] Relevant ADRs checked (ADR-007, ADR-009, ADR-019, ADR-021, ADR-023)
- [ ] Glossary terms used correctly - [ ] Glossary terms used correctly
- [ ] Error handling complete (Logger + HttpException) - [ ] Error handling complete (Logger + HttpException)
- [ ] i18n keys used instead of hardcode text - [ ] i18n keys used instead of hardcode text
@@ -485,6 +484,7 @@ This file is a **quick reference**. For detailed information:
| Version | Date | Changes | Updated By | | Version | Date | Changes | Updated By |
| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | | ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| 1.9.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI |
| 1.9.1 | 2026-05-13 | Added `bugfix` workflow and skill (migrated and improved from `docs/bugfix.md`) | Windsurf AI | | 1.9.1 | 2026-05-13 | Added `bugfix` workflow and skill (migrated and improved from `docs/bugfix.md`) | Windsurf AI |
| 1.9.0 | 2026-05-03 | Integrated Global TypeScript Coding Standards (Headers, JSDoc, Thai comments, Single Export, No blank lines) | Windsurf AI | | 1.9.0 | 2026-05-03 | Integrated Global TypeScript Coding Standards (Headers, JSDoc, Thai comments, Single Export, No blank lines) | Windsurf AI |
| 1.8.9 | 2026-04-22 | `.agents/skills/` LCBP3-native rebuild (20 skills @ v1.8.9) + `_LCBP3-CONTEXT.md` appendix + `specs/03-Data-and-Storage/deltas/` + AGENTS.md sync | Windsurf AI | | 1.8.9 | 2026-04-22 | `.agents/skills/` LCBP3-native rebuild (20 skills @ v1.8.9) + `_LCBP3-CONTEXT.md` appendix + `specs/03-Data-and-Storage/deltas/` + AGENTS.md sync | Windsurf AI |
+15
View File
@@ -0,0 +1,15 @@
# Architecture
## Current Structure
- `backend/`: NestJS application with shared `CommonModule`, TypeORM entities, BullMQ queue integration, and feature modules such as `rfa`, `review-team`, `response-code`, `delegation`, `reminder`, and `distribution`.
- `frontend/`: Next.js dashboard application with route groups under `app`, shared components under `components`, and feature hooks under `hooks`.
- `specs/`: Core specifications plus categorized feature work. The active RFA approval refactor lives in `specs/200-fullstacks/204-rfa-approval-refactor`.
## RFA Approval Refactor
- `review-team`: review team CRUD, member assignment, review task creation, aggregate status, consensus, and veto override.
- `response-code`: response code lookup, matrix rules, implications, and notification triggering.
- `delegation`, `reminder`, `distribution`: supporting modules for proxy assignment, scheduled reminders, and post-approval distribution. Delegation resolution is applied during parallel review task creation while preserving `delegatedFromUserId` for audit/display.
- `distribution`: schema-aligned Distribution Matrix CRUD, BullMQ queueing, approval listener integration, draft Transmittal creation, and `/distribution-matrices` admin UI.
- Queue names are centralized in `backend/src/modules/common/constants/queue.constants.ts`.
+22 -2
View File
@@ -1,17 +1,37 @@
# Version History # Version History
## 1.9.1 (2026-05-14)
### docs(architecture): Unified AI Architecture Consolidation (ADR-023)
#### Summary
ยุบรวมข้อกำหนดและสถาปัตยกรรมด้าน AI ที่กระจัดกระจาย (ADR-017, ADR-017B, ADR-018, ADR-020 และ ADR-022) เข้าสู่เอกสารหลักฉบับเดียวคือ **ADR-023: Unified AI Architecture** เพื่อเป็น Single Source of Truth และป้องกันปัญหา Revision Drift
#### Changes
- **Master AI Spec**: สร้างเอกสาร `ADR-023-unified-ai-architecture.md` ควบคุมสถาปัตยกรรม AI ทั้งหมดของระบบ
- **Superseded Legacy ADRs**: เปลี่ยนสถานะของ ADR-017, ADR-017B, ADR-018, ADR-020 และ ADR-022 เป็น `Superseded by ADR-023`
- **Zero Trust Boundary**: ย้ำเกณฑ์การแยกส่วน AI Workloads (Ollama, Qdrant, n8n) ให้อยู่บนเครื่อง Admin Desktop (`Desk-5439`) เท่านั้น และห้ามเข้าถึง DB/Storage โดยตรง (ต้องผ่าน DMS API Gateway)
- **Documentation Synced**: ปรับปรุงเอกสารอ้างอิงทั้งหมดใน `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md` และ `AGENTS.md` ให้ชี้มาที่ ADR-023
## 1.9.0 (2026-05-13) ## 1.9.0 (2026-05-13)
### feat(agent): Agent Infrastructure Standardization (v1.9.0) ### feat(migration): RFA System & Agent Infrastructure Standardization (v1.9.0)
#### Summary #### Summary
สร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Windsurf/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต
การปรับปรุงระบบ RFA Approval ให้สมบูรณ์พร้อมใช้งานจริง และสร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Windsurf/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต
#### Changes #### Changes
- **RFA System Migration**: อัปเดต Schema (v1.9.0), ปรับปรุง Data Dictionary และขยายสิทธิ์ใน RBAC Matrix (Lead Engineer role)
- **Workflow Context**: เชื่อมต่อ ADR-021 (Integrated Workflow Context) เข้ากับทุกกระบวนการ Workflow
- **Agent Infrastructure**: ย้ายและรวบรวม Rules, Skills และ Workflows ไว้ที่ `.agents/` เป็น Single Source of Truth - **Agent Infrastructure**: ย้ายและรวบรวม Rules, Skills และ Workflows ไว้ที่ `.agents/` เป็น Single Source of Truth
- **Hybrid Specs Structure**: เริ่มใช้โครงสร้างโฟลเดอร์ `specs/[100/200/300]-category/` เพื่อจัดระเบียบงาน Infra, Fullstack และงานทั่วไป - **Hybrid Specs Structure**: เริ่มใช้โครงสร้างโฟลเดอร์ `specs/[100/200/300]-category/` เพื่อจัดระเบียบงาน Infra, Fullstack และงานทั่วไป
- **Automation**: เพิ่มสคริปต์ `sync-agent-configs.ps1` และ `audit-skills.sh` เพื่อตรวจสอบความสมบูรณ์และซิงค์ข้อมูลอัตโนมัติ - **Automation**: เพิ่มสคริปต์ `sync-agent-configs.ps1` และ `audit-skills.sh` เพื่อตรวจสอบความสมบูรณ์และซิงค์ข้อมูลอัตโนมัติ
- **Standardization**: กำหนดมาตรฐานการเขียนโค้ดใหม่ (File Headers, Change Logs, Thai JSDoc) ทั่วทั้งโครงการ - **Standardization**: กำหนดมาตรฐานการเขียนโค้ดใหม่ (File Headers, Change Logs, Thai JSDoc) ทั่วทั้งโครงการ
- **Node.js v24 Environment**: ปรับปรุงสภาพแวดล้อมให้เป็น Node.js v24.15.0 LTS ทั้งหมด
- **Drift Prevention**: ใช้ Directory Junctions เชื่อมโยง `.windsurf/` เข้ากับ `.agents/` โดยตรง - **Drift Prevention**: ใช้ Directory Junctions เชื่อมโยง `.windsurf/` เข้ากับ `.agents/` โดยตรง
## 1.8.11 (2026-05-05) ## 1.8.11 (2026-05-05)
+22 -20
View File
@@ -8,13 +8,13 @@
## 📚 Table of Contents ## 📚 Table of Contents
- [ภาพรวม Specification Structure](#-specification-structure) - [ภาพรวม Specification Structure](#specification-structure)
- [หลักการเขียน Specifications](#-writing-principles) - [หลักการเขียน Specifications](#writing-principles)
- [Workflow การแก้ไข Specs](#-contribution-workflow) - [Workflow การแก้ไข Specs](#contribution-workflow)
- [Template และ Guidelines](#-templates--guidelines) - [Template และ Guidelines](#templates--guidelines)
- [Review Process](#-review-process) - [Review Process](#review-process)
- [Best Practices](#-best-practices) - [Best Practices](#best-practices)
- [Tools และ Resources](#-tools--resources) - [Tools และ Resources](#tools--resources)
--- ---
@@ -50,16 +50,16 @@ specs/
│ ├── 02-03-network-design.md │ ├── 02-03-network-design.md
│ └── 02-04-api-design.md │ └── 02-04-api-design.md
├── 03-Data-and-Storage/ # Database Schema v1.8.0 (3-file split) ├── 03-Data-and-Storage/ # Database Schema v1.9.0 (3-file split)
│ ├── README.md │ ├── README.md
│ ├── lcbp3-v1.8.0-schema-01-drop.sql # DROP statements │ ├── lcbp3-v1.9.0-schema-01-drop.sql # DROP statements
│ ├── lcbp3-v1.8.0-schema-02-tables.sql # CREATE TABLE │ ├── lcbp3-v1.9.0-schema-02-tables.sql # CREATE TABLE
│ ├── lcbp3-v1.8.0-schema-03-views-indexes.sql # Views + Indexes │ ├── lcbp3-v1.9.0-schema-03-views-indexes.sql # Views + Indexes
│ ├── lcbp3-v1.8.0-seed-basic.sql # Master Data Seed │ ├── lcbp3-v1.9.0-seed-basic.sql # Master Data Seed
│ ├── lcbp3-v1.8.0-seed-permissions.sql # RBAC Permissions Seed │ ├── lcbp3-v1.9.0-seed-permissions.sql # RBAC Permissions Seed
│ ├── 03-01-data-dictionary.md │ ├── 03-01-data-dictionary.md
│ ├── 03-06-migration-business-scope.md # Gap 7: Migration Scope [★ NEW] │ ├── 03-06-migration-business-scope.md # Gap 7: Migration Scope [★ NEW]
│ └── deltas/ # Incremental SQL (ADR-009) [★ v1.8.9] │ └── deltas/ # Incremental SQL (ADR-009) [★ v1.9.0]
├── 04-Infrastructure-OPS/ # Deployment & Operations (9 docs) ├── 04-Infrastructure-OPS/ # Deployment & Operations (9 docs)
│ ├── README.md │ ├── README.md
@@ -79,7 +79,7 @@ specs/
│ ├── 05-03-frontend-guidelines.md │ ├── 05-03-frontend-guidelines.md
│ └── 05-04-testing-strategy.md │ └── 05-04-testing-strategy.md
├── 06-Decision-Records/ # Architecture Decision Records (22 ADRs) ├── 06-Decision-Records/ # Architecture Decision Records (23 ADRs)
│ ├── README.md │ ├── README.md
│ ├── ADR-001-unified-workflow-engine.md │ ├── ADR-001-unified-workflow-engine.md
│ └── ... │ └── ...
@@ -104,7 +104,7 @@ specs/
| **03-Data-and-Storage** | Schema v1.8.0, Migration Scope | Gap 7 | Backend Lead + DBA | | **03-Data-and-Storage** | Schema v1.8.0, Migration Scope | Gap 7 | Backend Lead + DBA |
| **04-Infrastructure-OPS** | Deployment, Operations, Release Policy | Gap 8 | DevOps Team | | **04-Infrastructure-OPS** | Deployment, Operations, Release Policy | Gap 8 | DevOps Team |
| **05-Engineering-Guidelines** | แผนการพัฒนาและ Implementation | — | Development Team Leads | | **05-Engineering-Guidelines** | แผนการพัฒนาและ Implementation | — | Development Team Leads |
| **06-Decision-Records** | Architecture Decision Records (22) | ADR-018/019/020/021 | Tech Lead + Senior Devs | | **06-Decision-Records** | Architecture Decision Records (23) | ADR-019/021/023 | Tech Lead + Senior Devs |
| **100-Infrastructures** | Infrastructure Operations & Ops | — | DevOps / SRE Team | | **100-Infrastructures** | Infrastructure Operations & Ops | — | DevOps / SRE Team |
| **200-fullstacks** | Feature Implementation (Fullstack) | spec.md, plan.md | Development Team | | **200-fullstacks** | Feature Implementation (Fullstack) | spec.md, plan.md | Development Team |
| **300-others** | Documentation & Research | — | All Team Members | | **300-others** | Documentation & Research | — | All Team Members |
@@ -555,12 +555,14 @@ graph LR
| 1.8.7 | 2026-04-14 | Tech Lead | ADR-021 integration complete (22 ADRs), workflow context features | | 1.8.7 | 2026-04-14 | Tech Lead | ADR-021 integration complete (22 ADRs), workflow context features |
| 1.8.8 | 2026-04-14 | Tech Lead | Step-specific attachments, IntegratedBanner, WorkflowLifecycle | | 1.8.8 | 2026-04-14 | Tech Lead | Step-specific attachments, IntegratedBanner, WorkflowLifecycle |
| 1.8.9 | 2026-04-18 | Tech Lead | Docker Compose hardening — 27 findings (C1S4) addressed | | 1.8.9 | 2026-04-18 | Tech Lead | Docker Compose hardening — 27 findings (C1S4) addressed |
| 1.9.0 | 2026-05-13 | Tech Lead | Agent Infrastructure standard & RFA System migration finalized |
| 1.9.1 | 2026-05-14 | Tech Lead | Consolidated AI master architecture into ADR-023 |
**Current Version**: 1.8.9 **Current Version**: 1.9.0
**Status**: Approved **Status**: Approved
**Last Updated**: 2026-04-18 **Last Updated**: 2026-05-13
**Security**: 0 vulnerabilities (backend) + Compose stack hardened (27 findings → 0) **Security**: 0 vulnerabilities (backend) + Compose stack hardened (27 findings → 0)
**Workflow Engine**: ADR-021 Integrated Context complete **Workflow Engine**: ADR-021 Integrated Context complete + RFA v1.9.0 finalized
``` ```
### 5. UUID Conventions (ADR-019) ### 5. UUID Conventions (ADR-019)
@@ -754,7 +756,7 @@ bash ./.agents/scripts/bash/audit-skills.sh
- **ADR-019 UUID**`publicId` exposed directly; ห้าม `parseInt`/`Number`/`+` บน UUID; ห้าม `id ?? ''` fallback; ห้ามใช้ `@Expose({ name: 'id' })` rename - **ADR-019 UUID**`publicId` exposed directly; ห้าม `parseInt`/`Number`/`+` บน UUID; ห้าม `id ?? ''` fallback; ห้ามใช้ `@Expose({ name: 'id' })` rename
- **ADR-009 Schema** — แก้ `lcbp3-v1.8.0-schema-02-tables.sql` โดยตรง + เพิ่ม delta ที่ `specs/03-Data-and-Storage/deltas/`; ห้าม TypeORM migrations - **ADR-009 Schema** — แก้ `lcbp3-v1.8.0-schema-02-tables.sql` โดยตรง + เพิ่ม delta ที่ `specs/03-Data-and-Storage/deltas/`; ห้าม TypeORM migrations
- **ADR-016 Security** — CASL + `Idempotency-Key` + ClamAV two-phase upload - **ADR-016 Security** — CASL + `Idempotency-Key` + ClamAV two-phase upload
- **ADR-018/020 AI Boundary** — Ollama on Admin Desktop only; human-in-the-loop validation - **ADR-023 AI Architecture** — Ollama on Admin Desktop only; human-in-the-loop validation
--- ---
+10 -8
View File
@@ -12,9 +12,9 @@
## 📈 Current Status (As of 2026-05-13) ## 📈 Current Status (As of 2026-05-13)
**Version 1.9.0 — Universal Agent Standard & Hybrid Specs Structure** **Version 1.9.0 — RFA Migration & Universal Agent Infrastructure**
> v1.8.11 shipped May 5; v1.9.0 (Agent-Agnostic Infra & Hybrid Specs) shipped May 13. > v1.8.11 shipped May 5; v1.9.0 (RFA Migration & Agent-Agnostic Infra) shipped May 13.
| Area | Status | หมายเหตุ | | Area | Status | หมายเหตุ |
| ---------------------- | ------------------------ | ------------------------------------------------------------------ | | ---------------------- | ------------------------ | ------------------------------------------------------------------ |
@@ -22,7 +22,7 @@
| 🎨 **Frontend** | ✅ 100% Complete | Next.js 16.2.0, React 19.2.4, ESLint 9 | | 🎨 **Frontend** | ✅ 100% Complete | Next.js 16.2.0, React 19.2.4, ESLint 9 |
| 💾 **Database** | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration Policy | | 💾 **Database** | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration Policy |
| 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy (Categorized Feature Specs) | | 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy (Categorized Feature Specs) |
| 🤖 **AI Migration** | ✅ Production Ready | n8n + Ollama (ADR-017/018) | | 🤖 **AI Migration** | ✅ Production Ready | n8n + Ollama (ADR-023) |
| 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context | | 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context |
| 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready | | 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready |
| 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station | | 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station |
@@ -50,7 +50,7 @@ LCBP3-DMS เป็นระบบบริหารจัดการเอก
- 🔐 **RBAC 4-Level** - ควบคุมสิทธิ์แบบละเอียด (Global, Organization, Project, Contract) - 🔐 **RBAC 4-Level** - ควบคุมสิทธิ์แบบละเอียด (Global, Organization, Project, Contract)
- 📁 **Two-Phase File Storage** - จัดการไฟล์แบบ Transactional พร้อม Virus Scanning - 📁 **Two-Phase File Storage** - จัดการไฟล์แบบ Transactional พร้อม Virus Scanning
- 🔢 **Document Numbering** - สร้างเลขที่เอกสารอัตโนมัติ ป้องกัน Race Condition - 🔢 **Document Numbering** - สร้างเลขที่เอกสารอัตโนมัติ ป้องกัน Race Condition
- 🤖 **AI-Assisted Migration** - Ollama + n8n นำเข้าเอกสารเก่า ~20,000 ไฟล์ (ADR-017/018) - 🤖 **AI-Assisted Migration** - Ollama + n8n นำเข้าเอกสารเก่า ~20,000 ไฟล์ (ADR-023)
--- ---
@@ -325,7 +325,7 @@ lcbp3-dms/
| **Edge Cases & Rules** | 37 Edge Cases, Business Logic Guards | Gap 10 ✅ | `01-06-edge-cases-and-rules.md` | | **Edge Cases & Rules** | 37 Edge Cases, Business Logic Guards | Gap 10 ✅ | `01-06-edge-cases-and-rules.md` |
| **Schema v1.8.0** | Tables, Views, Indexes (3-file split) | — | `lcbp3-v1.8.0-schema-*.sql` | | **Schema v1.8.0** | Tables, Views, Indexes (3-file split) | — | `lcbp3-v1.8.0-schema-*.sql` |
| **Data Dictionary** | Field Meanings, Business Rules | — | `03-01-data-dictionary.md` | | **Data Dictionary** | Field Meanings, Business Rules | — | `03-01-data-dictionary.md` |
| **ADRs (22)** | All Architecture Decisions incl. ADR-003/004/007/018/019/020/021 | - | `06-Decision-Records/` | | **ADRs (23)** | All Architecture Decisions incl. ADR-003/004/007/019/021/023 | - | `06-Decision-Records/` |
--- ---
@@ -374,7 +374,7 @@ lcbp3-dms/
| 1 | [`AGENTS.md`](./AGENTS.md) | Quick-reference rules (Tier 1/2/3 enforcement, ADR-019 March 2026 pattern, forbidden actions) | | 1 | [`AGENTS.md`](./AGENTS.md) | Quick-reference rules (Tier 1/2/3 enforcement, ADR-019 March 2026 pattern, forbidden actions) |
| 2 | [`.agents/skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md) | Shared context appendix injected into every speckit-\* skill | | 2 | [`.agents/skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md) | Shared context appendix injected into every speckit-\* skill |
| 3 | [`.agents/skills/README.md`](./.agents/skills/README.md) | Skill-pack layout + slash-command invocation guide | | 3 | [`.agents/skills/README.md`](./.agents/skills/README.md) | Skill-pack layout + slash-command invocation guide |
| 4 | `specs/06-Decision-Records/` | 22 ADRs (architectural decisions) | | 4 | `specs/06-Decision-Records/` | 23 ADRs (architectural decisions) |
**Unified workflows (v1.9.0):** `/00-speckit.all``/102-speckit.specify``/104-speckit.plan``/107-speckit.implement``/110-speckit.reviewer` **Unified workflows (v1.9.0):** `/00-speckit.all``/102-speckit.specify``/104-speckit.plan``/107-speckit.implement``/110-speckit.reviewer`
@@ -382,15 +382,17 @@ lcbp3-dms/
## 🗺️ Roadmap ## 🗺️ Roadmap
### ✅ Version 1.9.0 (May 2026) — Universal Agent Standard & Hybrid Specs Structure ### ✅ Version 1.9.0 (May 2026) — RFA System & Agent Infrastructure Standardization
**Agent Infrastructure standardized (`.agents/` @ v1.9.0) — 2026-05-13:** **RFA System Migration & Agent Infrastructure standardized (`.agents/` @ v1.9.0) — 2026-05-13:**
- ✅ **RFA System**: Finalized RFA migration, schema v1.9.0, and RBAC matrix expansion.
- ✅ **Agent-Agnostic**: ย้าย Workflows และ Rules มาไว้ที่ `.agents/` เพื่อให้ใช้ร่วมกันได้ทุก AI - ✅ **Agent-Agnostic**: ย้าย Workflows และ Rules มาไว้ที่ `.agents/` เพื่อให้ใช้ร่วมกันได้ทุก AI
- ✅ **Hybrid Specs**: เริ่มใช้โครงสร้างโฟลเดอร์ 100/200/300 ใน `specs/` อย่างเป็นทางการ - ✅ **Hybrid Specs**: เริ่มใช้โครงสร้างโฟลเดอร์ 100/200/300 ใน `specs/` อย่างเป็นทางการ
- ✅ **Auto-Sync**: ระบบ Sync อัตโนมัติระหว่าง `.agents/` และ `.windsurf/` (Drift Prevention) - ✅ **Auto-Sync**: ระบบ Sync อัตโนมัติระหว่าง `.agents/` และ `.windsurf/` (Drift Prevention)
- ✅ **Audit Enhanced**: สคริปต์ตรวจสอบสุขภาพระบบรองรับการตรวจโครงสร้าง Specs folder - ✅ **Audit Enhanced**: สคริปต์ตรวจสอบสุขภาพระบบรองรับการตรวจโครงสร้าง Specs folder
- ✅ **TS Standards**: บังคับใช้ File Headers และ Change Logs ทั่วโครงการ - ✅ **TS Standards**: บังคับใช้ File Headers และ Change Logs ทั่วโครงการ
- ✅ **AI Architecture**: ยุบรวมสถาปัตยกรรม AI หลักเข้าสู่ ADR-023 (แทนที่ ADR-017, 017B, 018, 020, 022)
**Docker Compose stacks fully hardened — 27 findings across 4 phases:** **Docker Compose stacks fully hardened — 27 findings across 4 phases:**
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -54,7 +54,7 @@
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"async-retry": "^1.3.3", "async-retry": "^1.3.3",
"axios": "^1.15.0", "axios": "^1.15.2",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.63.2", "bullmq": "^5.63.2",
"cache-manager": "^7.2.5", "cache-manager": "^7.2.5",
@@ -81,7 +81,7 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.27", "typeorm": "^0.3.27",
"uuid": "^13.0.0", "uuid": "^13.0.1",
"winston": "^3.18.3", "winston": "^3.18.3",
"zod": "^4.1.13" "zod": "^4.1.13"
}, },
+17
View File
@@ -0,0 +1,17 @@
// File: src/config/bullmq.config.ts
// Change Log:
// - 2026-05-13: Add BullMQ config registry for reminder and distribution queues.
import { registerAs } from '@nestjs/config';
export default registerAs('bullmq', () => ({
prefix: process.env.BULLMQ_QUEUE_PREFIX || 'rfa',
reminderQueue: process.env.BULLMQ_REMINDER_QUEUE || 'rfa-reminders',
distributionQueue:
process.env.BULLMQ_DISTRIBUTION_QUEUE || 'rfa-distribution',
connection: {
host: process.env.REDIS_HOST || 'cache',
port: Number(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD || undefined,
},
}));
+5
View File
@@ -0,0 +1,5 @@
// File: src/config/redis.config.ts
// Change Log:
// - 2026-05-13: Add task-path config wrapper for Redis settings used by BullMQ and Redlock.
export { default } from '../common/config/redis.config';
@@ -11,6 +11,7 @@ import { Delegation } from './entities/delegation.entity';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity';
import { CircularDetectionService } from './services/circular-detection.service'; import { CircularDetectionService } from './services/circular-detection.service';
import { CreateDelegationDto } from './dto/create-delegation.dto'; import { CreateDelegationDto } from './dto/create-delegation.dto';
import { DelegationScope } from '../common/enums/review.enums';
@Injectable() @Injectable()
export class DelegationService { export class DelegationService {
@@ -63,6 +64,23 @@ export class DelegationService {
); );
} }
const delegateOnward = await this.findActiveDelegate(
delegate.user_id,
dto.startDate,
[
DelegationScope.ALL,
DelegationScope.RFA_ONLY,
DelegationScope.CORRESPONDENCE_ONLY,
DelegationScope.SPECIFIC_TYPES,
]
);
if (delegateOnward) {
throw new BadRequestException(
'Nested delegation is not allowed — delegatee already delegates onward'
);
}
const delegation = this.delegationRepo.create({ const delegation = this.delegationRepo.create({
delegatorUserId: delegator.user_id, delegatorUserId: delegator.user_id,
delegateUserId: delegate.user_id, delegateUserId: delegate.user_id,
@@ -98,7 +116,8 @@ export class DelegationService {
*/ */
async findActiveDelegate( async findActiveDelegate(
userId: number, userId: number,
date: Date = new Date() date: Date = new Date(),
scopes: DelegationScope[] = [DelegationScope.ALL]
): Promise<User | null> { ): Promise<User | null> {
const delegation = await this.delegationRepo const delegation = await this.delegationRepo
.createQueryBuilder('d') .createQueryBuilder('d')
@@ -107,6 +126,7 @@ export class DelegationService {
.andWhere('d.is_active = 1') .andWhere('d.is_active = 1')
.andWhere('d.start_date <= :date', { date }) .andWhere('d.start_date <= :date', { date })
.andWhere('d.end_date >= :date', { date }) .andWhere('d.end_date >= :date', { date })
.andWhere('d.scope IN (:...scopes)', { scopes })
.orderBy('d.created_at', 'DESC') .orderBy('d.created_at', 'DESC')
.getOne(); .getOne();
@@ -1,50 +1,62 @@
// File: src/modules/distribution/distribution-matrix.service.ts // File: src/modules/distribution/distribution-matrix.service.ts
// Change Log
// - 2026-05-14: Resolve public IDs internally and align CRUD with canonical schema.
// CRUD สำหรับ DistributionMatrix และ Recipients (T053) // CRUD สำหรับ DistributionMatrix และ Recipients (T053)
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { IsNull, Repository } from 'typeorm';
import { validate as uuidValidate } from 'uuid';
import { DistributionMatrix } from './entities/distribution-matrix.entity'; import { DistributionMatrix } from './entities/distribution-matrix.entity';
import { DistributionRecipient } from './entities/distribution-recipient.entity'; import { DistributionRecipient } from './entities/distribution-recipient.entity';
import { Project } from '../project/entities/project.entity'; import { Project } from '../project/entities/project.entity';
import { ResponseCode } from '../response-code/entities/response-code.entity';
export interface CreateDistributionMatrixDto { import { CreateDistributionMatrixDto } from './dto/create-distribution-matrix.dto';
projectId: number; import { UpdateDistributionMatrixDto } from './dto/update-distribution-matrix.dto';
documentTypeCode: string; import { AddDistributionRecipientDto } from './dto/add-distribution-recipient.dto';
responseCodeFilter?: string[];
}
export interface AddRecipientDto {
recipientType: string;
recipientId?: number;
roleCode?: string;
deliveryMethod?: string;
isCc?: boolean;
}
@Injectable() @Injectable()
export class DistributionMatrixService { export class DistributionMatrixService {
private readonly logger = new Logger(DistributionMatrixService.name);
constructor( constructor(
@InjectRepository(DistributionMatrix) @InjectRepository(DistributionMatrix)
private readonly matrixRepo: Repository<DistributionMatrix>, private readonly matrixRepo: Repository<DistributionMatrix>,
@InjectRepository(DistributionRecipient) @InjectRepository(DistributionRecipient)
private readonly recipientRepo: Repository<DistributionRecipient>, private readonly recipientRepo: Repository<DistributionRecipient>,
@InjectRepository(Project) @InjectRepository(Project)
private readonly projectRepo: Repository<Project> private readonly projectRepo: Repository<Project>,
@InjectRepository(ResponseCode)
private readonly responseCodeRepo: Repository<ResponseCode>
) {} ) {}
async findByProject(projectId: number): Promise<DistributionMatrix[]> { /**
* Matrix global defaults.
*/
async findByProject(projectId?: number): Promise<DistributionMatrix[]> {
return this.matrixRepo.find({ return this.matrixRepo.find({
where: { projectId, isActive: true }, where:
relations: ['recipients'], projectId === undefined
order: { documentTypeCode: 'ASC' }, ? { isActive: true }
: [
{ projectId, isActive: true },
{ projectId: IsNull(), isActive: true },
],
relations: ['recipients', 'responseCode'],
order: { documentTypeId: 'ASC', createdAt: 'DESC' },
}); });
} }
async findByProjectPublicId( async findByProjectPublicId(
projectPublicId: string projectPublicId?: string
): Promise<DistributionMatrix[]> { ): Promise<DistributionMatrix[]> {
if (!projectPublicId) return this.findByProject();
if (!uuidValidate(projectPublicId)) {
throw new BadRequestException(
`Invalid projectPublicId: ${projectPublicId}`
);
}
const project = await this.projectRepo.findOne({ const project = await this.projectRepo.findOne({
where: { publicId: projectPublicId }, where: { publicId: projectPublicId },
}); });
@@ -55,34 +67,64 @@ export class DistributionMatrixService {
async findOneByDocType( async findOneByDocType(
projectId: number, projectId: number,
documentTypeCode: string documentTypeId: number
): Promise<DistributionMatrix | null> { ): Promise<DistributionMatrix | null> {
return this.matrixRepo.findOne({ return this.matrixRepo.findOne({
where: { projectId, documentTypeCode, isActive: true }, where: [
relations: ['recipients'], { projectId, documentTypeId, isActive: true },
{ projectId: IsNull(), documentTypeId, isActive: true },
],
relations: ['recipients', 'responseCode'],
}); });
} }
async create(dto: CreateDistributionMatrixDto): Promise<DistributionMatrix> { async create(dto: CreateDistributionMatrixDto): Promise<DistributionMatrix> {
const matrix = this.matrixRepo.create(dto as Partial<DistributionMatrix>); const matrix = this.matrixRepo.create({
name: dto.name,
projectId: await this.resolveProjectId(dto.projectPublicId),
documentTypeId: dto.documentTypeId,
responseCodeId: await this.resolveResponseCodeId(
dto.responseCodePublicId
),
conditions: dto.conditions,
isActive: true,
});
return this.matrixRepo.save(matrix);
}
async update(
publicId: string,
dto: UpdateDistributionMatrixDto
): Promise<DistributionMatrix> {
const matrix = await this.findMatrix(publicId);
if (dto.name !== undefined) matrix.name = dto.name;
if (dto.documentTypeId !== undefined) {
matrix.documentTypeId = dto.documentTypeId;
}
if (dto.projectPublicId !== undefined) {
matrix.projectId = await this.resolveProjectId(dto.projectPublicId);
}
if (dto.responseCodePublicId !== undefined) {
matrix.responseCodeId = await this.resolveResponseCodeId(
dto.responseCodePublicId
);
}
if (dto.conditions !== undefined) matrix.conditions = dto.conditions;
return this.matrixRepo.save(matrix); return this.matrixRepo.save(matrix);
} }
async addRecipient( async addRecipient(
matrixPublicId: string, matrixPublicId: string,
dto: AddRecipientDto dto: AddDistributionRecipientDto
): Promise<DistributionRecipient> { ): Promise<DistributionRecipient> {
const matrix = await this.matrixRepo.findOne({ const matrix = await this.findMatrix(matrixPublicId);
where: { publicId: matrixPublicId },
});
if (!matrix)
throw new NotFoundException(`Matrix not found: ${matrixPublicId}`);
const recipient = this.recipientRepo.create({ const recipient = this.recipientRepo.create({
matrixId: matrix.id, matrixId: matrix.id,
...dto, recipientType: dto.recipientType,
} as Partial<DistributionRecipient>); recipientPublicId: dto.recipientPublicId,
deliveryMethod: dto.deliveryMethod,
sequence: dto.sequence,
});
return this.recipientRepo.save(recipient); return this.recipientRepo.save(recipient);
} }
@@ -95,9 +137,44 @@ export class DistributionMatrixService {
} }
async remove(publicId: string): Promise<void> { async remove(publicId: string): Promise<void> {
const matrix = await this.matrixRepo.findOne({ where: { publicId } }); const matrix = await this.findMatrix(publicId);
if (!matrix) throw new NotFoundException(publicId);
matrix.isActive = false; matrix.isActive = false;
await this.matrixRepo.save(matrix); await this.matrixRepo.save(matrix);
} }
private async findMatrix(publicId: string): Promise<DistributionMatrix> {
const matrix = await this.matrixRepo.findOne({ where: { publicId } });
if (!matrix) {
throw new NotFoundException(`Distribution Matrix not found: ${publicId}`);
}
return matrix;
}
private async resolveProjectId(
projectPublicId?: string
): Promise<number | undefined> {
if (!projectPublicId) return undefined;
const project = await this.projectRepo.findOne({
where: { publicId: projectPublicId },
});
if (!project) {
throw new NotFoundException(`Project not found: ${projectPublicId}`);
}
return project.id;
}
private async resolveResponseCodeId(
responseCodePublicId?: string
): Promise<number | undefined> {
if (!responseCodePublicId) return undefined;
const responseCode = await this.responseCodeRepo.findOne({
where: { publicId: responseCodePublicId },
});
if (!responseCode) {
throw new NotFoundException(
`Response Code not found: ${responseCodePublicId}`
);
}
return responseCode.id;
}
} }
@@ -1,4 +1,6 @@
// File: src/modules/distribution/distribution.controller.ts // File: src/modules/distribution/distribution.controller.ts
// Change Log
// - 2026-05-14: Add RBAC and validated public-ID DTOs for Distribution Matrix CRUD.
// Admin endpoints สำหรับจัดการ Distribution Matrix (T058) // Admin endpoints สำหรับจัดการ Distribution Matrix (T058)
import { import {
Controller, Controller,
@@ -9,57 +11,64 @@ import {
Param, Param,
Query, Query,
UseGuards, UseGuards,
Patch,
} from '@nestjs/common'; } from '@nestjs/common';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { DistributionMatrixService } from './distribution-matrix.service'; import { DistributionMatrixService } from './distribution-matrix.service';
import { CreateDistributionMatrixDto } from './dto/create-distribution-matrix.dto';
class CreateMatrixDto { import { AddDistributionRecipientDto } from './dto/add-distribution-recipient.dto';
projectId!: number; import { UpdateDistributionMatrixDto } from './dto/update-distribution-matrix.dto';
documentTypeCode!: string;
responseCodeFilter?: string[];
}
class AddRecipientDto {
recipientType!: string;
recipientId?: number;
roleCode?: string;
deliveryMethod?: string;
isCc?: boolean;
}
@Controller('admin/distribution-matrices') @Controller('admin/distribution-matrices')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, RbacGuard)
export class DistributionController { export class DistributionController {
constructor(private readonly matrixService: DistributionMatrixService) {} constructor(private readonly matrixService: DistributionMatrixService) {}
@Get() @Get()
findByProject( findByProject(@Query('projectPublicId') projectPublicId?: string) {
@Query('projectPublicId', ParseUuidPipe) projectPublicId: string
) {
return this.matrixService.findByProjectPublicId(projectPublicId); return this.matrixService.findByProjectPublicId(projectPublicId);
} }
@Post() @Post()
create(@Body() dto: CreateMatrixDto) { @RequirePermission('master_data.manage')
create(@Body() dto: CreateDistributionMatrixDto) {
return this.matrixService.create(dto); return this.matrixService.create(dto);
} }
@Patch(':publicId')
@RequirePermission('master_data.manage')
update(
@Param('publicId', ParseUuidPipe) publicId: string,
@Body() dto: UpdateDistributionMatrixDto
) {
return this.matrixService.update(publicId, dto);
}
@Post(':publicId/recipients') @Post(':publicId/recipients')
@RequirePermission('master_data.manage')
addRecipient( addRecipient(
@Param('publicId') publicId: string, @Param('publicId', ParseUuidPipe) publicId: string,
@Body() dto: AddRecipientDto @Body() dto: AddDistributionRecipientDto
) { ) {
return this.matrixService.addRecipient(publicId, dto); return this.matrixService.addRecipient(publicId, dto);
} }
@Delete(':publicId/recipients/:recipientPublicId') @Delete(':publicId/recipients/:recipientPublicId')
removeRecipient(@Param('recipientPublicId') recipientPublicId: string) { @RequirePermission('master_data.manage')
return this.matrixService.removeRecipient(recipientPublicId); async removeRecipient(
@Param('recipientPublicId', ParseUuidPipe) recipientPublicId: string
) {
await this.matrixService.removeRecipient(recipientPublicId);
return { success: true };
} }
@Delete(':publicId') @Delete(':publicId')
remove(@Param('publicId') publicId: string) { @RequirePermission('master_data.manage')
return this.matrixService.remove(publicId); async remove(@Param('publicId', ParseUuidPipe) publicId: string) {
await this.matrixService.remove(publicId);
return { success: true };
} }
} }
@@ -1,4 +1,6 @@
// File: src/modules/distribution/distribution.module.ts // File: src/modules/distribution/distribution.module.ts
// Change Log
// - 2026-05-14: Register ResponseCode repository for Distribution Matrix publicId resolution.
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
@@ -13,6 +15,8 @@ import { TransmittalCreatorService } from './services/transmittal-creator.servic
import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants'; import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants';
import { NotificationModule } from '../notification/notification.module'; import { NotificationModule } from '../notification/notification.module';
import { Project } from '../project/entities/project.entity'; import { Project } from '../project/entities/project.entity';
import { ResponseCode } from '../response-code/entities/response-code.entity';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
@Module({ @Module({
imports: [ imports: [
@@ -20,9 +24,11 @@ import { Project } from '../project/entities/project.entity';
DistributionMatrix, DistributionMatrix,
DistributionRecipient, DistributionRecipient,
Project, Project,
ResponseCode,
]), ]),
BullModule.registerQueue({ name: QUEUE_DISTRIBUTION }), BullModule.registerQueue({ name: QUEUE_DISTRIBUTION }),
NotificationModule, NotificationModule,
DocumentNumberingModule,
], ],
providers: [ providers: [
DistributionService, DistributionService,
@@ -1,4 +1,6 @@
// File: src/modules/distribution/distribution.service.ts // File: src/modules/distribution/distribution.service.ts
// Change Log
// - 2026-05-14: Carry canonical documentTypeId in queue payload while preserving legacy code metadata.
// Enqueue distribution jobs เมื่อ RFA ได้รับการอนุมัติ (T054) // Enqueue distribution jobs เมื่อ RFA ได้รับการอนุมัติ (T054)
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
@@ -9,7 +11,8 @@ export interface DistributionJobPayload {
rfaPublicId: string; rfaPublicId: string;
rfaRevisionPublicId: string; rfaRevisionPublicId: string;
projectId: number; projectId: number;
documentTypeCode: string; documentTypeId?: number;
documentTypeCode?: string;
responseCode: string; responseCode: string;
approvedAt: Date; approvedAt: Date;
} }
@@ -0,0 +1,21 @@
// File: src/modules/distribution/dto/add-distribution-recipient.dto.ts
// Change Log
// - 2026-05-14: Add validated DTO for polymorphic Distribution recipients.
import { IsEnum, IsInt, IsOptional, IsUUID } from 'class-validator';
import { DeliveryMethod, RecipientType } from '../../common/enums/review.enums';
export class AddDistributionRecipientDto {
@IsEnum(RecipientType)
recipientType!: RecipientType;
@IsUUID()
recipientPublicId!: string;
@IsOptional()
@IsEnum(DeliveryMethod)
deliveryMethod?: DeliveryMethod;
@IsOptional()
@IsInt()
sequence?: number;
}
@@ -0,0 +1,44 @@
// File: src/modules/distribution/dto/create-distribution-matrix.dto.ts
// Change Log
// - 2026-05-14: Add validated DTO for Distribution Matrix creation.
import {
IsInt,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
class DistributionConditionsDto {
@IsOptional()
@IsString({ each: true })
codes?: string[];
@IsOptional()
@IsString({ each: true })
excludeCodes?: string[];
}
export class CreateDistributionMatrixDto {
@IsString()
@MaxLength(100)
name!: string;
@IsOptional()
@IsUUID()
projectPublicId?: string;
@IsInt()
documentTypeId!: number;
@IsOptional()
@IsUUID()
responseCodePublicId?: string;
@IsOptional()
@ValidateNested()
@Type(() => DistributionConditionsDto)
conditions?: DistributionConditionsDto;
}
@@ -0,0 +1,9 @@
// File: src/modules/distribution/dto/update-distribution-matrix.dto.ts
// Change Log
// - 2026-05-14: Add validated DTO for Distribution Matrix updates.
import { PartialType } from '@nestjs/swagger';
import { CreateDistributionMatrixDto } from './create-distribution-matrix.dto';
export class UpdateDistributionMatrixDto extends PartialType(
CreateDistributionMatrixDto
) {}
@@ -1,10 +1,11 @@
// File: src/modules/distribution/entities/distribution-matrix.entity.ts // File: src/modules/distribution/entities/distribution-matrix.entity.ts
// Change Log
// - 2026-05-14: Align columns with canonical v1.9.0 schema and ADR-019 publicId contract.
import { import {
Entity, Entity,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn,
OneToMany, OneToMany,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
@@ -13,6 +14,12 @@ import { Exclude } from 'class-transformer';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { DistributionRecipient } from './distribution-recipient.entity'; import { DistributionRecipient } from './distribution-recipient.entity';
import { Project } from '../../project/entities/project.entity'; import { Project } from '../../project/entities/project.entity';
import { ResponseCode } from '../../response-code/entities/response-code.entity';
export interface DistributionConditions {
codes?: string[];
excludeCodes?: string[];
}
@Entity('distribution_matrices') @Entity('distribution_matrices')
export class DistributionMatrix extends UuidBaseEntity { export class DistributionMatrix extends UuidBaseEntity {
@@ -20,19 +27,23 @@ export class DistributionMatrix extends UuidBaseEntity {
@Exclude() @Exclude()
id!: number; id!: number;
@Column({ name: 'project_id' }) @Column({ length: 100 })
name!: string;
@Column({ name: 'project_id', nullable: true })
@Exclude() @Exclude()
projectId!: number; projectId?: number;
@Column({ name: 'document_type_code', length: 20 }) @Column({ name: 'document_type_id' })
documentTypeCode!: string; // 'SDW', 'DDW', 'ADW', 'MS'... @Exclude()
documentTypeId!: number;
@Column({ @Column({ name: 'response_code_id', nullable: true })
name: 'response_code_filter', @Exclude()
type: 'simple-array', responseCodeId?: number;
nullable: true,
}) @Column({ type: 'json', nullable: true })
responseCodeFilter?: string[]; // ['1A','1B'] — NULL = ทุก code conditions?: DistributionConditions;
@Column({ name: 'is_active', type: 'tinyint', default: 1 }) @Column({ name: 'is_active', type: 'tinyint', default: 1 })
isActive!: boolean; isActive!: boolean;
@@ -40,14 +51,14 @@ export class DistributionMatrix extends UuidBaseEntity {
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt!: Date; createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
// Relations
@ManyToOne(() => Project) @ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' }) @JoinColumn({ name: 'project_id' })
project?: Project; project?: Project;
@ManyToOne(() => ResponseCode)
@JoinColumn({ name: 'response_code_id' })
responseCode?: ResponseCode;
@OneToMany( @OneToMany(
() => DistributionRecipient, () => DistributionRecipient,
(r: DistributionRecipient) => r.matrix, (r: DistributionRecipient) => r.matrix,
@@ -1,4 +1,6 @@
// File: src/modules/distribution/entities/distribution-recipient.entity.ts // File: src/modules/distribution/entities/distribution-recipient.entity.ts
// Change Log
// - 2026-05-14: Store polymorphic recipient public IDs instead of internal numeric IDs.
import { import {
Entity, Entity,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
@@ -23,32 +25,29 @@ export class DistributionRecipient extends UuidBaseEntity {
matrixId!: number; matrixId!: number;
@Column({ @Column({
name: 'recipient_type',
type: 'enum', type: 'enum',
enum: RecipientType, enum: RecipientType,
}) })
recipientType!: RecipientType; recipientType!: RecipientType;
@Column({ name: 'recipient_id', nullable: true }) @Column({ name: 'recipient_public_id', type: 'uuid' })
@Exclude() recipientPublicId!: string;
recipientId?: number; // userId / organizationId / teamId (FK based on type)
@Column({ name: 'role_code', length: 50, nullable: true })
roleCode?: string; // 'ALL_QS', 'ALL_SITE_ENG' (when type = ROLE)
@Column({ @Column({
name: 'delivery_method',
type: 'enum', type: 'enum',
enum: DeliveryMethod, enum: DeliveryMethod,
default: DeliveryMethod.BOTH, default: DeliveryMethod.BOTH,
}) })
deliveryMethod!: DeliveryMethod; deliveryMethod!: DeliveryMethod;
@Column({ name: 'is_cc', type: 'tinyint', default: 0 }) @Column({ nullable: true })
isCc!: boolean; // true = CC recipient, false = primary sequence?: number;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt!: Date; createdAt!: Date;
// Relations
@ManyToOne( @ManyToOne(
() => DistributionMatrix, () => DistributionMatrix,
(m: DistributionMatrix) => m.recipients, (m: DistributionMatrix) => m.recipients,
@@ -1,4 +1,6 @@
// File: src/modules/distribution/processors/distribution.processor.ts // File: src/modules/distribution/processors/distribution.processor.ts
// Change Log
// - 2026-05-14: Notify direct USER recipients after Distribution processing.
// BullMQ Worker สำหรับประมวลผล Distribution jobs (T056, ADR-008) // BullMQ Worker สำหรับประมวลผล Distribution jobs (T056, ADR-008)
import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@@ -7,6 +9,7 @@ import { QUEUE_DISTRIBUTION } from '../../common/constants/queue.constants';
import { DistributionJobPayload } from '../distribution.service'; import { DistributionJobPayload } from '../distribution.service';
import { TransmittalCreatorService } from '../services/transmittal-creator.service'; import { TransmittalCreatorService } from '../services/transmittal-creator.service';
import { NotificationService } from '../../notification/notification.service'; import { NotificationService } from '../../notification/notification.service';
import { DeliveryMethod } from '../../common/enums/review.enums';
@Processor(QUEUE_DISTRIBUTION) @Processor(QUEUE_DISTRIBUTION)
export class DistributionProcessor extends WorkerHost { export class DistributionProcessor extends WorkerHost {
@@ -23,7 +26,7 @@ export class DistributionProcessor extends WorkerHost {
const payload = job.data; const payload = job.data;
this.logger.log( this.logger.log(
`Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeCode}, code ${payload.responseCode})` `Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeId ?? payload.documentTypeCode}, code ${payload.responseCode})`
); );
// 1. สร้าง Transmittal records // 1. สร้าง Transmittal records
@@ -34,7 +37,33 @@ export class DistributionProcessor extends WorkerHost {
`Created ${result.transmittalPublicIds.length} transmittals for RFA ${payload.rfaPublicId}` `Created ${result.transmittalPublicIds.length} transmittals for RFA ${payload.rfaPublicId}`
); );
// 2. แจ้งเตือน submitter await Promise.all(
result.notificationTargets.flatMap((target) => {
const base = {
userId: target.userId,
title: `RFA ${payload.responseCode} distributed`,
message: `RFA ${payload.rfaPublicId} has been distributed after approval.`,
entityType: 'rfa',
link: `/rfa/${payload.rfaPublicId}`,
};
if (target.deliveryMethod === DeliveryMethod.BOTH) {
return [
this.notificationService.send({ ...base, type: 'SYSTEM' }),
this.notificationService.send({ ...base, type: 'EMAIL' }),
];
}
return [
this.notificationService.send({
...base,
type:
target.deliveryMethod === DeliveryMethod.EMAIL
? 'EMAIL'
: 'SYSTEM',
}),
];
})
);
this.logger.log(`Distribution complete for RFA ${payload.rfaPublicId}`); this.logger.log(`Distribution complete for RFA ${payload.rfaPublicId}`);
} }
} }
@@ -1,4 +1,6 @@
// File: src/modules/distribution/services/approval-listener.service.ts // File: src/modules/distribution/services/approval-listener.service.ts
// Change Log
// - 2026-05-14: Accept canonical documentTypeId in approval events.
// Strangler Pattern — listens for RFA approval events and triggers distribution (T055) // Strangler Pattern — listens for RFA approval events and triggers distribution (T055)
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
@@ -24,7 +26,8 @@ export class ApprovalListenerService {
rfaPublicId: string; rfaPublicId: string;
rfaRevisionPublicId: string; rfaRevisionPublicId: string;
projectId: number; projectId: number;
documentTypeCode: string; documentTypeId?: number;
documentTypeCode?: string;
responseCode: string; responseCode: string;
decision: ConsensusDecision; decision: ConsensusDecision;
approvedAt: Date; approvedAt: Date;
@@ -45,6 +48,7 @@ export class ApprovalListenerService {
rfaPublicId: event.rfaPublicId, rfaPublicId: event.rfaPublicId,
rfaRevisionPublicId: event.rfaRevisionPublicId, rfaRevisionPublicId: event.rfaRevisionPublicId,
projectId: event.projectId, projectId: event.projectId,
documentTypeId: event.documentTypeId,
documentTypeCode: event.documentTypeCode, documentTypeCode: event.documentTypeCode,
responseCode: event.responseCode, responseCode: event.responseCode,
approvedAt: event.approvedAt, approvedAt: event.approvedAt,
@@ -1,9 +1,33 @@
// File: src/modules/distribution/services/transmittal-creator.service.ts // File: src/modules/distribution/services/transmittal-creator.service.ts
// Change Log
// - 2026-05-14: Use schema-aligned Matrix conditions and canonical documentTypeId lookup.
// สร้าง Transmittal records จาก Distribution jobs (T057) // สร้าง Transmittal records จาก Distribution jobs (T057)
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DataSource, IsNull, Repository } from 'typeorm';
import { DistributionMatrix } from '../entities/distribution-matrix.entity'; import { DistributionMatrix } from '../entities/distribution-matrix.entity';
import { DistributionRecipient } from '../entities/distribution-recipient.entity';
import { DeliveryMethod, RecipientType } from '../../common/enums/review.enums';
import { CorrespondenceRevision } from '../../correspondence/entities/correspondence-revision.entity';
import { Correspondence } from '../../correspondence/entities/correspondence.entity';
import { CorrespondenceType } from '../../correspondence/entities/correspondence-type.entity';
import { CorrespondenceStatus } from '../../correspondence/entities/correspondence-status.entity';
import { CorrespondenceRecipient } from '../../correspondence/entities/correspondence-recipient.entity';
import { Transmittal } from '../../transmittal/entities/transmittal.entity';
import { TransmittalItem } from '../../transmittal/entities/transmittal-item.entity';
import { DocumentNumberingService } from '../../document-numbering/services/document-numbering.service';
import { Organization } from '../../organization/entities/organization.entity';
import { User } from '../../user/entities/user.entity';
export interface DistributionNotificationTarget {
userId: number;
deliveryMethod: DeliveryMethod;
}
export interface DistributionCreationResult {
transmittalPublicIds: string[];
notificationTargets: DistributionNotificationTarget[];
}
/** /**
* TransmittalCreatorService Strangler Pattern TransmittalService * TransmittalCreatorService Strangler Pattern TransmittalService
@@ -15,7 +39,9 @@ export class TransmittalCreatorService {
constructor( constructor(
@InjectRepository(DistributionMatrix) @InjectRepository(DistributionMatrix)
private readonly matrixRepo: Repository<DistributionMatrix> private readonly matrixRepo: Repository<DistributionMatrix>,
private readonly dataSource: DataSource,
private readonly numberingService: DocumentNumberingService
) {} ) {}
/** /**
@@ -26,44 +52,271 @@ export class TransmittalCreatorService {
rfaPublicId: string; rfaPublicId: string;
rfaRevisionPublicId: string; rfaRevisionPublicId: string;
projectId: number; projectId: number;
documentTypeCode: string; documentTypeId?: number;
documentTypeCode?: string;
responseCode: string; responseCode: string;
}): Promise<{ transmittalPublicIds: string[] }> { }): Promise<DistributionCreationResult> {
if (!payload.documentTypeId) {
this.logger.warn(
`Distribution skipped for RFA ${payload.rfaPublicId}: documentTypeId missing`
);
return { transmittalPublicIds: [], notificationTargets: [] };
}
const matrix = await this.matrixRepo.findOne({ const matrix = await this.matrixRepo.findOne({
where: { where: [
projectId: payload.projectId, {
documentTypeCode: payload.documentTypeCode, projectId: payload.projectId,
isActive: true, documentTypeId: payload.documentTypeId,
}, isActive: true,
},
{
projectId: IsNull(),
documentTypeId: payload.documentTypeId,
isActive: true,
},
],
relations: ['recipients'], relations: ['recipients'],
}); });
if (!matrix || !matrix.recipients || matrix.recipients.length === 0) { if (!matrix || !matrix.recipients || matrix.recipients.length === 0) {
this.logger.log( this.logger.log(
`No distribution matrix found for project ${payload.projectId}, docType ${payload.documentTypeCode}` `No distribution matrix found for project ${payload.projectId}, docType ${payload.documentTypeId}`
); );
return { transmittalPublicIds: [] }; return { transmittalPublicIds: [], notificationTargets: [] };
} }
// ตรวจสอบ response code filter // ตรวจสอบ response code filter
if ( if (
matrix.responseCodeFilter && matrix.conditions?.codes &&
matrix.responseCodeFilter.length > 0 && matrix.conditions.codes.length > 0 &&
!matrix.responseCodeFilter.includes(payload.responseCode) !matrix.conditions.codes.includes(payload.responseCode)
) { ) {
this.logger.log( this.logger.log(
`Response code ${payload.responseCode} not in filter — skipping distribution` `Response code ${payload.responseCode} not in filter — skipping distribution`
); );
return { transmittalPublicIds: [] }; return { transmittalPublicIds: [], notificationTargets: [] };
}
if (matrix.conditions?.excludeCodes?.includes(payload.responseCode)) {
this.logger.log(
`Response code ${payload.responseCode} is excluded — skipping distribution`
);
return { transmittalPublicIds: [], notificationTargets: [] };
}
const sourceRevision = await this.dataSource.manager.findOne(
CorrespondenceRevision,
{
where: { publicId: payload.rfaRevisionPublicId },
relations: ['correspondence'],
}
);
if (!sourceRevision?.correspondence) {
this.logger.warn(
`Distribution skipped for RFA ${payload.rfaPublicId}: source revision not found`
);
return { transmittalPublicIds: [], notificationTargets: [] };
}
const recipientOrganizationIds = await this.resolveRecipientOrganizations(
matrix.recipients
);
const notificationTargets = await this.resolveNotificationTargets(
matrix.recipients
);
if (recipientOrganizationIds.length === 0) {
this.logger.warn(
`Distribution skipped for RFA ${payload.rfaPublicId}: no organization recipients resolved`
);
return { transmittalPublicIds: [], notificationTargets };
} }
this.logger.log( this.logger.log(
`Creating Transmittal for RFA ${payload.rfaPublicId}${matrix.recipients.length} recipients` `Creating Transmittal for RFA ${payload.rfaPublicId}${recipientOrganizationIds.length} recipient organizations`
); );
// TODO: เรียก TransmittalService.create() เมื่อ integrate ใน Sprint ถัดไป const transmittalPublicIds: string[] = [];
// return transmittalService.createDraft({ rfaPublicId, recipients }); for (const recipientOrganizationId of recipientOrganizationIds) {
const existingPublicId = await this.findExistingTransmittalPublicId(
sourceRevision.correspondence.id,
recipientOrganizationId
);
if (existingPublicId) {
transmittalPublicIds.push(existingPublicId);
continue;
}
const createdPublicId = await this.createDraftTransmittal({
sourceCorrespondence: sourceRevision.correspondence,
recipientOrganizationId,
payload,
});
if (createdPublicId) transmittalPublicIds.push(createdPublicId);
}
return { transmittalPublicIds, notificationTargets };
}
return { transmittalPublicIds: [] }; private async resolveNotificationTargets(
recipients: DistributionRecipient[]
): Promise<DistributionNotificationTarget[]> {
const targets = new Map<number, DistributionNotificationTarget>();
for (const recipient of recipients) {
if (recipient.recipientType !== RecipientType.USER) continue;
const user = await this.dataSource.manager.findOne(User, {
where: { publicId: recipient.recipientPublicId },
});
if (!user) continue;
targets.set(user.user_id, {
userId: user.user_id,
deliveryMethod: recipient.deliveryMethod,
});
}
return Array.from(targets.values());
}
private async resolveRecipientOrganizations(
recipients: DistributionRecipient[]
): Promise<number[]> {
const organizationIds = new Set<number>();
for (const recipient of recipients) {
const organizationId =
await this.resolveRecipientOrganizationId(recipient);
if (organizationId) organizationIds.add(organizationId);
}
return Array.from(organizationIds);
}
private async resolveRecipientOrganizationId(
recipient: DistributionRecipient
): Promise<number | undefined> {
if (recipient.deliveryMethod === DeliveryMethod.IN_APP) return undefined;
if (recipient.recipientType === RecipientType.ORGANIZATION) {
const organization = await this.dataSource.manager.findOne(Organization, {
where: { publicId: recipient.recipientPublicId },
});
return organization?.id;
}
if (recipient.recipientType === RecipientType.USER) {
const user = await this.dataSource.manager.findOne(User, {
where: { publicId: recipient.recipientPublicId },
});
return user?.primaryOrganizationId;
}
this.logger.warn(
`Recipient type ${recipient.recipientType} requires expansion and was skipped for Transmittal creation`
);
return undefined;
}
private async findExistingTransmittalPublicId(
sourceCorrespondenceId: number,
recipientOrganizationId: number
): Promise<string | undefined> {
const rows = await this.dataSource.query<Array<{ publicId: string }>>(
`
SELECT c.uuid AS publicId
FROM transmittals t
INNER JOIN correspondences c ON c.id = t.correspondence_id
INNER JOIN transmittal_items ti ON ti.transmittal_id = t.correspondence_id
INNER JOIN correspondence_recipients cr ON cr.correspondence_id = t.correspondence_id
WHERE ti.item_correspondence_id = ?
AND cr.recipient_organization_id = ?
LIMIT 1
`,
[sourceCorrespondenceId, recipientOrganizationId]
);
return rows[0]?.publicId;
}
private async createDraftTransmittal(context: {
sourceCorrespondence: Correspondence;
recipientOrganizationId: number;
payload: {
rfaPublicId: string;
projectId: number;
responseCode: string;
};
}): Promise<string | undefined> {
const type = await this.dataSource.manager.findOne(CorrespondenceType, {
where: { typeCode: 'TRN' },
});
const status = await this.dataSource.manager.findOne(CorrespondenceStatus, {
where: { statusCode: 'DRAFT' },
});
if (!type || !status || !context.sourceCorrespondence.originatorId) {
this.logger.warn(
`Distribution skipped for RFA ${context.payload.rfaPublicId}: missing Transmittal master data or originator`
);
return undefined;
}
const docNumber = await this.numberingService.generateNextNumber({
projectId: context.payload.projectId,
originatorOrganizationId: context.sourceCorrespondence.originatorId,
recipientOrganizationId: context.recipientOrganizationId,
typeId: type.id,
year: new Date().getFullYear(),
customTokens: {
TYPE_CODE: type.typeCode,
RESPONSE_CODE: context.payload.responseCode,
},
});
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const correspondence = queryRunner.manager.create(Correspondence, {
correspondenceNumber: docNumber.number,
correspondenceTypeId: type.id,
projectId: context.payload.projectId,
originatorId: context.sourceCorrespondence.originatorId,
isInternal: false,
});
const savedCorrespondence =
await queryRunner.manager.save(correspondence);
const revision = queryRunner.manager.create(CorrespondenceRevision, {
correspondenceId: savedCorrespondence.id,
revisionNumber: 0,
revisionLabel: '0',
isCurrent: true,
statusId: status.id,
subject: `Distribution for ${context.sourceCorrespondence.correspondenceNumber}`,
details: {
sourceRfaPublicId: context.payload.rfaPublicId,
},
});
await queryRunner.manager.save(revision);
const recipient = queryRunner.manager.create(CorrespondenceRecipient, {
correspondenceId: savedCorrespondence.id,
recipientOrganizationId: context.recipientOrganizationId,
recipientType: 'TO',
});
await queryRunner.manager.save(recipient);
const transmittal = queryRunner.manager.create(Transmittal, {
correspondenceId: savedCorrespondence.id,
purpose: 'FOR_INFORMATION',
remarks: `Auto-distributed from RFA ${context.payload.rfaPublicId}`,
});
await queryRunner.manager.save(transmittal);
const item = queryRunner.manager.create(TransmittalItem, {
transmittalId: savedCorrespondence.id,
itemCorrespondenceId: context.sourceCorrespondence.id,
quantity: 1,
remarks: `RFA response code ${context.payload.responseCode}`,
});
await queryRunner.manager.save(item);
await queryRunner.commitTransaction();
return savedCorrespondence.publicId;
} catch (error: unknown) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
} }
} }
@@ -14,6 +14,10 @@ export class CreateReminderRuleDto {
@IsInt() @IsInt()
projectId?: number; projectId?: number;
@IsString()
@MaxLength(100)
name!: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(20) @MaxLength(20)
@@ -33,4 +37,8 @@ export class CreateReminderRuleDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
notifyRoles?: string[]; notifyRoles?: string[];
@IsOptional()
@IsString()
messageTemplate?: string;
} }
@@ -0,0 +1,50 @@
// File: src/modules/reminder/entities/reminder-history.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Exclude } from 'class-transformer';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
import { ReviewTask } from '../../review-team/entities/review-task.entity';
import { User } from '../../user/entities/user.entity';
import { ReminderType } from '../../common/enums/review.enums';
@Entity('reminder_histories')
export class ReminderHistory extends UuidBaseEntity {
@PrimaryGeneratedColumn()
@Exclude()
id!: number;
@Column({ name: 'task_id' })
@Exclude()
taskId!: number;
@Column({ name: 'user_id' })
@Exclude()
userId!: number;
@Column({
type: 'enum',
enum: ReminderType,
})
reminderType!: ReminderType;
@Column({ name: 'escalation_level', type: 'tinyint', default: 0 })
escalationLevel!: number;
@CreateDateColumn({ name: 'sent_at' })
sentAt!: Date;
// Relations
@ManyToOne(() => ReviewTask)
@JoinColumn({ name: 'task_id' })
task?: ReviewTask;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user?: User;
}
@@ -20,6 +20,9 @@ export class ReminderRule extends UuidBaseEntity {
@Exclude() @Exclude()
projectId?: number; // NULL = global rule projectId?: number; // NULL = global rule
@Column({ length: 100 })
name!: string;
@Column({ name: 'document_type_code', length: 20, nullable: true }) @Column({ name: 'document_type_code', length: 20, nullable: true })
documentTypeCode?: string; // 'SDW', 'DDW' — NULL = all types documentTypeCode?: string; // 'SDW', 'DDW' — NULL = all types
@@ -38,6 +41,9 @@ export class ReminderRule extends UuidBaseEntity {
@Column({ name: 'notify_roles', type: 'simple-array', nullable: true }) @Column({ name: 'notify_roles', type: 'simple-array', nullable: true })
notifyRoles?: string[]; // เช่น ['TASK_ASSIGNEE', 'TEAM_LEAD', 'PROJECT_MANAGER'] notifyRoles?: string[]; // เช่น ['TASK_ASSIGNEE', 'TEAM_LEAD', 'PROJECT_MANAGER']
@Column({ name: 'message_template', type: 'text', nullable: true })
messageTemplate?: string;
@Column({ name: 'is_active', type: 'tinyint', default: 1 }) @Column({ name: 'is_active', type: 'tinyint', default: 1 })
isActive!: boolean; isActive!: boolean;
@@ -3,11 +3,14 @@
import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Job } from 'bullmq'; import { Job } from 'bullmq';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants'; import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
import { ReminderType } from '../../common/enums/review.enums'; import { ReminderType } from '../../common/enums/review.enums';
import { EscalationService } from '../services/escalation.service'; import { EscalationService } from '../services/escalation.service';
import { NotificationService } from '../../notification/notification.service'; import { NotificationService } from '../../notification/notification.service';
import { ScheduleReminderPayload } from '../services/scheduler.service'; import { ScheduleReminderPayload } from '../services/scheduler.service';
import { ReviewTask } from '../../review-team/entities/review-task.entity';
@Processor(QUEUE_REMINDERS) @Processor(QUEUE_REMINDERS)
export class ReminderProcessor extends WorkerHost { export class ReminderProcessor extends WorkerHost {
@@ -15,7 +18,9 @@ export class ReminderProcessor extends WorkerHost {
constructor( constructor(
private readonly escalationService: EscalationService, private readonly escalationService: EscalationService,
private readonly notificationService: NotificationService private readonly notificationService: NotificationService,
@InjectRepository(ReviewTask)
private readonly taskRepo: Repository<ReviewTask>
) { ) {
super(); super();
} }
@@ -27,17 +32,28 @@ export class ReminderProcessor extends WorkerHost {
`Processing reminder job: ${reminderType} for task ${taskPublicId}` `Processing reminder job: ${reminderType} for task ${taskPublicId}`
); );
// ดึง internal ID ของ task
const task = await this.taskRepo.findOne({
where: { publicId: taskPublicId },
select: ['id', 'assignedToUserId'],
});
if (!task) {
this.logger.warn(`Task ${taskPublicId} not found — skipping reminder`);
return;
}
switch (reminderType) { switch (reminderType) {
case ReminderType.DUE_SOON: case ReminderType.DUE_SOON:
await this.notificationService.send({ await this.notificationService.send({
userId: assigneeUserId, userId: assigneeUserId,
title: '⏰ Review Task Due Soon', title: '⏰ Review Task Due Soon',
message: message: 'Your review task is due soon. Please complete your review.',
'Your review task is due in 2 days. Please complete your review.',
type: 'SYSTEM', type: 'SYSTEM',
entityType: 'review_task', entityType: 'review_task',
entityId: taskPublicId as unknown as number, entityId: task.id,
}); });
await this.escalationService.recordHistory(task, reminderType, 0);
break; break;
case ReminderType.ON_DUE: case ReminderType.ON_DUE:
@@ -48,8 +64,9 @@ export class ReminderProcessor extends WorkerHost {
'Your review task is due today. Please complete it as soon as possible.', 'Your review task is due today. Please complete it as soon as possible.',
type: 'SYSTEM', type: 'SYSTEM',
entityType: 'review_task', entityType: 'review_task',
entityId: taskPublicId as unknown as number, entityId: task.id,
}); });
await this.escalationService.recordHistory(task, reminderType, 0);
break; break;
case ReminderType.OVERDUE: case ReminderType.OVERDUE:
@@ -60,8 +77,9 @@ export class ReminderProcessor extends WorkerHost {
'Your review task is overdue. Escalation will occur if not completed.', 'Your review task is overdue. Escalation will occur if not completed.',
type: 'SYSTEM', type: 'SYSTEM',
entityType: 'review_task', entityType: 'review_task',
entityId: taskPublicId as unknown as number, entityId: task.id,
}); });
await this.escalationService.recordHistory(task, reminderType, 0);
break; break;
case ReminderType.ESCALATION_L1: case ReminderType.ESCALATION_L1:
@@ -25,6 +25,11 @@ export class ReminderController {
return this.reminderService.findAllByProjectPublicId(projectPublicId); return this.reminderService.findAllByProjectPublicId(projectPublicId);
} }
@Get('history/:taskPublicId')
getHistory(@Param('taskPublicId') taskPublicId: string) {
return this.reminderService.findHistoryByTaskPublicId(taskPublicId);
}
@Get(':publicId') @Get(':publicId')
findOne(@Param('publicId') publicId: string) { findOne(@Param('publicId') publicId: string) {
return this.reminderService.findOne(publicId); return this.reminderService.findOne(publicId);
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
import { ReminderRule } from './entities/reminder-rule.entity'; import { ReminderRule } from './entities/reminder-rule.entity';
import { ReminderHistory } from './entities/reminder-history.entity';
import { ReviewTask } from '../review-team/entities/review-task.entity'; import { ReviewTask } from '../review-team/entities/review-task.entity';
import { ReminderService } from './reminder.service'; import { ReminderService } from './reminder.service';
import { ReminderController } from './reminder.controller'; import { ReminderController } from './reminder.controller';
@@ -15,7 +16,12 @@ import { Project } from '../project/entities/project.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ReminderRule, ReviewTask, Project]), TypeOrmModule.forFeature([
ReminderRule,
ReminderHistory,
ReviewTask,
Project,
]),
BullModule.registerQueue({ name: QUEUE_REMINDERS }), BullModule.registerQueue({ name: QUEUE_REMINDERS }),
NotificationModule, NotificationModule,
], ],
@@ -10,8 +10,10 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { validate as uuidValidate } from 'uuid'; import { validate as uuidValidate } from 'uuid';
import { ReminderRule } from './entities/reminder-rule.entity'; import { ReminderRule } from './entities/reminder-rule.entity';
import { ReminderHistory } from './entities/reminder-history.entity';
import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto'; import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto';
import { Project } from '../project/entities/project.entity'; import { Project } from '../project/entities/project.entity';
import { ReviewTask } from '../review-team/entities/review-task.entity';
export { CreateReminderRuleDto }; export { CreateReminderRuleDto };
@@ -22,8 +24,12 @@ export class ReminderService {
constructor( constructor(
@InjectRepository(ReminderRule) @InjectRepository(ReminderRule)
private readonly ruleRepo: Repository<ReminderRule>, private readonly ruleRepo: Repository<ReminderRule>,
@InjectRepository(ReminderHistory)
private readonly historyRepo: Repository<ReminderHistory>,
@InjectRepository(Project) @InjectRepository(Project)
private readonly projectRepo: Repository<Project> private readonly projectRepo: Repository<Project>,
@InjectRepository(ReviewTask)
private readonly taskRepo: Repository<ReviewTask>
) {} ) {}
async findAll(projectId?: number): Promise<ReminderRule[]> { async findAll(projectId?: number): Promise<ReminderRule[]> {
@@ -58,6 +64,21 @@ export class ReminderService {
return rule; return rule;
} }
async findHistoryByTaskPublicId(
taskPublicId: string
): Promise<ReminderHistory[]> {
const task = await this.taskRepo.findOne({
where: { publicId: taskPublicId },
});
if (!task) throw new NotFoundException('Task', taskPublicId);
return this.historyRepo.find({
where: { taskId: task.id },
relations: ['user'],
order: { sentAt: 'DESC' },
});
}
async create(dto: CreateReminderRuleDto): Promise<ReminderRule> { async create(dto: CreateReminderRuleDto): Promise<ReminderRule> {
const rule = this.ruleRepo.create(dto as Partial<ReminderRule>); const rule = this.ruleRepo.create(dto as Partial<ReminderRule>);
return this.ruleRepo.save(rule); return this.ruleRepo.save(rule);
@@ -4,9 +4,13 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm'; import { Repository, LessThan } from 'typeorm';
import { ReviewTask } from '../../review-team/entities/review-task.entity'; import { ReviewTask } from '../../review-team/entities/review-task.entity';
import { ReviewTaskStatus } from '../../common/enums/review.enums'; import {
ReviewTaskStatus,
ReminderType,
} from '../../common/enums/review.enums';
import { NotificationService } from '../../notification/notification.service'; import { NotificationService } from '../../notification/notification.service';
import { ReminderRule } from '../entities/reminder-rule.entity'; import { ReminderRule } from '../entities/reminder-rule.entity';
import { ReminderHistory } from '../entities/reminder-history.entity';
@Injectable() @Injectable()
export class EscalationService { export class EscalationService {
@@ -17,12 +21,40 @@ export class EscalationService {
private readonly reviewTaskRepo: Repository<ReviewTask>, private readonly reviewTaskRepo: Repository<ReviewTask>,
@InjectRepository(ReminderRule) @InjectRepository(ReminderRule)
private readonly reminderRuleRepo: Repository<ReminderRule>, private readonly reminderRuleRepo: Repository<ReminderRule>,
@InjectRepository(ReminderHistory)
private readonly historyRepo: Repository<ReminderHistory>,
private readonly notificationService: NotificationService private readonly notificationService: NotificationService
) {} ) {}
/**
* reminder (FR-018)
*/
async recordHistory(
task: ReviewTask,
type: ReminderType,
level: number
): Promise<void> {
const history = this.historyRepo.create({
taskId: task.id,
userId: task.assignedToUserId,
reminderType: type,
escalationLevel: level,
});
await this.historyRepo.save(history);
}
/**
* reminder level
*/
async getStrikeCount(taskId: number, level: number): Promise<number> {
return this.historyRepo.count({
where: { taskId, escalationLevel: level },
});
}
/** /**
* Escalation Level 1 (FR-015): Team Lead * Escalation Level 1 (FR-015): Team Lead
* task due date 1 * task due date
*/ */
async escalateLevel1(taskPublicId: string): Promise<void> { async escalateLevel1(taskPublicId: string): Promise<void> {
const task = await this.reviewTaskRepo.findOne({ const task = await this.reviewTaskRepo.findOne({
@@ -32,32 +64,35 @@ export class EscalationService {
if (!task || task.status === ReviewTaskStatus.COMPLETED) return; if (!task || task.status === ReviewTaskStatus.COMPLETED) return;
const daysOverdue = task.dueDate const strikes = await this.getStrikeCount(task.id, 1);
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000) if (strikes >= 3) {
: 0; this.logger.log(
`Task ${taskPublicId} L1 strikes reached 3 — moving to L2`
if (daysOverdue < 1) return; );
await this.escalateLevel2(taskPublicId);
return;
}
this.logger.log( this.logger.log(
`Escalation L1: task ${taskPublicId} is ${daysOverdue} days overdue` `Escalation L1 (Strike ${strikes + 1}): task ${taskPublicId}`
); );
// แจ้ง Team Lead
if (task.assignedToUserId) { if (task.assignedToUserId) {
await this.notificationService.send({ await this.notificationService.send({
userId: task.assignedToUserId, userId: task.assignedToUserId,
title: `⚠ Review Task Overdue (${daysOverdue}d)`, title: `⚠ Review Task Overdue (L1 Strike ${strikes + 1})`,
message: `Your review task is overdue by ${daysOverdue} day(s). Please complete it immediately.`, message: `Your review task is overdue. Please complete it immediately.`,
type: 'SYSTEM', type: 'SYSTEM',
entityType: 'review_task', entityType: 'review_task',
entityId: task.id, entityId: task.id,
}); });
await this.recordHistory(task, ReminderType.ESCALATION_L1, 1);
} }
} }
/** /**
* Escalation Level 2 (FR-016): Project Manager * Escalation Level 2 (FR-016): Project Manager
* task due date 3 * L1 3 SLA
*/ */
async escalateLevel2(taskPublicId: string): Promise<void> { async escalateLevel2(taskPublicId: string): Promise<void> {
const task = await this.reviewTaskRepo.findOne({ const task = await this.reviewTaskRepo.findOne({
@@ -67,20 +102,25 @@ export class EscalationService {
if (!task || task.status === ReviewTaskStatus.COMPLETED) return; if (!task || task.status === ReviewTaskStatus.COMPLETED) return;
const daysOverdue = task.dueDate const strikes = await this.getStrikeCount(task.id, 2);
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000)
: 0;
if (daysOverdue < 3) return;
this.logger.warn( this.logger.warn(
`Escalation L2: task ${taskPublicId} is ${daysOverdue} days overdue — escalating to PM` `Escalation L2 (Strike ${strikes + 1}): task ${taskPublicId} — escalating to PM`
); );
// TODO: ดึง PM user ID จาก project membership — ใช้ placeholder สำหรับตอนนี้ // TODO: ดึง PM user ID จาก project membership
this.logger.log( // สำหรับตอนนี้ แจ้งผู้รับผิดชอบเดิมแต่หัวเรื่องแรงขึ้น
`L2 escalation notification queued for task ${taskPublicId}` if (task.assignedToUserId) {
); await this.notificationService.send({
userId: task.assignedToUserId,
title: `🛑 CRITICAL: Review Task Overdue (L2 Strike ${strikes + 1})`,
message: `Your review task is critically overdue. Project Management has been notified.`,
type: 'SYSTEM',
entityType: 'review_task',
entityId: task.id,
});
await this.recordHistory(task, ReminderType.ESCALATION_L2, 2);
}
} }
/** /**
@@ -100,14 +140,23 @@ export class EscalationService {
this.logger.log(`Processing ${overdueTasks.length} overdue tasks`); this.logger.log(`Processing ${overdueTasks.length} overdue tasks`);
for (const task of overdueTasks) { for (const task of overdueTasks) {
const daysOverdue = task.dueDate // ดึง history ล่าสุดเพื่อดูว่าควร escalate level ไหน
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000) const lastHistory = await this.historyRepo.findOne({
: 0; where: { taskId: task.id },
order: { sentAt: 'DESC' },
});
if (daysOverdue >= 3) { if (!lastHistory || lastHistory.escalationLevel === 0) {
await this.escalateLevel2(task.publicId);
} else if (daysOverdue >= 1) {
await this.escalateLevel1(task.publicId); await this.escalateLevel1(task.publicId);
} else if (lastHistory.escalationLevel === 1) {
const strikes = await this.getStrikeCount(task.id, 1);
if (strikes >= 3) {
await this.escalateLevel2(task.publicId);
} else {
await this.escalateLevel1(task.publicId); // FIXED typo
}
} else if (lastHistory.escalationLevel === 2) {
await this.escalateLevel2(task.publicId); // Daily reminder for L2
} }
} }
} }
@@ -6,6 +6,9 @@ import { Queue } from 'bullmq';
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants'; import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
import type { Job } from 'bullmq'; import type { Job } from 'bullmq';
import { ReminderType } from '../../common/enums/review.enums'; import { ReminderType } from '../../common/enums/review.enums';
import { ReminderRule } from '../entities/reminder-rule.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
export interface ScheduleReminderPayload { export interface ScheduleReminderPayload {
taskPublicId: string; taskPublicId: string;
@@ -13,6 +16,8 @@ export interface ScheduleReminderPayload {
assigneeUserId: number; assigneeUserId: number;
dueDate: Date; dueDate: Date;
reminderType: ReminderType; reminderType: ReminderType;
projectId?: number;
documentTypeCode?: string;
} }
type ReminderJob = Job<ScheduleReminderPayload>; type ReminderJob = Job<ScheduleReminderPayload>;
@@ -23,64 +28,67 @@ export class SchedulerService {
constructor( constructor(
@InjectQueue(QUEUE_REMINDERS) @InjectQueue(QUEUE_REMINDERS)
private readonly reminderQueue: Queue private readonly reminderQueue: Queue,
@InjectRepository(ReminderRule)
private readonly ruleRepo: Repository<ReminderRule>
) {} ) {}
/** /**
* Schedule reminders Review Task (FR-013) * Schedule reminders Review Task (FR-013) ReminderRule
* TaskCreationService tasks * TaskCreationService tasks
*/ */
async scheduleForTask(payload: ScheduleReminderPayload): Promise<void> { async scheduleForTask(payload: ScheduleReminderPayload): Promise<void> {
const { taskPublicId, dueDate } = payload; const { taskPublicId, dueDate, projectId, documentTypeCode } = payload;
const now = Date.now(); const now = Date.now();
const remindersToSchedule: Array<{ type: ReminderType; delayMs: number }> = // ดึงกฎที่เกี่ยวข้อง (Global + Project specific)
[]; const rules = await this.ruleRepo.find({
where: [
// 2 วันก่อน due date { projectId, documentTypeCode, isActive: true },
const twoDaysBefore = dueDate.getTime() - 2 * 86_400_000; { projectId: undefined, documentTypeCode, isActive: true },
if (twoDaysBefore > now) { { projectId, documentTypeCode: undefined, isActive: true },
remindersToSchedule.push({ { projectId: undefined, documentTypeCode: undefined, isActive: true },
type: ReminderType.DUE_SOON, ],
delayMs: twoDaysBefore - now,
});
}
// วัน due date เอง
const onDue = dueDate.getTime();
if (onDue > now) {
remindersToSchedule.push({
type: ReminderType.ON_DUE,
delayMs: onDue - now,
});
}
// 1 วันหลัง due (Escalation L1)
const oneDayAfter = dueDate.getTime() + 1 * 86_400_000;
remindersToSchedule.push({
type: ReminderType.ESCALATION_L1,
delayMs: Math.max(oneDayAfter - now, 0),
}); });
// 3 วันหลัง due (Escalation L2) if (rules.length === 0) {
const threeDaysAfter = dueDate.getTime() + 3 * 86_400_000; this.logger.debug(`No reminder rules found for task ${taskPublicId}`);
remindersToSchedule.push({ return;
type: ReminderType.ESCALATION_L2, }
delayMs: Math.max(threeDaysAfter - now, 0),
});
await Promise.all( const jobs = [];
remindersToSchedule.map(({ type, delayMs }) =>
for (const rule of rules) {
const triggerTime =
dueDate.getTime() - rule.daysBeforeDue * 24 * 60 * 60 * 1000;
const delayMs = triggerTime - now;
// ถ้าเวลาผ่านไปแล้ว ไม่ต้อง schedule (ยกเว้น overdue ที่อาจจะต้องการส่งทันที)
if (delayMs <= 0 && rule.reminderType !== ReminderType.OVERDUE) {
continue;
}
jobs.push(
this.reminderQueue.add( this.reminderQueue.add(
'send-reminder', 'send-reminder',
{ ...payload, reminderType: type }, {
{ delay: delayMs, removeOnComplete: true, removeOnFail: 100 } ...payload,
reminderType: rule.reminderType,
},
{
delay: Math.max(delayMs, 0),
removeOnComplete: true,
removeOnFail: 100,
jobId: `${taskPublicId}-${rule.reminderType}-${rule.id}`, // ป้องกัน duplicate
}
) )
) );
); }
await Promise.all(jobs);
this.logger.log( this.logger.log(
`Scheduled ${remindersToSchedule.length} reminders for task ${taskPublicId}` `Scheduled ${jobs.length} reminders for task ${taskPublicId} based on ${rules.length} rules`
); );
} }
@@ -0,0 +1,54 @@
// File: src/modules/response-code/dto/create-response-code.dto.ts
// Change Log:
// - 2026-05-13: Add DTO for creating custom response codes.
import {
IsArray,
IsBoolean,
IsEnum,
IsObject,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { ResponseCodeCategory } from '../../common/enums/review.enums';
export class CreateResponseCodeDto {
@IsString()
@MinLength(1)
@MaxLength(10)
code!: string;
@IsOptional()
@IsString()
@MaxLength(10)
subStatus?: string;
@IsEnum(ResponseCodeCategory)
category!: ResponseCodeCategory;
@IsString()
descriptionTh!: string;
@IsString()
descriptionEn!: string;
@IsOptional()
@IsObject()
implications?: {
affectsSchedule?: boolean;
affectsCost?: boolean;
requiresContractReview?: boolean;
requiresEiaAmendment?: boolean;
};
@IsOptional()
@IsArray()
@IsString({ each: true })
notifyRoles?: string[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -0,0 +1,58 @@
// File: src/modules/response-code/dto/update-response-code.dto.ts
// Change Log:
// - 2026-05-13: Add DTO for updating response codes by publicId.
import {
IsArray,
IsBoolean,
IsEnum,
IsObject,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { ResponseCodeCategory } from '../../common/enums/review.enums';
export class UpdateResponseCodeDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(10)
code?: string;
@IsOptional()
@IsString()
@MaxLength(10)
subStatus?: string;
@IsOptional()
@IsEnum(ResponseCodeCategory)
category?: ResponseCodeCategory;
@IsOptional()
@IsString()
descriptionTh?: string;
@IsOptional()
@IsString()
descriptionEn?: string;
@IsOptional()
@IsObject()
implications?: {
affectsSchedule?: boolean;
affectsCost?: boolean;
requiresContractReview?: boolean;
requiresEiaAmendment?: boolean;
};
@IsOptional()
@IsArray()
@IsString({ each: true })
notifyRoles?: string[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -0,0 +1,29 @@
// File: src/modules/response-code/dto/upsert-response-code-rule.dto.ts
// Change Log:
// - 2026-05-13: Add DTO for response code matrix rule upsert endpoints.
import { IsBoolean, IsInt, IsOptional, IsUUID, Min } from 'class-validator';
export class UpsertResponseCodeRuleDto {
@IsInt()
@Min(1)
documentTypeId!: number;
@IsUUID()
responseCodePublicId!: string;
@IsOptional()
@IsUUID()
projectPublicId?: string;
@IsBoolean()
isEnabled!: boolean;
@IsOptional()
@IsBoolean()
requiresComments?: boolean;
@IsOptional()
@IsBoolean()
triggersNotification?: boolean;
}
@@ -1,19 +1,51 @@
// File: src/modules/response-code/response-code.controller.ts // File: src/modules/response-code/response-code.controller.ts
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; // Change Log:
// - 2026-05-13: Resolve project query identifiers through UuidResolverService and stop numeric coercion on public IDs.
// - 2026-05-13: Add basic CRUD endpoints with RBAC enforcement for response code management.
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
import { ResponseCodeService } from './response-code.service'; import { ResponseCodeService } from './response-code.service';
import { ResponseCodeCategory } from '../common/enums/review.enums'; import { ResponseCodeCategory } from '../common/enums/review.enums';
import { CreateResponseCodeDto } from './dto/create-response-code.dto';
import { UpdateResponseCodeDto } from './dto/update-response-code.dto';
import { UpsertResponseCodeRuleDto } from './dto/upsert-response-code-rule.dto';
import { MatrixManagementService } from './services/matrix-management.service';
import { InheritanceService } from './services/inheritance.service';
@ApiTags('Response Codes')
@ApiBearerAuth()
@Controller('response-codes') @Controller('response-codes')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, RbacGuard)
export class ResponseCodeController { export class ResponseCodeController {
constructor(private readonly responseCodeService: ResponseCodeService) {} constructor(
private readonly responseCodeService: ResponseCodeService,
private readonly uuidResolver: UuidResolverService,
private readonly matrixManagementService: MatrixManagementService,
private readonly inheritanceService: InheritanceService
) {}
/** /**
* GET /response-codes * GET /response-codes
* Response Codes * Response Codes
*/ */
@Get() @Get()
@ApiOperation({ summary: 'Get all active response codes' })
findAll() { findAll() {
return this.responseCodeService.findAll(); return this.responseCodeService.findAll();
} }
@@ -23,6 +55,7 @@ export class ResponseCodeController {
* Response Codes Category (FR-006) * Response Codes Category (FR-006)
*/ */
@Get('category/:category') @Get('category/:category')
@ApiOperation({ summary: 'Get response codes by category' })
findByCategory(@Param('category') category: ResponseCodeCategory) { findByCategory(@Param('category') category: ResponseCodeCategory) {
return this.responseCodeService.findByCategory(category); return this.responseCodeService.findByCategory(category);
} }
@@ -32,13 +65,18 @@ export class ResponseCodeController {
* Response Codes document type + project * Response Codes document type + project
*/ */
@Get('document-type/:documentTypeId') @Get('document-type/:documentTypeId')
findByDocumentType( @ApiOperation({ summary: 'Get response codes by document type and project' })
@Param('documentTypeId') documentTypeId: string, async findByDocumentType(
@Param('documentTypeId', ParseIntPipe) documentTypeId: number,
@Query('projectId') projectId?: string @Query('projectId') projectId?: string
) { ) {
const resolvedProjectId = projectId
? await this.uuidResolver.resolveProjectId(projectId)
: undefined;
return this.responseCodeService.findByDocumentType( return this.responseCodeService.findByDocumentType(
Number(documentTypeId), documentTypeId,
projectId ? Number(projectId) : undefined resolvedProjectId
); );
} }
@@ -47,7 +85,79 @@ export class ResponseCodeController {
* Response Code publicId (ADR-019) * Response Code publicId (ADR-019)
*/ */
@Get(':publicId') @Get(':publicId')
findOne(@Param('publicId') publicId: string) { @ApiOperation({ summary: 'Get response code by publicId' })
findOne(@Param('publicId', ParseUuidPipe) publicId: string) {
return this.responseCodeService.findByPublicId(publicId); return this.responseCodeService.findByPublicId(publicId);
} }
@Post()
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Create a custom response code' })
create(@Body() dto: CreateResponseCodeDto) {
return this.responseCodeService.create(dto);
}
@Patch(':publicId')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Update response code by publicId' })
update(
@Param('publicId', ParseUuidPipe) publicId: string,
@Body() dto: UpdateResponseCodeDto
) {
return this.responseCodeService.update(publicId, dto);
}
@Delete(':publicId')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Deactivate response code by publicId' })
async remove(@Param('publicId', ParseUuidPipe) publicId: string) {
await this.responseCodeService.deactivate(publicId);
return { success: true };
}
@Get('matrix/:documentTypeId')
@ApiOperation({ summary: 'Resolve response code matrix by document type' })
async getMatrix(
@Param('documentTypeId', ParseIntPipe) documentTypeId: number,
@Query('projectId') projectId?: string
) {
const resolvedProjectId = projectId
? await this.uuidResolver.resolveProjectId(projectId)
: undefined;
return this.inheritanceService.resolveMatrix(
documentTypeId,
resolvedProjectId
);
}
@Post('matrix/rules')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Create or update a response code matrix rule' })
async upsertRule(@Body() dto: UpsertResponseCodeRuleDto) {
const resolvedProjectId = dto.projectPublicId
? await this.uuidResolver.resolveProjectId(dto.projectPublicId)
: undefined;
return this.matrixManagementService.upsertRule({
documentTypeId: dto.documentTypeId,
responseCodePublicId: dto.responseCodePublicId,
projectId: resolvedProjectId,
isEnabled: dto.isEnabled,
requiresComments: dto.requiresComments,
triggersNotification: dto.triggersNotification,
});
}
@Delete('matrix/rules/:rulePublicId')
@RequirePermission('master_data.manage')
@ApiOperation({
summary: 'Delete a project-specific response code matrix override',
})
async deleteRuleOverride(
@Param('rulePublicId', ParseUuidPipe) rulePublicId: string
) {
await this.matrixManagementService.deleteProjectOverride(rulePublicId);
return { success: true };
}
} }
@@ -1,10 +1,12 @@
// File: src/modules/response-code/response-code.module.ts // File: src/modules/response-code/response-code.module.ts
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLog } from '../../common/entities/audit-log.entity';
import { ResponseCode } from './entities/response-code.entity'; import { ResponseCode } from './entities/response-code.entity';
import { ResponseCodeRule } from './entities/response-code-rule.entity'; import { ResponseCodeRule } from './entities/response-code-rule.entity';
import { ResponseCodeService } from './response-code.service'; import { ResponseCodeService } from './response-code.service';
import { ResponseCodeController } from './response-code.controller'; import { ResponseCodeController } from './response-code.controller';
import { ResponseCodeAuditService } from './services/audit.service';
import { ImplicationsService } from './services/implications.service'; import { ImplicationsService } from './services/implications.service';
import { NotificationTriggerService } from './services/notification-trigger.service'; import { NotificationTriggerService } from './services/notification-trigger.service';
import { MatrixManagementService } from './services/matrix-management.service'; import { MatrixManagementService } from './services/matrix-management.service';
@@ -14,11 +16,12 @@ import { NotificationModule } from '../notification/notification.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User]), TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User, AuditLog]),
NotificationModule, NotificationModule,
], ],
providers: [ providers: [
ResponseCodeService, ResponseCodeService,
ResponseCodeAuditService,
ImplicationsService, ImplicationsService,
NotificationTriggerService, NotificationTriggerService,
MatrixManagementService, MatrixManagementService,
@@ -27,6 +30,7 @@ import { NotificationModule } from '../notification/notification.module';
controllers: [ResponseCodeController], controllers: [ResponseCodeController],
exports: [ exports: [
ResponseCodeService, ResponseCodeService,
ResponseCodeAuditService,
ImplicationsService, ImplicationsService,
NotificationTriggerService, NotificationTriggerService,
MatrixManagementService, MatrixManagementService,
@@ -1,10 +1,20 @@
// File: src/modules/response-code/response-code.service.ts // File: src/modules/response-code/response-code.service.ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; // Change Log:
// - 2026-05-13: Add basic CRUD methods for response codes to support controller mutations.
import {
ConflictException,
Injectable,
Logger,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm'; import { Repository, IsNull } from 'typeorm';
import { ResponseCode } from './entities/response-code.entity'; import { ResponseCode } from './entities/response-code.entity';
import { ResponseCodeRule } from './entities/response-code-rule.entity'; import { ResponseCodeRule } from './entities/response-code-rule.entity';
import { ResponseCodeCategory } from '../common/enums/review.enums'; import { ResponseCodeCategory } from '../common/enums/review.enums';
import { CreateResponseCodeDto } from './dto/create-response-code.dto';
import { UpdateResponseCodeDto } from './dto/update-response-code.dto';
@Injectable() @Injectable()
export class ResponseCodeService { export class ResponseCodeService {
@@ -85,6 +95,83 @@ export class ResponseCodeService {
return code; return code;
} }
/**
* Response Code Master Approval Matrix
*/
async create(dto: CreateResponseCodeDto): Promise<ResponseCode> {
const existing = await this.responseCodeRepo.findOne({
where: {
code: dto.code,
category: dto.category,
},
});
if (existing) {
throw new ConflictException(
`Response Code already exists for code=${dto.code}, category=${dto.category}`
);
}
const entity = this.responseCodeRepo.create({
code: dto.code,
subStatus: dto.subStatus,
category: dto.category,
descriptionTh: dto.descriptionTh,
descriptionEn: dto.descriptionEn,
implications: dto.implications,
notifyRoles: dto.notifyRoles,
isActive: dto.isActive ?? true,
isSystem: false,
});
return this.responseCodeRepo.save(entity);
}
/**
* Response Code publicId
*/
async update(
publicId: string,
dto: UpdateResponseCodeDto
): Promise<ResponseCode> {
const entity = await this.findByPublicId(publicId);
if (
(dto.code && dto.code !== entity.code) ||
(dto.category && dto.category !== entity.category)
) {
const existing = await this.responseCodeRepo.findOne({
where: {
code: dto.code ?? entity.code,
category: dto.category ?? entity.category,
},
});
if (existing && existing.publicId !== entity.publicId) {
throw new ConflictException(
`Response Code already exists for code=${dto.code ?? entity.code}, category=${dto.category ?? entity.category}`
);
}
}
Object.assign(entity, dto);
return this.responseCodeRepo.save(entity);
}
/**
* Response Code
*/
async deactivate(publicId: string): Promise<void> {
const entity = await this.findByPublicId(publicId);
if (entity.isSystem) {
throw new BadRequestException('Cannot deactivate a system response code');
}
entity.isActive = false;
await this.responseCodeRepo.save(entity);
}
/** /**
* Response Code triggers notification (FR-007) * Response Code triggers notification (FR-007)
* Code 1C, 1D, 3 trigger notification * Code 1C, 1D, 3 trigger notification
@@ -0,0 +1,49 @@
// File: src/modules/response-code/services/audit.service.ts
// Change Log:
// - 2026-05-13: Add response code audit service for review task response code changes.
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditLog } from '../../../common/entities/audit-log.entity';
@Injectable()
export class ResponseCodeAuditService {
private readonly logger = new Logger(ResponseCodeAuditService.name);
constructor(
@InjectRepository(AuditLog)
private readonly auditLogRepo: Repository<AuditLog>
) {}
/**
* audit trail Response Code Review Task
*/
async logReviewTaskResponseCodeChange(input: {
reviewTaskPublicId: string;
responseCodePublicId: string;
previousResponseCodeId?: number;
currentResponseCodeId: number;
comments?: string;
userId?: number;
}): Promise<void> {
const auditLog = this.auditLogRepo.create({
userId: input.userId ?? null,
action: 'response_code.change',
severity: 'INFO',
entityType: 'review_task',
entityId: input.reviewTaskPublicId,
detailsJson: {
previousResponseCodeId: input.previousResponseCodeId ?? null,
currentResponseCodeId: input.currentResponseCodeId,
responseCodePublicId: input.responseCodePublicId,
comments: input.comments ?? null,
},
});
await this.auditLogRepo.save(auditLog);
this.logger.debug(
`Recorded response code audit for review task ${input.reviewTaskPublicId}`
);
}
}
@@ -1,4 +1,6 @@
// File: src/modules/review-team/dto/shared/review-team.dto.ts // File: src/modules/review-team/dto/shared/review-team.dto.ts
// Change Log:
// - 2026-05-13: Align AddTeamMemberDto discipline identifier with the INT-based disciplines schema.
// Shared DTOs สำหรับ Review Team และ Review Task APIs // Shared DTOs สำหรับ Review Team และ Review Task APIs
import { import {
@@ -69,8 +71,9 @@ export class AddTeamMemberDto {
@IsUUID() @IsUUID()
userPublicId!: string; // ADR-019 userPublicId!: string; // ADR-019
@IsUUID() @IsInt()
disciplinePublicId!: string; // ADR-019 @IsPositive()
disciplineId!: number; // disciplines table is internal INT per current schema
@IsEnum(ReviewTeamMemberRole) @IsEnum(ReviewTeamMemberRole)
role!: ReviewTeamMemberRole; role!: ReviewTeamMemberRole;
@@ -0,0 +1,110 @@
// File: src/modules/review-team/review-task.controller.ts
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ReviewTaskService } from './review-task.service';
import { ConsensusService } from './services/consensus.service';
import { VetoOverrideService } from './services/veto-override.service';
import type { VetoOverrideDto } from './services/veto-override.service';
import {
CompleteReviewTaskDto,
SearchReviewTaskDto,
} from './dto/shared/review-team.dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity';
@Controller('review-tasks')
@UseGuards(JwtAuthGuard)
export class ReviewTaskController {
constructor(
private readonly reviewTaskService: ReviewTaskService,
private readonly consensusService: ConsensusService,
private readonly vetoOverrideService: VetoOverrideService
) {}
@Get()
findAll(@Query() dto: SearchReviewTaskDto) {
return this.reviewTaskService.findAll(dto);
}
@Get(':publicId')
findOne(@Param('publicId', ParseUUIDPipe) publicId: string) {
return this.reviewTaskService.findByPublicId(publicId);
}
@Patch(':publicId/start')
startReview(@Param('publicId', ParseUUIDPipe) publicId: string) {
return this.reviewTaskService.startReview(publicId);
}
@Patch(':publicId/complete')
async completeReview(
@Param('publicId', ParseUUIDPipe) publicId: string,
@Body() dto: CompleteReviewTaskDto,
@CurrentUser() _user: User
) {
const task = await this.reviewTaskService.completeReview(publicId, dto);
// Evaluate consensus after completion (FR-010)
try {
const fullTask = (await this.reviewTaskService.findFullTaskContext(
publicId
)) as unknown as Record<string, unknown>;
const rfaRevision = fullTask.rfaRevision as
| Record<string, unknown>
| undefined;
const corrRevision = rfaRevision?.correspondenceRevision as
| Record<string, unknown>
| undefined;
const correspondence = corrRevision?.correspondence as
| Record<string, unknown>
| undefined;
if (rfaRevision && correspondence) {
await this.consensusService.evaluateAfterTaskComplete(
fullTask.rfaRevisionId,
{
rfaPublicId: correspondence.publicId as string,
rfaRevisionPublicId: corrRevision.publicId as string,
projectId: correspondence.projectId as number,
documentTypeId: (
correspondence.type as Record<string, unknown> | undefined
)?.id as number | undefined,
documentTypeCode:
((correspondence.type as Record<string, unknown> | undefined)
?.typeCode as string | undefined) ?? 'RFA',
}
);
}
} catch (_error: unknown) {
// Log error but don't fail the task completion response
// (error as any).logger?.error(`Consensus evaluation failed: ${(error as Error).message}`);
}
return task;
}
@Post('veto-override')
async overrideVeto(@Body() dto: VetoOverrideDto, @CurrentUser() user: User) {
return this.vetoOverrideService.executeOverride({
...dto,
overriddenByUserId: user.user_id,
});
}
}
@@ -1,4 +1,6 @@
// File: src/modules/review-team/review-task.service.ts // File: src/modules/review-team/review-task.service.ts
// Change Log:
// - 2026-05-13: Record audit trail when a review task response code is completed or changed.
import { import {
Injectable, Injectable,
Logger, Logger,
@@ -10,6 +12,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ReviewTask } from './entities/review-task.entity'; import { ReviewTask } from './entities/review-task.entity';
import { ResponseCode } from '../response-code/entities/response-code.entity'; import { ResponseCode } from '../response-code/entities/response-code.entity';
import { ResponseCodeAuditService } from '../response-code/services/audit.service';
import { import {
CompleteReviewTaskDto, CompleteReviewTaskDto,
SearchReviewTaskDto, SearchReviewTaskDto,
@@ -25,7 +28,8 @@ export class ReviewTaskService {
@InjectRepository(ReviewTask) @InjectRepository(ReviewTask)
private readonly reviewTaskRepo: Repository<ReviewTask>, private readonly reviewTaskRepo: Repository<ReviewTask>,
@InjectRepository(ResponseCode) @InjectRepository(ResponseCode)
private readonly responseCodeRepo: Repository<ResponseCode> private readonly responseCodeRepo: Repository<ResponseCode>,
private readonly responseCodeAuditService: ResponseCodeAuditService
) {} ) {}
/** /**
@@ -91,6 +95,48 @@ export class ReviewTaskService {
return task; return task;
} }
/**
* Review Task context (RFA, Project, Type)
*/
async findFullTaskContext(publicId: string): Promise<ReviewTask> {
const task = await this.reviewTaskRepo
.createQueryBuilder('task')
.leftJoinAndSelect('task.responseCode', 'responseCode')
.leftJoinAndSelect('task.team', 'team')
.innerJoinAndMapOne(
'task.rfaRevision',
'rfa_revisions',
'rfaRev',
'rfaRev.id = task.rfa_revision_id'
)
.innerJoinAndMapOne(
'rfaRev.correspondenceRevision',
'correspondence_revisions',
'corrRev',
'corrRev.id = rfaRev.id'
)
.innerJoinAndMapOne(
'corrRev.correspondence',
'correspondences',
'corr',
'corr.id = corrRev.correspondence_id'
)
.leftJoinAndMapOne(
'corr.type',
'correspondence_types',
'corrType',
'corrType.id = corr.correspondence_type_id'
)
.where('task.uuid = :publicId', { publicId })
.getOne();
if (!task) {
throw new NotFoundException(`Review Task not found: ${publicId}`);
}
return task;
}
/** /**
* Tasks RFA Revision Aggregate Status (FR-004) * Tasks RFA Revision Aggregate Status (FR-004)
*/ */
@@ -143,6 +189,7 @@ export class ReviewTaskService {
dto: CompleteReviewTaskDto dto: CompleteReviewTaskDto
): Promise<ReviewTask> { ): Promise<ReviewTask> {
const task = await this.findByPublicId(publicId); const task = await this.findByPublicId(publicId);
const previousResponseCodeId = task.responseCodeId;
if ( if (
task.status === ReviewTaskStatus.COMPLETED || task.status === ReviewTaskStatus.COMPLETED ||
@@ -180,7 +227,15 @@ export class ReviewTaskService {
try { try {
// TypeORM จะ throw OptimisticLockVersionMismatchError ถ้า version ไม่ตรง (ADR-002) // TypeORM จะ throw OptimisticLockVersionMismatchError ถ้า version ไม่ตรง (ADR-002)
return await this.reviewTaskRepo.save(task); const savedTask = await this.reviewTaskRepo.save(task);
await this.responseCodeAuditService.logReviewTaskResponseCodeChange({
reviewTaskPublicId: savedTask.publicId,
responseCodePublicId: dto.responseCodePublicId,
previousResponseCodeId,
currentResponseCodeId: responseCode.id,
comments: dto.comments,
});
return savedTask;
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
if ( if (
@@ -22,12 +22,15 @@ import { VetoOverrideService } from './services/veto-override.service';
// Controllers // Controllers
import { ReviewTeamController } from './review-team.controller'; import { ReviewTeamController } from './review-team.controller';
import { ReviewTaskController } from './review-task.controller';
// Modules // Modules
import { ResponseCodeModule } from '../response-code/response-code.module'; import { ResponseCodeModule } from '../response-code/response-code.module';
import { NotificationModule } from '../notification/notification.module'; import { NotificationModule } from '../notification/notification.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { DistributionModule } from '../distribution/distribution.module'; import { DistributionModule } from '../distribution/distribution.module';
import { DelegationModule } from '../delegation/delegation.module';
import { ReminderModule } from '../reminder/reminder.module';
// Queue constants // Queue constants
import { import {
@@ -52,6 +55,8 @@ import {
NotificationModule, NotificationModule,
UserModule, UserModule,
DistributionModule, DistributionModule,
DelegationModule,
ReminderModule,
], ],
providers: [ providers: [
ReviewTeamService, ReviewTeamService,
@@ -61,7 +66,7 @@ import {
ConsensusService, ConsensusService,
VetoOverrideService, VetoOverrideService,
], ],
controllers: [ReviewTeamController], controllers: [ReviewTeamController, ReviewTaskController],
exports: [ exports: [
ReviewTeamService, ReviewTeamService,
ReviewTaskService, ReviewTaskService,
@@ -1,4 +1,6 @@
// File: src/modules/review-team/review-team.service.ts // File: src/modules/review-team/review-team.service.ts
// Change Log:
// - 2026-05-13: Resolve project public IDs with UuidResolverService and align discipline lookup with INT discipline IDs.
import { import {
Injectable, Injectable,
Logger, Logger,
@@ -11,6 +13,7 @@ import { ReviewTeam } from './entities/review-team.entity';
import { ReviewTeamMember } from './entities/review-team-member.entity'; import { ReviewTeamMember } from './entities/review-team-member.entity';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity';
import { Discipline } from '../master/entities/discipline.entity'; import { Discipline } from '../master/entities/discipline.entity';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
import { import {
CreateReviewTeamDto, CreateReviewTeamDto,
UpdateReviewTeamDto, UpdateReviewTeamDto,
@@ -30,7 +33,8 @@ export class ReviewTeamService {
@InjectRepository(User) @InjectRepository(User)
private readonly userRepo: Repository<User>, private readonly userRepo: Repository<User>,
@InjectRepository(Discipline) @InjectRepository(Discipline)
private readonly disciplineRepo: Repository<Discipline> private readonly disciplineRepo: Repository<Discipline>,
private readonly uuidResolver: UuidResolverService
) {} ) {}
/** /**
@@ -97,21 +101,14 @@ export class ReviewTeamService {
* Review Team * Review Team
*/ */
async create(dto: CreateReviewTeamDto): Promise<ReviewTeam> { async create(dto: CreateReviewTeamDto): Promise<ReviewTeam> {
// ตรวจสอบว่า project มีอยู่จริง (via publicId) const projectId = await this.uuidResolver.resolveProjectId(
const project = await this.teamRepo.manager dto.projectPublicId
.getRepository('projects') );
.findOne({
where: { uuid: dto.projectPublicId } as Record<string, unknown>,
});
if (!project) {
throw new NotFoundException(`Project not found: ${dto.projectPublicId}`);
}
const team = this.teamRepo.create({ const team = this.teamRepo.create({
name: dto.name, name: dto.name,
description: dto.description, description: dto.description,
projectId: (project as { id: number }).id, projectId,
defaultForRfaTypes: dto.defaultForRfaTypes, defaultForRfaTypes: dto.defaultForRfaTypes,
isActive: true, isActive: true,
}); });
@@ -155,12 +152,10 @@ export class ReviewTeamService {
// ตรวจสอบ Discipline // ตรวจสอบ Discipline
const discipline = await this.disciplineRepo.findOne({ const discipline = await this.disciplineRepo.findOne({
where: { id: Number(dto.disciplinePublicId) }, where: { id: dto.disciplineId },
}); });
if (!discipline) if (!discipline)
throw new NotFoundException( throw new NotFoundException(`Discipline not found: ${dto.disciplineId}`);
`Discipline not found: ${dto.disciplinePublicId}`
);
// ตรวจสอบซ้ำ // ตรวจสอบซ้ำ
const existing = await this.memberRepo.findOne({ const existing = await this.memberRepo.findOne({
@@ -173,7 +168,7 @@ export class ReviewTeamService {
if (existing) { if (existing) {
throw new BadRequestException( throw new BadRequestException(
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}` `User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplineId}`
); );
} }
@@ -38,6 +38,7 @@ export class ConsensusService {
rfaPublicId: string; rfaPublicId: string;
rfaRevisionPublicId: string; rfaRevisionPublicId: string;
projectId: number; projectId: number;
documentTypeId?: number;
documentTypeCode: string; documentTypeCode: string;
} }
): Promise<ConsensusResult> { ): Promise<ConsensusResult> {
@@ -11,7 +11,11 @@ import { ReviewTask } from '../entities/review-task.entity';
import { import {
ReviewTaskStatus, ReviewTaskStatus,
ReviewTeamMemberRole, ReviewTeamMemberRole,
DelegationScope,
ReminderType,
} from '../../common/enums/review.enums'; } from '../../common/enums/review.enums';
import { DelegationService } from '../../delegation/delegation.service';
import { SchedulerService } from '../../reminder/services/scheduler.service';
@Injectable() @Injectable()
export class TaskCreationService { export class TaskCreationService {
@@ -23,7 +27,9 @@ export class TaskCreationService {
@InjectRepository(ReviewTeamMember) @InjectRepository(ReviewTeamMember)
private readonly memberRepo: Repository<ReviewTeamMember>, private readonly memberRepo: Repository<ReviewTeamMember>,
@InjectRepository(ReviewTask) @InjectRepository(ReviewTask)
private readonly reviewTaskRepo: Repository<ReviewTask> private readonly reviewTaskRepo: Repository<ReviewTask>,
private readonly delegationService: DelegationService,
private readonly schedulerService: SchedulerService
) {} ) {}
/** /**
@@ -34,12 +40,16 @@ export class TaskCreationService {
* @param reviewTeamPublicId - publicId Review Team (ADR-019) * @param reviewTeamPublicId - publicId Review Team (ADR-019)
* @param dueDate - * @param dueDate -
* @param manager - EntityManager QueryRunner ( Transaction ) * @param manager - EntityManager QueryRunner ( Transaction )
* @param projectId - (Optional) ID reminder rules
* @param documentTypeCode - (Optional) reminder rules
*/ */
async createParallelTasks( async createParallelTasks(
rfaRevisionId: number, rfaRevisionId: number,
reviewTeamPublicId: string, reviewTeamPublicId: string,
dueDate: Date, dueDate: Date,
manager: EntityManager manager: EntityManager,
projectId?: number,
documentTypeCode?: string
): Promise<ReviewTask[]> { ): Promise<ReviewTask[]> {
// ดึง ReviewTeam พร้อม members // ดึง ReviewTeam พร้อม members
const team = await this.reviewTeamRepo.findOne({ const team = await this.reviewTeamRepo.findOne({
@@ -77,16 +87,40 @@ export class TaskCreationService {
// สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel) // สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel)
for (const [disciplineId, leadMember] of disciplineMap) { for (const [disciplineId, leadMember] of disciplineMap) {
const activeDelegate = await this.delegationService.findActiveDelegate(
leadMember.userId,
dueDate,
[DelegationScope.ALL, DelegationScope.RFA_ONLY]
);
const assignedToUserId = activeDelegate?.user_id ?? leadMember.userId;
const delegatedFromUserId = activeDelegate
? leadMember.userId
: undefined;
const task = manager.create(ReviewTask, { const task = manager.create(ReviewTask, {
rfaRevisionId, rfaRevisionId,
teamId: team.id, teamId: team.id,
disciplineId, disciplineId,
assignedToUserId: leadMember.userId, assignedToUserId,
delegatedFromUserId,
status: ReviewTaskStatus.PENDING, status: ReviewTaskStatus.PENDING,
dueDate, dueDate,
}); });
const saved = await manager.save(ReviewTask, task); const saved = await manager.save(ReviewTask, task);
tasks.push(saved); tasks.push(saved);
// Schedule Reminders & Escalation (US4)
if (saved.assignedToUserId) {
await this.schedulerService.scheduleForTask({
taskPublicId: saved.publicId,
rfaPublicId: rfaRevisionId.toString(), // ใช้ rfaRevisionId เป็น placeholder
assigneeUserId: saved.assignedToUserId,
dueDate: saved.dueDate ?? dueDate,
reminderType: ReminderType.DUE_SOON, // Start type, scheduler will fetch rules
projectId: projectId ?? team.projectId,
documentTypeCode,
});
}
} }
this.logger.log( this.logger.log(
@@ -17,6 +17,7 @@ export interface VetoOverrideDto {
rfaPublicId: string; rfaPublicId: string;
rfaRevisionPublicId: string; rfaRevisionPublicId: string;
projectId: number; projectId: number;
documentTypeId?: number;
documentTypeCode: string; documentTypeCode: string;
overrideReason: string; overrideReason: string;
overriddenByUserId: number; overriddenByUserId: number;
@@ -72,6 +73,7 @@ export class VetoOverrideService {
rfaPublicId: dto.rfaPublicId, rfaPublicId: dto.rfaPublicId,
rfaRevisionPublicId: dto.rfaRevisionPublicId, rfaRevisionPublicId: dto.rfaRevisionPublicId,
projectId: dto.projectId, projectId: dto.projectId,
documentTypeId: dto.documentTypeId,
documentTypeCode: dto.documentTypeCode, documentTypeCode: dto.documentTypeCode,
responseCode: '1A', responseCode: '1A',
decision: ConsensusDecision.OVERRIDDEN, decision: ConsensusDecision.OVERRIDDEN,
+8 -1
View File
@@ -1,4 +1,6 @@
// File: src/modules/rfa/rfa.controller.ts // File: src/modules/rfa/rfa.controller.ts
// Change Log:
// - 2026-05-13: Wire submit reviewTeamPublicId through to the submit workflow for parallel review task creation.
import { import {
Body, Body,
Controller, Controller,
@@ -76,7 +78,12 @@ export class RfaController {
) { ) {
// ADR-019: resolve UUID → internal INT id via findOneByUuidRaw // ADR-019: resolve UUID → internal INT id via findOneByUuidRaw
const rfa = await this.rfaService.findOneByUuidRaw(uuid); const rfa = await this.rfaService.findOneByUuidRaw(uuid);
return this.rfaService.submit(rfa.id, submitDto.templateId, user); return this.rfaService.submit(
rfa.id,
submitDto.templateId,
user,
submitDto.reviewTeamPublicId
);
} }
@Post(':uuid/action') @Post(':uuid/action')
+2
View File
@@ -34,6 +34,7 @@ import { RfaService } from './rfa.service';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module'; import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { NotificationModule } from '../notification/notification.module'; import { NotificationModule } from '../notification/notification.module';
import { ProjectModule } from '../project/project.module'; import { ProjectModule } from '../project/project.module';
import { ReviewTeamModule } from '../review-team/review-team.module';
import { SearchModule } from '../search/search.module'; import { SearchModule } from '../search/search.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'; import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
@@ -66,6 +67,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
DocumentNumberingModule, DocumentNumberingModule,
UserModule, UserModule,
ProjectModule, ProjectModule,
ReviewTeamModule,
SearchModule, SearchModule,
WorkflowEngineModule, WorkflowEngineModule,
NotificationModule, NotificationModule,
+21 -1
View File
@@ -1,4 +1,6 @@
// File: src/modules/rfa/rfa.service.ts // File: src/modules/rfa/rfa.service.ts
// Change Log:
// - 2026-05-13: Invoke TaskCreationService during submit when a review team is selected.
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
@@ -59,6 +61,7 @@ import { SearchService } from '../search/search.service';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service'; import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
import { UuidResolverService } from '../../common/services/uuid-resolver.service'; import { UuidResolverService } from '../../common/services/uuid-resolver.service';
import { TaskCreationService } from '../review-team/services/task-creation.service';
@Injectable() @Injectable()
export class RfaService { export class RfaService {
@@ -109,6 +112,7 @@ export class RfaService {
private userService: UserService, private userService: UserService,
private workflowEngine: WorkflowEngineService, private workflowEngine: WorkflowEngineService,
private notificationService: NotificationService, private notificationService: NotificationService,
private taskCreationService: TaskCreationService,
private dataSource: DataSource, private dataSource: DataSource,
private searchService: SearchService, private searchService: SearchService,
private uuidResolver: UuidResolverService private uuidResolver: UuidResolverService
@@ -664,7 +668,12 @@ export class RfaService {
return mappedRfa; return mappedRfa;
} }
async submit(rfaId: number, templateId: number, user: User) { async submit(
rfaId: number,
templateId: number,
user: User,
reviewTeamPublicId?: string
) {
const rfa = await this.findOne(rfaId, true); const rfa = await this.findOne(rfaId, true);
const corrRevisions = const corrRevisions =
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? []; (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
@@ -747,6 +756,17 @@ export class RfaService {
}); });
await queryRunner.manager.save(routing); await queryRunner.manager.save(routing);
if (reviewTeamPublicId) {
await this.taskCreationService.createParallelTasks(
currentRfaRev.id,
reviewTeamPublicId,
routing.dueDate ?? new Date(),
queryRunner.manager,
rfa.correspondence.projectId,
rfa.rfaType.typeCode
);
}
// Notify // Notify
const recipientUserId = await this.userService.findDocControlIdByOrg( const recipientUserId = await this.userService.findDocControlIdByOrg(
firstStep.toOrganizationId firstStep.toOrganizationId
@@ -1,3 +1,6 @@
// File: backend/src/modules/user/entities/role.entity.ts
// Change Log:
// - v1.9.0 (2026-05-13): เพิ่ม publicId (uuid) column ตาม delta-11 + ADR-019
import { import {
Entity, Entity,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
@@ -6,7 +9,9 @@ import {
JoinTable, JoinTable,
} from 'typeorm'; } from 'typeorm';
import { Permission } from './permission.entity'; import { Permission } from './permission.entity';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
/** ขอบเขตของบทบาท */
export enum RoleScope { export enum RoleScope {
GLOBAL = 'Global', GLOBAL = 'Global',
ORGANIZATION = 'Organization', ORGANIZATION = 'Organization',
@@ -14,8 +19,15 @@ export enum RoleScope {
CONTRACT = 'Contract', CONTRACT = 'Contract',
} }
/**
* Entity roles
*
* @remarks
* - Internal PK: roleId (INT) expose API (ADR-019)
* - Public ID: publicId (UUID) API Response distribution_recipients (delta-11)
*/
@Entity('roles') @Entity('roles')
export class Role { export class Role extends UuidBaseEntity {
@PrimaryGeneratedColumn({ name: 'role_id' }) @PrimaryGeneratedColumn({ name: 'role_id' })
roleId!: number; roleId!: number;
@@ -0,0 +1,129 @@
// File: tests/unit/delegation/delegation.service.spec.ts
// Change Log
// - 2026-05-13: เพิ่ม regression test เพื่อป้องกัน multi-level delegation chain
import { DelegationService } from '../../../src/modules/delegation/delegation.service';
import { CircularDetectionService } from '../../../src/modules/delegation/services/circular-detection.service';
import { Delegation } from '../../../src/modules/delegation/entities/delegation.entity';
import { User } from '../../../src/modules/user/entities/user.entity';
import { DelegationScope } from '../../../src/modules/common/enums/review.enums';
import { Repository } from 'typeorm';
type RepositoryMock<T extends object> = {
findOne: jest.MockedFunction<(options: unknown) => Promise<T | null>>;
create: jest.MockedFunction<(payload: Partial<T>) => T>;
save: jest.MockedFunction<(entity: T) => Promise<T>>;
createQueryBuilder: jest.MockedFunction<(alias: string) => QueryBuilderMock>;
};
type QueryBuilderMock = {
innerJoinAndSelect: jest.MockedFunction<
(relation: string, alias: string) => QueryBuilderMock
>;
where: jest.MockedFunction<
(
condition: string,
parameters?: Record<string, unknown>
) => QueryBuilderMock
>;
andWhere: jest.MockedFunction<
(
condition: string,
parameters?: Record<string, unknown>
) => QueryBuilderMock
>;
orderBy: jest.MockedFunction<
(sort: string, order: 'ASC' | 'DESC') => QueryBuilderMock
>;
getOne: jest.MockedFunction<() => Promise<Delegation | null>>;
};
const createQueryBuilderMock = (
delegation: Delegation | null
): QueryBuilderMock => {
const queryBuilder = {} as QueryBuilderMock;
queryBuilder.innerJoinAndSelect = jest.fn(
(_relation: string, _alias: string): QueryBuilderMock => queryBuilder
);
queryBuilder.where = jest.fn(
(
_condition: string,
_parameters?: Record<string, unknown>
): QueryBuilderMock => queryBuilder
);
queryBuilder.andWhere = jest.fn(
(
_condition: string,
_parameters?: Record<string, unknown>
): QueryBuilderMock => queryBuilder
);
queryBuilder.orderBy = jest.fn(
(_sort: string, _order: 'ASC' | 'DESC'): QueryBuilderMock => queryBuilder
);
queryBuilder.getOne = jest.fn(
(): Promise<Delegation | null> => Promise.resolve(delegation)
);
return queryBuilder;
};
const createRepositoryMock = <T extends object>(): RepositoryMock<T> => ({
findOne: jest.fn(),
create: jest.fn((payload: Partial<T>): T => payload as T),
save: jest.fn((entity: T): Promise<T> => Promise.resolve(entity)),
createQueryBuilder: jest.fn(),
});
describe('DelegationService', () => {
const delegationRepo = createRepositoryMock<Delegation>();
const userRepo = createRepositoryMock<User>();
const circularDetectionService = {
wouldCreateCircle: jest.fn(),
} as unknown as CircularDetectionService;
beforeEach(() => {
jest.clearAllMocks();
delegationRepo.createQueryBuilder.mockReturnValue(
createQueryBuilderMock(null)
);
(circularDetectionService.wouldCreateCircle as jest.Mock).mockResolvedValue(
false
);
});
it('rejects delegated user who already delegates onward', async () => {
const service = new DelegationService(
delegationRepo as unknown as Repository<Delegation>,
userRepo as unknown as Repository<User>,
circularDetectionService
);
const delegator = {
user_id: 1,
publicId: '019505a1-7c3e-7000-8000-000000000001',
} as User;
const delegate = {
user_id: 2,
publicId: '019505a1-7c3e-7000-8000-000000000002',
} as User;
const onwardDelegation = {
publicId: '019505a1-7c3e-7000-8000-000000000003',
delegatorUserId: delegate.user_id,
delegateUserId: 3,
delegate: { user_id: 3 } as User,
} as Delegation;
(userRepo.findOne as jest.Mock)
.mockResolvedValueOnce(delegator)
.mockResolvedValueOnce(delegate);
delegationRepo.createQueryBuilder.mockReturnValue(
createQueryBuilderMock(onwardDelegation)
);
await expect(
service.create(delegator.publicId, {
delegateUserPublicId: delegate.publicId,
scope: DelegationScope.RFA_ONLY,
startDate: new Date('2026-05-20T00:00:00.000Z'),
endDate: new Date('2026-05-27T00:00:00.000Z'),
})
).rejects.toThrow('Nested delegation is not allowed');
});
});
@@ -0,0 +1,109 @@
// File: tests/unit/distribution/distribution-matrix.service.spec.ts
// Change Log
// - 2026-05-14: Add regression coverage for Distribution Matrix public-ID handling.
import { DistributionMatrixService } from '../../../src/modules/distribution/distribution-matrix.service';
import { DistributionMatrix } from '../../../src/modules/distribution/entities/distribution-matrix.entity';
import { DistributionRecipient } from '../../../src/modules/distribution/entities/distribution-recipient.entity';
import { Project } from '../../../src/modules/project/entities/project.entity';
import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity';
import {
DeliveryMethod,
RecipientType,
} from '../../../src/modules/common/enums/review.enums';
import { Repository } from 'typeorm';
type RepositoryMock<T extends object> = {
find: jest.MockedFunction<(options: unknown) => Promise<T[]>>;
findOne: jest.MockedFunction<(options: unknown) => Promise<T | null>>;
create: jest.MockedFunction<(payload: Partial<T>) => T>;
save: jest.MockedFunction<(payload: T) => Promise<T>>;
remove: jest.MockedFunction<(payload: T) => Promise<T>>;
};
const createRepositoryMock = <T extends object>(): RepositoryMock<T> => ({
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn((payload: Partial<T>): T => payload as T),
save: jest.fn((payload: T): Promise<T> => Promise.resolve(payload)),
remove: jest.fn((payload: T): Promise<T> => Promise.resolve(payload)),
});
describe('DistributionMatrixService', () => {
const matrixRepo = createRepositoryMock<DistributionMatrix>();
const recipientRepo = createRepositoryMock<DistributionRecipient>();
const projectRepo = createRepositoryMock<Project>();
const responseCodeRepo = createRepositoryMock<ResponseCode>();
beforeEach(() => {
jest.clearAllMocks();
});
it('creates a schema-aligned matrix by resolving public IDs internally', async () => {
const service = new DistributionMatrixService(
matrixRepo as unknown as Repository<DistributionMatrix>,
recipientRepo as unknown as Repository<DistributionRecipient>,
projectRepo as unknown as Repository<Project>,
responseCodeRepo as unknown as Repository<ResponseCode>
);
matrixRepo.findOne.mockResolvedValue({
id: 7,
publicId: '019505a1-7c3e-7000-8000-000000000001',
} as unknown as DistributionMatrix);
projectRepo.findOne.mockResolvedValue({
id: 7,
publicId: '019505a1-7c3e-7000-8000-000000000001',
} as Project);
responseCodeRepo.findOne.mockResolvedValue({
id: 9,
publicId: '019505a1-7c3e-7000-8000-000000000002',
} as ResponseCode);
await service.create({
name: 'Shop Drawing Distribution',
projectPublicId: '019505a1-7c3e-7000-8000-000000000001',
documentTypeId: 3,
responseCodePublicId: '019505a1-7c3e-7000-8000-000000000002',
conditions: { codes: ['1A', '1B'], excludeCodes: ['3'] },
});
expect(matrixRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Shop Drawing Distribution',
projectId: 7,
documentTypeId: 3,
responseCodeId: 9,
conditions: { codes: ['1A', '1B'], excludeCodes: ['3'] },
})
);
});
it('adds recipients with recipientPublicId instead of internal recipient ids', async () => {
const service = new DistributionMatrixService(
matrixRepo as unknown as Repository<DistributionMatrix>,
recipientRepo as unknown as Repository<DistributionRecipient>,
projectRepo as unknown as Repository<Project>,
responseCodeRepo as unknown as Repository<ResponseCode>
);
matrixRepo.findOne.mockResolvedValue({
id: 11,
publicId: '019505a1-7c3e-7000-8000-000000000003',
} as DistributionMatrix);
await service.addRecipient('019505a1-7c3e-7000-8000-000000000003', {
recipientType: RecipientType.ORGANIZATION,
recipientPublicId: '019505a1-7c3e-7000-8000-000000000004',
deliveryMethod: DeliveryMethod.BOTH,
sequence: 1,
});
expect(recipientRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
matrixId: 11,
recipientType: RecipientType.ORGANIZATION,
recipientPublicId: '019505a1-7c3e-7000-8000-000000000004',
deliveryMethod: DeliveryMethod.BOTH,
sequence: 1,
})
);
});
});
@@ -0,0 +1,59 @@
// File: tests/unit/distribution/transmittal-creator.service.spec.ts
// Change Log
// - 2026-05-14: Add regression coverage for Distribution Matrix response-code filtering.
import { TransmittalCreatorService } from '../../../src/modules/distribution/services/transmittal-creator.service';
import { DistributionMatrix } from '../../../src/modules/distribution/entities/distribution-matrix.entity';
import { DistributionRecipient } from '../../../src/modules/distribution/entities/distribution-recipient.entity';
import { DocumentNumberingService } from '../../../src/modules/document-numbering/services/document-numbering.service';
import { DataSource, Repository } from 'typeorm';
type MatrixRepositoryMock = {
findOne: jest.MockedFunction<
(options: unknown) => Promise<DistributionMatrix | null>
>;
};
describe('TransmittalCreatorService', () => {
const matrixRepo: MatrixRepositoryMock = {
findOne: jest.fn(),
};
const dataSource = {
manager: {
findOne: jest.fn(),
},
query: jest.fn(),
createQueryRunner: jest.fn(),
};
const numberingService = {
generateNextNumber: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('skips distribution when the response code is excluded', async () => {
const service = new TransmittalCreatorService(
matrixRepo as unknown as Repository<DistributionMatrix>,
dataSource as unknown as DataSource,
numberingService as unknown as DocumentNumberingService
);
matrixRepo.findOne.mockResolvedValue({
conditions: { excludeCodes: ['3', '4'] },
recipients: [{} as DistributionRecipient],
} as DistributionMatrix);
const result = await service.createFromDistribution({
rfaPublicId: '019505a1-7c3e-7000-8000-000000000001',
rfaRevisionPublicId: '019505a1-7c3e-7000-8000-000000000002',
projectId: 1,
documentTypeId: 2,
responseCode: '3',
});
expect(result).toEqual({
transmittalPublicIds: [],
notificationTargets: [],
});
});
});
@@ -6,6 +6,7 @@ import { ResponseCodeService } from '../../../src/modules/response-code/response
import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity'; import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity';
import { ResponseCodeRule } from '../../../src/modules/response-code/entities/response-code-rule.entity'; import { ResponseCodeRule } from '../../../src/modules/response-code/entities/response-code-rule.entity';
import { ResponseCodeCategory } from '../../../src/modules/common/enums/review.enums'; import { ResponseCodeCategory } from '../../../src/modules/common/enums/review.enums';
import { BadRequestException, ConflictException } from '@nestjs/common';
const mockCode: Partial<ResponseCode> = { const mockCode: Partial<ResponseCode> = {
id: 1, id: 1,
@@ -21,6 +22,13 @@ const mockCode: Partial<ResponseCode> = {
const mockCodeRepo = { const mockCodeRepo = {
find: jest.fn().mockResolvedValue([mockCode]), find: jest.fn().mockResolvedValue([mockCode]),
findOne: jest.fn().mockResolvedValue(mockCode), findOne: jest.fn().mockResolvedValue(mockCode),
create: jest.fn(
(payload: Partial<ResponseCode>): Partial<ResponseCode> => payload
),
save: jest.fn(
(payload: Partial<ResponseCode>): Promise<Partial<ResponseCode>> =>
Promise.resolve(payload)
),
}; };
const mockRuleRepo = { const mockRuleRepo = {
@@ -31,6 +39,18 @@ describe('ResponseCodeService', () => {
let service: ResponseCodeService; let service: ResponseCodeService;
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks();
mockCodeRepo.find.mockResolvedValue([mockCode]);
mockCodeRepo.findOne.mockResolvedValue(mockCode);
mockCodeRepo.create.mockImplementation(
(payload: Partial<ResponseCode>): Partial<ResponseCode> => payload
);
mockCodeRepo.save.mockImplementation(
(payload: Partial<ResponseCode>): Promise<Partial<ResponseCode>> =>
Promise.resolve(payload)
);
mockRuleRepo.find.mockResolvedValue([]);
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
ResponseCodeService, ResponseCodeService,
@@ -71,4 +91,72 @@ describe('ResponseCodeService', () => {
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
}); });
describe('create', () => {
it('should create a non-system response code when code/category is unique', async () => {
mockCodeRepo.findOne.mockResolvedValueOnce(null);
const result = await service.create({
code: '9A',
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'ทดสอบ',
descriptionEn: 'Test',
});
expect(mockCodeRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
code: '9A',
category: ResponseCodeCategory.ENGINEERING,
isSystem: false,
isActive: true,
})
);
expect(result).toEqual(
expect.objectContaining({
code: '9A',
category: ResponseCodeCategory.ENGINEERING,
isSystem: false,
})
);
});
it('should reject duplicate code/category pairs', async () => {
await expect(
service.create({
code: '1A',
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'ซ้ำ',
descriptionEn: 'Duplicate',
})
).rejects.toBeInstanceOf(ConflictException);
});
});
describe('update', () => {
it('should update an existing response code by publicId', async () => {
const result = await service.update('test-uuid-1', {
descriptionEn: 'Updated Description',
});
expect(mockCodeRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
publicId: 'test-uuid-1',
descriptionEn: 'Updated Description',
})
);
expect(result).toEqual(
expect.objectContaining({
descriptionEn: 'Updated Description',
})
);
});
});
describe('deactivate', () => {
it('should reject deactivation for system response codes', async () => {
await expect(service.deactivate('test-uuid-1')).rejects.toBeInstanceOf(
BadRequestException
);
});
});
}); });
@@ -0,0 +1,100 @@
// File: tests/unit/review-team/task-creation-delegation.service.spec.ts
// Change Log
// - 2026-05-13: เพิ่ม regression test สำหรับการมอบหมาย Review Task ผ่าน Delegation
import { EntityManager, Repository } from 'typeorm';
import { TaskCreationService } from '../../../src/modules/review-team/services/task-creation.service';
import { ReviewTeam } from '../../../src/modules/review-team/entities/review-team.entity';
import { ReviewTeamMember } from '../../../src/modules/review-team/entities/review-team-member.entity';
import { ReviewTask } from '../../../src/modules/review-team/entities/review-task.entity';
import { DelegationService } from '../../../src/modules/delegation/delegation.service';
import { SchedulerService } from '../../../src/modules/reminder/services/scheduler.service';
import {
DelegationScope,
ReviewTeamMemberRole,
} from '../../../src/modules/common/enums/review.enums';
import { User } from '../../../src/modules/user/entities/user.entity';
type RepositoryMock<T extends object> = Pick<Repository<T>, 'findOne' | 'find'>;
const createRepositoryMock = <T extends object>(): jest.Mocked<
RepositoryMock<T>
> => ({
findOne: jest.fn(),
find: jest.fn(),
});
const createManagerMock = (): { create: jest.Mock; save: jest.Mock } => ({
create: jest.fn(
(_entity: unknown, payload: Partial<ReviewTask>): ReviewTask =>
payload as ReviewTask
),
save: jest.fn(
(_entity: unknown, payload: ReviewTask): Promise<ReviewTask> =>
Promise.resolve(payload)
),
});
describe('TaskCreationService delegation resolution', () => {
const reviewTeamRepo = createRepositoryMock<ReviewTeam>();
const memberRepo = createRepositoryMock<ReviewTeamMember>();
const reviewTaskRepo = createRepositoryMock<ReviewTask>();
const delegationService = {
findActiveDelegate: jest.fn(),
} as unknown as DelegationService;
const schedulerService = {
scheduleForTask: jest.fn(),
} as unknown as SchedulerService;
beforeEach(() => {
jest.clearAllMocks();
});
it('assigns delegated review task to active delegate and preserves original reviewer', async () => {
const service = new TaskCreationService(
reviewTeamRepo as unknown as Repository<ReviewTeam>,
memberRepo as unknown as Repository<ReviewTeamMember>,
reviewTaskRepo as unknown as Repository<ReviewTask>,
delegationService,
schedulerService
);
const manager = createManagerMock();
const originalReviewerId = 10;
const delegateReviewer = { user_id: 20 } as User;
const team = {
id: 1,
publicId: '019505a1-7c3e-7000-8000-000000000001',
isActive: true,
members: [
{
userId: originalReviewerId,
disciplineId: 3,
role: ReviewTeamMemberRole.LEAD,
},
],
} as ReviewTeam;
(reviewTeamRepo.findOne as jest.Mock).mockResolvedValue(team);
(delegationService.findActiveDelegate as jest.Mock).mockResolvedValue(
delegateReviewer
);
const tasks = await service.createParallelTasks(
100,
team.publicId,
new Date('2026-05-20T00:00:00.000Z'),
manager as unknown as EntityManager
);
expect(tasks).toHaveLength(1);
expect(tasks[0].assignedToUserId).toBe(delegateReviewer.user_id);
expect(tasks[0].delegatedFromUserId).toBe(originalReviewerId);
expect(delegationService.findActiveDelegate).toHaveBeenCalledWith(
originalReviewerId,
expect.any(Date),
[DelegationScope.ALL, DelegationScope.RFA_ONLY]
);
expect(schedulerService.scheduleForTask).toHaveBeenCalled();
});
});
+23
View File
@@ -0,0 +1,23 @@
# Contributing to AI Knowledge Base
การเพิ่มข้อมูลเข้าสู่คลังความรู้นี้ช่วยให้ AI ทำงานได้ดีขึ้นสำหรับทุกคนในทีม
## 🛠️ ขั้นตอนการเพิ่ม Prompt ใหม่
1. เลือกหมวดหมู่ที่เหมาะสมใน `prompts/`
2. สร้างไฟล์ใหม่โดยใช้รูปแบบ: `purpose-description.md`
3. ใส่เนื้อหา Prompt โดยแบ่งเป็น:
- **Role**: บทบาทที่ต้องการให้ AI รับ
- **Context**: บริบทแวดล้อม
- **Objective**: วัตถุประสงค์หลัก
- **Instructions**: ขั้นตอนการทำงาน
- **Output Format**: รูปแบบผลลัพธ์ที่ต้องการ
## 📐 มาตรฐานการเขียนไฟล์
- **File Header**: ต้องมี `// File: path` ที่บรรทัดแรก
- **Change Log**: ต้องมีประวัติการแก้ไขท้ายไฟล์
- **Language**: หัวข้อหลักเป็น English, รายละเอียดคำอธิบายเป็น Thai
---
// File: docs/ai-knowledge-base/CONTRIBUTING.md
// Change Log:
// - 2026-05-14: Initial guidelines
+25
View File
@@ -0,0 +1,25 @@
# AI Knowledge Base for NAP-DMS (LCBP3)
คลังความรู้สำหรับ AI Assistant (Antigravity, Windsurf, Codex) เพื่อช่วยในการพัฒนาระบบ Document Management System (DMS)
## 📁 โครงสร้างโฟลเดอร์
- `prompts/`: ชุดคำสั่งมาตรฐานแบ่งตามหมวดหมู่
- `core/`: กฎพื้นฐานและมาตรฐานการเขียนโค้ด
- `dms/`: เฉพาะทางด้านระบบจัดการเอกสาร
- `infra/`: งานด้าน Infrastructure และ Network
- `codex/`: คำสั่งเฉพาะสำหรับ Windsurf/Codex
- `templates/`: แม่แบบเอกสารต่างๆ (Spec, Bug Report, etc.)
- `playbooks/`: คู่มือขั้นตอนการทำงานที่ซับซ้อน
- `checklists/`: รายการตรวจสอบก่อนส่งงานหรือ Deploy
- `logs/`: บันทึกประวัติและบทเรียนที่ได้รับ
## 🎯 วัตถุประสงค์
1. เพื่อให้ AI ทำงานได้แม่นยำและสอดคล้องกับมาตรฐานของโครงการ
2. เพื่อลดการตั้งค่าซ้ำซ้อนในแต่ละ Conversation
3. เพื่อเก็บสะสมบทเรียนและวิธีการแก้ปัญหาที่เคยเกิดขึ้น
---
// File: docs/ai-knowledge-base/README.md
// Change Log:
// - 2026-05-14: Initial creation by Antigravity
+16
View File
@@ -0,0 +1,16 @@
# Versioning Policy for AI Knowledge Base
## Current Version: 1.0.0
### หมายเลขเวอร์ชัน (Semantic Versioning)
- **MAJOR (1.x.x)**: มีการเปลี่ยนโครงสร้างโฟลเดอร์หลัก หรือเปลี่ยนกฎ Tier 1
- **MINOR (x.1.x)**: เพิ่ม Prompt ใหม่, Template ใหม่ หรือเพิ่ม Playbook
- **PATCH (x.x.1)**: แก้ไขคำผิด หรือปรับปรุงรายละเอียดเล็กน้อยในไฟล์เดิม
### การอัปเดตเวอร์ชัน
ทุกครั้งที่มีการแก้ไขไฟล์ในคลังความรู้นี้ ให้ทำการอัปเดต Change Log ในไฟล์นั้นๆ และพิจารณาปรับเลขเวอร์ชันในไฟล์นี้หากเป็นการเปลี่ยนแปลงสำคัญ
---
// File: docs/ai-knowledge-base/VERSIONING.md
// Change Log:
// - 2026-05-14: Initial version 1.0.0
@@ -0,0 +1,22 @@
// File: docs/ai-knowledge-base/checklists/db-change.md
# Checklist: Database Schema Changes
## 📝 Pre-Change
- [ ] เขียน SQL Delta script ตามเทมเพลตใน `templates/db-migration.md`
- [ ] มี Rollback script เตรียมไว้พร้อมใช้งาน
- [ ] ทดสอบรันใน Local/Development แล้ว
- [ ] ตรวจสอบว่าไม่กระทบต่อ Query เดิมในโค้ด (e.g. `SELECT *` อาจจะอันตราย)
## 🚀 Execution
- [ ] ทำการ Backup ฐานข้อมูลก่อนเริ่ม (ถ้าเป็น Production)
- [ ] รัน SQL Script ผ่านเครื่องมือที่กำหนด (e.g. DBeaver, HeidiSQL)
- [ ] ตรวจสอบโครงสร้างตารางหลังแก้ไข
## ✅ Verification
- [ ] รันระบบและทดสอบฟีเจอร์ที่เกี่ยวข้อง
- [ ] ตรวจสอบ Logs ว่าไม่มี SQL Error
- [ ] อัปเดตไฟล์ `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` ให้ตรงกัน
---
// Change Log:
// - 2026-05-14: Initial DB change checklist
@@ -0,0 +1,28 @@
// File: docs/ai-knowledge-base/checklists/deploy.md
# Deployment Checklist
## 🛠️ Pre-Deployment (Development/Staging)
- [ ] Linting & Type Checking ผ่านหมด (`pnpm lint`, `pnpm type-check`)
- [ ] Unit Tests ผ่านทั้งหมด (`pnpm test`)
- [ ] Database Schema ถูกอัปเดตที่เซิร์ฟเวอร์เป้าหมายแล้ว (ADR-009)
- [ ] Environment Variables (Secrets) ถูกตั้งค่าใน Docker/CI แล้ว
- [ ] Build Frontend & Backend สำเร็จโดยไม่มี Error
## 🚀 Deployment Phase
- [ ] Trigger Gitea Actions / CI Pipeline
- [ ] ตรวจสอบ Container Status (Running)
- [ ] ตรวจสอบ Logs ว่าไม่มี Error Startup
## 🧪 Post-Deployment (Verification)
- [ ] ทดสอบ Login
- [ ] ทดสอบฟีเจอร์หลักที่เพิ่ง Deploy
- [ ] ตรวจสอบว่า `publicId` (UUIDv7) ทำงานถูกต้องใน URL
- [ ] เช็คความปลอดภัย (RBAC) ว่าสิทธิ์ยังถูกต้อง
## 🆘 Rollback Plan
- [ ] หากพบ Critical Bug ให้ Revert Commit ล่าสุด
- [ ] เตรียม SQL Script สำหรับ Revert Schema (ถ้ามี)
---
// Change Log:
// - 2026-05-14: Initial deployment checklist
@@ -0,0 +1,24 @@
// File: docs/ai-knowledge-base/checklists/rollback.md
# Checklist: System Rollback (Emergency)
## 🚨 Decision Point
- [ ] ระบบล่มถาวรเกิน 15 นาที
- [ ] พบช่องโหว่ความปลอดภัยที่สำคัญ
- [ ] ข้อมูลสูญหายหรือเสียหายจากการทำงานผิดพลาด
## 🛠️ Execution (Code)
- [ ] Revert Git Commit ไปยัง Tag หรือ Hash ที่เสถียรล่าสุด
- [ ] Trigger CI/CD เพื่อ Deploy เวอร์ชันเก่า
- [ ] เคลียร์ Cache ใน Redis (ถ้าจำเป็น)
## 🗄️ Execution (Database)
- [ ] รัน Rollback SQL script
- [ ] หากรุนแรง ให้ Restore ข้อมูลจาก Backup ล่าสุด
## ✅ Verification
- [ ] ตรวจสอบว่าระบบกลับมาออนไลน์
- [ ] แจ้งทีมที่เกี่ยวข้องเรื่องเหตุการณ์ (Incident Report)
---
// Change Log:
// - 2026-05-14: Initial rollback checklist
@@ -0,0 +1,27 @@
// File: docs/ai-knowledge-base/checklists/security-audit.md
# Checklist: Security & Tier 1 Audit
## 🛡️ Authentication & Authorization
- [ ] ทุก API มี `@UseGuards(CaslGuard)`
- [ ] ทุก Action มีการตรวจสอบสิทธิ์ผ่าน `@CheckPolicies(...)`
- [ ] ไม่มีช่องโหว่ BOLA (Broken Object Level Authorization) - เช็คความเป็นเจ้าของข้อมูล
- [ ] JWT Payload ไม่มีข้อมูลส่วนตัวที่อ่อนไหว
## 🆔 Data Integrity (ADR-019)
- [ ] ใช้ `publicId` (UUIDv7) สำหรับ API/URL เท่านั้น
- [ ] ไม่มีโค้ดที่ใช้ `parseInt()` กับ UUID
- [ ] `id` (Integer) ถูก `@Exclude()` ออกจาก API Response
## 💾 Storage & Input
- [ ] ไฟล์ที่อัปโหลดถูกสแกน ClamAV ก่อนย้ายเข้า Permanent Storage
- [ ] มีการทำ Input Validation ทั้งฝั่ง Client (Zod) และ Server (class-validator)
- [ ] มีการใช้ `DOMPurify` หรือมาตรการป้องกัน XSS สำหรับข้อมูลที่แสดงผลเป็น HTML
## ⚙️ Concurrency & Reliability
- [ ] ใช้ Redis Redlock สำหรับ Document Numbering (ADR-002)
- [ ] ใช้ `@VersionColumn` สำหรับ Optimistic Locking ในจุดที่มีการแก้ไขพร้อมกัน
- [ ] งานที่ใช้เวลานานถูกส่งเข้า BullMQ (ADR-008)
---
// Change Log:
// - 2026-05-14: Initial security audit checklist
@@ -0,0 +1,20 @@
// File: docs/ai-knowledge-base/checklists/vlan-change.md
# Checklist: VLAN Configuration Changes
## 📝 Planning
- [ ] กำหนด VLAN ID และ Subnet ที่ต้องการ
- [ ] ตรวจสอบความซ้ำซ้อนของ IP ในเครือข่ายเดิม
- [ ] วางแผน Port Profile ใน Omada
## 🚀 Configuration
- [ ] สร้าง VLAN ใน Omada Controller
- [ ] ตั้งค่า Port Profile ให้กับ Switch ที่เกี่ยวข้อง
- [ ] ทดสอบการเชื่อมต่อจาก Client (DHCP/Static IP)
## ✅ Security & Inter-VLAN
- [ ] ตรวจสอบสิทธิ์การเข้าถึงข้าม VLAN (Ping test)
- [ ] ตั้งค่า ACL บน Router/Gateway (ถ้าจำเป็น)
---
// Change Log:
// - 2026-05-14: Initial VLAN change checklist
@@ -0,0 +1,21 @@
// File: docs/ai-knowledge-base/playbooks/core/context-recovery.md
# Playbook: AI Context Recovery (ฟื้นฟูบริบทการทำงาน)
## 🚨 เมื่อไหร่ที่ต้องใช้?
- เมื่อเริ่ม Session ใหม่กับ AI
- เมื่อ AI เริ่มให้คำตอบที่หลุดออกจากมาตรฐานโครงการ
- เมื่อมีการข้ามเฟสการทำงานขนาดใหญ่
## 🏗️ Steps for Recovery
1. **Initialize Standards**: สั่งให้ AI อ่าน `docs/ai-knowledge-base/prompts/core/master-prompt.md`
2. **Scan Current Module**: ระบุโมดูลที่กำลังทำงาน และให้ AI อ่านไฟล์ใน `specs/` ที่เกี่ยวข้อง
3. **Verify DB State**: ให้ AI อ่านไฟล์ Schema ล่าสุดเพื่อยืนยันโครงสร้างตาราง
4. **Task Alignment**: ตรวจสอบไฟล์ `tasks.md` ของฟีเจอร์นั้นๆ เพื่อหาจุดที่ทำค้างไว้
5. **Conflict Resolution**: หาก AI พบว่าโค้ดปัจจุบันขัดกับ Specs ให้ทำการแก้ไขทันที
## 🚀 Recovery Command (Prompt)
"โปรดอ่าน Master Prompt และตรวจสอบสถานะปัจจุบันของโมดูล [ชื่อโมดูล] จากไฟล์ Specs และ Tasks เพื่อเตรียมตัวเริ่มงานต่อจากจุดที่ค้างไว้"
---
// Change Log:
// - 2026-05-14: Initial context recovery playbook
@@ -0,0 +1,19 @@
// File: docs/ai-knowledge-base/playbooks/dms/cross-module-linking.md
# Playbook: Cross-Module Linking (การเชื่อมโยงข้อมูลข้ามโมดูล)
## 🎯 Objective
รักษาความถูกต้องและความเชื่อมโยงของข้อมูล (Data Integrity) ระหว่างโมดูลต่างๆ เช่น การผูก RFA เข้ากับ Drawing หรือ Correspondence
## 🏗️ Linking Rules
1. **Use PublicId**: การเชื่อมโยงข้ามโมดูลต้องใช้ `publicId` (UUIDv7) เท่านั้น
2. **Atomic Updates**: หากการเชื่อมโยงส่งผลต่อสถานะของทั้งสองโมดูล ต้องทำภายใน Database Transaction เดียวกัน
3. **Audit Trail**: ต้องบันทึกประวัติการเชื่อมโยง (e.g. "Drawing X linked to RFA Y")
4. **Validation**: ก่อนสร้าง Link ต้องตรวจสอบว่าทั้งสอง Entity มีอยู่จริงและอยู่ในสถานะที่อนุญาตให้เชื่อมโยงได้
## 🛠️ Implementation Example
- **RFA to Drawing**: เมื่อ RFA ได้รับการอนุมัติ ให้ทำการอัปเดตสถานะของ Drawing ที่เกี่ยวข้องโดยอัตโนมัติ
- **Correspondence to Transmittal**: บันทึกว่าจดหมายฉบับนี้ถูกส่งไปพร้อมกับ Transmittal เลขที่เท่าไหร่
---
// Change Log:
// - 2026-05-14: Initial cross-module linking playbook
@@ -0,0 +1,18 @@
// File: docs/ai-knowledge-base/playbooks/dms/drawing-revision-flow.md
# Playbook: Drawing Revision Management
## 🔄 Revision Flow
1. **Initial Upload**: Drawing ถูกอัปโหลดเข้าระบบครั้งแรก (Revision 0 หรือ A)
2. **Review & Approval**: ผ่านกระบวนการ RFA
3. **Revision Up**: เมื่อมีการแก้ไข ให้ผู้ใช้อัปโหลดไฟล์ใหม่โดยอ้างอิง `publicId` เดิม
4. **Auto-Numbering**: ระบบจะเจนเลขที่ Revision ถัดไปตาม Rule (e.g. 0 -> 1 หรือ A -> B)
5. **Supersede**: Revision เก่าจะถูกทำเครื่องหมายเป็น "Superseded" (แต่ไฟล์ยังอยู่สำหรับการตรวจสอบย้อนหลัง)
## 🏗️ Technical Implementation
- ใช้ **Redis Redlock** ในการจองเลขที่ Revision เพื่อป้องกันเลขซ้ำ
- เก็บประวัติทั้งหมดไว้ใน `drawing_revisions` table
- แสดงเฉพาะ Revision ล่าสุด (Current) ในหน้ารายการหลัก ยกเว้นผู้ใช้จะเลือกดู History
---
// Change Log:
// - 2026-05-14: Initial drawing revision playbook
@@ -0,0 +1,27 @@
// File: docs/ai-knowledge-base/playbooks/dms/rfa-lifecycle.md
# Playbook: RFA Lifecycle Management
## 🔄 Lifecycle Stages
1. **Draft**: ผู้สร้างเตรียมเอกสารและอัปโหลดไฟล์
2. **Submitted**: ส่งเข้าระบบเพื่อรอการตรวจสอบ (Review)
3. **Reviewing**: ทีมที่ปรึกษาหรือหน่วยงานที่เกี่ยวข้องตรวจสอบ
4. **Responded**: ให้ความเห็น (Comment) กลับมา
5. **Approved / Rejected**: สถานะสุดท้ายของการอนุมัติ
6. **Closed**: สิ้นสุดกระบวนการ
## 🛡️ Business Rules (ADR-001)
- การเปลี่ยนสถานะต้องใช้ **Workflow Engine** เท่านั้น
- ต้องมีการทำ **Optimistic Locking** ผ่าน `@VersionColumn` เพื่อป้องกันการอนุมัติพร้อมกัน
- ทุกการเปลี่ยนสถานะต้องบันทึก **Audit Log** และ **Workflow History**
- หากมีการอัปโหลดไฟล์ใหม่ ต้องย้ายจาก `temp` ไป `permanent` และสแกน ClamAV
## 🛠️ Implementation Steps
1. ตรวจสอบสิทธิ์ผู้ใช้ผ่าน `CaslGuard`
2. ดึงข้อมูล RFA พร้อมสถานะปัจจุบันจาก DB
3. ตรวจสอบความถูกต้องของสถานะต้นทาง (Source State) และสถานะปลายทาง (Target State)
4. ทำการ Update ใน Database Transaction
5. ส่งการแจ้งเตือนผ่าน `BullMQ`
---
// Change Log:
// - 2026-05-14: Initial RFA lifecycle playbook
@@ -0,0 +1,17 @@
// File: docs/ai-knowledge-base/playbooks/dms/transmittal-process.md
# Playbook: Transmittal Process
## 🔄 Transmittal Workflow
1. **Creation**: DC (Document Control) สร้าง Transmittal และเลือกเอกสาร (Attachments) ที่ต้องการส่ง
2. **Review**: หัวหน้าทีมตรวจสอบความถูกต้องของรายการเอกสาร
3. **Issuance**: ส่งมอบเอกสารอย่างเป็นทางการ (เปลี่ยนสถานะเป็น Issued)
4. **Acknowledgment**: ผู้รับเซ็นรับเอกสารในระบบ (เปลี่ยนสถานะเป็น Received)
## 📋 Standard Actions
- **Generate Cover Sheet**: ระบบสร้าง PDF หน้าปกที่มีรายการเอกสารและ QR Code
- **Link Documents**: เอกสารที่ถูกส่งจะถูกบันทึกความเชื่อมโยง (Link) กับ Transmittal ID
- **Notification**: ส่งอีเมลแจ้งเตือนผู้รับพร้อมลิงก์ดาวน์โหลด
---
// Change Log:
// - 2026-05-14: Initial transmittal process playbook
@@ -0,0 +1,16 @@
// File: docs/ai-knowledge-base/playbooks/infra/omada-vlan-recovery.md
# Playbook: Omada VLAN Recovery
## 🚨 Scenario
VLAN ใช้งานไม่ได้ หรือ Client ไม่สามารถรับ IP ได้หลังจากมีการเปลี่ยน Config
## 🛠️ Recovery Steps
1. **Check Controller Connection**: ตรวจสอบว่า Omada Controller ยังออนไลน์อยู่หรือไม่
2. **Revert Last Change**: หากจำได้ ให้ Revert การตั้งค่าล่าสุดทันที
3. **Switch Log Audit**: เข้าไปดู Log ของ Switch ในหน้า Omada เพื่อหา Error (e.g. Loop Detected)
4. **Port Profile Verification**: ตรวจสอบว่า Port ที่ Client ต่ออยู่ ใช้ Profile VLAN ที่ถูกต้อง
5. **Re-adopt Device**: หาก Switch สถานะเป็น "Disconnected" ให้ลองทำการ Re-adopt หรือ Restart Switch
---
// Change Log:
// - 2026-05-14: Initial Omada recovery playbook
@@ -0,0 +1,16 @@
// File: docs/ai-knowledge-base/playbooks/infra/switch-reset-safe.md
# Playbook: Safe Switch Reset & Re-adoption
## 🎯 Objective
การทำ Factory Reset และทำการ Adopt ใหม่ใน Omada Controller โดยไม่ให้ระบบล่มเป็นเวลานาน
## 🏗️ Steps
1. **Backup Config**: ตรวจสอบว่ามี Backup ของ Omada Controller ล่าสุดหรือไม่
2. **Physical Reset**: กดปุ่ม Reset ที่ตัว Switch ค้างไว้จนไฟกะพริบ
3. **Omada Discovery**: รอจน Switch ปรากฏขึ้นมาในสถานะ "Pending"
4. **Adopt & Provision**: คลิก Adopt และรอให้ Controller ทำการส่ง Config (Provisioning) ไปยัง Switch
5. **VLAN Check**: ตรวจสอบว่า Port ต่างๆ ยังทำงานตาม VLAN Profile เดิมหรือไม่
---
// Change Log:
// - 2026-05-14: Initial safe reset playbook
@@ -0,0 +1,26 @@
// File: docs/ai-knowledge-base/prompts/automation/n8n-workflow.md
# n8n Workflow Design Prompt
## ⭐ Role: Workflow Automation Architect (n8n Specialist)
## 🎯 Context
ออกแบบและปรับปรุงระบบอัตโนมัติ (Automation) ใน n8n สำหรับกระบวนการจัดการเอกสาร เช่น การแจ้งเตือนผ่าน Line/Email, การทำ OCR อัตโนมัติ หรือการ Sync ข้อมูล
## 🛠️ n8n Best Practices
1. **Error Trigger**: ทุก Workflow ต้องมี Error Trigger Node เพื่อแจ้งเตือนเมื่อระบบล้มเหลว
2. **Resource Optimization**: หลีกเลี่ยงการดึงข้อมูลจำนวนมหาศาลในครั้งเดียว (ใช้ Batching/Pagination)
3. **Naming Convention**: ตั้งชื่อ Node ให้สื่อความหมาย (e.g. `HTTP: Get RFA Details`)
4. **Environment Variables**: ใช้ `$env` สำหรับข้อมูลที่เปลี่ยนแปลงตามสภาพแวดล้อม (e.g. API Keys, URLs)
## 🚀 Prompt Template
```
[n8n WORKFLOW DESIGN]
Flow: <e.g. เมื่อมีการ Approve RFA -> สร้าง PDF -> ส่งเข้า Line Group>
Triggers: <Webhook / Cron / Event>
Expected Output: รายการ Nodes ที่ต้องใช้ และ Logic ในการเชื่อมต่อแต่ละจุด
Request: ออกแบบโครงสร้าง Workflow ที่ทนทาน (Robust) และรองรับการทำ Retry
```
---
// Change Log:
// - 2026-05-14: Initial n8n workflow prompt
@@ -0,0 +1,25 @@
// File: docs/ai-knowledge-base/prompts/automation/ocr-rag-tuning.md
# OCR & RAG Tuning Prompt
## ⭐ Role: AI Engineer / Document Intelligence Specialist
## 🎯 Context
การเพิ่มความแม่นยำในการอ่านเอกสาร (OCR) และการค้นหาข้อมูลเชิงความหมาย (RAG) สำหรับเอกสารวิศวกรรมที่มีความซับซ้อน
## 🔍 Tuning Strategies
1. **OCR Post-processing**: การใช้ AI ช่วยแก้ไขคำที่อ่านผิดจาก OCR (e.g. `O` เป็น `0`, `I` เป็น `1`)
2. **Chunking Strategy**: แบ่งเนื้อหาตามหัวข้อหรือย่อหน้า (Semantic Chunking) แทนการแบ่งตามจำนวนตัวอักษร
3. **Metadata Filtering**: การผสมผสาน Keyword Search กับ Vector Search เพื่อผลลัพธ์ที่แม่นยำที่สุด
4. **Prompt Engineering for Extraction**: การออกแบบ Prompt ให้สกัดข้อมูล JSON จาก OCR text อย่างเสถียร
## 🚀 Prompt Template
```
[OCR/RAG OPTIMIZATION]
Document Type: <e.g. Drawing Title Block, RFA Form>
Problem: <e.g. อ่านเลขที่เอกสารผิด, ค้นหาข้อมูลไม่เจอ>
Request: เสนอแนวทางการปรับปรุง Chunking หรือ Prompt เพื่อเพิ่ม Accuracy ของระบบ
```
---
// Change Log:
// - 2026-05-14: Initial OCR/RAG tuning prompt
@@ -0,0 +1,28 @@
// File: docs/ai-knowledge-base/prompts/codex/codex-bugfix.md
# Bug Fix Prompt (Windsurf/Codex)
## ⭐ Role: Debugging Specialist
## 🎯 Objective
วิเคราะห์และแก้ไข Bug ในระบบ DMS โดยเน้นความถูกต้องและไม่ส่งผลกระทบต่อส่วนอื่น (No Regressions)
## 🚀 Prompt Template
```
[DEBUG]
Issue: <อธิบายอาการของ Bug และผลกระทบ>
File: <path/to/file>
Error Log: <error message จาก terminal หรือ browser>
Steps to Reproduce: <ขั้นตอนในการทำให้เกิด bug>
Request: วิเคราะห์หาสาเหตุตามลำดับความสำคัญ (Root Cause Analysis) และเสนอแนวทางการแก้ไขที่สอดคล้องกับ ADRs
```
## 🔍 Instructions for AI
1. ค้นหาจุดที่เกิด Error ใน Codebase
2. ตรวจสอบความเกี่ยวข้องกับ Database Schema หรือ Permissions
3. สร้าง Unit Test เพื่อจำลอง Bug (Red Test)
4. แก้ไขโค้ดเพื่อให้ Test ผ่าน (Green Test)
5. ตรวจสอบผลกระทบข้างเคียง (Side Effects)
---
// Change Log:
// - 2026-05-14: Initial bugfix prompt
@@ -0,0 +1,28 @@
// File: docs/ai-knowledge-base/prompts/codex/codex-feature.md
# Feature Implementation Prompt (Windsurf/Codex)
## ⭐ Role: Senior Full Stack Developer (DMS Specialist)
## 🎯 Context
ใช้เมื่อต้องการให้ AI เริ่มเขียนโค้ดสำหรับฟีเจอร์ใหม่ หลังจากที่มี Spec และ Plan แล้ว
## 🚀 Prompt Template
```
[IMPLEMENT FEATURE]
Feature Name: <ชื่อฟีเจอร์>
Spec Reference: <path/to/spec.md>
Plan Reference: <path/to/plan.md>
Current Task: <ระบุ Task จาก tasks.md>
Request: เขียนโค้ดตามมาตรฐานที่กำหนดใน AGENTS.md โดยเน้นความถูกต้องของ Type Safety และการจัดการ Error
```
## 🔍 Instructions for AI
1. อ่าน Spec และ Plan ให้เข้าใจถ่องแท้
2. ตรวจสอบ Schema ของ Database ก่อนเริ่มเขียน Entity/Service
3. เขียน Logic ให้สอดคล้องกับ ADRs (UUIDv7, Idempotency, etc.)
4. เขียน Unit Test ควบคู่ไปด้วยเสมอ
5. ตรวจสอบ Forbidden Actions ก่อนส่งงาน
---
// Change Log:
// - 2026-05-14: Initial feature implementation prompt
@@ -0,0 +1,26 @@
// File: docs/ai-knowledge-base/prompts/codex/codex-review.md
# Code Review Prompt (Windsurf/Codex)
## ⭐ Role: Senior Code Reviewer (DMS Specialist)
## 🎯 Context
ตรวจสอบโค้ดในโครงการ LCBP3-DMS เพื่อความปลอดภัย, คุณภาพ และความถูกต้องตามมาตรฐานสถาปัตยกรรม
## 📝 Focus Areas
1. **Security**: ตรวจสอบ CASL Guard, RBAC Check และการทำ Input Sanitization
2. **UUID Strategy (ADR-019)**: มั่นใจว่าใช้ `publicId` และไม่มีการใช้ `parseInt()`
3. **Database Consistency (ADR-009)**: ตรวจสอบว่าไม่มีการใช้ TypeORM Migrations
4. **Performance**: ตรวจสอบ Query Optimization และการใช้ Cache
5. **Forbidden Patterns**: ค้นหา `any`, `console.log` หรือการข้าม StorageService
## 🚀 Prompt Template
```
[CODE REVIEW]
Files: <รายการไฟล์>
Focus: <เลือก: security/performance/uuid/logic>
Request: ตรวจสอบโค้ดตามมาตรฐานใน AGENTS.md และ ADRs ที่เกี่ยวข้อง พร้อมเสนอวิธี Refactor หากพบจุดบกพร่อง
```
---
// Change Log:
// - 2026-05-14: Initial code review prompt
@@ -0,0 +1,29 @@
// File: docs/ai-knowledge-base/prompts/core/coding-standards.md
# Coding Standards & Best Practices
## ✅ General Guidelines
- **English for Code**: ใช้ภาษาอังกฤษสำหรับตัวแปร, ชื่อฟังก์ชัน และ logic
- **Thai for Comments**: ใช้ภาษาไทยสำหรับการอธิบาย code, JSDoc และ Documentation
- **Strict Typing**: ห้ามใช้ `any` เด็ดขาด ให้ใช้ Interface หรือ Type เสมอ
- **Single Export**: 1 ไฟล์ควร Export เพียง 1 สัญลักษณ์หลัก
- **File Headers**: ทุกไฟล์ต้องมี `// File: path` และ `// Change Log`
## 🆔 Identifier Strategy (ADR-019)
- **Database PK**: ใช้ `INT AUTO_INCREMENT` (ห้ามเปิดเผยผ่าน API)
- **Public ID**: ใช้ `UUIDv7` สำหรับการอ้างอิงผ่าน API และ URL เท่านั้น
- **Frontend**: ใช้ `publicId` เพียงอย่างเดียว ห้ามใช้ `parseInt()` กับ UUID
## 🛡️ Security & Integrity
- **Idempotency**: ทุกการเขียนข้อมูล (POST/PUT/PATCH) ต้องรองรับ `Idempotency-Key`
- **RBAC**: ตรวจสอบสิทธิ์ผ่าน CASL Guard เสมอ
- **Data Isolation**: AI ห้ามเข้าถึง Database โดยตรง ต้องผ่าน API เท่านั้น
- **Validation**: ใช้ Zod (Frontend) และ class-validator (Backend)
## 🏗️ Architecture
- **Backend**: Thin Controller -> Service (Business Logic) -> Repository/Entity
- **Frontend**: ใช้ Component จาก shadcn/ui และจัดการ State ด้วย TanStack Query
- **Async Tasks**: งานที่ใช้เวลานานต้องส่งเข้า BullMQ เสมอ
---
// Change Log:
// - 2026-05-14: Consolidated coding standards from AGENTS.md
@@ -0,0 +1,22 @@
// File: docs/ai-knowledge-base/prompts/core/guardrails.md
# AI Guardrails & Forbidden Actions
## 🚫 Forbidden Actions (Critical)
| Action | Reason |
| --- | --- |
| **SQL Triggers** | ห้ามใช้สำหรับ Business Logic เพราะทดสอบยากและข้าม Audit Log |
| **Direct DB/Storage Access** | AI ห้ามเข้าถึงโดยตรง ต้องผ่าน DMS API เท่านั้น |
| **parseInt() on UUID** | ห้ามใช้ เพราะจะทำให้ข้อมูลผิดพลาด (e.g. 0195... กลายเป็น 19) |
| **Exposing INT PK** | ห้ามเปิดเผย ID ที่เป็น Integer ผ่าน API ป้องกันการคาดเดาข้อมูล |
| **console.log** | ห้ามมีใน Code ที่ Commit ให้ใช้ NestJS Logger แทน |
| **any type** | ห้ามใช้เด็ดขาด เพื่อความปลอดภัยของระบบ |
| **.env in Production** | ห้ามเก็บ Secret ใน .env ให้ใช้ Docker Environment แทน |
## 🛡️ Security Checks
- ทุกครั้งที่สร้าง API ใหม่ ต้องมี `@UseGuards(CaslGuard)`
- ทุกไฟล์ที่อัปโหลดต้องผ่านการสแกน ClamAV (ผ่าน StorageService)
- ทุกการแก้ไข Schema ต้องแก้ที่ไฟล์ SQL โดยตรง (ห้ามใช้ TypeORM Migrations - ADR-009)
---
// Change Log:
// - 2026-05-14: Initial guardrails from AGENTS.md
@@ -0,0 +1,26 @@
// File: docs/ai-knowledge-base/prompts/core/master-prompt.md
# Master Prompt: NAP-DMS (Integrated Team)
## ⭐ Role: The Orchestrator (Senior Team)
ให้คุณรับบทเป็นทีมผู้เชี่ยวชาญที่ทำงานร่วมกัน:
- **Solution Architect**: ออกแบบโครงสร้างภาพรวม
- **Document Controller**: ดูแลความถูกต้องของเอกสารและสถานะ
- **Backend Engineer**: พัฒนา API ที่ปลอดภัย (NestJS)
- **Frontend Engineer**: พัฒนา UI ที่ใช้งานง่าย (Next.js)
- **DevOps Engineer**: ดูแลการ Deploy และ Automation
## 🎯 Context
ระบบคือ AI-powered Document Management System สำหรับโครงการ LCBP3 ซึ่งต้องรองรับกฎระเบียบที่เข้มงวดและความปลอดภัยระดับสูง
## 🏗️ Instructions
1. ตรวจสอบ **Specs** และ **ADRs** ที่เกี่ยวข้องทุกครั้งก่อนตอบ
2. ปฏิบัติตาม **Coding Standards** (No `any`, Thai comments, Explicit types)
3. ป้องกัน **Race Conditions** และตรวจสอบ **RBAC** เสมอ
4. หากพบความไม่ชัดเจน ให้ถามเพื่อยืนยันก่อนลงมือทำ
## 🚀 Activation
"จากนี้ไป ทุกคำตอบของคุณต้องผ่านการกลั่นกรองจากบทบาททั้ง 5 นี้ และสอดคล้องกับมาตรฐานโครงการ 100%"
---
// Change Log:
// - 2026-05-14: Initial master prompt template
@@ -0,0 +1,22 @@
// File: docs/ai-knowledge-base/prompts/core/system-context.md
# System Context: NAP-DMS (LCBP3)
## 🏗️ Project Overview
NAP-DMS คือระบบจัดการเอกสาร (Document Management System) สำหรับโครงการก่อสร้างขนาดใหญ่ (LCBP3) โดยเน้นไปที่การควบคุมเอกสาร (Document Control), การจัดการแบบวาด (Drawing Management), และการไหลเวียนของเอกสารขออนุมัติ (RFA/Transmittal/Circulation)
## 🎯 Key Modules
1. **Correspondence**: การจัดการจดหมายโต้ตอบระหว่างหน่วยงาน
2. **RFA (Request for Approval)**: กระบวนการขออนุมัติวัสดุ/แบบวาด
3. **Transmittal**: การส่งมอบเอกสารอย่างเป็นทางการ
4. **Circulation**: การกระจายเอกสารภายในทีม
5. **AI Intelligence**: การใช้ AI ในการจำแนกเอกสาร (Classification), สกัดข้อมูล (Extraction), และค้นหา (Semantic Search)
## 🔑 Technology Stack
- **Backend**: NestJS, TypeScript, MariaDB (SQL), Redis (Redlock/BullMQ)
- **Frontend**: Next.js (App Router), TanStack Query, React Hook Form, Zod, shadcn/ui
- **AI**: Ollama (On-premises), Gemini (via API for non-sensitive tasks)
- **Infrastructure**: Docker, Gitea Actions, QNAP Container Station
---
// Change Log:
// - 2026-05-14: Initial project context
@@ -0,0 +1,34 @@
// File: docs/ai-knowledge-base/prompts/dms/api-design.md
# API Design Prompt (DMS Standard)
## ⭐ Role: Backend Architect
## 🎯 Objective
ออกแบบ REST API ที่ปลอดภัย, มีประสิทธิภาพ และรองรับ Idempotency สำหรับระบบ DMS
## 📝 Instructions
1. **Naming**: ใช้ kebab-case สำหรับ URL และ camelCase สำหรับ JSON field
2. **Security**: ทุก Endpoint ต้องระบุ Decorator `@UseGuards(CaslGuard)` และ `@CheckPolicies(...)`
3. **Idempotency**: สำหรับ POST/PATCH ต้องตรวจสอบ `Idempotency-Key` ใน Header
4. **Validation**: ใช้ `Zod` สำหรับ Frontend และ `class-validator` ใน Backend DTOs
5. **Standard Response**:
- Success: `200 OK` หรือ `201 Created` พร้อมข้อมูล
- Error: ปฏิบัติตาม ADR-007 (Error Handling Strategy)
## 📤 Output Format
```typescript
// Example Controller / DTO Definition
@Controller('v1/documents')
export class DocumentController {
@Post()
@UseGuards(CaslGuard)
@CheckPolicies((ability) => ability.can(Action.Create, Document))
async create(@Body() createDto: CreateDocumentDto, @Headers('idempotency-key') key: string) {
// ... logic
}
}
```
---
// Change Log:
// - 2026-05-14: Initial API design standard
@@ -0,0 +1,30 @@
// File: docs/ai-knowledge-base/prompts/dms/bug-fix.md
# Bug Fix Prompt (DMS Module)
## ⭐ Role: Debugging Specialist (DMS Expert)
## 🎯 Context
ระบบ DMS มีความซับซ้อนเรื่องสถานะ (State) และสิทธิ์ (Permissions) การแก้ไข Bug ต้องระวังไม่ให้กระทบต่อ Workflow Logic
## 🚀 Prompt Template
```
[DEBUG DMS]
Module: <e.g. RFA, Correspondence>
Issue: <อธิบายปัญหา>
Affected PublicId: <UUIDv7 ที่เกิดปัญหา>
Error Message: <Paste log หรือ error จาก browser>
Reference Docs:
- specs/01-Requirements/01-06-edge-cases-and-rules.md
- specs/06-Decision-Records/ADR-007-error-handling-strategy.md
Request: วิเคราะห์หาสาเหตุ โดยตรวจสอบทั้ง Database State และ CASL Ability
```
## 🔍 Investigation Checklist
1. **Database Check**: ตรวจสอบสถานะจริงใน DB เทียบกับสถานะที่ควรจะเป็น
2. **Permission Check**: ผู้ใช้ที่เกิดปัญหามีสิทธิ์ทำ Action นั้นหรือไม่?
3. **Concurrency**: เกิด Race Condition หรือไม่? (เช็ค Redis locks)
4. **Validation**: ติด Zod หรือ class-validator หรือไม่?
---
// Change Log:
// - 2026-05-14: Initial bug fix prompt for DMS
@@ -0,0 +1,31 @@
// File: docs/ai-knowledge-base/prompts/dms/db-schema.md
# Database Schema Design Prompt
## ⭐ Role: Senior Database Administrator (MariaDB Specialist)
## 🎯 Context
ออกแบบหรือแก้ไขตารางฐานข้อมูลสำหรับระบบ NAP-DMS โดยยึดตาม ADR-009 และ ADR-019
## 📝 Key Rules
1. **No Migrations**: ห้ามสร้างไฟล์ Migration ให้เขียน SQL Script โดยตรง
2. **Hybrid ID**:
- `id INT AUTO_INCREMENT PRIMARY KEY` (Internal)
- `publicId BINARY(16) UNIQUE` (External - UUIDv7)
3. **Audit Fields**:
- `createdBy INT` (FK to user internal id)
- `updatedBy INT`
- `createdAt TIMESTAMP`
- `updatedAt TIMESTAMP`
- `version INT DEFAULT 1` (For optimistic locking)
## 🚀 Prompt Template
```
[DB SCHEMA DESIGN]
Feature: <ชื่อฟีเจอร์>
Requirements: <รายละเอียดข้อมูลที่ต้องเก็บ>
Request: ออกแบบตารางพร้อมความสัมพันธ์ (FK) และดัชนี (Index) ที่เหมาะสม โดยใช้มาตรฐาน Hybrid UUID
```
---
// Change Log:
// - 2026-05-14: Initial DB schema prompt standard
@@ -0,0 +1,32 @@
// File: docs/ai-knowledge-base/prompts/dms/feature-design.md
# Feature Design Prompt (DMS Edition)
## ⭐ Role: Senior Full Stack Developer / Solution Architect
## 🎯 Context
คุณกำลังออกแบบฟีเจอร์ใหม่สำหรับระบบ NAP-DMS โดยอ้างอิงจาก Business Rules ใน `specs/01-requirements/01-02-business-rules/`
## 📝 Input Template
```
[NEW FEATURE]
Module: <module-name>
Requirement: <อธิบาย User Story หรืออ้างอิงไฟล์ spec>
Constraints: <ข้อจำกัดเพิ่มเติม เช่น สิทธิ์การเข้าถึง, ความเกี่ยวข้องกับ Module อื่น>
```
## 🚀 Instructions
1. ตรวจสอบ **Glossary** (`specs/00-overview/00-02-glossary.md`) เพื่อใช้คำศัพท์ให้ถูกต้อง
2. วิเคราะห์ **Edge Cases** (`specs/01-Requirements/01-06-edge-cases-and-rules.md`)
3. ออกแบบ **Data Model & Schema** ตามมาตรฐาน ADR-019 (Hybrid UUID)
4. กำหนด **RBAC Matrix** สำหรับฟีเจอร์นี้
5. ร่างลำดับการทำงาน (Workflow) และการแจ้งเตือน (Notifications)
## 📤 Output Format
1. **Summary**: สรุปแนวทางการแก้ปัญหา
2. **Database Schema**: คำสั่ง SQL สำหรับสร้าง/แก้ไขตาราง
3. **API Contracts**: นิยาม DTO และ Endpoint
4. **Implementation Plan**: ขั้นตอนการพัฒนาทีละ Step
---
// Change Log:
// - 2026-05-14: Initial template
@@ -0,0 +1,27 @@
// File: docs/ai-knowledge-base/prompts/dms/rbac-enforcement.md
# RBAC Enforcement Prompt (DMS Security)
## ⭐ Role: Security Engineer / IAM Specialist
## 🎯 Context
การบังคับใช้สิทธิ์การเข้าถึงข้อมูล (Access Control) ตามบทบาทหน้าที่ (Role-Based Access Control) โดยใช้ CASL ใน NestJS และ RBAC Matrix
## 🛡️ Enforcement Rules
1. **CASL Guard**: ทุก API Controller ต้องประดับด้วย `@UseGuards(CaslGuard)`
2. **Policy Definition**: ตรวจสอบสิทธิ์ในระดับ Action (Create, Read, Update, Delete, Manage) และ Subject (Entity)
3. **Field Level Security**: บางฟิลด์อาจต้องซ่อนตามระดับสิทธิ์ (e.g. ข้อมูลราคา)
4. **Project Isolation**: ตรวจสอบว่าผู้ใช้มีสิทธิ์เข้าถึงโครงการนั้นๆ จริงหรือไม่ (Project ID Check)
## 🚀 Prompt Template
```
[RBAC CHECK]
Endpoint: <e.g. PATCH /v1/rfa/:publicId>
User Role: <e.g. Consultant>
Desired Action: <e.g. Approve RFA>
Reference: specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md
Request: วิเคราะห์และเขียนโค้ด CASL Policy สำหรับกรณีนี้
```
---
// Change Log:
// - 2026-05-14: Initial RBAC enforcement prompt
@@ -0,0 +1,25 @@
// File: docs/ai-knowledge-base/prompts/dms/refactor.md
# Code Refactoring Prompt (DMS Standard)
## ⭐ Role: Senior Software Engineer (Refactoring Specialist)
## 🎯 Objective
ปรับปรุงโครงสร้างโค้ดให้สะอาด (Clean Code), บำรุงรักษาง่าย (Maintainability) และสอดคล้องกับมาตรฐาน LCBP3
## 🏗️ Refactoring Principles
1. **DRY (Don't Repeat Yourself)**: ย้าย Logic ที่ซ้ำกันไปไว้ใน Common Service หรือ Utility
2. **SOLID**: แยกความรับผิดชอบของ Class และ Method ให้ชัดเจน
3. **Type Safety**: กำจัด `any` และ `unknown` โดยใช้ Interface/Type ที่ถูกต้อง
4. **Performance**: ลดการ Query ซ้ำซ้อน และใช้ Transaction เมื่อจำเป็น
## 🚀 Prompt Template
```
[REFACTOR]
Target File: <path/to/file>
Goal: <e.g. แยก logic ออกจาก controller, ปรับปรุง type safety>
Request: ช่วยวิเคราะห์โค้ดเดิมและเสนอเวอร์ชัน Refactor ที่ยังคงรักษา functionality เดิมไว้ 100% พร้อมอธิบายเหตุผลของการเปลี่ยนแปลง
```
---
// Change Log:
// - 2026-05-14: Initial refactor prompt
@@ -0,0 +1,27 @@
// File: docs/ai-knowledge-base/prompts/dms/report-generation.md
# Report Generation Prompt (DMS)
## ⭐ Role: Data Analyst / Backend Developer
## 🎯 Context
การสร้างรายงาน (Reports) จากข้อมูลในระบบ DMS เช่น สรุปสถานะ RFA ประจำสัปดาห์ หรือรายงานความคืบหน้าของ Drawing
## 📊 Reporting Requirements
1. **Data Source**: ดึงข้อมูลจากตารางหลัก (Correspondence, RFA, etc.) และตาราง History
2. **Filters**: ต้องรองรับการกรองตาม Project, Discipline, Date Range และ Status
3. **Export Formats**: รองรับ PDF (สำหรับเซ็นชื่อ) และ Excel (สำหรับวิเคราะห์ข้อมูล)
4. **Performance**: ใช้ Aggregate Queries ที่มีประสิทธิภาพ และทำ Caching หากจำเป็น
## 🚀 Prompt Template
```
[REPORT DESIGN]
Report Name: <ชื่อรายงาน>
Columns: <รายการฟิลด์ที่ต้องการแสดง>
Group By: <e.g. Discipline, Sub-contractor>
Export Format: <Excel/PDF>
Request: ออกแบบ Query และโครงสร้างข้อมูลสำหรับสร้างรายงานนี้
```
---
// Change Log:
// - 2026-05-14: Initial report generation prompt
@@ -0,0 +1,26 @@
// File: docs/ai-knowledge-base/prompts/dms/ui-flow.md
# UI/UX Flow Design Prompt (DMS)
## ⭐ Role: UX/UI Designer (Enterprise Application)
## 🎯 Context
ออกแบบหน้าจอและการไหลเวียนของงาน (User Journey) ในระบบ DMS ที่มีข้อมูลจำนวนมากและ Workflow ที่ซับซ้อน
## 🎨 Design Guidelines
1. **Consistency**: ใช้ Component จาก `shadcn/ui` และ Palette สีตามมาตรฐานโครงการ
2. **Efficiency**: ลดจำนวนการคลิกเพื่อเข้าถึงข้อมูลสำคัญ (e.g. Document Preview)
3. **Feedback**: ต้องมี Loading states, Success/Error toasts และ Skeleton screens
4. **Responsive**: รองรับทั้ง Desktop (หลัก) และ Tablet สำหรับงานหน้าไซต์
## 🚀 Prompt Template
```
[UI FLOW DESIGN]
Feature: <ชื่อฟีเจอร์>
User Role: <e.g. Document Control, Sub-contractor>
Objective: <สิ่งที่ผู้ใช้ต้องการทำ>
Request: ออกแบบ User Flow ตั้งแต่เริ่มต้นจนจบ พร้อมระบุ UI Components ที่ต้องใช้ในแต่ละหน้า
```
---
// Change Log:
// - 2026-05-14: Initial UI flow prompt
@@ -0,0 +1,26 @@
// File: docs/ai-knowledge-base/prompts/infra/network-troubleshoot.md
# Network Troubleshooting Prompt (DMS Infra)
## ⭐ Role: Network Engineer (Omada Specialist)
## 🎯 Context
การแก้ไขปัญหาเครือข่ายภายในโครงการที่ใช้ TP-Link Omada สำหรับการจัดการ VLAN และ Switch
## 🔍 Diagnosis Steps
1. **Physical Link**: ตรวจสอบสถานะไฟที่ Port Switch และสายแลน
2. **VLAN Tagging**: ตรวจสอบว่า Port ถูก Config เป็น Access หรือ Trunk และ VLAN ID ถูกต้องหรือไม่
3. **DHCP Status**: ตรวจสอบว่า Client ได้รับ IP Address หรือไม่
4. **Gateway Ping**: ทดสอบการเชื่อมต่อกับ Default Gateway และ Internet
## 🚀 Prompt Template
```
[NETWORK DEBUG]
Device: <e.g. Switch-Floor-2, Omada Controller>
Symptom: <e.g. ไม่สามารถเชื่อมต่อ Server ได้, VLAN 20 ใช้งานไม่ได้>
Recent Changes: <การแก้ไขล่าสุดก่อนเกิดปัญหา>
Request: ช่วยวิเคราะห์สาเหตุและเสนอขั้นตอนการแก้ไข (Step-by-step recovery)
```
---
// Change Log:
// - 2026-05-14: Initial network troubleshoot prompt
@@ -0,0 +1,27 @@
// File: docs/ai-knowledge-base/prompts/infra/server-debug.md
# Server & Docker Debugging Prompt
## ⭐ Role: Systems Administrator / DevOps Engineer
## 🎯 Context
การแก้ไขปัญหาที่เกี่ยวข้องกับ Linux Server, Docker Containers และการเชื่อมต่อฐานข้อมูล
## 🔍 Commands for Debugging
- `docker ps`: ตรวจสอบสถานะ Container
- `docker logs -f <container_name>`: ดู Log การทำงานแบบ Real-time
- `df -h`: ตรวจสอบพื้นที่ว่างใน Disk
- `free -m`: ตรวจสอบการใช้งาน RAM
- `netstat -tulpn`: ตรวจสอบ Port ที่เปิดใช้งานอยู่
## 🚀 Prompt Template
```
[SERVER DEBUG]
Service: <e.g. DMS-Backend, MariaDB>
Problem: <e.g. Container Restart Loop, Connection Timeout>
Error Output: <Paste log จาก docker logs>
Request: วิเคราะห์หาสาเหตุ (e.g. Out of Memory, Disk Full, Env Config Error) และวิธีแก้ไข
```
---
// Change Log:
// - 2026-05-14: Initial server debug prompt
@@ -0,0 +1,26 @@
// File: docs/ai-knowledge-base/prompts/infra/vlan-change.md
# VLAN Configuration Change Prompt
## ⭐ Role: Network Architect
## 🎯 Context
ขั้นตอนการเพิ่ม หรือแก้ไข VLAN ภายในโครงการเพื่อความปลอดภัยและการแยกแยะการใช้งาน (Segmentation)
## 🏗️ Configuration Rules
1. **Naming Standard**: ใช้ชื่อที่สื่อความหมาย (e.g. VLAN-10-SERVER, VLAN-20-CCTV)
2. **Subnet Planning**: กำหนด Range IP ที่ไม่ซ้ำซ้อนกับ VLAN เดิม
3. **ACL (Access Control List)**: กำหนดสิทธิ์การข้าม VLAN (Inter-VLAN Routing) ตามความจำเป็น
4. **Port Profile**: สร้าง Profile ใน Omada เพื่อความง่ายในการนำไปใช้กับ Switch หลายตัว
## 🚀 Prompt Template
```
[VLAN CHANGE]
Purpose: <e.g. แยกเครือข่ายสำหรับทีม Sub-contractor>
VLAN ID: <e.g. 30>
IP Range: <e.g. 192.168.30.0/24>
Request: ออกแบบขั้นตอนการ Config ใน Omada Controller และ Switch Port
```
---
// Change Log:
// - 2026-05-14: Initial VLAN change prompt
@@ -0,0 +1,43 @@
// File: docs/ai-knowledge-base/templates/api-spec.md
# API Specification: [Endpoint Name]
## 📋 Metadata
- **Version**: v1
- **Module**: [e.g. RFA]
- **Protocol**: REST (JSON)
- **Status**: Draft / Proposed
## 🚀 Endpoint
`METHOD /v1/[path]`
## 🛡️ Authentication & Authorization
- **Auth Required**: Yes/No
- **Roles**: [Admin, Consultant, etc.]
- **CASL Action**: `Action.Create / Action.Read / ...`
## 📥 Request Parameters
### Headers
- `Idempotency-Key`: UUID (Required for Write actions)
- `Authorization`: Bearer [token]
### Body (JSON)
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | String | Yes | Name of entity |
## 📤 Response (JSON)
### Success (200/201)
```json
{
"publicId": "...",
"status": "success",
"data": { ... }
}
```
### Error (400/401/403/500)
- ปฏิบัติตาม ADR-007
---
// Change Log:
// - 2026-05-14: Initial API spec template
@@ -0,0 +1,32 @@
// File: docs/ai-knowledge-base/templates/bug-report.md
# Bug Report: [Short Title]
## 🚨 Issue Description
[อธิบายปัญหาที่เกิดขึ้น]
## 🛠️ Environment
- **Branch**: [main / develop / ...]
- **Device**: [Desktop / Mobile]
- **User Role**: [Admin / Document Control / ...]
## 🔄 Steps to Reproduce
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
## ❌ Actual Result
[สิ่งที่เกิดขึ้นจริง เช่น หน้าจอค้าง หรือ Error Message]
## ✅ Expected Result
[สิ่งที่ควรจะเป็น]
## 🪵 Logs & Screenshots
```
[Paste Error Log Here]
```
![Link to screenshot]
---
// Change Log:
// - 2026-05-14: Initial template

Some files were not shown because too many files have changed in this diff Show More