diff --git a/.dockerignore b/.dockerignore index bb682f00..43b49fc1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -32,6 +32,7 @@ examples # Test artifacts coverage +**/coverage **/*.spec.ts **/*.test.ts **/*.test.tsx @@ -60,4 +61,6 @@ scripts Thumbs.db # Environment files +**/.env +**/.env.* **/.env.local diff --git a/.prettierignore b/.prettierignore index 84d18edc..ca76e3e5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,4 +3,7 @@ backend/node_modules/ frontend/node_modules/ dist build +coverage +.next +out *.min.js diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 982678cc..e0e0e7c7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -33,6 +33,7 @@ "github.copilot", "bierner.markdown-mermaid", "vitest.explorer", - "google.geminicodeassist" + "google.geminicodeassist", + "openai.chatgpt" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a44c216..f437be57 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "editor.fontSize": 16, - "npm.packageManager": "pnpm" + "npm.packageManager": "pnpm", + "chatgpt.runCodexInWindowsSubsystemForLinux": true } diff --git a/AGENTS.md b/AGENTS.md index 0270ca9e..db9a2982 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,9 @@ # NAP-DMS Project Context & Rules - 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) -- 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) - **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) --- @@ -81,7 +81,7 @@ Build fails immediately if violated: - UUID Strategy (ADR-019) — no `parseInt` / `Number` / `+` on UUID - Database correctness — verify schema before writing queries - File upload security (ClamAV + whitelist) -- AI validation boundary (ADR-018) +- AI validation boundary (ADR-023) - Error handling strategy (ADR-007) - 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-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-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-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-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 | | **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 | @@ -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 6. **Rate Limiting:** `ThrottlerGuard` on all auth endpoints 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 -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` @@ -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 | | `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 | -| 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 | | 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 | -| AI direct cloud API calls | On-premises Ollama only (ADR-018) | 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 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-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` 4. **Check data dictionary** — confirm field meanings + business rules 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 ### 🟡 Normal Work — UI / Feature / Integration @@ -408,25 +407,25 @@ When user asks about... check these files: | Request | Files to Check | Expected Response | | ----------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| "สร้าง API ใหม่" | `05-02-backend-guidelines.md`, `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 | -| "เพิ่ม field ใหม่" | `ADR-009`, `data-dictionary.md`, `schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity | +| "สร้าง 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-and-rules.md` | RHF+Zod + TanStack Query + Thai comments | +| "เพิ่ม 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 | | "สร้าง 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 | | "เพิ่ม 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 | | "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 | | "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 | | "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 | | "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 สำหรับเคสที่สาเหตุชัดเจน | ## 🛠️ Final Checklist (Tier 1 & Tier 2) @@ -441,7 +440,7 @@ When user asks about... check these files: - [ ] **One main export per file** - [ ] Schema changes via SQL directly (not migration) - [ ] 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 - [ ] Error handling complete (Logger + HttpException) - [ ] 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 | | ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| 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.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 | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..14be12dd --- /dev/null +++ b/ARCHITECTURE.md @@ -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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index e231bf9e..c0448338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,37 @@ # 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) -### feat(agent): Agent Infrastructure Standardization (v1.9.0) +### feat(migration): RFA System & Agent Infrastructure Standardization (v1.9.0) #### Summary -สร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Windsurf/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต + +การปรับปรุงระบบ RFA Approval ให้สมบูรณ์พร้อมใช้งานจริง และสร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Windsurf/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต #### 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 - **Hybrid Specs Structure**: เริ่มใช้โครงสร้างโฟลเดอร์ `specs/[100/200/300]-category/` เพื่อจัดระเบียบงาน Infra, Fullstack และงานทั่วไป - **Automation**: เพิ่มสคริปต์ `sync-agent-configs.ps1` และ `audit-skills.sh` เพื่อตรวจสอบความสมบูรณ์และซิงค์ข้อมูลอัตโนมัติ - **Standardization**: กำหนดมาตรฐานการเขียนโค้ดใหม่ (File Headers, Change Logs, Thai JSDoc) ทั่วทั้งโครงการ +- **Node.js v24 Environment**: ปรับปรุงสภาพแวดล้อมให้เป็น Node.js v24.15.0 LTS ทั้งหมด - **Drift Prevention**: ใช้ Directory Junctions เชื่อมโยง `.windsurf/` เข้ากับ `.agents/` โดยตรง ## 1.8.11 (2026-05-05) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d56c5338..772305bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,13 +8,13 @@ ## 📚 Table of Contents -- [ภาพรวม Specification Structure](#-specification-structure) -- [หลักการเขียน Specifications](#-writing-principles) -- [Workflow การแก้ไข Specs](#-contribution-workflow) -- [Template และ Guidelines](#-templates--guidelines) -- [Review Process](#-review-process) -- [Best Practices](#-best-practices) -- [Tools และ Resources](#-tools--resources) +- [ภาพรวม Specification Structure](#specification-structure) +- [หลักการเขียน Specifications](#writing-principles) +- [Workflow การแก้ไข Specs](#contribution-workflow) +- [Template และ Guidelines](#templates--guidelines) +- [Review Process](#review-process) +- [Best Practices](#best-practices) +- [Tools และ Resources](#tools--resources) --- @@ -50,16 +50,16 @@ specs/ │ ├── 02-03-network-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 -│ ├── lcbp3-v1.8.0-schema-01-drop.sql # DROP statements -│ ├── lcbp3-v1.8.0-schema-02-tables.sql # CREATE TABLE -│ ├── lcbp3-v1.8.0-schema-03-views-indexes.sql # Views + Indexes -│ ├── lcbp3-v1.8.0-seed-basic.sql # Master Data Seed -│ ├── lcbp3-v1.8.0-seed-permissions.sql # RBAC Permissions Seed +│ ├── lcbp3-v1.9.0-schema-01-drop.sql # DROP statements +│ ├── lcbp3-v1.9.0-schema-02-tables.sql # CREATE TABLE +│ ├── lcbp3-v1.9.0-schema-03-views-indexes.sql # Views + Indexes +│ ├── lcbp3-v1.9.0-seed-basic.sql # Master Data Seed +│ ├── lcbp3-v1.9.0-seed-permissions.sql # RBAC Permissions Seed │ ├── 03-01-data-dictionary.md │ ├── 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) │ ├── README.md @@ -79,7 +79,7 @@ specs/ │ ├── 05-03-frontend-guidelines.md │ └── 05-04-testing-strategy.md │ -├── 06-Decision-Records/ # Architecture Decision Records (22 ADRs) +├── 06-Decision-Records/ # Architecture Decision Records (23 ADRs) │ ├── README.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 | | **04-Infrastructure-OPS** | Deployment, Operations, Release Policy | Gap 8 | DevOps Team | | **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 | | **200-fullstacks** | Feature Implementation (Fullstack) | spec.md, plan.md | Development Team | | **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.8 | 2026-04-14 | Tech Lead | Step-specific attachments, IntegratedBanner, WorkflowLifecycle | | 1.8.9 | 2026-04-18 | Tech Lead | Docker Compose hardening — 27 findings (C1–S4) 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 -**Last Updated**: 2026-04-18 +**Last Updated**: 2026-05-13 **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) @@ -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-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-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 --- diff --git a/README.md b/README.md index 1cb5f84d..9776767e 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ ## 📈 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 | หมายเหตุ | | ---------------------- | ------------------------ | ------------------------------------------------------------------ | @@ -22,7 +22,7 @@ | 🎨 **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 | | 📘 **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 | | 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready | | 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station | @@ -50,7 +50,7 @@ LCBP3-DMS เป็นระบบบริหารจัดการเอก - 🔐 **RBAC 4-Level** - ควบคุมสิทธิ์แบบละเอียด (Global, Organization, Project, Contract) - 📁 **Two-Phase File Storage** - จัดการไฟล์แบบ Transactional พร้อม Virus Scanning - 🔢 **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` | | **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` | -| **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) | | 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 | -| 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` @@ -382,15 +382,17 @@ lcbp3-dms/ ## 🗺️ 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 - ✅ **Hybrid Specs**: เริ่มใช้โครงสร้างโฟลเดอร์ 100/200/300 ใน `specs/` อย่างเป็นทางการ - ✅ **Auto-Sync**: ระบบ Sync อัตโนมัติระหว่าง `.agents/` และ `.windsurf/` (Drift Prevention) - ✅ **Audit Enhanced**: สคริปต์ตรวจสอบสุขภาพระบบรองรับการตรวจโครงสร้าง Specs folder - ✅ **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:** diff --git a/_tmp_22656_4997bf2e83403f8155701b87fc5cc8cc b/_tmp_22656_4997bf2e83403f8155701b87fc5cc8cc new file mode 100644 index 00000000..e69de29b diff --git a/_tmp_22656_affcd545658e51f46e08adc29e0902d7 b/_tmp_22656_affcd545658e51f46e08adc29e0902d7 new file mode 100644 index 00000000..e69de29b diff --git a/backend-lint.json b/backend-lint.json new file mode 100644 index 00000000..ff02d700 Binary files /dev/null and b/backend-lint.json differ diff --git a/backend-tsc-fix.txt b/backend-tsc-fix.txt new file mode 100644 index 00000000..2ff09949 Binary files /dev/null and b/backend-tsc-fix.txt differ diff --git a/backend-tsc.txt b/backend-tsc.txt new file mode 100644 index 00000000..3521ef86 Binary files /dev/null and b/backend-tsc.txt differ diff --git a/backend/package.json b/backend/package.json index 0795c2b5..2e5c8fc7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -54,7 +54,7 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "async-retry": "^1.3.3", - "axios": "^1.15.0", + "axios": "^1.15.2", "bcrypt": "^6.0.0", "bullmq": "^5.63.2", "cache-manager": "^7.2.5", @@ -81,7 +81,7 @@ "socket.io": "^4.8.1", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.27", - "uuid": "^13.0.0", + "uuid": "^13.0.1", "winston": "^3.18.3", "zod": "^4.1.13" }, diff --git a/backend/src/config/bullmq.config.ts b/backend/src/config/bullmq.config.ts new file mode 100644 index 00000000..f8ac7955 --- /dev/null +++ b/backend/src/config/bullmq.config.ts @@ -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, + }, +})); diff --git a/backend/src/config/redis.config.ts b/backend/src/config/redis.config.ts new file mode 100644 index 00000000..a465ca69 --- /dev/null +++ b/backend/src/config/redis.config.ts @@ -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'; diff --git a/backend/src/modules/delegation/delegation.service.ts b/backend/src/modules/delegation/delegation.service.ts index d72202b8..06b15ec2 100644 --- a/backend/src/modules/delegation/delegation.service.ts +++ b/backend/src/modules/delegation/delegation.service.ts @@ -11,6 +11,7 @@ import { Delegation } from './entities/delegation.entity'; import { User } from '../user/entities/user.entity'; import { CircularDetectionService } from './services/circular-detection.service'; import { CreateDelegationDto } from './dto/create-delegation.dto'; +import { DelegationScope } from '../common/enums/review.enums'; @Injectable() 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({ delegatorUserId: delegator.user_id, delegateUserId: delegate.user_id, @@ -98,7 +116,8 @@ export class DelegationService { */ async findActiveDelegate( userId: number, - date: Date = new Date() + date: Date = new Date(), + scopes: DelegationScope[] = [DelegationScope.ALL] ): Promise { const delegation = await this.delegationRepo .createQueryBuilder('d') @@ -107,6 +126,7 @@ export class DelegationService { .andWhere('d.is_active = 1') .andWhere('d.start_date <= :date', { date }) .andWhere('d.end_date >= :date', { date }) + .andWhere('d.scope IN (:...scopes)', { scopes }) .orderBy('d.created_at', 'DESC') .getOne(); diff --git a/backend/src/modules/distribution/distribution-matrix.service.ts b/backend/src/modules/distribution/distribution-matrix.service.ts index ce909598..a6215f67 100644 --- a/backend/src/modules/distribution/distribution-matrix.service.ts +++ b/backend/src/modules/distribution/distribution-matrix.service.ts @@ -1,50 +1,62 @@ // 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) -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; 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 { DistributionRecipient } from './entities/distribution-recipient.entity'; import { Project } from '../project/entities/project.entity'; - -export interface CreateDistributionMatrixDto { - projectId: number; - documentTypeCode: string; - responseCodeFilter?: string[]; -} - -export interface AddRecipientDto { - recipientType: string; - recipientId?: number; - roleCode?: string; - deliveryMethod?: string; - isCc?: boolean; -} +import { ResponseCode } from '../response-code/entities/response-code.entity'; +import { CreateDistributionMatrixDto } from './dto/create-distribution-matrix.dto'; +import { UpdateDistributionMatrixDto } from './dto/update-distribution-matrix.dto'; +import { AddDistributionRecipientDto } from './dto/add-distribution-recipient.dto'; @Injectable() export class DistributionMatrixService { - private readonly logger = new Logger(DistributionMatrixService.name); - constructor( @InjectRepository(DistributionMatrix) private readonly matrixRepo: Repository, @InjectRepository(DistributionRecipient) private readonly recipientRepo: Repository, @InjectRepository(Project) - private readonly projectRepo: Repository + private readonly projectRepo: Repository, + @InjectRepository(ResponseCode) + private readonly responseCodeRepo: Repository ) {} - async findByProject(projectId: number): Promise { + /** + * ดึง Matrix ของโครงการ พร้อม global defaults. + */ + async findByProject(projectId?: number): Promise { return this.matrixRepo.find({ - where: { projectId, isActive: true }, - relations: ['recipients'], - order: { documentTypeCode: 'ASC' }, + where: + projectId === undefined + ? { isActive: true } + : [ + { projectId, isActive: true }, + { projectId: IsNull(), isActive: true }, + ], + relations: ['recipients', 'responseCode'], + order: { documentTypeId: 'ASC', createdAt: 'DESC' }, }); } async findByProjectPublicId( - projectPublicId: string + projectPublicId?: string ): Promise { + if (!projectPublicId) return this.findByProject(); + if (!uuidValidate(projectPublicId)) { + throw new BadRequestException( + `Invalid projectPublicId: ${projectPublicId}` + ); + } const project = await this.projectRepo.findOne({ where: { publicId: projectPublicId }, }); @@ -55,34 +67,64 @@ export class DistributionMatrixService { async findOneByDocType( projectId: number, - documentTypeCode: string + documentTypeId: number ): Promise { return this.matrixRepo.findOne({ - where: { projectId, documentTypeCode, isActive: true }, - relations: ['recipients'], + where: [ + { projectId, documentTypeId, isActive: true }, + { projectId: IsNull(), documentTypeId, isActive: true }, + ], + relations: ['recipients', 'responseCode'], }); } async create(dto: CreateDistributionMatrixDto): Promise { - const matrix = this.matrixRepo.create(dto as Partial); + 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 { + 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); } async addRecipient( matrixPublicId: string, - dto: AddRecipientDto + dto: AddDistributionRecipientDto ): Promise { - const matrix = await this.matrixRepo.findOne({ - where: { publicId: matrixPublicId }, - }); - if (!matrix) - throw new NotFoundException(`Matrix not found: ${matrixPublicId}`); - + const matrix = await this.findMatrix(matrixPublicId); const recipient = this.recipientRepo.create({ matrixId: matrix.id, - ...dto, - } as Partial); - + recipientType: dto.recipientType, + recipientPublicId: dto.recipientPublicId, + deliveryMethod: dto.deliveryMethod, + sequence: dto.sequence, + }); return this.recipientRepo.save(recipient); } @@ -95,9 +137,44 @@ export class DistributionMatrixService { } async remove(publicId: string): Promise { - const matrix = await this.matrixRepo.findOne({ where: { publicId } }); - if (!matrix) throw new NotFoundException(publicId); + const matrix = await this.findMatrix(publicId); matrix.isActive = false; await this.matrixRepo.save(matrix); } + + private async findMatrix(publicId: string): Promise { + 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 { + 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 { + 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; + } } diff --git a/backend/src/modules/distribution/distribution.controller.ts b/backend/src/modules/distribution/distribution.controller.ts index d36392cc..a18bec7d 100644 --- a/backend/src/modules/distribution/distribution.controller.ts +++ b/backend/src/modules/distribution/distribution.controller.ts @@ -1,4 +1,6 @@ // 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) import { Controller, @@ -9,57 +11,64 @@ import { Param, Query, UseGuards, + Patch, } from '@nestjs/common'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { RbacGuard } from '../../common/guards/rbac.guard'; import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; import { DistributionMatrixService } from './distribution-matrix.service'; - -class CreateMatrixDto { - projectId!: number; - documentTypeCode!: string; - responseCodeFilter?: string[]; -} - -class AddRecipientDto { - recipientType!: string; - recipientId?: number; - roleCode?: string; - deliveryMethod?: string; - isCc?: boolean; -} +import { CreateDistributionMatrixDto } from './dto/create-distribution-matrix.dto'; +import { AddDistributionRecipientDto } from './dto/add-distribution-recipient.dto'; +import { UpdateDistributionMatrixDto } from './dto/update-distribution-matrix.dto'; @Controller('admin/distribution-matrices') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RbacGuard) export class DistributionController { constructor(private readonly matrixService: DistributionMatrixService) {} @Get() - findByProject( - @Query('projectPublicId', ParseUuidPipe) projectPublicId: string - ) { + findByProject(@Query('projectPublicId') projectPublicId?: string) { return this.matrixService.findByProjectPublicId(projectPublicId); } @Post() - create(@Body() dto: CreateMatrixDto) { + @RequirePermission('master_data.manage') + create(@Body() dto: CreateDistributionMatrixDto) { 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') + @RequirePermission('master_data.manage') addRecipient( - @Param('publicId') publicId: string, - @Body() dto: AddRecipientDto + @Param('publicId', ParseUuidPipe) publicId: string, + @Body() dto: AddDistributionRecipientDto ) { return this.matrixService.addRecipient(publicId, dto); } @Delete(':publicId/recipients/:recipientPublicId') - removeRecipient(@Param('recipientPublicId') recipientPublicId: string) { - return this.matrixService.removeRecipient(recipientPublicId); + @RequirePermission('master_data.manage') + async removeRecipient( + @Param('recipientPublicId', ParseUuidPipe) recipientPublicId: string + ) { + await this.matrixService.removeRecipient(recipientPublicId); + return { success: true }; } @Delete(':publicId') - remove(@Param('publicId') publicId: string) { - return this.matrixService.remove(publicId); + @RequirePermission('master_data.manage') + async remove(@Param('publicId', ParseUuidPipe) publicId: string) { + await this.matrixService.remove(publicId); + return { success: true }; } } diff --git a/backend/src/modules/distribution/distribution.module.ts b/backend/src/modules/distribution/distribution.module.ts index 1fa08ae1..bbd5508c 100644 --- a/backend/src/modules/distribution/distribution.module.ts +++ b/backend/src/modules/distribution/distribution.module.ts @@ -1,4 +1,6 @@ // 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 { TypeOrmModule } from '@nestjs/typeorm'; 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 { NotificationModule } from '../notification/notification.module'; 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({ imports: [ @@ -20,9 +24,11 @@ import { Project } from '../project/entities/project.entity'; DistributionMatrix, DistributionRecipient, Project, + ResponseCode, ]), BullModule.registerQueue({ name: QUEUE_DISTRIBUTION }), NotificationModule, + DocumentNumberingModule, ], providers: [ DistributionService, diff --git a/backend/src/modules/distribution/distribution.service.ts b/backend/src/modules/distribution/distribution.service.ts index a8dda326..2f4dd604 100644 --- a/backend/src/modules/distribution/distribution.service.ts +++ b/backend/src/modules/distribution/distribution.service.ts @@ -1,4 +1,6 @@ // 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) import { Injectable, Logger } from '@nestjs/common'; import { InjectQueue } from '@nestjs/bullmq'; @@ -9,7 +11,8 @@ export interface DistributionJobPayload { rfaPublicId: string; rfaRevisionPublicId: string; projectId: number; - documentTypeCode: string; + documentTypeId?: number; + documentTypeCode?: string; responseCode: string; approvedAt: Date; } diff --git a/backend/src/modules/distribution/dto/add-distribution-recipient.dto.ts b/backend/src/modules/distribution/dto/add-distribution-recipient.dto.ts new file mode 100644 index 00000000..df4f7b80 --- /dev/null +++ b/backend/src/modules/distribution/dto/add-distribution-recipient.dto.ts @@ -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; +} diff --git a/backend/src/modules/distribution/dto/create-distribution-matrix.dto.ts b/backend/src/modules/distribution/dto/create-distribution-matrix.dto.ts new file mode 100644 index 00000000..4afbeba0 --- /dev/null +++ b/backend/src/modules/distribution/dto/create-distribution-matrix.dto.ts @@ -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; +} diff --git a/backend/src/modules/distribution/dto/update-distribution-matrix.dto.ts b/backend/src/modules/distribution/dto/update-distribution-matrix.dto.ts new file mode 100644 index 00000000..c205787f --- /dev/null +++ b/backend/src/modules/distribution/dto/update-distribution-matrix.dto.ts @@ -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 +) {} diff --git a/backend/src/modules/distribution/entities/distribution-matrix.entity.ts b/backend/src/modules/distribution/entities/distribution-matrix.entity.ts index e8df9a8a..fcde9c5b 100644 --- a/backend/src/modules/distribution/entities/distribution-matrix.entity.ts +++ b/backend/src/modules/distribution/entities/distribution-matrix.entity.ts @@ -1,10 +1,11 @@ // 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 { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, - UpdateDateColumn, OneToMany, ManyToOne, JoinColumn, @@ -13,6 +14,12 @@ import { Exclude } from 'class-transformer'; import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; import { DistributionRecipient } from './distribution-recipient.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') export class DistributionMatrix extends UuidBaseEntity { @@ -20,19 +27,23 @@ export class DistributionMatrix extends UuidBaseEntity { @Exclude() id!: number; - @Column({ name: 'project_id' }) + @Column({ length: 100 }) + name!: string; + + @Column({ name: 'project_id', nullable: true }) @Exclude() - projectId!: number; + projectId?: number; - @Column({ name: 'document_type_code', length: 20 }) - documentTypeCode!: string; // 'SDW', 'DDW', 'ADW', 'MS'... + @Column({ name: 'document_type_id' }) + @Exclude() + documentTypeId!: number; - @Column({ - name: 'response_code_filter', - type: 'simple-array', - nullable: true, - }) - responseCodeFilter?: string[]; // ['1A','1B'] — NULL = ทุก code + @Column({ name: 'response_code_id', nullable: true }) + @Exclude() + responseCodeId?: number; + + @Column({ type: 'json', nullable: true }) + conditions?: DistributionConditions; @Column({ name: 'is_active', type: 'tinyint', default: 1 }) isActive!: boolean; @@ -40,14 +51,14 @@ export class DistributionMatrix extends UuidBaseEntity { @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt!: Date; - - // Relations @ManyToOne(() => Project) @JoinColumn({ name: 'project_id' }) project?: Project; + @ManyToOne(() => ResponseCode) + @JoinColumn({ name: 'response_code_id' }) + responseCode?: ResponseCode; + @OneToMany( () => DistributionRecipient, (r: DistributionRecipient) => r.matrix, diff --git a/backend/src/modules/distribution/entities/distribution-recipient.entity.ts b/backend/src/modules/distribution/entities/distribution-recipient.entity.ts index 6353ff0c..6b5a639b 100644 --- a/backend/src/modules/distribution/entities/distribution-recipient.entity.ts +++ b/backend/src/modules/distribution/entities/distribution-recipient.entity.ts @@ -1,4 +1,6 @@ // 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 { Entity, PrimaryGeneratedColumn, @@ -23,32 +25,29 @@ export class DistributionRecipient extends UuidBaseEntity { matrixId!: number; @Column({ + name: 'recipient_type', type: 'enum', enum: RecipientType, }) recipientType!: RecipientType; - @Column({ name: 'recipient_id', nullable: true }) - @Exclude() - 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({ name: 'recipient_public_id', type: 'uuid' }) + recipientPublicId!: string; @Column({ + name: 'delivery_method', type: 'enum', enum: DeliveryMethod, default: DeliveryMethod.BOTH, }) deliveryMethod!: DeliveryMethod; - @Column({ name: 'is_cc', type: 'tinyint', default: 0 }) - isCc!: boolean; // true = CC recipient, false = primary + @Column({ nullable: true }) + sequence?: number; @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; - // Relations @ManyToOne( () => DistributionMatrix, (m: DistributionMatrix) => m.recipients, diff --git a/backend/src/modules/distribution/processors/distribution.processor.ts b/backend/src/modules/distribution/processors/distribution.processor.ts index a4f277d8..6e2133f8 100644 --- a/backend/src/modules/distribution/processors/distribution.processor.ts +++ b/backend/src/modules/distribution/processors/distribution.processor.ts @@ -1,4 +1,6 @@ // 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) import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Logger } from '@nestjs/common'; @@ -7,6 +9,7 @@ import { QUEUE_DISTRIBUTION } from '../../common/constants/queue.constants'; import { DistributionJobPayload } from '../distribution.service'; import { TransmittalCreatorService } from '../services/transmittal-creator.service'; import { NotificationService } from '../../notification/notification.service'; +import { DeliveryMethod } from '../../common/enums/review.enums'; @Processor(QUEUE_DISTRIBUTION) export class DistributionProcessor extends WorkerHost { @@ -23,7 +26,7 @@ export class DistributionProcessor extends WorkerHost { const payload = job.data; 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 @@ -34,7 +37,33 @@ export class DistributionProcessor extends WorkerHost { `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}`); } } diff --git a/backend/src/modules/distribution/services/approval-listener.service.ts b/backend/src/modules/distribution/services/approval-listener.service.ts index d9c4719c..9dc34aff 100644 --- a/backend/src/modules/distribution/services/approval-listener.service.ts +++ b/backend/src/modules/distribution/services/approval-listener.service.ts @@ -1,4 +1,6 @@ // 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) import { Injectable, Logger } from '@nestjs/common'; import { @@ -24,7 +26,8 @@ export class ApprovalListenerService { rfaPublicId: string; rfaRevisionPublicId: string; projectId: number; - documentTypeCode: string; + documentTypeId?: number; + documentTypeCode?: string; responseCode: string; decision: ConsensusDecision; approvedAt: Date; @@ -45,6 +48,7 @@ export class ApprovalListenerService { rfaPublicId: event.rfaPublicId, rfaRevisionPublicId: event.rfaRevisionPublicId, projectId: event.projectId, + documentTypeId: event.documentTypeId, documentTypeCode: event.documentTypeCode, responseCode: event.responseCode, approvedAt: event.approvedAt, diff --git a/backend/src/modules/distribution/services/transmittal-creator.service.ts b/backend/src/modules/distribution/services/transmittal-creator.service.ts index 4554679c..eda90e34 100644 --- a/backend/src/modules/distribution/services/transmittal-creator.service.ts +++ b/backend/src/modules/distribution/services/transmittal-creator.service.ts @@ -1,9 +1,33 @@ // 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) import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DataSource, IsNull, Repository } from 'typeorm'; 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 เดิม @@ -15,7 +39,9 @@ export class TransmittalCreatorService { constructor( @InjectRepository(DistributionMatrix) - private readonly matrixRepo: Repository + private readonly matrixRepo: Repository, + private readonly dataSource: DataSource, + private readonly numberingService: DocumentNumberingService ) {} /** @@ -26,44 +52,271 @@ export class TransmittalCreatorService { rfaPublicId: string; rfaRevisionPublicId: string; projectId: number; - documentTypeCode: string; + documentTypeId?: number; + documentTypeCode?: string; responseCode: string; - }): Promise<{ transmittalPublicIds: string[] }> { + }): Promise { + if (!payload.documentTypeId) { + this.logger.warn( + `Distribution skipped for RFA ${payload.rfaPublicId}: documentTypeId missing` + ); + return { transmittalPublicIds: [], notificationTargets: [] }; + } + const matrix = await this.matrixRepo.findOne({ - where: { - projectId: payload.projectId, - documentTypeCode: payload.documentTypeCode, - isActive: true, - }, + where: [ + { + projectId: payload.projectId, + documentTypeId: payload.documentTypeId, + isActive: true, + }, + { + projectId: IsNull(), + documentTypeId: payload.documentTypeId, + isActive: true, + }, + ], relations: ['recipients'], }); if (!matrix || !matrix.recipients || matrix.recipients.length === 0) { 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 if ( - matrix.responseCodeFilter && - matrix.responseCodeFilter.length > 0 && - !matrix.responseCodeFilter.includes(payload.responseCode) + matrix.conditions?.codes && + matrix.conditions.codes.length > 0 && + !matrix.conditions.codes.includes(payload.responseCode) ) { this.logger.log( `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( - `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 ถัดไป - // return transmittalService.createDraft({ rfaPublicId, recipients }); + const transmittalPublicIds: string[] = []; + 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 { + const targets = new Map(); + 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 { + const organizationIds = new Set(); + 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 { + 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 { + const rows = await this.dataSource.query>( + ` + 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 { + 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(); + } } } diff --git a/backend/src/modules/reminder/dto/create-reminder-rule.dto.ts b/backend/src/modules/reminder/dto/create-reminder-rule.dto.ts index 15fb92a7..ab1e9819 100644 --- a/backend/src/modules/reminder/dto/create-reminder-rule.dto.ts +++ b/backend/src/modules/reminder/dto/create-reminder-rule.dto.ts @@ -14,6 +14,10 @@ export class CreateReminderRuleDto { @IsInt() projectId?: number; + @IsString() + @MaxLength(100) + name!: string; + @IsOptional() @IsString() @MaxLength(20) @@ -33,4 +37,8 @@ export class CreateReminderRuleDto { @IsArray() @IsString({ each: true }) notifyRoles?: string[]; + + @IsOptional() + @IsString() + messageTemplate?: string; } diff --git a/backend/src/modules/reminder/entities/reminder-history.entity.ts b/backend/src/modules/reminder/entities/reminder-history.entity.ts new file mode 100644 index 00000000..5d2789d4 --- /dev/null +++ b/backend/src/modules/reminder/entities/reminder-history.entity.ts @@ -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; +} diff --git a/backend/src/modules/reminder/entities/reminder-rule.entity.ts b/backend/src/modules/reminder/entities/reminder-rule.entity.ts index bd20b266..466d12a2 100644 --- a/backend/src/modules/reminder/entities/reminder-rule.entity.ts +++ b/backend/src/modules/reminder/entities/reminder-rule.entity.ts @@ -20,6 +20,9 @@ export class ReminderRule extends UuidBaseEntity { @Exclude() projectId?: number; // NULL = global rule + @Column({ length: 100 }) + name!: string; + @Column({ name: 'document_type_code', length: 20, nullable: true }) 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 }) 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 }) isActive!: boolean; diff --git a/backend/src/modules/reminder/processors/reminder.processor.ts b/backend/src/modules/reminder/processors/reminder.processor.ts index 3e2deafc..cc4f55c0 100644 --- a/backend/src/modules/reminder/processors/reminder.processor.ts +++ b/backend/src/modules/reminder/processors/reminder.processor.ts @@ -3,11 +3,14 @@ import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Logger } from '@nestjs/common'; import { Job } from 'bullmq'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { QUEUE_REMINDERS } from '../../common/constants/queue.constants'; import { ReminderType } from '../../common/enums/review.enums'; import { EscalationService } from '../services/escalation.service'; import { NotificationService } from '../../notification/notification.service'; import { ScheduleReminderPayload } from '../services/scheduler.service'; +import { ReviewTask } from '../../review-team/entities/review-task.entity'; @Processor(QUEUE_REMINDERS) export class ReminderProcessor extends WorkerHost { @@ -15,7 +18,9 @@ export class ReminderProcessor extends WorkerHost { constructor( private readonly escalationService: EscalationService, - private readonly notificationService: NotificationService + private readonly notificationService: NotificationService, + @InjectRepository(ReviewTask) + private readonly taskRepo: Repository ) { super(); } @@ -27,17 +32,28 @@ export class ReminderProcessor extends WorkerHost { `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) { case ReminderType.DUE_SOON: await this.notificationService.send({ userId: assigneeUserId, title: '⏰ Review Task Due Soon', - message: - 'Your review task is due in 2 days. Please complete your review.', + message: 'Your review task is due soon. Please complete your review.', type: 'SYSTEM', entityType: 'review_task', - entityId: taskPublicId as unknown as number, + entityId: task.id, }); + await this.escalationService.recordHistory(task, reminderType, 0); break; 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.', type: 'SYSTEM', entityType: 'review_task', - entityId: taskPublicId as unknown as number, + entityId: task.id, }); + await this.escalationService.recordHistory(task, reminderType, 0); break; case ReminderType.OVERDUE: @@ -60,8 +77,9 @@ export class ReminderProcessor extends WorkerHost { 'Your review task is overdue. Escalation will occur if not completed.', type: 'SYSTEM', entityType: 'review_task', - entityId: taskPublicId as unknown as number, + entityId: task.id, }); + await this.escalationService.recordHistory(task, reminderType, 0); break; case ReminderType.ESCALATION_L1: diff --git a/backend/src/modules/reminder/reminder.controller.ts b/backend/src/modules/reminder/reminder.controller.ts index 88962df1..e21e52ef 100644 --- a/backend/src/modules/reminder/reminder.controller.ts +++ b/backend/src/modules/reminder/reminder.controller.ts @@ -25,6 +25,11 @@ export class ReminderController { return this.reminderService.findAllByProjectPublicId(projectPublicId); } + @Get('history/:taskPublicId') + getHistory(@Param('taskPublicId') taskPublicId: string) { + return this.reminderService.findHistoryByTaskPublicId(taskPublicId); + } + @Get(':publicId') findOne(@Param('publicId') publicId: string) { return this.reminderService.findOne(publicId); diff --git a/backend/src/modules/reminder/reminder.module.ts b/backend/src/modules/reminder/reminder.module.ts index cbda9c7c..1ee1e0c3 100644 --- a/backend/src/modules/reminder/reminder.module.ts +++ b/backend/src/modules/reminder/reminder.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BullModule } from '@nestjs/bullmq'; import { ReminderRule } from './entities/reminder-rule.entity'; +import { ReminderHistory } from './entities/reminder-history.entity'; import { ReviewTask } from '../review-team/entities/review-task.entity'; import { ReminderService } from './reminder.service'; import { ReminderController } from './reminder.controller'; @@ -15,7 +16,12 @@ import { Project } from '../project/entities/project.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([ReminderRule, ReviewTask, Project]), + TypeOrmModule.forFeature([ + ReminderRule, + ReminderHistory, + ReviewTask, + Project, + ]), BullModule.registerQueue({ name: QUEUE_REMINDERS }), NotificationModule, ], diff --git a/backend/src/modules/reminder/reminder.service.ts b/backend/src/modules/reminder/reminder.service.ts index dd475414..4593d7f1 100644 --- a/backend/src/modules/reminder/reminder.service.ts +++ b/backend/src/modules/reminder/reminder.service.ts @@ -10,8 +10,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { validate as uuidValidate } from 'uuid'; import { ReminderRule } from './entities/reminder-rule.entity'; +import { ReminderHistory } from './entities/reminder-history.entity'; import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto'; import { Project } from '../project/entities/project.entity'; +import { ReviewTask } from '../review-team/entities/review-task.entity'; export { CreateReminderRuleDto }; @@ -22,8 +24,12 @@ export class ReminderService { constructor( @InjectRepository(ReminderRule) private readonly ruleRepo: Repository, + @InjectRepository(ReminderHistory) + private readonly historyRepo: Repository, @InjectRepository(Project) - private readonly projectRepo: Repository + private readonly projectRepo: Repository, + @InjectRepository(ReviewTask) + private readonly taskRepo: Repository ) {} async findAll(projectId?: number): Promise { @@ -58,6 +64,21 @@ export class ReminderService { return rule; } + async findHistoryByTaskPublicId( + taskPublicId: string + ): Promise { + 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 { const rule = this.ruleRepo.create(dto as Partial); return this.ruleRepo.save(rule); diff --git a/backend/src/modules/reminder/services/escalation.service.ts b/backend/src/modules/reminder/services/escalation.service.ts index ed0e3c4d..677759f3 100644 --- a/backend/src/modules/reminder/services/escalation.service.ts +++ b/backend/src/modules/reminder/services/escalation.service.ts @@ -4,9 +4,13 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan } from 'typeorm'; 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 { ReminderRule } from '../entities/reminder-rule.entity'; +import { ReminderHistory } from '../entities/reminder-history.entity'; @Injectable() export class EscalationService { @@ -17,12 +21,40 @@ export class EscalationService { private readonly reviewTaskRepo: Repository, @InjectRepository(ReminderRule) private readonly reminderRuleRepo: Repository, + @InjectRepository(ReminderHistory) + private readonly historyRepo: Repository, private readonly notificationService: NotificationService ) {} + /** + * บันทึกประวัติการส่ง reminder (FR-018) + */ + async recordHistory( + task: ReviewTask, + type: ReminderType, + level: number + ): Promise { + 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 { + return this.historyRepo.count({ + where: { taskId, escalationLevel: level }, + }); + } + /** * Escalation Level 1 (FR-015): Team Lead ได้รับแจ้งเตือน - * เรียกเมื่อ task เกิน due date 1 วัน + * เรียกเมื่อ task เกิน due date */ async escalateLevel1(taskPublicId: string): Promise { const task = await this.reviewTaskRepo.findOne({ @@ -32,32 +64,35 @@ export class EscalationService { if (!task || task.status === ReviewTaskStatus.COMPLETED) return; - const daysOverdue = task.dueDate - ? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000) - : 0; - - if (daysOverdue < 1) return; + const strikes = await this.getStrikeCount(task.id, 1); + if (strikes >= 3) { + this.logger.log( + `Task ${taskPublicId} L1 strikes reached 3 — moving to L2` + ); + await this.escalateLevel2(taskPublicId); + return; + } this.logger.log( - `Escalation L1: task ${taskPublicId} is ${daysOverdue} days overdue` + `Escalation L1 (Strike ${strikes + 1}): task ${taskPublicId}` ); - // แจ้ง Team Lead if (task.assignedToUserId) { await this.notificationService.send({ userId: task.assignedToUserId, - title: `⚠ Review Task Overdue (${daysOverdue}d)`, - message: `Your review task is overdue by ${daysOverdue} day(s). Please complete it immediately.`, + title: `⚠ Review Task Overdue (L1 Strike ${strikes + 1})`, + message: `Your review task is overdue. Please complete it immediately.`, type: 'SYSTEM', entityType: 'review_task', entityId: task.id, }); + await this.recordHistory(task, ReminderType.ESCALATION_L1, 1); } } /** * Escalation Level 2 (FR-016): Project Manager ได้รับแจ้งเตือน - * เรียกเมื่อ task เกิน due date 3 วัน + * เรียกเมื่อ L1 ครบ 3 ครั้ง หรือตามเงื่อนไข SLA */ async escalateLevel2(taskPublicId: string): Promise { const task = await this.reviewTaskRepo.findOne({ @@ -67,20 +102,25 @@ export class EscalationService { if (!task || task.status === ReviewTaskStatus.COMPLETED) return; - const daysOverdue = task.dueDate - ? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000) - : 0; - - if (daysOverdue < 3) return; + const strikes = await this.getStrikeCount(task.id, 2); 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 สำหรับตอนนี้ - this.logger.log( - `L2 escalation notification queued for task ${taskPublicId}` - ); + // TODO: ดึง PM user ID จาก project membership + // สำหรับตอนนี้ แจ้งผู้รับผิดชอบเดิมแต่หัวเรื่องแรงขึ้น + 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`); for (const task of overdueTasks) { - const daysOverdue = task.dueDate - ? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000) - : 0; + // ดึง history ล่าสุดเพื่อดูว่าควร escalate level ไหน + const lastHistory = await this.historyRepo.findOne({ + where: { taskId: task.id }, + order: { sentAt: 'DESC' }, + }); - if (daysOverdue >= 3) { - await this.escalateLevel2(task.publicId); - } else if (daysOverdue >= 1) { + if (!lastHistory || lastHistory.escalationLevel === 0) { 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 } } } diff --git a/backend/src/modules/reminder/services/scheduler.service.ts b/backend/src/modules/reminder/services/scheduler.service.ts index a21aa39e..33e8df6c 100644 --- a/backend/src/modules/reminder/services/scheduler.service.ts +++ b/backend/src/modules/reminder/services/scheduler.service.ts @@ -6,6 +6,9 @@ import { Queue } from 'bullmq'; import { QUEUE_REMINDERS } from '../../common/constants/queue.constants'; import type { Job } from 'bullmq'; 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 { taskPublicId: string; @@ -13,6 +16,8 @@ export interface ScheduleReminderPayload { assigneeUserId: number; dueDate: Date; reminderType: ReminderType; + projectId?: number; + documentTypeCode?: string; } type ReminderJob = Job; @@ -23,64 +28,67 @@ export class SchedulerService { constructor( @InjectQueue(QUEUE_REMINDERS) - private readonly reminderQueue: Queue + private readonly reminderQueue: Queue, + @InjectRepository(ReminderRule) + private readonly ruleRepo: Repository ) {} /** - * Schedule ชุด reminders ให้ Review Task (FR-013) + * Schedule ชุด reminders ให้ Review Task (FR-013) ตาม ReminderRule * เรียกหลังจาก TaskCreationService สร้าง tasks เรียบร้อยแล้ว */ async scheduleForTask(payload: ScheduleReminderPayload): Promise { - const { taskPublicId, dueDate } = payload; + const { taskPublicId, dueDate, projectId, documentTypeCode } = payload; const now = Date.now(); - const remindersToSchedule: Array<{ type: ReminderType; delayMs: number }> = - []; - - // 2 วันก่อน due date - const twoDaysBefore = dueDate.getTime() - 2 * 86_400_000; - if (twoDaysBefore > now) { - remindersToSchedule.push({ - 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), + // ดึงกฎที่เกี่ยวข้อง (Global + Project specific) + const rules = await this.ruleRepo.find({ + where: [ + { projectId, documentTypeCode, isActive: true }, + { projectId: undefined, documentTypeCode, isActive: true }, + { projectId, documentTypeCode: undefined, isActive: true }, + { projectId: undefined, documentTypeCode: undefined, isActive: true }, + ], }); - // 3 วันหลัง due (Escalation L2) - const threeDaysAfter = dueDate.getTime() + 3 * 86_400_000; - remindersToSchedule.push({ - type: ReminderType.ESCALATION_L2, - delayMs: Math.max(threeDaysAfter - now, 0), - }); + if (rules.length === 0) { + this.logger.debug(`No reminder rules found for task ${taskPublicId}`); + return; + } - await Promise.all( - remindersToSchedule.map(({ type, delayMs }) => + const jobs = []; + + 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( '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( - `Scheduled ${remindersToSchedule.length} reminders for task ${taskPublicId}` + `Scheduled ${jobs.length} reminders for task ${taskPublicId} based on ${rules.length} rules` ); } diff --git a/backend/src/modules/response-code/dto/create-response-code.dto.ts b/backend/src/modules/response-code/dto/create-response-code.dto.ts new file mode 100644 index 00000000..a07dcaca --- /dev/null +++ b/backend/src/modules/response-code/dto/create-response-code.dto.ts @@ -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; +} diff --git a/backend/src/modules/response-code/dto/update-response-code.dto.ts b/backend/src/modules/response-code/dto/update-response-code.dto.ts new file mode 100644 index 00000000..e7b5c202 --- /dev/null +++ b/backend/src/modules/response-code/dto/update-response-code.dto.ts @@ -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; +} diff --git a/backend/src/modules/response-code/dto/upsert-response-code-rule.dto.ts b/backend/src/modules/response-code/dto/upsert-response-code-rule.dto.ts new file mode 100644 index 00000000..c92eca3f --- /dev/null +++ b/backend/src/modules/response-code/dto/upsert-response-code-rule.dto.ts @@ -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; +} diff --git a/backend/src/modules/response-code/response-code.controller.ts b/backend/src/modules/response-code/response-code.controller.ts index 1632a0e8..91664d40 100644 --- a/backend/src/modules/response-code/response-code.controller.ts +++ b/backend/src/modules/response-code/response-code.controller.ts @@ -1,19 +1,51 @@ // 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 { 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 { 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') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RbacGuard) 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 * ดึง Response Codes ทั้งหมด */ @Get() + @ApiOperation({ summary: 'Get all active response codes' }) findAll() { return this.responseCodeService.findAll(); } @@ -23,6 +55,7 @@ export class ResponseCodeController { * ดึง Response Codes ตาม Category (FR-006) */ @Get('category/:category') + @ApiOperation({ summary: 'Get response codes by category' }) findByCategory(@Param('category') category: ResponseCodeCategory) { return this.responseCodeService.findByCategory(category); } @@ -32,13 +65,18 @@ export class ResponseCodeController { * ดึง Response Codes ที่ใช้ได้กับ document type + project */ @Get('document-type/:documentTypeId') - findByDocumentType( - @Param('documentTypeId') documentTypeId: string, + @ApiOperation({ summary: 'Get response codes by document type and project' }) + async findByDocumentType( + @Param('documentTypeId', ParseIntPipe) documentTypeId: number, @Query('projectId') projectId?: string ) { + const resolvedProjectId = projectId + ? await this.uuidResolver.resolveProjectId(projectId) + : undefined; + return this.responseCodeService.findByDocumentType( - Number(documentTypeId), - projectId ? Number(projectId) : undefined + documentTypeId, + resolvedProjectId ); } @@ -47,7 +85,79 @@ export class ResponseCodeController { * ดึง Response Code ตาม publicId (ADR-019) */ @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); } + + @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 }; + } } diff --git a/backend/src/modules/response-code/response-code.module.ts b/backend/src/modules/response-code/response-code.module.ts index b905d64b..40530585 100644 --- a/backend/src/modules/response-code/response-code.module.ts +++ b/backend/src/modules/response-code/response-code.module.ts @@ -1,10 +1,12 @@ // File: src/modules/response-code/response-code.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditLog } from '../../common/entities/audit-log.entity'; import { ResponseCode } from './entities/response-code.entity'; import { ResponseCodeRule } from './entities/response-code-rule.entity'; import { ResponseCodeService } from './response-code.service'; import { ResponseCodeController } from './response-code.controller'; +import { ResponseCodeAuditService } from './services/audit.service'; import { ImplicationsService } from './services/implications.service'; import { NotificationTriggerService } from './services/notification-trigger.service'; import { MatrixManagementService } from './services/matrix-management.service'; @@ -14,11 +16,12 @@ import { NotificationModule } from '../notification/notification.module'; @Module({ imports: [ - TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User]), + TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User, AuditLog]), NotificationModule, ], providers: [ ResponseCodeService, + ResponseCodeAuditService, ImplicationsService, NotificationTriggerService, MatrixManagementService, @@ -27,6 +30,7 @@ import { NotificationModule } from '../notification/notification.module'; controllers: [ResponseCodeController], exports: [ ResponseCodeService, + ResponseCodeAuditService, ImplicationsService, NotificationTriggerService, MatrixManagementService, diff --git a/backend/src/modules/response-code/response-code.service.ts b/backend/src/modules/response-code/response-code.service.ts index 0cb02e09..e1a22c6f 100644 --- a/backend/src/modules/response-code/response-code.service.ts +++ b/backend/src/modules/response-code/response-code.service.ts @@ -1,10 +1,20 @@ // 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 { Repository, IsNull } from 'typeorm'; import { ResponseCode } from './entities/response-code.entity'; import { ResponseCodeRule } from './entities/response-code-rule.entity'; import { ResponseCodeCategory } from '../common/enums/review.enums'; +import { CreateResponseCodeDto } from './dto/create-response-code.dto'; +import { UpdateResponseCodeDto } from './dto/update-response-code.dto'; @Injectable() export class ResponseCodeService { @@ -85,6 +95,83 @@ export class ResponseCodeService { return code; } + /** + * สร้าง Response Code ใหม่สำหรับ Master Approval Matrix + */ + async create(dto: CreateResponseCodeDto): Promise { + 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 { + 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 { + 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) * Code 1C, 1D, 3 → trigger notification diff --git a/backend/src/modules/response-code/services/audit.service.ts b/backend/src/modules/response-code/services/audit.service.ts new file mode 100644 index 00000000..bb5107af --- /dev/null +++ b/backend/src/modules/response-code/services/audit.service.ts @@ -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 + ) {} + + /** + * บันทึก audit trail เมื่อมีการเลือกหรือเปลี่ยน Response Code บน Review Task + */ + async logReviewTaskResponseCodeChange(input: { + reviewTaskPublicId: string; + responseCodePublicId: string; + previousResponseCodeId?: number; + currentResponseCodeId: number; + comments?: string; + userId?: number; + }): Promise { + 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}` + ); + } +} diff --git a/backend/src/modules/review-team/dto/shared/review-team.dto.ts b/backend/src/modules/review-team/dto/shared/review-team.dto.ts index a745eb08..e05b7c82 100644 --- a/backend/src/modules/review-team/dto/shared/review-team.dto.ts +++ b/backend/src/modules/review-team/dto/shared/review-team.dto.ts @@ -1,4 +1,6 @@ // 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 import { @@ -69,8 +71,9 @@ export class AddTeamMemberDto { @IsUUID() userPublicId!: string; // ADR-019 - @IsUUID() - disciplinePublicId!: string; // ADR-019 + @IsInt() + @IsPositive() + disciplineId!: number; // disciplines table is internal INT per current schema @IsEnum(ReviewTeamMemberRole) role!: ReviewTeamMemberRole; diff --git a/backend/src/modules/review-team/review-task.controller.ts b/backend/src/modules/review-team/review-task.controller.ts new file mode 100644 index 00000000..ec3caccc --- /dev/null +++ b/backend/src/modules/review-team/review-task.controller.ts @@ -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; + + const rfaRevision = fullTask.rfaRevision as + | Record + | undefined; + + const corrRevision = rfaRevision?.correspondenceRevision as + | Record + | undefined; + + const correspondence = corrRevision?.correspondence as + | Record + | 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 | undefined + )?.id as number | undefined, + + documentTypeCode: + ((correspondence.type as Record | 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, + }); + } +} diff --git a/backend/src/modules/review-team/review-task.service.ts b/backend/src/modules/review-team/review-task.service.ts index 99950fd4..a617d14a 100644 --- a/backend/src/modules/review-team/review-task.service.ts +++ b/backend/src/modules/review-team/review-task.service.ts @@ -1,4 +1,6 @@ // 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 { Injectable, Logger, @@ -10,6 +12,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ReviewTask } from './entities/review-task.entity'; import { ResponseCode } from '../response-code/entities/response-code.entity'; +import { ResponseCodeAuditService } from '../response-code/services/audit.service'; import { CompleteReviewTaskDto, SearchReviewTaskDto, @@ -25,7 +28,8 @@ export class ReviewTaskService { @InjectRepository(ReviewTask) private readonly reviewTaskRepo: Repository, @InjectRepository(ResponseCode) - private readonly responseCodeRepo: Repository + private readonly responseCodeRepo: Repository, + private readonly responseCodeAuditService: ResponseCodeAuditService ) {} /** @@ -91,6 +95,48 @@ export class ReviewTaskService { return task; } + /** + * ดึง Review Task พร้อม context ทั้งหมด (RFA, Project, Type) + */ + async findFullTaskContext(publicId: string): Promise { + 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) */ @@ -143,6 +189,7 @@ export class ReviewTaskService { dto: CompleteReviewTaskDto ): Promise { const task = await this.findByPublicId(publicId); + const previousResponseCodeId = task.responseCodeId; if ( task.status === ReviewTaskStatus.COMPLETED || @@ -180,7 +227,15 @@ export class ReviewTaskService { try { // 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) { const errorMessage = err instanceof Error ? err.message : String(err); if ( diff --git a/backend/src/modules/review-team/review-team.module.ts b/backend/src/modules/review-team/review-team.module.ts index eaca2afb..978c2e1f 100644 --- a/backend/src/modules/review-team/review-team.module.ts +++ b/backend/src/modules/review-team/review-team.module.ts @@ -22,12 +22,15 @@ import { VetoOverrideService } from './services/veto-override.service'; // Controllers import { ReviewTeamController } from './review-team.controller'; +import { ReviewTaskController } from './review-task.controller'; // Modules import { ResponseCodeModule } from '../response-code/response-code.module'; import { NotificationModule } from '../notification/notification.module'; import { UserModule } from '../user/user.module'; import { DistributionModule } from '../distribution/distribution.module'; +import { DelegationModule } from '../delegation/delegation.module'; +import { ReminderModule } from '../reminder/reminder.module'; // Queue constants import { @@ -52,6 +55,8 @@ import { NotificationModule, UserModule, DistributionModule, + DelegationModule, + ReminderModule, ], providers: [ ReviewTeamService, @@ -61,7 +66,7 @@ import { ConsensusService, VetoOverrideService, ], - controllers: [ReviewTeamController], + controllers: [ReviewTeamController, ReviewTaskController], exports: [ ReviewTeamService, ReviewTaskService, diff --git a/backend/src/modules/review-team/review-team.service.ts b/backend/src/modules/review-team/review-team.service.ts index 8c236ed1..9ec55773 100644 --- a/backend/src/modules/review-team/review-team.service.ts +++ b/backend/src/modules/review-team/review-team.service.ts @@ -1,4 +1,6 @@ // 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 { Injectable, Logger, @@ -11,6 +13,7 @@ import { ReviewTeam } from './entities/review-team.entity'; import { ReviewTeamMember } from './entities/review-team-member.entity'; import { User } from '../user/entities/user.entity'; import { Discipline } from '../master/entities/discipline.entity'; +import { UuidResolverService } from '../../common/services/uuid-resolver.service'; import { CreateReviewTeamDto, UpdateReviewTeamDto, @@ -30,7 +33,8 @@ export class ReviewTeamService { @InjectRepository(User) private readonly userRepo: Repository, @InjectRepository(Discipline) - private readonly disciplineRepo: Repository + private readonly disciplineRepo: Repository, + private readonly uuidResolver: UuidResolverService ) {} /** @@ -97,21 +101,14 @@ export class ReviewTeamService { * สร้าง Review Team ใหม่ */ async create(dto: CreateReviewTeamDto): Promise { - // ตรวจสอบว่า project มีอยู่จริง (via publicId) - const project = await this.teamRepo.manager - .getRepository('projects') - .findOne({ - where: { uuid: dto.projectPublicId } as Record, - }); - - if (!project) { - throw new NotFoundException(`Project not found: ${dto.projectPublicId}`); - } + const projectId = await this.uuidResolver.resolveProjectId( + dto.projectPublicId + ); const team = this.teamRepo.create({ name: dto.name, description: dto.description, - projectId: (project as { id: number }).id, + projectId, defaultForRfaTypes: dto.defaultForRfaTypes, isActive: true, }); @@ -155,12 +152,10 @@ export class ReviewTeamService { // ตรวจสอบ Discipline const discipline = await this.disciplineRepo.findOne({ - where: { id: Number(dto.disciplinePublicId) }, + where: { id: dto.disciplineId }, }); if (!discipline) - throw new NotFoundException( - `Discipline not found: ${dto.disciplinePublicId}` - ); + throw new NotFoundException(`Discipline not found: ${dto.disciplineId}`); // ตรวจสอบซ้ำ const existing = await this.memberRepo.findOne({ @@ -173,7 +168,7 @@ export class ReviewTeamService { if (existing) { 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}` ); } diff --git a/backend/src/modules/review-team/services/consensus.service.ts b/backend/src/modules/review-team/services/consensus.service.ts index d36efd9f..2d0238b7 100644 --- a/backend/src/modules/review-team/services/consensus.service.ts +++ b/backend/src/modules/review-team/services/consensus.service.ts @@ -38,6 +38,7 @@ export class ConsensusService { rfaPublicId: string; rfaRevisionPublicId: string; projectId: number; + documentTypeId?: number; documentTypeCode: string; } ): Promise { diff --git a/backend/src/modules/review-team/services/task-creation.service.ts b/backend/src/modules/review-team/services/task-creation.service.ts index 7edbf1a1..114a062a 100644 --- a/backend/src/modules/review-team/services/task-creation.service.ts +++ b/backend/src/modules/review-team/services/task-creation.service.ts @@ -11,7 +11,11 @@ import { ReviewTask } from '../entities/review-task.entity'; import { ReviewTaskStatus, ReviewTeamMemberRole, + DelegationScope, + ReminderType, } from '../../common/enums/review.enums'; +import { DelegationService } from '../../delegation/delegation.service'; +import { SchedulerService } from '../../reminder/services/scheduler.service'; @Injectable() export class TaskCreationService { @@ -23,7 +27,9 @@ export class TaskCreationService { @InjectRepository(ReviewTeamMember) private readonly memberRepo: Repository, @InjectRepository(ReviewTask) - private readonly reviewTaskRepo: Repository + private readonly reviewTaskRepo: Repository, + private readonly delegationService: DelegationService, + private readonly schedulerService: SchedulerService ) {} /** @@ -34,12 +40,16 @@ export class TaskCreationService { * @param reviewTeamPublicId - publicId ของ Review Team (ADR-019) * @param dueDate - กำหนดเวลาตรวจสอบ * @param manager - EntityManager จาก QueryRunner (ใช้ Transaction เดิม) + * @param projectId - (Optional) ID ของโครงการ สำหรับ reminder rules + * @param documentTypeCode - (Optional) ประเภทเอกสาร สำหรับ reminder rules */ async createParallelTasks( rfaRevisionId: number, reviewTeamPublicId: string, dueDate: Date, - manager: EntityManager + manager: EntityManager, + projectId?: number, + documentTypeCode?: string ): Promise { // ดึง ReviewTeam พร้อม members const team = await this.reviewTeamRepo.findOne({ @@ -77,16 +87,40 @@ export class TaskCreationService { // สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel) 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, { rfaRevisionId, teamId: team.id, disciplineId, - assignedToUserId: leadMember.userId, + assignedToUserId, + delegatedFromUserId, status: ReviewTaskStatus.PENDING, dueDate, }); const saved = await manager.save(ReviewTask, task); 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( diff --git a/backend/src/modules/review-team/services/veto-override.service.ts b/backend/src/modules/review-team/services/veto-override.service.ts index 955ef6c0..9dea16c5 100644 --- a/backend/src/modules/review-team/services/veto-override.service.ts +++ b/backend/src/modules/review-team/services/veto-override.service.ts @@ -17,6 +17,7 @@ export interface VetoOverrideDto { rfaPublicId: string; rfaRevisionPublicId: string; projectId: number; + documentTypeId?: number; documentTypeCode: string; overrideReason: string; overriddenByUserId: number; @@ -72,6 +73,7 @@ export class VetoOverrideService { rfaPublicId: dto.rfaPublicId, rfaRevisionPublicId: dto.rfaRevisionPublicId, projectId: dto.projectId, + documentTypeId: dto.documentTypeId, documentTypeCode: dto.documentTypeCode, responseCode: '1A', decision: ConsensusDecision.OVERRIDDEN, diff --git a/backend/src/modules/rfa/rfa.controller.ts b/backend/src/modules/rfa/rfa.controller.ts index 05d8c4e9..900c144b 100644 --- a/backend/src/modules/rfa/rfa.controller.ts +++ b/backend/src/modules/rfa/rfa.controller.ts @@ -1,4 +1,6 @@ // 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 { Body, Controller, @@ -76,7 +78,12 @@ export class RfaController { ) { // ADR-019: resolve UUID → internal INT id via findOneByUuidRaw 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') diff --git a/backend/src/modules/rfa/rfa.module.ts b/backend/src/modules/rfa/rfa.module.ts index 84cfe9bb..352f089b 100644 --- a/backend/src/modules/rfa/rfa.module.ts +++ b/backend/src/modules/rfa/rfa.module.ts @@ -34,6 +34,7 @@ import { RfaService } from './rfa.service'; import { DocumentNumberingModule } from '../document-numbering/document-numbering.module'; import { NotificationModule } from '../notification/notification.module'; import { ProjectModule } from '../project/project.module'; +import { ReviewTeamModule } from '../review-team/review-team.module'; import { SearchModule } from '../search/search.module'; import { UserModule } from '../user/user.module'; import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'; @@ -66,6 +67,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module' DocumentNumberingModule, UserModule, ProjectModule, + ReviewTeamModule, SearchModule, WorkflowEngineModule, NotificationModule, diff --git a/backend/src/modules/rfa/rfa.service.ts b/backend/src/modules/rfa/rfa.service.ts index 16a3a8a1..30c02d7d 100644 --- a/backend/src/modules/rfa/rfa.service.ts +++ b/backend/src/modules/rfa/rfa.service.ts @@ -1,4 +1,6 @@ // 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 { @@ -59,6 +61,7 @@ import { SearchService } from '../search/search.service'; import { UserService } from '../user/user.service'; import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service'; import { UuidResolverService } from '../../common/services/uuid-resolver.service'; +import { TaskCreationService } from '../review-team/services/task-creation.service'; @Injectable() export class RfaService { @@ -109,6 +112,7 @@ export class RfaService { private userService: UserService, private workflowEngine: WorkflowEngineService, private notificationService: NotificationService, + private taskCreationService: TaskCreationService, private dataSource: DataSource, private searchService: SearchService, private uuidResolver: UuidResolverService @@ -664,7 +668,12 @@ export class RfaService { 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 corrRevisions = (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? []; @@ -747,6 +756,17 @@ export class RfaService { }); 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 const recipientUserId = await this.userService.findDocControlIdByOrg( firstStep.toOrganizationId diff --git a/backend/src/modules/user/entities/role.entity.ts b/backend/src/modules/user/entities/role.entity.ts index 79c18398..f097bfa9 100644 --- a/backend/src/modules/user/entities/role.entity.ts +++ b/backend/src/modules/user/entities/role.entity.ts @@ -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 { Entity, PrimaryGeneratedColumn, @@ -6,7 +9,9 @@ import { JoinTable, } from 'typeorm'; import { Permission } from './permission.entity'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; +/** ขอบเขตของบทบาท */ export enum RoleScope { GLOBAL = 'Global', ORGANIZATION = 'Organization', @@ -14,8 +19,15 @@ export enum RoleScope { 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') -export class Role { +export class Role extends UuidBaseEntity { @PrimaryGeneratedColumn({ name: 'role_id' }) roleId!: number; diff --git a/backend/tests/unit/delegation/delegation.service.spec.ts b/backend/tests/unit/delegation/delegation.service.spec.ts new file mode 100644 index 00000000..0513cf56 --- /dev/null +++ b/backend/tests/unit/delegation/delegation.service.spec.ts @@ -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 = { + findOne: jest.MockedFunction<(options: unknown) => Promise>; + create: jest.MockedFunction<(payload: Partial) => T>; + save: jest.MockedFunction<(entity: T) => Promise>; + createQueryBuilder: jest.MockedFunction<(alias: string) => QueryBuilderMock>; +}; + +type QueryBuilderMock = { + innerJoinAndSelect: jest.MockedFunction< + (relation: string, alias: string) => QueryBuilderMock + >; + where: jest.MockedFunction< + ( + condition: string, + parameters?: Record + ) => QueryBuilderMock + >; + andWhere: jest.MockedFunction< + ( + condition: string, + parameters?: Record + ) => QueryBuilderMock + >; + orderBy: jest.MockedFunction< + (sort: string, order: 'ASC' | 'DESC') => QueryBuilderMock + >; + getOne: jest.MockedFunction<() => Promise>; +}; + +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 + ): QueryBuilderMock => queryBuilder + ); + queryBuilder.andWhere = jest.fn( + ( + _condition: string, + _parameters?: Record + ): QueryBuilderMock => queryBuilder + ); + queryBuilder.orderBy = jest.fn( + (_sort: string, _order: 'ASC' | 'DESC'): QueryBuilderMock => queryBuilder + ); + queryBuilder.getOne = jest.fn( + (): Promise => Promise.resolve(delegation) + ); + return queryBuilder; +}; + +const createRepositoryMock = (): RepositoryMock => ({ + findOne: jest.fn(), + create: jest.fn((payload: Partial): T => payload as T), + save: jest.fn((entity: T): Promise => Promise.resolve(entity)), + createQueryBuilder: jest.fn(), +}); + +describe('DelegationService', () => { + const delegationRepo = createRepositoryMock(); + const userRepo = createRepositoryMock(); + 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, + userRepo as unknown as Repository, + 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'); + }); +}); diff --git a/backend/tests/unit/distribution/distribution-matrix.service.spec.ts b/backend/tests/unit/distribution/distribution-matrix.service.spec.ts new file mode 100644 index 00000000..3bd676b3 --- /dev/null +++ b/backend/tests/unit/distribution/distribution-matrix.service.spec.ts @@ -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 = { + find: jest.MockedFunction<(options: unknown) => Promise>; + findOne: jest.MockedFunction<(options: unknown) => Promise>; + create: jest.MockedFunction<(payload: Partial) => T>; + save: jest.MockedFunction<(payload: T) => Promise>; + remove: jest.MockedFunction<(payload: T) => Promise>; +}; + +const createRepositoryMock = (): RepositoryMock => ({ + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn((payload: Partial): T => payload as T), + save: jest.fn((payload: T): Promise => Promise.resolve(payload)), + remove: jest.fn((payload: T): Promise => Promise.resolve(payload)), +}); + +describe('DistributionMatrixService', () => { + const matrixRepo = createRepositoryMock(); + const recipientRepo = createRepositoryMock(); + const projectRepo = createRepositoryMock(); + const responseCodeRepo = createRepositoryMock(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a schema-aligned matrix by resolving public IDs internally', async () => { + const service = new DistributionMatrixService( + matrixRepo as unknown as Repository, + recipientRepo as unknown as Repository, + projectRepo as unknown as Repository, + responseCodeRepo as unknown as Repository + ); + 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, + recipientRepo as unknown as Repository, + projectRepo as unknown as Repository, + responseCodeRepo as unknown as Repository + ); + 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, + }) + ); + }); +}); diff --git a/backend/tests/unit/distribution/transmittal-creator.service.spec.ts b/backend/tests/unit/distribution/transmittal-creator.service.spec.ts new file mode 100644 index 00000000..78a20b4b --- /dev/null +++ b/backend/tests/unit/distribution/transmittal-creator.service.spec.ts @@ -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 + >; +}; + +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, + 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: [], + }); + }); +}); diff --git a/backend/tests/unit/response-code/response-code.service.spec.ts b/backend/tests/unit/response-code/response-code.service.spec.ts index b6a5e16d..eac84ff2 100644 --- a/backend/tests/unit/response-code/response-code.service.spec.ts +++ b/backend/tests/unit/response-code/response-code.service.spec.ts @@ -6,6 +6,7 @@ import { ResponseCodeService } from '../../../src/modules/response-code/response import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity'; import { ResponseCodeRule } from '../../../src/modules/response-code/entities/response-code-rule.entity'; import { ResponseCodeCategory } from '../../../src/modules/common/enums/review.enums'; +import { BadRequestException, ConflictException } from '@nestjs/common'; const mockCode: Partial = { id: 1, @@ -21,6 +22,13 @@ const mockCode: Partial = { const mockCodeRepo = { find: jest.fn().mockResolvedValue([mockCode]), findOne: jest.fn().mockResolvedValue(mockCode), + create: jest.fn( + (payload: Partial): Partial => payload + ), + save: jest.fn( + (payload: Partial): Promise> => + Promise.resolve(payload) + ), }; const mockRuleRepo = { @@ -31,6 +39,18 @@ describe('ResponseCodeService', () => { let service: ResponseCodeService; beforeEach(async () => { + jest.clearAllMocks(); + mockCodeRepo.find.mockResolvedValue([mockCode]); + mockCodeRepo.findOne.mockResolvedValue(mockCode); + mockCodeRepo.create.mockImplementation( + (payload: Partial): Partial => payload + ); + mockCodeRepo.save.mockImplementation( + (payload: Partial): Promise> => + Promise.resolve(payload) + ); + mockRuleRepo.find.mockResolvedValue([]); + const module: TestingModule = await Test.createTestingModule({ providers: [ ResponseCodeService, @@ -71,4 +91,72 @@ describe('ResponseCodeService', () => { 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 + ); + }); + }); }); diff --git a/backend/tests/unit/review-team/task-creation-delegation.service.spec.ts b/backend/tests/unit/review-team/task-creation-delegation.service.spec.ts new file mode 100644 index 00000000..f26e257a --- /dev/null +++ b/backend/tests/unit/review-team/task-creation-delegation.service.spec.ts @@ -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 = Pick, 'findOne' | 'find'>; + +const createRepositoryMock = (): jest.Mocked< + RepositoryMock +> => ({ + findOne: jest.fn(), + find: jest.fn(), +}); + +const createManagerMock = (): { create: jest.Mock; save: jest.Mock } => ({ + create: jest.fn( + (_entity: unknown, payload: Partial): ReviewTask => + payload as ReviewTask + ), + save: jest.fn( + (_entity: unknown, payload: ReviewTask): Promise => + Promise.resolve(payload) + ), +}); + +describe('TaskCreationService delegation resolution', () => { + const reviewTeamRepo = createRepositoryMock(); + const memberRepo = createRepositoryMock(); + const reviewTaskRepo = createRepositoryMock(); + + 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, + memberRepo as unknown as Repository, + reviewTaskRepo as unknown as Repository, + 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(); + }); +}); diff --git a/docs/ai-knowledge-base/CONTRIBUTING.md b/docs/ai-knowledge-base/CONTRIBUTING.md new file mode 100644 index 00000000..221d4313 --- /dev/null +++ b/docs/ai-knowledge-base/CONTRIBUTING.md @@ -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 diff --git a/docs/ai-knowledge-base/README.md b/docs/ai-knowledge-base/README.md new file mode 100644 index 00000000..4639bc93 --- /dev/null +++ b/docs/ai-knowledge-base/README.md @@ -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 diff --git a/docs/ai-knowledge-base/VERSIONING.md b/docs/ai-knowledge-base/VERSIONING.md new file mode 100644 index 00000000..00f81544 --- /dev/null +++ b/docs/ai-knowledge-base/VERSIONING.md @@ -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 diff --git a/docs/ai-knowledge-base/checklists/db-change.md b/docs/ai-knowledge-base/checklists/db-change.md new file mode 100644 index 00000000..a5c72f6c --- /dev/null +++ b/docs/ai-knowledge-base/checklists/db-change.md @@ -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 diff --git a/docs/ai-knowledge-base/checklists/deploy.md b/docs/ai-knowledge-base/checklists/deploy.md new file mode 100644 index 00000000..a97599b1 --- /dev/null +++ b/docs/ai-knowledge-base/checklists/deploy.md @@ -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 diff --git a/docs/ai-knowledge-base/checklists/rollback.md b/docs/ai-knowledge-base/checklists/rollback.md new file mode 100644 index 00000000..66838493 --- /dev/null +++ b/docs/ai-knowledge-base/checklists/rollback.md @@ -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 diff --git a/docs/ai-knowledge-base/checklists/security-audit.md b/docs/ai-knowledge-base/checklists/security-audit.md new file mode 100644 index 00000000..a225c5d8 --- /dev/null +++ b/docs/ai-knowledge-base/checklists/security-audit.md @@ -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 diff --git a/docs/ai-knowledge-base/checklists/vlan-change.md b/docs/ai-knowledge-base/checklists/vlan-change.md new file mode 100644 index 00000000..9e00db23 --- /dev/null +++ b/docs/ai-knowledge-base/checklists/vlan-change.md @@ -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 diff --git a/docs/ai-knowledge-base/playbooks/core/context-recovery.md b/docs/ai-knowledge-base/playbooks/core/context-recovery.md new file mode 100644 index 00000000..db8b9d29 --- /dev/null +++ b/docs/ai-knowledge-base/playbooks/core/context-recovery.md @@ -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 diff --git a/docs/ai-knowledge-base/playbooks/dms/cross-module-linking.md b/docs/ai-knowledge-base/playbooks/dms/cross-module-linking.md new file mode 100644 index 00000000..e5ed2636 --- /dev/null +++ b/docs/ai-knowledge-base/playbooks/dms/cross-module-linking.md @@ -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 diff --git a/docs/ai-knowledge-base/playbooks/dms/drawing-revision-flow.md b/docs/ai-knowledge-base/playbooks/dms/drawing-revision-flow.md new file mode 100644 index 00000000..e8ca4053 --- /dev/null +++ b/docs/ai-knowledge-base/playbooks/dms/drawing-revision-flow.md @@ -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 diff --git a/docs/ai-knowledge-base/playbooks/dms/rfa-lifecycle.md b/docs/ai-knowledge-base/playbooks/dms/rfa-lifecycle.md new file mode 100644 index 00000000..9ba50365 --- /dev/null +++ b/docs/ai-knowledge-base/playbooks/dms/rfa-lifecycle.md @@ -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 diff --git a/docs/ai-knowledge-base/playbooks/dms/transmittal-process.md b/docs/ai-knowledge-base/playbooks/dms/transmittal-process.md new file mode 100644 index 00000000..57d65afa --- /dev/null +++ b/docs/ai-knowledge-base/playbooks/dms/transmittal-process.md @@ -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 diff --git a/docs/ai-knowledge-base/playbooks/infra/omada-vlan-recovery.md b/docs/ai-knowledge-base/playbooks/infra/omada-vlan-recovery.md new file mode 100644 index 00000000..7fbff288 --- /dev/null +++ b/docs/ai-knowledge-base/playbooks/infra/omada-vlan-recovery.md @@ -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 diff --git a/docs/ai-knowledge-base/playbooks/infra/switch-reset-safe.md b/docs/ai-knowledge-base/playbooks/infra/switch-reset-safe.md new file mode 100644 index 00000000..3669c759 --- /dev/null +++ b/docs/ai-knowledge-base/playbooks/infra/switch-reset-safe.md @@ -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 diff --git a/docs/ai-knowledge-base/prompts/automation/n8n-workflow.md b/docs/ai-knowledge-base/prompts/automation/n8n-workflow.md new file mode 100644 index 00000000..b8b82595 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/automation/n8n-workflow.md @@ -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: สร้าง PDF -> ส่งเข้า Line Group> +Triggers: +Expected Output: รายการ Nodes ที่ต้องใช้ และ Logic ในการเชื่อมต่อแต่ละจุด +Request: ออกแบบโครงสร้าง Workflow ที่ทนทาน (Robust) และรองรับการทำ Retry +``` + +--- +// Change Log: +// - 2026-05-14: Initial n8n workflow prompt diff --git a/docs/ai-knowledge-base/prompts/automation/ocr-rag-tuning.md b/docs/ai-knowledge-base/prompts/automation/ocr-rag-tuning.md new file mode 100644 index 00000000..8a759fe0 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/automation/ocr-rag-tuning.md @@ -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: +Problem: +Request: เสนอแนวทางการปรับปรุง Chunking หรือ Prompt เพื่อเพิ่ม Accuracy ของระบบ +``` + +--- +// Change Log: +// - 2026-05-14: Initial OCR/RAG tuning prompt diff --git a/docs/ai-knowledge-base/prompts/codex/codex-bugfix.md b/docs/ai-knowledge-base/prompts/codex/codex-bugfix.md new file mode 100644 index 00000000..83b347d6 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/codex/codex-bugfix.md @@ -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: +Error Log: +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 diff --git a/docs/ai-knowledge-base/prompts/codex/codex-feature.md b/docs/ai-knowledge-base/prompts/codex/codex-feature.md new file mode 100644 index 00000000..a9626856 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/codex/codex-feature.md @@ -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: +Plan Reference: +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 diff --git a/docs/ai-knowledge-base/prompts/codex/codex-review.md b/docs/ai-knowledge-base/prompts/codex/codex-review.md new file mode 100644 index 00000000..17ac9930 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/codex/codex-review.md @@ -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 diff --git a/docs/ai-knowledge-base/prompts/core/coding-standards.md b/docs/ai-knowledge-base/prompts/core/coding-standards.md new file mode 100644 index 00000000..4cd2a81c --- /dev/null +++ b/docs/ai-knowledge-base/prompts/core/coding-standards.md @@ -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 diff --git a/docs/ai-knowledge-base/prompts/core/guardrails.md b/docs/ai-knowledge-base/prompts/core/guardrails.md new file mode 100644 index 00000000..7c66a5ae --- /dev/null +++ b/docs/ai-knowledge-base/prompts/core/guardrails.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 diff --git a/docs/ai-knowledge-base/prompts/core/master-prompt.md b/docs/ai-knowledge-base/prompts/core/master-prompt.md new file mode 100644 index 00000000..55aabca5 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/core/master-prompt.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 diff --git a/docs/ai-knowledge-base/prompts/core/system-context.md b/docs/ai-knowledge-base/prompts/core/system-context.md new file mode 100644 index 00000000..0a2320f9 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/core/system-context.md @@ -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 diff --git a/docs/ai-knowledge-base/prompts/dms/api-design.md b/docs/ai-knowledge-base/prompts/dms/api-design.md new file mode 100644 index 00000000..f08babcb --- /dev/null +++ b/docs/ai-knowledge-base/prompts/dms/api-design.md @@ -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 diff --git a/docs/ai-knowledge-base/prompts/dms/bug-fix.md b/docs/ai-knowledge-base/prompts/dms/bug-fix.md new file mode 100644 index 00000000..f8f4c7cf --- /dev/null +++ b/docs/ai-knowledge-base/prompts/dms/bug-fix.md @@ -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: +Issue: <อธิบายปัญหา> +Affected PublicId: +Error Message: +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 diff --git a/docs/ai-knowledge-base/prompts/dms/db-schema.md b/docs/ai-knowledge-base/prompts/dms/db-schema.md new file mode 100644 index 00000000..a242c46e --- /dev/null +++ b/docs/ai-knowledge-base/prompts/dms/db-schema.md @@ -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 diff --git a/docs/ai-knowledge-base/prompts/dms/feature-design.md b/docs/ai-knowledge-base/prompts/dms/feature-design.md new file mode 100644 index 00000000..7d1934a1 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/dms/feature-design.md @@ -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: +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 diff --git a/docs/ai-knowledge-base/prompts/dms/rbac-enforcement.md b/docs/ai-knowledge-base/prompts/dms/rbac-enforcement.md new file mode 100644 index 00000000..987638cf --- /dev/null +++ b/docs/ai-knowledge-base/prompts/dms/rbac-enforcement.md @@ -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: +User Role: +Desired Action: +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 diff --git a/docs/ai-knowledge-base/prompts/dms/refactor.md b/docs/ai-knowledge-base/prompts/dms/refactor.md new file mode 100644 index 00000000..1d68a2ff --- /dev/null +++ b/docs/ai-knowledge-base/prompts/dms/refactor.md @@ -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: +Goal: +Request: ช่วยวิเคราะห์โค้ดเดิมและเสนอเวอร์ชัน Refactor ที่ยังคงรักษา functionality เดิมไว้ 100% พร้อมอธิบายเหตุผลของการเปลี่ยนแปลง +``` + +--- +// Change Log: +// - 2026-05-14: Initial refactor prompt diff --git a/docs/ai-knowledge-base/prompts/dms/report-generation.md b/docs/ai-knowledge-base/prompts/dms/report-generation.md new file mode 100644 index 00000000..d7352e18 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/dms/report-generation.md @@ -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: +Export Format: +Request: ออกแบบ Query และโครงสร้างข้อมูลสำหรับสร้างรายงานนี้ +``` + +--- +// Change Log: +// - 2026-05-14: Initial report generation prompt diff --git a/docs/ai-knowledge-base/prompts/dms/ui-flow.md b/docs/ai-knowledge-base/prompts/dms/ui-flow.md new file mode 100644 index 00000000..bd6d30e4 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/dms/ui-flow.md @@ -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: +Objective: <สิ่งที่ผู้ใช้ต้องการทำ> +Request: ออกแบบ User Flow ตั้งแต่เริ่มต้นจนจบ พร้อมระบุ UI Components ที่ต้องใช้ในแต่ละหน้า +``` + +--- +// Change Log: +// - 2026-05-14: Initial UI flow prompt diff --git a/docs/ai-knowledge-base/prompts/infra/network-troubleshoot.md b/docs/ai-knowledge-base/prompts/infra/network-troubleshoot.md new file mode 100644 index 00000000..4c69a14f --- /dev/null +++ b/docs/ai-knowledge-base/prompts/infra/network-troubleshoot.md @@ -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: +Symptom: +Recent Changes: <การแก้ไขล่าสุดก่อนเกิดปัญหา> +Request: ช่วยวิเคราะห์สาเหตุและเสนอขั้นตอนการแก้ไข (Step-by-step recovery) +``` + +--- +// Change Log: +// - 2026-05-14: Initial network troubleshoot prompt diff --git a/docs/ai-knowledge-base/prompts/infra/server-debug.md b/docs/ai-knowledge-base/prompts/infra/server-debug.md new file mode 100644 index 00000000..9d8b3dd5 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/infra/server-debug.md @@ -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 `: ดู Log การทำงานแบบ Real-time +- `df -h`: ตรวจสอบพื้นที่ว่างใน Disk +- `free -m`: ตรวจสอบการใช้งาน RAM +- `netstat -tulpn`: ตรวจสอบ Port ที่เปิดใช้งานอยู่ + +## 🚀 Prompt Template +``` +[SERVER DEBUG] +Service: +Problem: +Error Output: +Request: วิเคราะห์หาสาเหตุ (e.g. Out of Memory, Disk Full, Env Config Error) และวิธีแก้ไข +``` + +--- +// Change Log: +// - 2026-05-14: Initial server debug prompt diff --git a/docs/ai-knowledge-base/prompts/infra/vlan-change.md b/docs/ai-knowledge-base/prompts/infra/vlan-change.md new file mode 100644 index 00000000..54d66f71 --- /dev/null +++ b/docs/ai-knowledge-base/prompts/infra/vlan-change.md @@ -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: +VLAN ID: +IP Range: +Request: ออกแบบขั้นตอนการ Config ใน Omada Controller และ Switch Port +``` + +--- +// Change Log: +// - 2026-05-14: Initial VLAN change prompt diff --git a/docs/ai-knowledge-base/templates/api-spec.md b/docs/ai-knowledge-base/templates/api-spec.md new file mode 100644 index 00000000..55720621 --- /dev/null +++ b/docs/ai-knowledge-base/templates/api-spec.md @@ -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 diff --git a/docs/ai-knowledge-base/templates/bug-report.md b/docs/ai-knowledge-base/templates/bug-report.md new file mode 100644 index 00000000..d83ccb46 --- /dev/null +++ b/docs/ai-knowledge-base/templates/bug-report.md @@ -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 diff --git a/docs/ai-knowledge-base/templates/db-migration.md b/docs/ai-knowledge-base/templates/db-migration.md new file mode 100644 index 00000000..bf3d3d91 --- /dev/null +++ b/docs/ai-knowledge-base/templates/db-migration.md @@ -0,0 +1,42 @@ +// File: docs/ai-knowledge-base/templates/db-migration.md +# Database Change Script (SQL Delta) + +## 📋 Metadata +- **Feature**: [Feature Name] +- **Requested By**: [Name] +- **Date**: [YYYY-MM-DD] +- **Risk Level**: Low / Medium / High + +## 🏗️ SQL Script (ADR-009 Standard) +```sql +-- Purpose: [Add new column/table for feature X] +-- Target Table: [table_name] + +-- 1. Create Table (if new) +CREATE TABLE IF NOT EXISTS `table_name` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `publicId` BINARY(16) NOT NULL UNIQUE, + -- Custom fields... + `version` INT DEFAULT 1, + `createdAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updatedAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `createdBy` INT, + `updatedBy` INT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. Alter Table (if existing) +-- ALTER TABLE `table_name` ADD COLUMN `new_field` VARCHAR(255); + +-- 3. Add Indexes +-- CREATE INDEX `idx_table_field` ON `table_name` (`field`); +``` + +## 🆘 Rollback Script +```sql +-- DROP TABLE IF EXISTS `table_name`; +-- ALTER TABLE `table_name` DROP COLUMN `new_field`; +``` + +--- +// Change Log: +// - 2026-05-14: Initial SQL delta template based on ADR-009 diff --git a/docs/ai-knowledge-base/templates/feature-spec.md b/docs/ai-knowledge-base/templates/feature-spec.md new file mode 100644 index 00000000..cd439583 --- /dev/null +++ b/docs/ai-knowledge-base/templates/feature-spec.md @@ -0,0 +1,45 @@ +// File: docs/ai-knowledge-base/templates/feature-spec.md +# Feature Specification: [Feature Name] + +## 📋 Metadata +- **Status**: Draft / In Review / Approved +- **Author**: [Name] +- **Date**: [YYYY-MM-DD] +- **Module**: [e.g., Correspondence, RFA] +- **Reference**: [PRD Link / Issue ID] + +## 🎯 1. Overview +[อธิบายวัตถุประสงค์สั้นๆ ของฟีเจอร์นี้ และปัญหาที่ต้องการแก้ไข] + +## 📑 2. Requirements +- **Functional Requirements**: + - [ ] Requirement 1 + - [ ] Requirement 2 +- **Non-Functional Requirements**: + - [ ] Performance: < 200ms API response + - [ ] Security: RBAC 4-Level Check + +## 🏗️ 3. Proposed Solution +### Data Model (ADR-019) +- Table: `table_name` +- Fields: `publicId (UUIDv7)`, `name`, `status`, ... + +### API Endpoints +- `POST /v1/[module]/...` +- `GET /v1/[module]/:publicId` + +### Workflow / Logic +[อธิบายลำดับการทำงาน หรือ Flow Chart] + +## 🛡️ 4. Security & Edge Cases +- **Permissions**: ใครสามารถทำอะไรได้บ้าง? +- **Edge Cases**: จะเกิดอะไรขึ้นถ้าข้อมูลไม่ครบ? หรือส่งซ้ำ? + +## ✅ 5. Acceptance Criteria +- [ ] UI แสดงผลถูกต้องตามแบบ +- [ ] API ทำงานได้ตามที่กำหนด +- [ ] Unit Test Coverage > 80% + +--- +// Change Log: +// - 2026-05-14: Initial template based on Hybrid Specs diff --git a/docs/ai-knowledge-base/templates/test-case.md b/docs/ai-knowledge-base/templates/test-case.md new file mode 100644 index 00000000..ea9f72c9 --- /dev/null +++ b/docs/ai-knowledge-base/templates/test-case.md @@ -0,0 +1,34 @@ +// File: docs/ai-knowledge-base/templates/test-case.md +# Test Case Specification + +## 📋 Metadata +- **Module**: [e.g. Authentication] +- **Type**: Unit / Integration / E2E +- **Author**: [Name] + +## 🧪 Test Case: [Descriptive Title] +### Objective +[อธิบายว่าต้องการทดสอบอะไร] + +### Pre-conditions +1. User logged in as [Role] +2. Data [X] exists in database + +### Test Steps +1. Call API `METHOD /v1/...` with data `[Y]` +2. Verify response status is `200` +3. Verify database record is updated + +### Expected Result +- API return success +- Audit log is created +- No side effects on unrelated data + +### Edge Cases to Cover +- Missing `Idempotency-Key` +- Unauthorized role access +- Invalid UUID format + +--- +// Change Log: +// - 2026-05-14: Initial test case template diff --git a/frontend-lint.json b/frontend-lint.json new file mode 100644 index 00000000..1f912f9f Binary files /dev/null and b/frontend-lint.json differ diff --git a/frontend-tsc.txt b/frontend-tsc.txt new file mode 100644 index 00000000..e69de29b diff --git a/frontend/app/(dashboard)/delegation/page.tsx b/frontend/app/(dashboard)/delegation/page.tsx new file mode 100644 index 00000000..3b22e032 --- /dev/null +++ b/frontend/app/(dashboard)/delegation/page.tsx @@ -0,0 +1,4 @@ +// File: app/(dashboard)/delegation/page.tsx +// Change Log +// - 2026-05-13: เพิ่ม route alias สำหรับหน้า Delegation Settings ตาม Phase 5 +export { default } from '../settings/delegation/page'; diff --git a/frontend/app/(dashboard)/distribution-matrices/page.tsx b/frontend/app/(dashboard)/distribution-matrices/page.tsx new file mode 100644 index 00000000..5c447b7c --- /dev/null +++ b/frontend/app/(dashboard)/distribution-matrices/page.tsx @@ -0,0 +1,214 @@ +'use client'; + +// File: app/(dashboard)/distribution-matrices/page.tsx +// Change Log +// - 2026-05-14: Add admin UI for Distribution Matrix management. +import { useState } from 'react'; +import { Network, Plus, Trash2 } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { + useCreateDistributionMatrix, + useDeleteDistributionMatrix, + useDistributionMatrices, +} from '@/hooks/use-distribution-matrices'; +import { useProjectStore } from '@/lib/stores/project-store'; + +const formSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100), + documentTypeId: z.coerce.number().int().positive(), + codes: z.string().optional(), + excludeCodes: z.string().optional(), +}); + +type DistributionMatrixForm = z.infer; + +const splitCodes = (value?: string): string[] | undefined => { + const codes = value + ?.split(',') + .map((item) => item.trim()) + .filter(Boolean); + return codes && codes.length > 0 ? codes : undefined; +}; + +export default function DistributionMatricesPage() { + const [createOpen, setCreateOpen] = useState(false); + const selectedProjectId = useProjectStore((state) => state.selectedProjectId); + const { data: matrices = [], isLoading } = useDistributionMatrices(selectedProjectId ?? undefined); + const createMatrix = useCreateDistributionMatrix(); + const deleteMatrix = useDeleteDistributionMatrix(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + documentTypeId: 1, + codes: '', + excludeCodes: '3,4', + }, + }); + + const onSubmit = (values: DistributionMatrixForm) => { + createMatrix.mutate( + { + name: values.name, + projectPublicId: selectedProjectId ?? undefined, + documentTypeId: values.documentTypeId, + conditions: { + codes: splitCodes(values.codes), + excludeCodes: splitCodes(values.excludeCodes), + }, + }, + { + onSuccess: () => { + form.reset(); + setCreateOpen(false); + }, + } + ); + }; + + return ( +
+
+
+

Distribution Matrix

+

+ ตั้งค่าการกระจาย RFA หลังอนุมัติผ่าน BullMQ และ Transmittal +

+
+ + + + + + + Create Distribution Matrix + +
+ + ( + + Name + + + + + + )} + /> + ( + + Document Type ID + + + + + + )} + /> +
+ ( + + Included Codes + + + + + + )} + /> + ( + + Excluded Codes + + + + + + )} + /> +
+ + + +
+
+
+ + {isLoading &&
Loading distribution matrices...
} + +
+ {matrices.map((matrix) => ( + + +
+
+
+ + {matrix.name} +
+
+ Type {matrix.documentTypeId} + {(matrix.conditions?.codes ?? []).map((code) => ( + + {code} + + ))} + {(matrix.conditions?.excludeCodes ?? []).map((code) => ( + + Exclude {code} + + ))} +
+
+ +
+
+ +
Recipients: {(matrix.recipients ?? []).length}
+
+
+ ))} + {!isLoading && matrices.length === 0 && ( +
+ +

No Distribution Matrix configured.

+
+ )} +
+
+ ); +} diff --git a/frontend/app/(dashboard)/response-codes/page.tsx b/frontend/app/(dashboard)/response-codes/page.tsx new file mode 100644 index 00000000..daae662d --- /dev/null +++ b/frontend/app/(dashboard)/response-codes/page.tsx @@ -0,0 +1,95 @@ +'use client'; + +// File: app/(dashboard)/response-codes/page.tsx +// Master Approval Matrix management UI (T031, FR-022) +import { useState } from 'react'; +import { Settings, ShieldCheck, ChevronRight } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useResponseCodes } from '@/hooks/use-response-codes'; +import { MatrixEditor } from '@/components/response-code/MatrixEditor'; +import { ProjectOverrideManager } from '@/components/response-code/ProjectOverrideManager'; +import { useProjectStore } from '@/lib/stores/project-store'; + +export default function ResponseCodesPage() { + const [activeTab, setActiveTab] = useState('global'); + const selectedProjectId = useProjectStore((state) => state.selectedProjectId); + + // Dummy data for example - in real app, these would come from specialized hooks + const { data: globalRules = [] } = useResponseCodes(); + + return ( +
+
+
+
+ Admin + + Master Data +
+

+ + Approval Matrix & Response Codes +

+

+ Manage global response codes and document-specific approval rules. +

+
+
+ + + + + Global Matrix + + + Project Overrides + + + Code Definitions + + + + +
+ ({ + publicId: r.publicId, + responseCode: { + publicId: r.publicId, + code: r.code, + descriptionEn: r.descriptionEn || '', + category: r.category || 'ENGINEERING' + }, + isEnabled: true, + requiresComments: false, + triggersNotification: false, + isOverridden: false + }))} + onToggleEnabled={() => {}} + onToggleRequiresComments={() => {}} + onToggleNotification={() => {}} + /> +
+
+ + + {}} + onAddOverride={() => {}} + /> + + + +
+ +

Response Code definition management is coming soon.

+
+
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/settings/delegation/page.tsx b/frontend/app/(dashboard)/settings/delegation/page.tsx index 6c07f306..90659241 100644 --- a/frontend/app/(dashboard)/settings/delegation/page.tsx +++ b/frontend/app/(dashboard)/settings/delegation/page.tsx @@ -14,13 +14,21 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; -import { useMyDelegations, useCreateDelegation, useRevokeDelegation, Delegation } from '@/hooks/use-delegation'; +import { + useMyDelegations, + useCreateDelegation, + useRevokeDelegation, + Delegation, +} from '@/hooks/use-delegation'; +import { useUsers } from '@/hooks/use-users'; import { DelegationForm } from '@/components/delegation/DelegationForm'; +import { User } from '@/types/user'; export default function DelegationPage() { const [createOpen, setCreateOpen] = useState(false); const { data: delegations = [], isLoading } = useMyDelegations(); + const { data: users = [] } = useUsers({ limit: 100 }); const createDelegation = useCreateDelegation(); const revokeDelegation = useRevokeDelegation(); @@ -48,7 +56,11 @@ export default function DelegationPage() { Create Delegation ({ + publicId: user.publicId, + fullName: `${user.firstName} ${user.lastName}`.trim(), + email: user.email, + }))} onSubmit={(dto) => createDelegation.mutate(dto, { onSuccess: () => setCreateOpen(false), diff --git a/frontend/app/(dashboard)/settings/reminder-rules/page.tsx b/frontend/app/(dashboard)/settings/reminder-rules/page.tsx new file mode 100644 index 00000000..174e6462 --- /dev/null +++ b/frontend/app/(dashboard)/settings/reminder-rules/page.tsx @@ -0,0 +1,207 @@ +'use client'; + +// File: app/(dashboard)/settings/reminder-rules/page.tsx +import { useState } from 'react'; +import { Plus, Bell, Trash2, Edit2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + useReminderRules, + useCreateReminderRule, + useUpdateReminderRule, + useDeleteReminderRule, + ReminderRule, +} from '@/hooks/use-reminder'; +import { ReminderRuleForm } from '@/components/reminder/ReminderRuleForm'; +import { ReminderType } from '@/types/workflow'; + +export default function ReminderRulesPage() { + const [createOpen, setCreateOpen] = useState(false); + const [editingRule, setEditingRule] = useState(null); + + const { data: rules = [], isLoading } = useReminderRules(); + const createRule = useCreateReminderRule(); + const updateRule = useUpdateReminderRule(); + const deleteRule = useDeleteReminderRule(); + + const getReminderTypeColor = (type: ReminderType) => { + switch (type) { + case ReminderType.DUE_SOON: + return 'bg-blue-100 text-blue-800 border-blue-200'; + case ReminderType.ON_DUE: + return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + case ReminderType.OVERDUE: + return 'bg-orange-100 text-orange-800 border-orange-200'; + case ReminderType.ESCALATION_L1: + return 'bg-red-100 text-red-800 border-red-200'; + case ReminderType.ESCALATION_L2: + return 'bg-purple-100 text-purple-800 border-purple-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } + }; + + return ( +
+
+
+

Reminder & Escalation Rules

+

+ ตั้งค่าการแจ้งเตือนและการยกระดับ (Escalation) เมื่อเกินกำหนด +

+
+ + + + + + + + Create Reminder Rule + + + createRule.mutate(dto, { + onSuccess: () => setCreateOpen(false), + }) + } + isLoading={createRule.isPending} + /> + + +
+ + {isLoading && ( +
+ Loading rules... +
+ )} + +
+ {rules.map((rule) => ( + + +
+
+
+ + {rule.name} +
+
+ + {rule.reminderType} + + {rule.escalationLevel > 0 && ( + + L{rule.escalationLevel} + + )} + {rule.documentTypeCode && ( + + {rule.documentTypeCode} + + )} +
+
+
+ + +
+
+
+ +
+

+ Trigger:{' '} + + {rule.daysBeforeDue === 0 + ? 'On Due Date' + : rule.daysBeforeDue > 0 + ? `${rule.daysBeforeDue} days before` + : `${Math.abs(rule.daysBeforeDue)} days after`} + +

+ {rule.messageTemplate && ( +

+ "{rule.messageTemplate}" +

+ )} +
+
+
+ ))} + + {!isLoading && rules.length === 0 && ( +
+ +

No reminder rules defined.

+ +
+ )} +
+ + !open && setEditingRule(null)} + > + + + Edit Reminder Rule + + {editingRule && ( + + updateRule.mutate( + { publicId: editingRule.publicId, data: dto }, + { onSuccess: () => setEditingRule(null) } + ) + } + isLoading={updateRule.isPending} + /> + )} + + +
+ ); +} diff --git a/frontend/app/(dashboard)/settings/review-teams/page.tsx b/frontend/app/(dashboard)/settings/review-teams/page.tsx index c018f9b6..eb337d70 100644 --- a/frontend/app/(dashboard)/settings/review-teams/page.tsx +++ b/frontend/app/(dashboard)/settings/review-teams/page.tsx @@ -18,17 +18,22 @@ import { useReviewTeams, useCreateReviewTeam, useUpdateReviewTeam } from '@/hook import { ReviewTeamForm } from '@/components/review-team/ReviewTeamForm'; import { TeamMemberManager } from '@/components/review-team/TeamMemberManager'; import { ReviewTeam } from '@/types/review-team'; - -// TODO: ดึง projectPublicId จาก context หรือ URL param จริง -const MOCK_PROJECT_ID = 'current-project-public-id'; +import { useProjectStore } from '@/lib/stores/project-store'; +import { useUsers } from '@/hooks/use-users'; +import { useContracts, useDisciplines } from '@/hooks/use-master-data'; export default function ReviewTeamsPage() { const [expandedTeam, setExpandedTeam] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [editTeam, setEditTeam] = useState(null); + const selectedProjectId = useProjectStore((state) => state.selectedProjectId); + const { data: availableUsers = [] } = useUsers(); + const { data: contracts = [] } = useContracts(selectedProjectId ?? undefined); + const primaryContractId = contracts[0]?.publicId; + const { data: availableDisciplines = [] } = useDisciplines(primaryContractId); const { data: teams = [], isLoading } = useReviewTeams({ - projectPublicId: MOCK_PROJECT_ID, + projectPublicId: selectedProjectId ?? undefined, }); const createTeam = useCreateReviewTeam(); @@ -56,7 +61,7 @@ export default function ReviewTeamsPage() { Create Review Team createTeam.mutate(values, { onSuccess: () => setCreateOpen(false), @@ -125,8 +130,8 @@ export default function ReviewTeamsPage() { )} @@ -149,7 +154,7 @@ export default function ReviewTeamsPage() { {editTeam && ( updateTeam.mutate( diff --git a/frontend/components/reminder/ReminderHistory.tsx b/frontend/components/reminder/ReminderHistory.tsx index 5c3443c2..b859996a 100644 --- a/frontend/components/reminder/ReminderHistory.tsx +++ b/frontend/components/reminder/ReminderHistory.tsx @@ -1,84 +1,81 @@ 'use client'; // File: components/reminder/ReminderHistory.tsx -// แสดงประวัติ Reminder และ Escalation ของ Review Task (T050) -import React from 'react'; -import { Badge } from '@/components/ui/badge'; -import { Clock, AlertTriangle, Bell } from 'lucide-react'; - -type ReminderType = 'DUE_SOON' | 'ON_DUE' | 'OVERDUE' | 'ESCALATION_L1' | 'ESCALATION_L2'; - -interface ReminderEntry { - id: string; - type: ReminderType; - sentAt: string; - recipient?: string; - isDelivered?: boolean; -} +import { format } from 'date-fns'; +import { History, Bell, ShieldAlert, AlertTriangle } from 'lucide-react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { useReminderHistory } from '@/hooks/use-reminder'; +import { ReminderType } from '@/types/workflow'; interface ReminderHistoryProps { - reminders: ReminderEntry[]; - isLoading?: boolean; + taskPublicId: string; } -const TYPE_CONFIG: Record< - ReminderType, - { label: string; icon: React.ElementType; variant: 'default' | 'secondary' | 'destructive' | 'outline' } -> = { - DUE_SOON: { label: 'Due Soon', icon: Clock, variant: 'outline' }, - ON_DUE: { label: 'Due Today', icon: Bell, variant: 'secondary' }, - OVERDUE: { label: 'Overdue', icon: AlertTriangle, variant: 'destructive' }, - ESCALATION_L1: { label: 'Escalation L1', icon: AlertTriangle, variant: 'destructive' }, - ESCALATION_L2: { label: 'Escalation L2 (PM)', icon: AlertTriangle, variant: 'destructive' }, -}; +export function ReminderHistoryViewer({ taskPublicId }: ReminderHistoryProps) { + const { data: history = [], isLoading } = useReminderHistory(taskPublicId); -export function ReminderHistory({ reminders, isLoading }: ReminderHistoryProps) { - if (isLoading) { - return
Loading reminder history...
; - } - - if (reminders.length === 0) { - return
No reminders sent yet.
; - } + const getIcon = (type: ReminderType) => { + switch (type) { + case ReminderType.ESCALATION_L1: + return ; + case ReminderType.ESCALATION_L2: + return ; + default: + return ; + } + }; return ( -
- {reminders.map((entry) => { - const config = TYPE_CONFIG[entry.type]; - const Icon = config.icon; - - return ( -
-
- - - {config.label} - - {entry.recipient && ( - → {entry.recipient} - )} -
-
- {entry.isDelivered !== undefined && ( - - {entry.isDelivered ? '✓ Delivered' : '⏳ Pending'} - - )} - - {new Date(entry.sentAt).toLocaleDateString('th-TH', { - day: '2-digit', - month: 'short', - hour: '2-digit', - minute: '2-digit', - })} - -
+ + +
+ + Reminder History +
+ ประวัติการแจ้งเตือนและการยกระดับ +
+ + {isLoading ? ( +
+ Loading history...
- ); - })} -
+ ) : history.length === 0 ? ( +
+ No reminders sent yet. +
+ ) : ( +
+ {history.map((item) => ( +
+
{getIcon(item.reminderType)}
+
+
+ + {item.reminderType.replace('_', ' ')} + + + {format(new Date(item.sentAt), 'dd MMM yyyy HH:mm')} + +
+

+ Sent to: {item.user?.fullName ?? 'Unknown User'} + {item.escalationLevel > 0 && ` (L${item.escalationLevel})`} +

+
+
+ ))} +
+ )} + + ); } diff --git a/frontend/components/reminder/ReminderRuleForm.tsx b/frontend/components/reminder/ReminderRuleForm.tsx new file mode 100644 index 00000000..cb9e0f9c --- /dev/null +++ b/frontend/components/reminder/ReminderRuleForm.tsx @@ -0,0 +1,145 @@ +'use client'; + +// File: components/reminder/ReminderRuleForm.tsx +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Button } from '@/components/ui/button'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { ReminderType } from '@/types/workflow'; +import { CreateReminderRuleDto } from '@/hooks/use-reminder'; + +const formSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100), + documentTypeCode: z.string().optional(), + reminderType: z.nativeEnum(ReminderType), + daysBeforeDue: z.coerce.number(), + escalationLevel: z.coerce.number().min(0).max(2).default(0), + messageTemplate: z.string().optional(), +}); + +interface ReminderRuleFormProps { + onSubmit: (data: CreateReminderRuleDto) => void; + isLoading?: boolean; + defaultValues?: Partial; +} + +export function ReminderRuleForm({ onSubmit, isLoading, defaultValues }: ReminderRuleFormProps) { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: defaultValues?.name ?? '', + documentTypeCode: defaultValues?.documentTypeCode ?? '', + reminderType: defaultValues?.reminderType ?? ReminderType.DUE_SOON, + daysBeforeDue: defaultValues?.daysBeforeDue ?? 2, + escalationLevel: defaultValues?.escalationLevel ?? 0, + messageTemplate: defaultValues?.messageTemplate ?? '', + }, + }); + + return ( +
+ + ( + + Rule Name + + + + + + )} + /> + +
+ ( + + Reminder Type + + + + )} + /> + + ( + + Trigger Days + + + + + for before, - for after due + + + )} + /> +
+ + ( + + Escalation Level + + + + )} + /> + + ( + + Message Template (Optional) + +