690514:2019 204-rfa-approval-refactor #01
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -3,4 +3,7 @@ backend/node_modules/
|
||||
frontend/node_modules/
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
.next
|
||||
out
|
||||
*.min.js
|
||||
|
||||
Vendored
+2
-1
@@ -33,6 +33,7 @@
|
||||
"github.copilot",
|
||||
"bierner.markdown-mermaid",
|
||||
"vitest.explorer",
|
||||
"google.geminicodeassist"
|
||||
"google.geminicodeassist",
|
||||
"openai.chatgpt"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"editor.fontSize": 16,
|
||||
"npm.packageManager": "pnpm"
|
||||
"npm.packageManager": "pnpm",
|
||||
"chatgpt.runCodexInWindowsSubsystemForLinux": true
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Architecture
|
||||
|
||||
## Current Structure
|
||||
|
||||
- `backend/`: NestJS application with shared `CommonModule`, TypeORM entities, BullMQ queue integration, and feature modules such as `rfa`, `review-team`, `response-code`, `delegation`, `reminder`, and `distribution`.
|
||||
- `frontend/`: Next.js dashboard application with route groups under `app`, shared components under `components`, and feature hooks under `hooks`.
|
||||
- `specs/`: Core specifications plus categorized feature work. The active RFA approval refactor lives in `specs/200-fullstacks/204-rfa-approval-refactor`.
|
||||
|
||||
## RFA Approval Refactor
|
||||
|
||||
- `review-team`: review team CRUD, member assignment, review task creation, aggregate status, consensus, and veto override.
|
||||
- `response-code`: response code lookup, matrix rules, implications, and notification triggering.
|
||||
- `delegation`, `reminder`, `distribution`: supporting modules for proxy assignment, scheduled reminders, and post-approval distribution. Delegation resolution is applied during parallel review task creation while preserving `delegatedFromUserId` for audit/display.
|
||||
- `distribution`: schema-aligned Distribution Matrix CRUD, BullMQ queueing, approval listener integration, draft Transmittal creation, and `/distribution-matrices` admin UI.
|
||||
- Queue names are centralized in `backend/src/modules/common/constants/queue.constants.ts`.
|
||||
+22
-2
@@ -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)
|
||||
|
||||
+22
-20
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,5 @@
|
||||
// File: src/config/redis.config.ts
|
||||
// Change Log:
|
||||
// - 2026-05-13: Add task-path config wrapper for Redis settings used by BullMQ and Redlock.
|
||||
|
||||
export { default } from '../common/config/redis.config';
|
||||
@@ -11,6 +11,7 @@ import { Delegation } from './entities/delegation.entity';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { 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<User | null> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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<DistributionMatrix>,
|
||||
@InjectRepository(DistributionRecipient)
|
||||
private readonly recipientRepo: Repository<DistributionRecipient>,
|
||||
@InjectRepository(Project)
|
||||
private readonly projectRepo: Repository<Project>
|
||||
private readonly projectRepo: Repository<Project>,
|
||||
@InjectRepository(ResponseCode)
|
||||
private readonly responseCodeRepo: Repository<ResponseCode>
|
||||
) {}
|
||||
|
||||
async findByProject(projectId: number): Promise<DistributionMatrix[]> {
|
||||
/**
|
||||
* ดึง Matrix ของโครงการ พร้อม global defaults.
|
||||
*/
|
||||
async findByProject(projectId?: number): Promise<DistributionMatrix[]> {
|
||||
return this.matrixRepo.find({
|
||||
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<DistributionMatrix[]> {
|
||||
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<DistributionMatrix | null> {
|
||||
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<DistributionMatrix> {
|
||||
const matrix = this.matrixRepo.create(dto as Partial<DistributionMatrix>);
|
||||
const matrix = this.matrixRepo.create({
|
||||
name: dto.name,
|
||||
projectId: await this.resolveProjectId(dto.projectPublicId),
|
||||
documentTypeId: dto.documentTypeId,
|
||||
responseCodeId: await this.resolveResponseCodeId(
|
||||
dto.responseCodePublicId
|
||||
),
|
||||
conditions: dto.conditions,
|
||||
isActive: true,
|
||||
});
|
||||
return this.matrixRepo.save(matrix);
|
||||
}
|
||||
|
||||
async update(
|
||||
publicId: string,
|
||||
dto: UpdateDistributionMatrixDto
|
||||
): Promise<DistributionMatrix> {
|
||||
const matrix = await this.findMatrix(publicId);
|
||||
if (dto.name !== undefined) matrix.name = dto.name;
|
||||
if (dto.documentTypeId !== undefined) {
|
||||
matrix.documentTypeId = dto.documentTypeId;
|
||||
}
|
||||
if (dto.projectPublicId !== undefined) {
|
||||
matrix.projectId = await this.resolveProjectId(dto.projectPublicId);
|
||||
}
|
||||
if (dto.responseCodePublicId !== undefined) {
|
||||
matrix.responseCodeId = await this.resolveResponseCodeId(
|
||||
dto.responseCodePublicId
|
||||
);
|
||||
}
|
||||
if (dto.conditions !== undefined) matrix.conditions = dto.conditions;
|
||||
return this.matrixRepo.save(matrix);
|
||||
}
|
||||
|
||||
async addRecipient(
|
||||
matrixPublicId: string,
|
||||
dto: AddRecipientDto
|
||||
dto: AddDistributionRecipientDto
|
||||
): Promise<DistributionRecipient> {
|
||||
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<DistributionRecipient>);
|
||||
|
||||
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<void> {
|
||||
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<DistributionMatrix> {
|
||||
const matrix = await this.matrixRepo.findOne({ where: { publicId } });
|
||||
if (!matrix) {
|
||||
throw new NotFoundException(`Distribution Matrix not found: ${publicId}`);
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
|
||||
private async resolveProjectId(
|
||||
projectPublicId?: string
|
||||
): Promise<number | undefined> {
|
||||
if (!projectPublicId) return undefined;
|
||||
const project = await this.projectRepo.findOne({
|
||||
where: { publicId: projectPublicId },
|
||||
});
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project not found: ${projectPublicId}`);
|
||||
}
|
||||
return project.id;
|
||||
}
|
||||
|
||||
private async resolveResponseCodeId(
|
||||
responseCodePublicId?: string
|
||||
): Promise<number | undefined> {
|
||||
if (!responseCodePublicId) return undefined;
|
||||
const responseCode = await this.responseCodeRepo.findOne({
|
||||
where: { publicId: responseCodePublicId },
|
||||
});
|
||||
if (!responseCode) {
|
||||
throw new NotFoundException(
|
||||
`Response Code not found: ${responseCodePublicId}`
|
||||
);
|
||||
}
|
||||
return responseCode.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// File: src/modules/distribution/distribution.controller.ts
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// File: src/modules/distribution/dto/add-distribution-recipient.dto.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: Add validated DTO for polymorphic Distribution recipients.
|
||||
import { IsEnum, IsInt, IsOptional, IsUUID } from 'class-validator';
|
||||
import { DeliveryMethod, RecipientType } from '../../common/enums/review.enums';
|
||||
|
||||
export class AddDistributionRecipientDto {
|
||||
@IsEnum(RecipientType)
|
||||
recipientType!: RecipientType;
|
||||
|
||||
@IsUUID()
|
||||
recipientPublicId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(DeliveryMethod)
|
||||
deliveryMethod?: DeliveryMethod;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sequence?: number;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// File: src/modules/distribution/dto/create-distribution-matrix.dto.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: Add validated DTO for Distribution Matrix creation.
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class DistributionConditionsDto {
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
codes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
excludeCodes?: string[];
|
||||
}
|
||||
|
||||
export class CreateDistributionMatrixDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectPublicId?: string;
|
||||
|
||||
@IsInt()
|
||||
documentTypeId!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
responseCodePublicId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DistributionConditionsDto)
|
||||
conditions?: DistributionConditionsDto;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// File: src/modules/distribution/dto/update-distribution-matrix.dto.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: Add validated DTO for Distribution Matrix updates.
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateDistributionMatrixDto } from './create-distribution-matrix.dto';
|
||||
|
||||
export class UpdateDistributionMatrixDto extends PartialType(
|
||||
CreateDistributionMatrixDto
|
||||
) {}
|
||||
@@ -1,10 +1,11 @@
|
||||
// File: src/modules/distribution/entities/distribution-matrix.entity.ts
|
||||
// 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DistributionMatrix>
|
||||
private readonly matrixRepo: Repository<DistributionMatrix>,
|
||||
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<DistributionCreationResult> {
|
||||
if (!payload.documentTypeId) {
|
||||
this.logger.warn(
|
||||
`Distribution skipped for RFA ${payload.rfaPublicId}: documentTypeId missing`
|
||||
);
|
||||
return { transmittalPublicIds: [], notificationTargets: [] };
|
||||
}
|
||||
|
||||
const matrix = await this.matrixRepo.findOne({
|
||||
where: {
|
||||
where: [
|
||||
{
|
||||
projectId: payload.projectId,
|
||||
documentTypeCode: payload.documentTypeCode,
|
||||
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<DistributionNotificationTarget[]> {
|
||||
const targets = new Map<number, DistributionNotificationTarget>();
|
||||
for (const recipient of recipients) {
|
||||
if (recipient.recipientType !== RecipientType.USER) continue;
|
||||
const user = await this.dataSource.manager.findOne(User, {
|
||||
where: { publicId: recipient.recipientPublicId },
|
||||
});
|
||||
if (!user) continue;
|
||||
targets.set(user.user_id, {
|
||||
userId: user.user_id,
|
||||
deliveryMethod: recipient.deliveryMethod,
|
||||
});
|
||||
}
|
||||
return Array.from(targets.values());
|
||||
}
|
||||
|
||||
private async resolveRecipientOrganizations(
|
||||
recipients: DistributionRecipient[]
|
||||
): Promise<number[]> {
|
||||
const organizationIds = new Set<number>();
|
||||
for (const recipient of recipients) {
|
||||
const organizationId =
|
||||
await this.resolveRecipientOrganizationId(recipient);
|
||||
if (organizationId) organizationIds.add(organizationId);
|
||||
}
|
||||
return Array.from(organizationIds);
|
||||
}
|
||||
|
||||
private async resolveRecipientOrganizationId(
|
||||
recipient: DistributionRecipient
|
||||
): Promise<number | undefined> {
|
||||
if (recipient.deliveryMethod === DeliveryMethod.IN_APP) return undefined;
|
||||
if (recipient.recipientType === RecipientType.ORGANIZATION) {
|
||||
const organization = await this.dataSource.manager.findOne(Organization, {
|
||||
where: { publicId: recipient.recipientPublicId },
|
||||
});
|
||||
return organization?.id;
|
||||
}
|
||||
if (recipient.recipientType === RecipientType.USER) {
|
||||
const user = await this.dataSource.manager.findOne(User, {
|
||||
where: { publicId: recipient.recipientPublicId },
|
||||
});
|
||||
return user?.primaryOrganizationId;
|
||||
}
|
||||
this.logger.warn(
|
||||
`Recipient type ${recipient.recipientType} requires expansion and was skipped for Transmittal creation`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async findExistingTransmittalPublicId(
|
||||
sourceCorrespondenceId: number,
|
||||
recipientOrganizationId: number
|
||||
): Promise<string | undefined> {
|
||||
const rows = await this.dataSource.query<Array<{ publicId: string }>>(
|
||||
`
|
||||
SELECT c.uuid AS publicId
|
||||
FROM transmittals t
|
||||
INNER JOIN correspondences c ON c.id = t.correspondence_id
|
||||
INNER JOIN transmittal_items ti ON ti.transmittal_id = t.correspondence_id
|
||||
INNER JOIN correspondence_recipients cr ON cr.correspondence_id = t.correspondence_id
|
||||
WHERE ti.item_correspondence_id = ?
|
||||
AND cr.recipient_organization_id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[sourceCorrespondenceId, recipientOrganizationId]
|
||||
);
|
||||
return rows[0]?.publicId;
|
||||
}
|
||||
|
||||
private async createDraftTransmittal(context: {
|
||||
sourceCorrespondence: Correspondence;
|
||||
recipientOrganizationId: number;
|
||||
payload: {
|
||||
rfaPublicId: string;
|
||||
projectId: number;
|
||||
responseCode: string;
|
||||
};
|
||||
}): Promise<string | undefined> {
|
||||
const type = await this.dataSource.manager.findOne(CorrespondenceType, {
|
||||
where: { typeCode: 'TRN' },
|
||||
});
|
||||
const status = await this.dataSource.manager.findOne(CorrespondenceStatus, {
|
||||
where: { statusCode: 'DRAFT' },
|
||||
});
|
||||
if (!type || !status || !context.sourceCorrespondence.originatorId) {
|
||||
this.logger.warn(
|
||||
`Distribution skipped for RFA ${context.payload.rfaPublicId}: missing Transmittal master data or originator`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const docNumber = await this.numberingService.generateNextNumber({
|
||||
projectId: context.payload.projectId,
|
||||
originatorOrganizationId: context.sourceCorrespondence.originatorId,
|
||||
recipientOrganizationId: context.recipientOrganizationId,
|
||||
typeId: type.id,
|
||||
year: new Date().getFullYear(),
|
||||
customTokens: {
|
||||
TYPE_CODE: type.typeCode,
|
||||
RESPONSE_CODE: context.payload.responseCode,
|
||||
},
|
||||
});
|
||||
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
try {
|
||||
const correspondence = queryRunner.manager.create(Correspondence, {
|
||||
correspondenceNumber: docNumber.number,
|
||||
correspondenceTypeId: type.id,
|
||||
projectId: context.payload.projectId,
|
||||
originatorId: context.sourceCorrespondence.originatorId,
|
||||
isInternal: false,
|
||||
});
|
||||
const savedCorrespondence =
|
||||
await queryRunner.manager.save(correspondence);
|
||||
|
||||
const revision = queryRunner.manager.create(CorrespondenceRevision, {
|
||||
correspondenceId: savedCorrespondence.id,
|
||||
revisionNumber: 0,
|
||||
revisionLabel: '0',
|
||||
isCurrent: true,
|
||||
statusId: status.id,
|
||||
subject: `Distribution for ${context.sourceCorrespondence.correspondenceNumber}`,
|
||||
details: {
|
||||
sourceRfaPublicId: context.payload.rfaPublicId,
|
||||
},
|
||||
});
|
||||
await queryRunner.manager.save(revision);
|
||||
|
||||
const recipient = queryRunner.manager.create(CorrespondenceRecipient, {
|
||||
correspondenceId: savedCorrespondence.id,
|
||||
recipientOrganizationId: context.recipientOrganizationId,
|
||||
recipientType: 'TO',
|
||||
});
|
||||
await queryRunner.manager.save(recipient);
|
||||
|
||||
const transmittal = queryRunner.manager.create(Transmittal, {
|
||||
correspondenceId: savedCorrespondence.id,
|
||||
purpose: 'FOR_INFORMATION',
|
||||
remarks: `Auto-distributed from RFA ${context.payload.rfaPublicId}`,
|
||||
});
|
||||
await queryRunner.manager.save(transmittal);
|
||||
|
||||
const item = queryRunner.manager.create(TransmittalItem, {
|
||||
transmittalId: savedCorrespondence.id,
|
||||
itemCorrespondenceId: context.sourceCorrespondence.id,
|
||||
quantity: 1,
|
||||
remarks: `RFA response code ${context.payload.responseCode}`,
|
||||
});
|
||||
await queryRunner.manager.save(item);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return savedCorrespondence.publicId;
|
||||
} catch (error: unknown) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ export class CreateReminderRuleDto {
|
||||
@IsInt()
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// File: src/modules/reminder/entities/reminder-history.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { ReviewTask } from '../../review-team/entities/review-task.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { ReminderType } from '../../common/enums/review.enums';
|
||||
|
||||
@Entity('reminder_histories')
|
||||
export class ReminderHistory extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'task_id' })
|
||||
@Exclude()
|
||||
taskId!: number;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
@Exclude()
|
||||
userId!: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ReminderType,
|
||||
})
|
||||
reminderType!: ReminderType;
|
||||
|
||||
@Column({ name: 'escalation_level', type: 'tinyint', default: 0 })
|
||||
escalationLevel!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'sent_at' })
|
||||
sentAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ReviewTask)
|
||||
@JoinColumn({ name: 'task_id' })
|
||||
task?: ReviewTask;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user?: User;
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export class ReminderRule extends UuidBaseEntity {
|
||||
@Exclude()
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<ReviewTask>
|
||||
) {
|
||||
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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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<ReminderRule>,
|
||||
@InjectRepository(ReminderHistory)
|
||||
private readonly historyRepo: Repository<ReminderHistory>,
|
||||
@InjectRepository(Project)
|
||||
private readonly projectRepo: Repository<Project>
|
||||
private readonly projectRepo: Repository<Project>,
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly taskRepo: Repository<ReviewTask>
|
||||
) {}
|
||||
|
||||
async findAll(projectId?: number): Promise<ReminderRule[]> {
|
||||
@@ -58,6 +64,21 @@ export class ReminderService {
|
||||
return rule;
|
||||
}
|
||||
|
||||
async findHistoryByTaskPublicId(
|
||||
taskPublicId: string
|
||||
): Promise<ReminderHistory[]> {
|
||||
const task = await this.taskRepo.findOne({
|
||||
where: { publicId: taskPublicId },
|
||||
});
|
||||
if (!task) throw new NotFoundException('Task', taskPublicId);
|
||||
|
||||
return this.historyRepo.find({
|
||||
where: { taskId: task.id },
|
||||
relations: ['user'],
|
||||
order: { sentAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateReminderRuleDto): Promise<ReminderRule> {
|
||||
const rule = this.ruleRepo.create(dto as Partial<ReminderRule>);
|
||||
return this.ruleRepo.save(rule);
|
||||
|
||||
@@ -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<ReviewTask>,
|
||||
@InjectRepository(ReminderRule)
|
||||
private readonly reminderRuleRepo: Repository<ReminderRule>,
|
||||
@InjectRepository(ReminderHistory)
|
||||
private readonly historyRepo: Repository<ReminderHistory>,
|
||||
private readonly notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* บันทึกประวัติการส่ง reminder (FR-018)
|
||||
*/
|
||||
async recordHistory(
|
||||
task: ReviewTask,
|
||||
type: ReminderType,
|
||||
level: number
|
||||
): Promise<void> {
|
||||
const history = this.historyRepo.create({
|
||||
taskId: task.id,
|
||||
userId: task.assignedToUserId,
|
||||
reminderType: type,
|
||||
escalationLevel: level,
|
||||
});
|
||||
await this.historyRepo.save(history);
|
||||
}
|
||||
|
||||
/**
|
||||
* นับจำนวนครั้งที่ส่ง reminder สำหรับ level นั้นๆ
|
||||
*/
|
||||
async getStrikeCount(taskId: number, level: number): Promise<number> {
|
||||
return this.historyRepo.count({
|
||||
where: { taskId, escalationLevel: level },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Escalation Level 1 (FR-015): Team Lead ได้รับแจ้งเตือน
|
||||
* เรียกเมื่อ task เกิน due date 1 วัน
|
||||
* เรียกเมื่อ task เกิน due date
|
||||
*/
|
||||
async escalateLevel1(taskPublicId: string): Promise<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ScheduleReminderPayload>;
|
||||
@@ -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<ReminderRule>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Schedule ชุด reminders ให้ Review Task (FR-013)
|
||||
* Schedule ชุด reminders ให้ Review Task (FR-013) ตาม ReminderRule
|
||||
* เรียกหลังจาก TaskCreationService สร้าง tasks เรียบร้อยแล้ว
|
||||
*/
|
||||
async scheduleForTask(payload: ScheduleReminderPayload): Promise<void> {
|
||||
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,
|
||||
// ดึงกฎที่เกี่ยวข้อง (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 },
|
||||
],
|
||||
});
|
||||
|
||||
if (rules.length === 0) {
|
||||
this.logger.debug(`No reminder rules found for task ${taskPublicId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// วัน due date เอง
|
||||
const onDue = dueDate.getTime();
|
||||
if (onDue > now) {
|
||||
remindersToSchedule.push({
|
||||
type: ReminderType.ON_DUE,
|
||||
delayMs: onDue - now,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
// 1 วันหลัง due (Escalation L1)
|
||||
const oneDayAfter = dueDate.getTime() + 1 * 86_400_000;
|
||||
remindersToSchedule.push({
|
||||
type: ReminderType.ESCALATION_L1,
|
||||
delayMs: Math.max(oneDayAfter - now, 0),
|
||||
});
|
||||
|
||||
// 3 วันหลัง due (Escalation L2)
|
||||
const threeDaysAfter = dueDate.getTime() + 3 * 86_400_000;
|
||||
remindersToSchedule.push({
|
||||
type: ReminderType.ESCALATION_L2,
|
||||
delayMs: Math.max(threeDaysAfter - now, 0),
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
remindersToSchedule.map(({ type, delayMs }) =>
|
||||
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`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// File: src/modules/response-code/dto/create-response-code.dto.ts
|
||||
// Change Log:
|
||||
// - 2026-05-13: Add DTO for creating custom response codes.
|
||||
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ResponseCodeCategory } from '../../common/enums/review.enums';
|
||||
|
||||
export class CreateResponseCodeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(10)
|
||||
code!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
subStatus?: string;
|
||||
|
||||
@IsEnum(ResponseCodeCategory)
|
||||
category!: ResponseCodeCategory;
|
||||
|
||||
@IsString()
|
||||
descriptionTh!: string;
|
||||
|
||||
@IsString()
|
||||
descriptionEn!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
implications?: {
|
||||
affectsSchedule?: boolean;
|
||||
affectsCost?: boolean;
|
||||
requiresContractReview?: boolean;
|
||||
requiresEiaAmendment?: boolean;
|
||||
};
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
notifyRoles?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// File: src/modules/response-code/dto/update-response-code.dto.ts
|
||||
// Change Log:
|
||||
// - 2026-05-13: Add DTO for updating response codes by publicId.
|
||||
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ResponseCodeCategory } from '../../common/enums/review.enums';
|
||||
|
||||
export class UpdateResponseCodeDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(10)
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
subStatus?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ResponseCodeCategory)
|
||||
category?: ResponseCodeCategory;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descriptionTh?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
descriptionEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
implications?: {
|
||||
affectsSchedule?: boolean;
|
||||
affectsCost?: boolean;
|
||||
requiresContractReview?: boolean;
|
||||
requiresEiaAmendment?: boolean;
|
||||
};
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
notifyRoles?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// File: src/modules/response-code/dto/upsert-response-code-rule.dto.ts
|
||||
// Change Log:
|
||||
// - 2026-05-13: Add DTO for response code matrix rule upsert endpoints.
|
||||
|
||||
import { IsBoolean, IsInt, IsOptional, IsUUID, Min } from 'class-validator';
|
||||
|
||||
export class UpsertResponseCodeRuleDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
documentTypeId!: number;
|
||||
|
||||
@IsUUID()
|
||||
responseCodePublicId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectPublicId?: string;
|
||||
|
||||
@IsBoolean()
|
||||
isEnabled!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresComments?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
triggersNotification?: boolean;
|
||||
}
|
||||
@@ -1,19 +1,51 @@
|
||||
// File: src/modules/response-code/response-code.controller.ts
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ResponseCode> {
|
||||
const existing = await this.responseCodeRepo.findOne({
|
||||
where: {
|
||||
code: dto.code,
|
||||
category: dto.category,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Response Code already exists for code=${dto.code}, category=${dto.category}`
|
||||
);
|
||||
}
|
||||
|
||||
const entity = this.responseCodeRepo.create({
|
||||
code: dto.code,
|
||||
subStatus: dto.subStatus,
|
||||
category: dto.category,
|
||||
descriptionTh: dto.descriptionTh,
|
||||
descriptionEn: dto.descriptionEn,
|
||||
implications: dto.implications,
|
||||
notifyRoles: dto.notifyRoles,
|
||||
isActive: dto.isActive ?? true,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
return this.responseCodeRepo.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* อัปเดต Response Code ตาม publicId
|
||||
*/
|
||||
async update(
|
||||
publicId: string,
|
||||
dto: UpdateResponseCodeDto
|
||||
): Promise<ResponseCode> {
|
||||
const entity = await this.findByPublicId(publicId);
|
||||
|
||||
if (
|
||||
(dto.code && dto.code !== entity.code) ||
|
||||
(dto.category && dto.category !== entity.category)
|
||||
) {
|
||||
const existing = await this.responseCodeRepo.findOne({
|
||||
where: {
|
||||
code: dto.code ?? entity.code,
|
||||
category: dto.category ?? entity.category,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing && existing.publicId !== entity.publicId) {
|
||||
throw new ConflictException(
|
||||
`Response Code already exists for code=${dto.code ?? entity.code}, category=${dto.category ?? entity.category}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(entity, dto);
|
||||
return this.responseCodeRepo.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* ปิดการใช้งาน Response Code โดยไม่ลบข้อมูล
|
||||
*/
|
||||
async deactivate(publicId: string): Promise<void> {
|
||||
const entity = await this.findByPublicId(publicId);
|
||||
|
||||
if (entity.isSystem) {
|
||||
throw new BadRequestException('Cannot deactivate a system response code');
|
||||
}
|
||||
|
||||
entity.isActive = false;
|
||||
await this.responseCodeRepo.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบว่า Response Code triggers notification หรือไม่ (FR-007)
|
||||
* Code 1C, 1D, 3 → trigger notification
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// File: src/modules/response-code/services/audit.service.ts
|
||||
// Change Log:
|
||||
// - 2026-05-13: Add response code audit service for review task response code changes.
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AuditLog } from '../../../common/entities/audit-log.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseCodeAuditService {
|
||||
private readonly logger = new Logger(ResponseCodeAuditService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AuditLog)
|
||||
private readonly auditLogRepo: Repository<AuditLog>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* บันทึก audit trail เมื่อมีการเลือกหรือเปลี่ยน Response Code บน Review Task
|
||||
*/
|
||||
async logReviewTaskResponseCodeChange(input: {
|
||||
reviewTaskPublicId: string;
|
||||
responseCodePublicId: string;
|
||||
previousResponseCodeId?: number;
|
||||
currentResponseCodeId: number;
|
||||
comments?: string;
|
||||
userId?: number;
|
||||
}): Promise<void> {
|
||||
const auditLog = this.auditLogRepo.create({
|
||||
userId: input.userId ?? null,
|
||||
action: 'response_code.change',
|
||||
severity: 'INFO',
|
||||
entityType: 'review_task',
|
||||
entityId: input.reviewTaskPublicId,
|
||||
detailsJson: {
|
||||
previousResponseCodeId: input.previousResponseCodeId ?? null,
|
||||
currentResponseCodeId: input.currentResponseCodeId,
|
||||
responseCodePublicId: input.responseCodePublicId,
|
||||
comments: input.comments ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.auditLogRepo.save(auditLog);
|
||||
this.logger.debug(
|
||||
`Recorded response code audit for review task ${input.reviewTaskPublicId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// File: src/modules/review-team/dto/shared/review-team.dto.ts
|
||||
// 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;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// File: src/modules/review-team/review-task.controller.ts
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { ReviewTaskService } from './review-task.service';
|
||||
import { ConsensusService } from './services/consensus.service';
|
||||
import { VetoOverrideService } from './services/veto-override.service';
|
||||
import type { VetoOverrideDto } from './services/veto-override.service';
|
||||
import {
|
||||
CompleteReviewTaskDto,
|
||||
SearchReviewTaskDto,
|
||||
} from './dto/shared/review-team.dto';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
|
||||
@Controller('review-tasks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReviewTaskController {
|
||||
constructor(
|
||||
private readonly reviewTaskService: ReviewTaskService,
|
||||
private readonly consensusService: ConsensusService,
|
||||
private readonly vetoOverrideService: VetoOverrideService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() dto: SearchReviewTaskDto) {
|
||||
return this.reviewTaskService.findAll(dto);
|
||||
}
|
||||
|
||||
@Get(':publicId')
|
||||
findOne(@Param('publicId', ParseUUIDPipe) publicId: string) {
|
||||
return this.reviewTaskService.findByPublicId(publicId);
|
||||
}
|
||||
|
||||
@Patch(':publicId/start')
|
||||
startReview(@Param('publicId', ParseUUIDPipe) publicId: string) {
|
||||
return this.reviewTaskService.startReview(publicId);
|
||||
}
|
||||
|
||||
@Patch(':publicId/complete')
|
||||
async completeReview(
|
||||
@Param('publicId', ParseUUIDPipe) publicId: string,
|
||||
@Body() dto: CompleteReviewTaskDto,
|
||||
@CurrentUser() _user: User
|
||||
) {
|
||||
const task = await this.reviewTaskService.completeReview(publicId, dto);
|
||||
|
||||
// Evaluate consensus after completion (FR-010)
|
||||
try {
|
||||
const fullTask = (await this.reviewTaskService.findFullTaskContext(
|
||||
publicId
|
||||
)) as unknown as Record<string, unknown>;
|
||||
|
||||
const rfaRevision = fullTask.rfaRevision as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
const corrRevision = rfaRevision?.correspondenceRevision as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
const correspondence = corrRevision?.correspondence as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (rfaRevision && correspondence) {
|
||||
await this.consensusService.evaluateAfterTaskComplete(
|
||||
fullTask.rfaRevisionId,
|
||||
{
|
||||
rfaPublicId: correspondence.publicId as string,
|
||||
|
||||
rfaRevisionPublicId: corrRevision.publicId as string,
|
||||
|
||||
projectId: correspondence.projectId as number,
|
||||
|
||||
documentTypeId: (
|
||||
correspondence.type as Record<string, unknown> | undefined
|
||||
)?.id as number | undefined,
|
||||
|
||||
documentTypeCode:
|
||||
((correspondence.type as Record<string, unknown> | undefined)
|
||||
?.typeCode as string | undefined) ?? 'RFA',
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (_error: unknown) {
|
||||
// Log error but don't fail the task completion response
|
||||
// (error as any).logger?.error(`Consensus evaluation failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
@Post('veto-override')
|
||||
async overrideVeto(@Body() dto: VetoOverrideDto, @CurrentUser() user: User) {
|
||||
return this.vetoOverrideService.executeOverride({
|
||||
...dto,
|
||||
overriddenByUserId: user.user_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// File: src/modules/review-team/review-task.service.ts
|
||||
// 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<ReviewTask>,
|
||||
@InjectRepository(ResponseCode)
|
||||
private readonly responseCodeRepo: Repository<ResponseCode>
|
||||
private readonly responseCodeRepo: Repository<ResponseCode>,
|
||||
private readonly responseCodeAuditService: ResponseCodeAuditService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -91,6 +95,48 @@ export class ReviewTaskService {
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Review Task พร้อม context ทั้งหมด (RFA, Project, Type)
|
||||
*/
|
||||
async findFullTaskContext(publicId: string): Promise<ReviewTask> {
|
||||
const task = await this.reviewTaskRepo
|
||||
.createQueryBuilder('task')
|
||||
.leftJoinAndSelect('task.responseCode', 'responseCode')
|
||||
.leftJoinAndSelect('task.team', 'team')
|
||||
.innerJoinAndMapOne(
|
||||
'task.rfaRevision',
|
||||
'rfa_revisions',
|
||||
'rfaRev',
|
||||
'rfaRev.id = task.rfa_revision_id'
|
||||
)
|
||||
.innerJoinAndMapOne(
|
||||
'rfaRev.correspondenceRevision',
|
||||
'correspondence_revisions',
|
||||
'corrRev',
|
||||
'corrRev.id = rfaRev.id'
|
||||
)
|
||||
.innerJoinAndMapOne(
|
||||
'corrRev.correspondence',
|
||||
'correspondences',
|
||||
'corr',
|
||||
'corr.id = corrRev.correspondence_id'
|
||||
)
|
||||
.leftJoinAndMapOne(
|
||||
'corr.type',
|
||||
'correspondence_types',
|
||||
'corrType',
|
||||
'corrType.id = corr.correspondence_type_id'
|
||||
)
|
||||
.where('task.uuid = :publicId', { publicId })
|
||||
.getOne();
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(`Review Task not found: ${publicId}`);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Tasks รวมทั้งหมดของ RFA Revision พร้อม Aggregate Status (FR-004)
|
||||
*/
|
||||
@@ -143,6 +189,7 @@ export class ReviewTaskService {
|
||||
dto: CompleteReviewTaskDto
|
||||
): Promise<ReviewTask> {
|
||||
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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<User>,
|
||||
@InjectRepository(Discipline)
|
||||
private readonly disciplineRepo: Repository<Discipline>
|
||||
private readonly disciplineRepo: Repository<Discipline>,
|
||||
private readonly uuidResolver: UuidResolverService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -97,21 +101,14 @@ export class ReviewTeamService {
|
||||
* สร้าง Review Team ใหม่
|
||||
*/
|
||||
async create(dto: CreateReviewTeamDto): Promise<ReviewTeam> {
|
||||
// ตรวจสอบว่า project มีอยู่จริง (via publicId)
|
||||
const project = await this.teamRepo.manager
|
||||
.getRepository('projects')
|
||||
.findOne({
|
||||
where: { uuid: dto.projectPublicId } as Record<string, unknown>,
|
||||
});
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export class ConsensusService {
|
||||
rfaPublicId: string;
|
||||
rfaRevisionPublicId: string;
|
||||
projectId: number;
|
||||
documentTypeId?: number;
|
||||
documentTypeCode: string;
|
||||
}
|
||||
): Promise<ConsensusResult> {
|
||||
|
||||
@@ -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<ReviewTeamMember>,
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly reviewTaskRepo: Repository<ReviewTask>
|
||||
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||
private readonly delegationService: DelegationService,
|
||||
private readonly schedulerService: SchedulerService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -34,12 +40,16 @@ export class TaskCreationService {
|
||||
* @param reviewTeamPublicId - publicId ของ Review Team (ADR-019)
|
||||
* @param 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<ReviewTask[]> {
|
||||
// ดึง 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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// File: tests/unit/delegation/delegation.service.spec.ts
|
||||
// Change Log
|
||||
// - 2026-05-13: เพิ่ม regression test เพื่อป้องกัน multi-level delegation chain
|
||||
import { DelegationService } from '../../../src/modules/delegation/delegation.service';
|
||||
import { CircularDetectionService } from '../../../src/modules/delegation/services/circular-detection.service';
|
||||
import { Delegation } from '../../../src/modules/delegation/entities/delegation.entity';
|
||||
import { User } from '../../../src/modules/user/entities/user.entity';
|
||||
import { DelegationScope } from '../../../src/modules/common/enums/review.enums';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
type RepositoryMock<T extends object> = {
|
||||
findOne: jest.MockedFunction<(options: unknown) => Promise<T | null>>;
|
||||
create: jest.MockedFunction<(payload: Partial<T>) => T>;
|
||||
save: jest.MockedFunction<(entity: T) => Promise<T>>;
|
||||
createQueryBuilder: jest.MockedFunction<(alias: string) => QueryBuilderMock>;
|
||||
};
|
||||
|
||||
type QueryBuilderMock = {
|
||||
innerJoinAndSelect: jest.MockedFunction<
|
||||
(relation: string, alias: string) => QueryBuilderMock
|
||||
>;
|
||||
where: jest.MockedFunction<
|
||||
(
|
||||
condition: string,
|
||||
parameters?: Record<string, unknown>
|
||||
) => QueryBuilderMock
|
||||
>;
|
||||
andWhere: jest.MockedFunction<
|
||||
(
|
||||
condition: string,
|
||||
parameters?: Record<string, unknown>
|
||||
) => QueryBuilderMock
|
||||
>;
|
||||
orderBy: jest.MockedFunction<
|
||||
(sort: string, order: 'ASC' | 'DESC') => QueryBuilderMock
|
||||
>;
|
||||
getOne: jest.MockedFunction<() => Promise<Delegation | null>>;
|
||||
};
|
||||
|
||||
const createQueryBuilderMock = (
|
||||
delegation: Delegation | null
|
||||
): QueryBuilderMock => {
|
||||
const queryBuilder = {} as QueryBuilderMock;
|
||||
queryBuilder.innerJoinAndSelect = jest.fn(
|
||||
(_relation: string, _alias: string): QueryBuilderMock => queryBuilder
|
||||
);
|
||||
queryBuilder.where = jest.fn(
|
||||
(
|
||||
_condition: string,
|
||||
_parameters?: Record<string, unknown>
|
||||
): QueryBuilderMock => queryBuilder
|
||||
);
|
||||
queryBuilder.andWhere = jest.fn(
|
||||
(
|
||||
_condition: string,
|
||||
_parameters?: Record<string, unknown>
|
||||
): QueryBuilderMock => queryBuilder
|
||||
);
|
||||
queryBuilder.orderBy = jest.fn(
|
||||
(_sort: string, _order: 'ASC' | 'DESC'): QueryBuilderMock => queryBuilder
|
||||
);
|
||||
queryBuilder.getOne = jest.fn(
|
||||
(): Promise<Delegation | null> => Promise.resolve(delegation)
|
||||
);
|
||||
return queryBuilder;
|
||||
};
|
||||
|
||||
const createRepositoryMock = <T extends object>(): RepositoryMock<T> => ({
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn((payload: Partial<T>): T => payload as T),
|
||||
save: jest.fn((entity: T): Promise<T> => Promise.resolve(entity)),
|
||||
createQueryBuilder: jest.fn(),
|
||||
});
|
||||
|
||||
describe('DelegationService', () => {
|
||||
const delegationRepo = createRepositoryMock<Delegation>();
|
||||
const userRepo = createRepositoryMock<User>();
|
||||
const circularDetectionService = {
|
||||
wouldCreateCircle: jest.fn(),
|
||||
} as unknown as CircularDetectionService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delegationRepo.createQueryBuilder.mockReturnValue(
|
||||
createQueryBuilderMock(null)
|
||||
);
|
||||
(circularDetectionService.wouldCreateCircle as jest.Mock).mockResolvedValue(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects delegated user who already delegates onward', async () => {
|
||||
const service = new DelegationService(
|
||||
delegationRepo as unknown as Repository<Delegation>,
|
||||
userRepo as unknown as Repository<User>,
|
||||
circularDetectionService
|
||||
);
|
||||
const delegator = {
|
||||
user_id: 1,
|
||||
publicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||
} as User;
|
||||
const delegate = {
|
||||
user_id: 2,
|
||||
publicId: '019505a1-7c3e-7000-8000-000000000002',
|
||||
} as User;
|
||||
const onwardDelegation = {
|
||||
publicId: '019505a1-7c3e-7000-8000-000000000003',
|
||||
delegatorUserId: delegate.user_id,
|
||||
delegateUserId: 3,
|
||||
delegate: { user_id: 3 } as User,
|
||||
} as Delegation;
|
||||
|
||||
(userRepo.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce(delegator)
|
||||
.mockResolvedValueOnce(delegate);
|
||||
delegationRepo.createQueryBuilder.mockReturnValue(
|
||||
createQueryBuilderMock(onwardDelegation)
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.create(delegator.publicId, {
|
||||
delegateUserPublicId: delegate.publicId,
|
||||
scope: DelegationScope.RFA_ONLY,
|
||||
startDate: new Date('2026-05-20T00:00:00.000Z'),
|
||||
endDate: new Date('2026-05-27T00:00:00.000Z'),
|
||||
})
|
||||
).rejects.toThrow('Nested delegation is not allowed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// File: tests/unit/distribution/distribution-matrix.service.spec.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: Add regression coverage for Distribution Matrix public-ID handling.
|
||||
import { DistributionMatrixService } from '../../../src/modules/distribution/distribution-matrix.service';
|
||||
import { DistributionMatrix } from '../../../src/modules/distribution/entities/distribution-matrix.entity';
|
||||
import { DistributionRecipient } from '../../../src/modules/distribution/entities/distribution-recipient.entity';
|
||||
import { Project } from '../../../src/modules/project/entities/project.entity';
|
||||
import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity';
|
||||
import {
|
||||
DeliveryMethod,
|
||||
RecipientType,
|
||||
} from '../../../src/modules/common/enums/review.enums';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
type RepositoryMock<T extends object> = {
|
||||
find: jest.MockedFunction<(options: unknown) => Promise<T[]>>;
|
||||
findOne: jest.MockedFunction<(options: unknown) => Promise<T | null>>;
|
||||
create: jest.MockedFunction<(payload: Partial<T>) => T>;
|
||||
save: jest.MockedFunction<(payload: T) => Promise<T>>;
|
||||
remove: jest.MockedFunction<(payload: T) => Promise<T>>;
|
||||
};
|
||||
|
||||
const createRepositoryMock = <T extends object>(): RepositoryMock<T> => ({
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn((payload: Partial<T>): T => payload as T),
|
||||
save: jest.fn((payload: T): Promise<T> => Promise.resolve(payload)),
|
||||
remove: jest.fn((payload: T): Promise<T> => Promise.resolve(payload)),
|
||||
});
|
||||
|
||||
describe('DistributionMatrixService', () => {
|
||||
const matrixRepo = createRepositoryMock<DistributionMatrix>();
|
||||
const recipientRepo = createRepositoryMock<DistributionRecipient>();
|
||||
const projectRepo = createRepositoryMock<Project>();
|
||||
const responseCodeRepo = createRepositoryMock<ResponseCode>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates a schema-aligned matrix by resolving public IDs internally', async () => {
|
||||
const service = new DistributionMatrixService(
|
||||
matrixRepo as unknown as Repository<DistributionMatrix>,
|
||||
recipientRepo as unknown as Repository<DistributionRecipient>,
|
||||
projectRepo as unknown as Repository<Project>,
|
||||
responseCodeRepo as unknown as Repository<ResponseCode>
|
||||
);
|
||||
matrixRepo.findOne.mockResolvedValue({
|
||||
id: 7,
|
||||
publicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||
} as unknown as DistributionMatrix);
|
||||
projectRepo.findOne.mockResolvedValue({
|
||||
id: 7,
|
||||
publicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||
} as Project);
|
||||
responseCodeRepo.findOne.mockResolvedValue({
|
||||
id: 9,
|
||||
publicId: '019505a1-7c3e-7000-8000-000000000002',
|
||||
} as ResponseCode);
|
||||
|
||||
await service.create({
|
||||
name: 'Shop Drawing Distribution',
|
||||
projectPublicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||
documentTypeId: 3,
|
||||
responseCodePublicId: '019505a1-7c3e-7000-8000-000000000002',
|
||||
conditions: { codes: ['1A', '1B'], excludeCodes: ['3'] },
|
||||
});
|
||||
|
||||
expect(matrixRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Shop Drawing Distribution',
|
||||
projectId: 7,
|
||||
documentTypeId: 3,
|
||||
responseCodeId: 9,
|
||||
conditions: { codes: ['1A', '1B'], excludeCodes: ['3'] },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('adds recipients with recipientPublicId instead of internal recipient ids', async () => {
|
||||
const service = new DistributionMatrixService(
|
||||
matrixRepo as unknown as Repository<DistributionMatrix>,
|
||||
recipientRepo as unknown as Repository<DistributionRecipient>,
|
||||
projectRepo as unknown as Repository<Project>,
|
||||
responseCodeRepo as unknown as Repository<ResponseCode>
|
||||
);
|
||||
matrixRepo.findOne.mockResolvedValue({
|
||||
id: 11,
|
||||
publicId: '019505a1-7c3e-7000-8000-000000000003',
|
||||
} as DistributionMatrix);
|
||||
|
||||
await service.addRecipient('019505a1-7c3e-7000-8000-000000000003', {
|
||||
recipientType: RecipientType.ORGANIZATION,
|
||||
recipientPublicId: '019505a1-7c3e-7000-8000-000000000004',
|
||||
deliveryMethod: DeliveryMethod.BOTH,
|
||||
sequence: 1,
|
||||
});
|
||||
|
||||
expect(recipientRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
matrixId: 11,
|
||||
recipientType: RecipientType.ORGANIZATION,
|
||||
recipientPublicId: '019505a1-7c3e-7000-8000-000000000004',
|
||||
deliveryMethod: DeliveryMethod.BOTH,
|
||||
sequence: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// File: tests/unit/distribution/transmittal-creator.service.spec.ts
|
||||
// Change Log
|
||||
// - 2026-05-14: Add regression coverage for Distribution Matrix response-code filtering.
|
||||
import { TransmittalCreatorService } from '../../../src/modules/distribution/services/transmittal-creator.service';
|
||||
import { DistributionMatrix } from '../../../src/modules/distribution/entities/distribution-matrix.entity';
|
||||
import { DistributionRecipient } from '../../../src/modules/distribution/entities/distribution-recipient.entity';
|
||||
import { DocumentNumberingService } from '../../../src/modules/document-numbering/services/document-numbering.service';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
type MatrixRepositoryMock = {
|
||||
findOne: jest.MockedFunction<
|
||||
(options: unknown) => Promise<DistributionMatrix | null>
|
||||
>;
|
||||
};
|
||||
|
||||
describe('TransmittalCreatorService', () => {
|
||||
const matrixRepo: MatrixRepositoryMock = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const dataSource = {
|
||||
manager: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
query: jest.fn(),
|
||||
createQueryRunner: jest.fn(),
|
||||
};
|
||||
const numberingService = {
|
||||
generateNextNumber: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('skips distribution when the response code is excluded', async () => {
|
||||
const service = new TransmittalCreatorService(
|
||||
matrixRepo as unknown as Repository<DistributionMatrix>,
|
||||
dataSource as unknown as DataSource,
|
||||
numberingService as unknown as DocumentNumberingService
|
||||
);
|
||||
matrixRepo.findOne.mockResolvedValue({
|
||||
conditions: { excludeCodes: ['3', '4'] },
|
||||
recipients: [{} as DistributionRecipient],
|
||||
} as DistributionMatrix);
|
||||
|
||||
const result = await service.createFromDistribution({
|
||||
rfaPublicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||
rfaRevisionPublicId: '019505a1-7c3e-7000-8000-000000000002',
|
||||
projectId: 1,
|
||||
documentTypeId: 2,
|
||||
responseCode: '3',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
transmittalPublicIds: [],
|
||||
notificationTargets: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { ResponseCodeService } from '../../../src/modules/response-code/response
|
||||
import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity';
|
||||
import { 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<ResponseCode> = {
|
||||
id: 1,
|
||||
@@ -21,6 +22,13 @@ const mockCode: Partial<ResponseCode> = {
|
||||
const mockCodeRepo = {
|
||||
find: jest.fn().mockResolvedValue([mockCode]),
|
||||
findOne: jest.fn().mockResolvedValue(mockCode),
|
||||
create: jest.fn(
|
||||
(payload: Partial<ResponseCode>): Partial<ResponseCode> => payload
|
||||
),
|
||||
save: jest.fn(
|
||||
(payload: Partial<ResponseCode>): Promise<Partial<ResponseCode>> =>
|
||||
Promise.resolve(payload)
|
||||
),
|
||||
};
|
||||
|
||||
const mockRuleRepo = {
|
||||
@@ -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<ResponseCode>): Partial<ResponseCode> => payload
|
||||
);
|
||||
mockCodeRepo.save.mockImplementation(
|
||||
(payload: Partial<ResponseCode>): Promise<Partial<ResponseCode>> =>
|
||||
Promise.resolve(payload)
|
||||
);
|
||||
mockRuleRepo.find.mockResolvedValue([]);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// File: tests/unit/review-team/task-creation-delegation.service.spec.ts
|
||||
// Change Log
|
||||
// - 2026-05-13: เพิ่ม regression test สำหรับการมอบหมาย Review Task ผ่าน Delegation
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
import { TaskCreationService } from '../../../src/modules/review-team/services/task-creation.service';
|
||||
import { ReviewTeam } from '../../../src/modules/review-team/entities/review-team.entity';
|
||||
import { ReviewTeamMember } from '../../../src/modules/review-team/entities/review-team-member.entity';
|
||||
import { ReviewTask } from '../../../src/modules/review-team/entities/review-task.entity';
|
||||
import { DelegationService } from '../../../src/modules/delegation/delegation.service';
|
||||
import { SchedulerService } from '../../../src/modules/reminder/services/scheduler.service';
|
||||
import {
|
||||
DelegationScope,
|
||||
ReviewTeamMemberRole,
|
||||
} from '../../../src/modules/common/enums/review.enums';
|
||||
import { User } from '../../../src/modules/user/entities/user.entity';
|
||||
|
||||
type RepositoryMock<T extends object> = Pick<Repository<T>, 'findOne' | 'find'>;
|
||||
|
||||
const createRepositoryMock = <T extends object>(): jest.Mocked<
|
||||
RepositoryMock<T>
|
||||
> => ({
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
});
|
||||
|
||||
const createManagerMock = (): { create: jest.Mock; save: jest.Mock } => ({
|
||||
create: jest.fn(
|
||||
(_entity: unknown, payload: Partial<ReviewTask>): ReviewTask =>
|
||||
payload as ReviewTask
|
||||
),
|
||||
save: jest.fn(
|
||||
(_entity: unknown, payload: ReviewTask): Promise<ReviewTask> =>
|
||||
Promise.resolve(payload)
|
||||
),
|
||||
});
|
||||
|
||||
describe('TaskCreationService delegation resolution', () => {
|
||||
const reviewTeamRepo = createRepositoryMock<ReviewTeam>();
|
||||
const memberRepo = createRepositoryMock<ReviewTeamMember>();
|
||||
const reviewTaskRepo = createRepositoryMock<ReviewTask>();
|
||||
|
||||
const delegationService = {
|
||||
findActiveDelegate: jest.fn(),
|
||||
} as unknown as DelegationService;
|
||||
|
||||
const schedulerService = {
|
||||
scheduleForTask: jest.fn(),
|
||||
} as unknown as SchedulerService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('assigns delegated review task to active delegate and preserves original reviewer', async () => {
|
||||
const service = new TaskCreationService(
|
||||
reviewTeamRepo as unknown as Repository<ReviewTeam>,
|
||||
memberRepo as unknown as Repository<ReviewTeamMember>,
|
||||
reviewTaskRepo as unknown as Repository<ReviewTask>,
|
||||
delegationService,
|
||||
schedulerService
|
||||
);
|
||||
const manager = createManagerMock();
|
||||
const originalReviewerId = 10;
|
||||
const delegateReviewer = { user_id: 20 } as User;
|
||||
const team = {
|
||||
id: 1,
|
||||
publicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||
isActive: true,
|
||||
members: [
|
||||
{
|
||||
userId: originalReviewerId,
|
||||
disciplineId: 3,
|
||||
role: ReviewTeamMemberRole.LEAD,
|
||||
},
|
||||
],
|
||||
} as ReviewTeam;
|
||||
|
||||
(reviewTeamRepo.findOne as jest.Mock).mockResolvedValue(team);
|
||||
(delegationService.findActiveDelegate as jest.Mock).mockResolvedValue(
|
||||
delegateReviewer
|
||||
);
|
||||
|
||||
const tasks = await service.createParallelTasks(
|
||||
100,
|
||||
team.publicId,
|
||||
new Date('2026-05-20T00:00:00.000Z'),
|
||||
manager as unknown as EntityManager
|
||||
);
|
||||
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].assignedToUserId).toBe(delegateReviewer.user_id);
|
||||
expect(tasks[0].delegatedFromUserId).toBe(originalReviewerId);
|
||||
expect(delegationService.findActiveDelegate).toHaveBeenCalledWith(
|
||||
originalReviewerId,
|
||||
expect.any(Date),
|
||||
[DelegationScope.ALL, DelegationScope.RFA_ONLY]
|
||||
);
|
||||
expect(schedulerService.scheduleForTask).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,16 @@
|
||||
# Versioning Policy for AI Knowledge Base
|
||||
|
||||
## Current Version: 1.0.0
|
||||
|
||||
### หมายเลขเวอร์ชัน (Semantic Versioning)
|
||||
- **MAJOR (1.x.x)**: มีการเปลี่ยนโครงสร้างโฟลเดอร์หลัก หรือเปลี่ยนกฎ Tier 1
|
||||
- **MINOR (x.1.x)**: เพิ่ม Prompt ใหม่, Template ใหม่ หรือเพิ่ม Playbook
|
||||
- **PATCH (x.x.1)**: แก้ไขคำผิด หรือปรับปรุงรายละเอียดเล็กน้อยในไฟล์เดิม
|
||||
|
||||
### การอัปเดตเวอร์ชัน
|
||||
ทุกครั้งที่มีการแก้ไขไฟล์ในคลังความรู้นี้ ให้ทำการอัปเดต Change Log ในไฟล์นั้นๆ และพิจารณาปรับเลขเวอร์ชันในไฟล์นี้หากเป็นการเปลี่ยนแปลงสำคัญ
|
||||
|
||||
---
|
||||
// File: docs/ai-knowledge-base/VERSIONING.md
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial version 1.0.0
|
||||
@@ -0,0 +1,22 @@
|
||||
// File: docs/ai-knowledge-base/checklists/db-change.md
|
||||
# Checklist: Database Schema Changes
|
||||
|
||||
## 📝 Pre-Change
|
||||
- [ ] เขียน SQL Delta script ตามเทมเพลตใน `templates/db-migration.md`
|
||||
- [ ] มี Rollback script เตรียมไว้พร้อมใช้งาน
|
||||
- [ ] ทดสอบรันใน Local/Development แล้ว
|
||||
- [ ] ตรวจสอบว่าไม่กระทบต่อ Query เดิมในโค้ด (e.g. `SELECT *` อาจจะอันตราย)
|
||||
|
||||
## 🚀 Execution
|
||||
- [ ] ทำการ Backup ฐานข้อมูลก่อนเริ่ม (ถ้าเป็น Production)
|
||||
- [ ] รัน SQL Script ผ่านเครื่องมือที่กำหนด (e.g. DBeaver, HeidiSQL)
|
||||
- [ ] ตรวจสอบโครงสร้างตารางหลังแก้ไข
|
||||
|
||||
## ✅ Verification
|
||||
- [ ] รันระบบและทดสอบฟีเจอร์ที่เกี่ยวข้อง
|
||||
- [ ] ตรวจสอบ Logs ว่าไม่มี SQL Error
|
||||
- [ ] อัปเดตไฟล์ `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` ให้ตรงกัน
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial DB change checklist
|
||||
@@ -0,0 +1,28 @@
|
||||
// File: docs/ai-knowledge-base/checklists/deploy.md
|
||||
# Deployment Checklist
|
||||
|
||||
## 🛠️ Pre-Deployment (Development/Staging)
|
||||
- [ ] Linting & Type Checking ผ่านหมด (`pnpm lint`, `pnpm type-check`)
|
||||
- [ ] Unit Tests ผ่านทั้งหมด (`pnpm test`)
|
||||
- [ ] Database Schema ถูกอัปเดตที่เซิร์ฟเวอร์เป้าหมายแล้ว (ADR-009)
|
||||
- [ ] Environment Variables (Secrets) ถูกตั้งค่าใน Docker/CI แล้ว
|
||||
- [ ] Build Frontend & Backend สำเร็จโดยไม่มี Error
|
||||
|
||||
## 🚀 Deployment Phase
|
||||
- [ ] Trigger Gitea Actions / CI Pipeline
|
||||
- [ ] ตรวจสอบ Container Status (Running)
|
||||
- [ ] ตรวจสอบ Logs ว่าไม่มี Error Startup
|
||||
|
||||
## 🧪 Post-Deployment (Verification)
|
||||
- [ ] ทดสอบ Login
|
||||
- [ ] ทดสอบฟีเจอร์หลักที่เพิ่ง Deploy
|
||||
- [ ] ตรวจสอบว่า `publicId` (UUIDv7) ทำงานถูกต้องใน URL
|
||||
- [ ] เช็คความปลอดภัย (RBAC) ว่าสิทธิ์ยังถูกต้อง
|
||||
|
||||
## 🆘 Rollback Plan
|
||||
- [ ] หากพบ Critical Bug ให้ Revert Commit ล่าสุด
|
||||
- [ ] เตรียม SQL Script สำหรับ Revert Schema (ถ้ามี)
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial deployment checklist
|
||||
@@ -0,0 +1,24 @@
|
||||
// File: docs/ai-knowledge-base/checklists/rollback.md
|
||||
# Checklist: System Rollback (Emergency)
|
||||
|
||||
## 🚨 Decision Point
|
||||
- [ ] ระบบล่มถาวรเกิน 15 นาที
|
||||
- [ ] พบช่องโหว่ความปลอดภัยที่สำคัญ
|
||||
- [ ] ข้อมูลสูญหายหรือเสียหายจากการทำงานผิดพลาด
|
||||
|
||||
## 🛠️ Execution (Code)
|
||||
- [ ] Revert Git Commit ไปยัง Tag หรือ Hash ที่เสถียรล่าสุด
|
||||
- [ ] Trigger CI/CD เพื่อ Deploy เวอร์ชันเก่า
|
||||
- [ ] เคลียร์ Cache ใน Redis (ถ้าจำเป็น)
|
||||
|
||||
## 🗄️ Execution (Database)
|
||||
- [ ] รัน Rollback SQL script
|
||||
- [ ] หากรุนแรง ให้ Restore ข้อมูลจาก Backup ล่าสุด
|
||||
|
||||
## ✅ Verification
|
||||
- [ ] ตรวจสอบว่าระบบกลับมาออนไลน์
|
||||
- [ ] แจ้งทีมที่เกี่ยวข้องเรื่องเหตุการณ์ (Incident Report)
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial rollback checklist
|
||||
@@ -0,0 +1,27 @@
|
||||
// File: docs/ai-knowledge-base/checklists/security-audit.md
|
||||
# Checklist: Security & Tier 1 Audit
|
||||
|
||||
## 🛡️ Authentication & Authorization
|
||||
- [ ] ทุก API มี `@UseGuards(CaslGuard)`
|
||||
- [ ] ทุก Action มีการตรวจสอบสิทธิ์ผ่าน `@CheckPolicies(...)`
|
||||
- [ ] ไม่มีช่องโหว่ BOLA (Broken Object Level Authorization) - เช็คความเป็นเจ้าของข้อมูล
|
||||
- [ ] JWT Payload ไม่มีข้อมูลส่วนตัวที่อ่อนไหว
|
||||
|
||||
## 🆔 Data Integrity (ADR-019)
|
||||
- [ ] ใช้ `publicId` (UUIDv7) สำหรับ API/URL เท่านั้น
|
||||
- [ ] ไม่มีโค้ดที่ใช้ `parseInt()` กับ UUID
|
||||
- [ ] `id` (Integer) ถูก `@Exclude()` ออกจาก API Response
|
||||
|
||||
## 💾 Storage & Input
|
||||
- [ ] ไฟล์ที่อัปโหลดถูกสแกน ClamAV ก่อนย้ายเข้า Permanent Storage
|
||||
- [ ] มีการทำ Input Validation ทั้งฝั่ง Client (Zod) และ Server (class-validator)
|
||||
- [ ] มีการใช้ `DOMPurify` หรือมาตรการป้องกัน XSS สำหรับข้อมูลที่แสดงผลเป็น HTML
|
||||
|
||||
## ⚙️ Concurrency & Reliability
|
||||
- [ ] ใช้ Redis Redlock สำหรับ Document Numbering (ADR-002)
|
||||
- [ ] ใช้ `@VersionColumn` สำหรับ Optimistic Locking ในจุดที่มีการแก้ไขพร้อมกัน
|
||||
- [ ] งานที่ใช้เวลานานถูกส่งเข้า BullMQ (ADR-008)
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial security audit checklist
|
||||
@@ -0,0 +1,20 @@
|
||||
// File: docs/ai-knowledge-base/checklists/vlan-change.md
|
||||
# Checklist: VLAN Configuration Changes
|
||||
|
||||
## 📝 Planning
|
||||
- [ ] กำหนด VLAN ID และ Subnet ที่ต้องการ
|
||||
- [ ] ตรวจสอบความซ้ำซ้อนของ IP ในเครือข่ายเดิม
|
||||
- [ ] วางแผน Port Profile ใน Omada
|
||||
|
||||
## 🚀 Configuration
|
||||
- [ ] สร้าง VLAN ใน Omada Controller
|
||||
- [ ] ตั้งค่า Port Profile ให้กับ Switch ที่เกี่ยวข้อง
|
||||
- [ ] ทดสอบการเชื่อมต่อจาก Client (DHCP/Static IP)
|
||||
|
||||
## ✅ Security & Inter-VLAN
|
||||
- [ ] ตรวจสอบสิทธิ์การเข้าถึงข้าม VLAN (Ping test)
|
||||
- [ ] ตั้งค่า ACL บน Router/Gateway (ถ้าจำเป็น)
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial VLAN change checklist
|
||||
@@ -0,0 +1,21 @@
|
||||
// File: docs/ai-knowledge-base/playbooks/core/context-recovery.md
|
||||
# Playbook: AI Context Recovery (ฟื้นฟูบริบทการทำงาน)
|
||||
|
||||
## 🚨 เมื่อไหร่ที่ต้องใช้?
|
||||
- เมื่อเริ่ม Session ใหม่กับ AI
|
||||
- เมื่อ AI เริ่มให้คำตอบที่หลุดออกจากมาตรฐานโครงการ
|
||||
- เมื่อมีการข้ามเฟสการทำงานขนาดใหญ่
|
||||
|
||||
## 🏗️ Steps for Recovery
|
||||
1. **Initialize Standards**: สั่งให้ AI อ่าน `docs/ai-knowledge-base/prompts/core/master-prompt.md`
|
||||
2. **Scan Current Module**: ระบุโมดูลที่กำลังทำงาน และให้ AI อ่านไฟล์ใน `specs/` ที่เกี่ยวข้อง
|
||||
3. **Verify DB State**: ให้ AI อ่านไฟล์ Schema ล่าสุดเพื่อยืนยันโครงสร้างตาราง
|
||||
4. **Task Alignment**: ตรวจสอบไฟล์ `tasks.md` ของฟีเจอร์นั้นๆ เพื่อหาจุดที่ทำค้างไว้
|
||||
5. **Conflict Resolution**: หาก AI พบว่าโค้ดปัจจุบันขัดกับ Specs ให้ทำการแก้ไขทันที
|
||||
|
||||
## 🚀 Recovery Command (Prompt)
|
||||
"โปรดอ่าน Master Prompt และตรวจสอบสถานะปัจจุบันของโมดูล [ชื่อโมดูล] จากไฟล์ Specs และ Tasks เพื่อเตรียมตัวเริ่มงานต่อจากจุดที่ค้างไว้"
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial context recovery playbook
|
||||
@@ -0,0 +1,19 @@
|
||||
// File: docs/ai-knowledge-base/playbooks/dms/cross-module-linking.md
|
||||
# Playbook: Cross-Module Linking (การเชื่อมโยงข้อมูลข้ามโมดูล)
|
||||
|
||||
## 🎯 Objective
|
||||
รักษาความถูกต้องและความเชื่อมโยงของข้อมูล (Data Integrity) ระหว่างโมดูลต่างๆ เช่น การผูก RFA เข้ากับ Drawing หรือ Correspondence
|
||||
|
||||
## 🏗️ Linking Rules
|
||||
1. **Use PublicId**: การเชื่อมโยงข้ามโมดูลต้องใช้ `publicId` (UUIDv7) เท่านั้น
|
||||
2. **Atomic Updates**: หากการเชื่อมโยงส่งผลต่อสถานะของทั้งสองโมดูล ต้องทำภายใน Database Transaction เดียวกัน
|
||||
3. **Audit Trail**: ต้องบันทึกประวัติการเชื่อมโยง (e.g. "Drawing X linked to RFA Y")
|
||||
4. **Validation**: ก่อนสร้าง Link ต้องตรวจสอบว่าทั้งสอง Entity มีอยู่จริงและอยู่ในสถานะที่อนุญาตให้เชื่อมโยงได้
|
||||
|
||||
## 🛠️ Implementation Example
|
||||
- **RFA to Drawing**: เมื่อ RFA ได้รับการอนุมัติ ให้ทำการอัปเดตสถานะของ Drawing ที่เกี่ยวข้องโดยอัตโนมัติ
|
||||
- **Correspondence to Transmittal**: บันทึกว่าจดหมายฉบับนี้ถูกส่งไปพร้อมกับ Transmittal เลขที่เท่าไหร่
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial cross-module linking playbook
|
||||
@@ -0,0 +1,18 @@
|
||||
// File: docs/ai-knowledge-base/playbooks/dms/drawing-revision-flow.md
|
||||
# Playbook: Drawing Revision Management
|
||||
|
||||
## 🔄 Revision Flow
|
||||
1. **Initial Upload**: Drawing ถูกอัปโหลดเข้าระบบครั้งแรก (Revision 0 หรือ A)
|
||||
2. **Review & Approval**: ผ่านกระบวนการ RFA
|
||||
3. **Revision Up**: เมื่อมีการแก้ไข ให้ผู้ใช้อัปโหลดไฟล์ใหม่โดยอ้างอิง `publicId` เดิม
|
||||
4. **Auto-Numbering**: ระบบจะเจนเลขที่ Revision ถัดไปตาม Rule (e.g. 0 -> 1 หรือ A -> B)
|
||||
5. **Supersede**: Revision เก่าจะถูกทำเครื่องหมายเป็น "Superseded" (แต่ไฟล์ยังอยู่สำหรับการตรวจสอบย้อนหลัง)
|
||||
|
||||
## 🏗️ Technical Implementation
|
||||
- ใช้ **Redis Redlock** ในการจองเลขที่ Revision เพื่อป้องกันเลขซ้ำ
|
||||
- เก็บประวัติทั้งหมดไว้ใน `drawing_revisions` table
|
||||
- แสดงเฉพาะ Revision ล่าสุด (Current) ในหน้ารายการหลัก ยกเว้นผู้ใช้จะเลือกดู History
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial drawing revision playbook
|
||||
@@ -0,0 +1,27 @@
|
||||
// File: docs/ai-knowledge-base/playbooks/dms/rfa-lifecycle.md
|
||||
# Playbook: RFA Lifecycle Management
|
||||
|
||||
## 🔄 Lifecycle Stages
|
||||
1. **Draft**: ผู้สร้างเตรียมเอกสารและอัปโหลดไฟล์
|
||||
2. **Submitted**: ส่งเข้าระบบเพื่อรอการตรวจสอบ (Review)
|
||||
3. **Reviewing**: ทีมที่ปรึกษาหรือหน่วยงานที่เกี่ยวข้องตรวจสอบ
|
||||
4. **Responded**: ให้ความเห็น (Comment) กลับมา
|
||||
5. **Approved / Rejected**: สถานะสุดท้ายของการอนุมัติ
|
||||
6. **Closed**: สิ้นสุดกระบวนการ
|
||||
|
||||
## 🛡️ Business Rules (ADR-001)
|
||||
- การเปลี่ยนสถานะต้องใช้ **Workflow Engine** เท่านั้น
|
||||
- ต้องมีการทำ **Optimistic Locking** ผ่าน `@VersionColumn` เพื่อป้องกันการอนุมัติพร้อมกัน
|
||||
- ทุกการเปลี่ยนสถานะต้องบันทึก **Audit Log** และ **Workflow History**
|
||||
- หากมีการอัปโหลดไฟล์ใหม่ ต้องย้ายจาก `temp` ไป `permanent` และสแกน ClamAV
|
||||
|
||||
## 🛠️ Implementation Steps
|
||||
1. ตรวจสอบสิทธิ์ผู้ใช้ผ่าน `CaslGuard`
|
||||
2. ดึงข้อมูล RFA พร้อมสถานะปัจจุบันจาก DB
|
||||
3. ตรวจสอบความถูกต้องของสถานะต้นทาง (Source State) และสถานะปลายทาง (Target State)
|
||||
4. ทำการ Update ใน Database Transaction
|
||||
5. ส่งการแจ้งเตือนผ่าน `BullMQ`
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial RFA lifecycle playbook
|
||||
@@ -0,0 +1,17 @@
|
||||
// File: docs/ai-knowledge-base/playbooks/dms/transmittal-process.md
|
||||
# Playbook: Transmittal Process
|
||||
|
||||
## 🔄 Transmittal Workflow
|
||||
1. **Creation**: DC (Document Control) สร้าง Transmittal และเลือกเอกสาร (Attachments) ที่ต้องการส่ง
|
||||
2. **Review**: หัวหน้าทีมตรวจสอบความถูกต้องของรายการเอกสาร
|
||||
3. **Issuance**: ส่งมอบเอกสารอย่างเป็นทางการ (เปลี่ยนสถานะเป็น Issued)
|
||||
4. **Acknowledgment**: ผู้รับเซ็นรับเอกสารในระบบ (เปลี่ยนสถานะเป็น Received)
|
||||
|
||||
## 📋 Standard Actions
|
||||
- **Generate Cover Sheet**: ระบบสร้าง PDF หน้าปกที่มีรายการเอกสารและ QR Code
|
||||
- **Link Documents**: เอกสารที่ถูกส่งจะถูกบันทึกความเชื่อมโยง (Link) กับ Transmittal ID
|
||||
- **Notification**: ส่งอีเมลแจ้งเตือนผู้รับพร้อมลิงก์ดาวน์โหลด
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial transmittal process playbook
|
||||
@@ -0,0 +1,16 @@
|
||||
// File: docs/ai-knowledge-base/playbooks/infra/omada-vlan-recovery.md
|
||||
# Playbook: Omada VLAN Recovery
|
||||
|
||||
## 🚨 Scenario
|
||||
VLAN ใช้งานไม่ได้ หรือ Client ไม่สามารถรับ IP ได้หลังจากมีการเปลี่ยน Config
|
||||
|
||||
## 🛠️ Recovery Steps
|
||||
1. **Check Controller Connection**: ตรวจสอบว่า Omada Controller ยังออนไลน์อยู่หรือไม่
|
||||
2. **Revert Last Change**: หากจำได้ ให้ Revert การตั้งค่าล่าสุดทันที
|
||||
3. **Switch Log Audit**: เข้าไปดู Log ของ Switch ในหน้า Omada เพื่อหา Error (e.g. Loop Detected)
|
||||
4. **Port Profile Verification**: ตรวจสอบว่า Port ที่ Client ต่ออยู่ ใช้ Profile VLAN ที่ถูกต้อง
|
||||
5. **Re-adopt Device**: หาก Switch สถานะเป็น "Disconnected" ให้ลองทำการ Re-adopt หรือ Restart Switch
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial Omada recovery playbook
|
||||
@@ -0,0 +1,16 @@
|
||||
// File: docs/ai-knowledge-base/playbooks/infra/switch-reset-safe.md
|
||||
# Playbook: Safe Switch Reset & Re-adoption
|
||||
|
||||
## 🎯 Objective
|
||||
การทำ Factory Reset และทำการ Adopt ใหม่ใน Omada Controller โดยไม่ให้ระบบล่มเป็นเวลานาน
|
||||
|
||||
## 🏗️ Steps
|
||||
1. **Backup Config**: ตรวจสอบว่ามี Backup ของ Omada Controller ล่าสุดหรือไม่
|
||||
2. **Physical Reset**: กดปุ่ม Reset ที่ตัว Switch ค้างไว้จนไฟกะพริบ
|
||||
3. **Omada Discovery**: รอจน Switch ปรากฏขึ้นมาในสถานะ "Pending"
|
||||
4. **Adopt & Provision**: คลิก Adopt และรอให้ Controller ทำการส่ง Config (Provisioning) ไปยัง Switch
|
||||
5. **VLAN Check**: ตรวจสอบว่า Port ต่างๆ ยังทำงานตาม VLAN Profile เดิมหรือไม่
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial safe reset playbook
|
||||
@@ -0,0 +1,26 @@
|
||||
// File: docs/ai-knowledge-base/prompts/automation/n8n-workflow.md
|
||||
# n8n Workflow Design Prompt
|
||||
|
||||
## ⭐ Role: Workflow Automation Architect (n8n Specialist)
|
||||
|
||||
## 🎯 Context
|
||||
ออกแบบและปรับปรุงระบบอัตโนมัติ (Automation) ใน n8n สำหรับกระบวนการจัดการเอกสาร เช่น การแจ้งเตือนผ่าน Line/Email, การทำ OCR อัตโนมัติ หรือการ Sync ข้อมูล
|
||||
|
||||
## 🛠️ n8n Best Practices
|
||||
1. **Error Trigger**: ทุก Workflow ต้องมี Error Trigger Node เพื่อแจ้งเตือนเมื่อระบบล้มเหลว
|
||||
2. **Resource Optimization**: หลีกเลี่ยงการดึงข้อมูลจำนวนมหาศาลในครั้งเดียว (ใช้ Batching/Pagination)
|
||||
3. **Naming Convention**: ตั้งชื่อ Node ให้สื่อความหมาย (e.g. `HTTP: Get RFA Details`)
|
||||
4. **Environment Variables**: ใช้ `$env` สำหรับข้อมูลที่เปลี่ยนแปลงตามสภาพแวดล้อม (e.g. API Keys, URLs)
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[n8n WORKFLOW DESIGN]
|
||||
Flow: <e.g. เมื่อมีการ Approve RFA -> สร้าง PDF -> ส่งเข้า Line Group>
|
||||
Triggers: <Webhook / Cron / Event>
|
||||
Expected Output: รายการ Nodes ที่ต้องใช้ และ Logic ในการเชื่อมต่อแต่ละจุด
|
||||
Request: ออกแบบโครงสร้าง Workflow ที่ทนทาน (Robust) และรองรับการทำ Retry
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial n8n workflow prompt
|
||||
@@ -0,0 +1,25 @@
|
||||
// File: docs/ai-knowledge-base/prompts/automation/ocr-rag-tuning.md
|
||||
# OCR & RAG Tuning Prompt
|
||||
|
||||
## ⭐ Role: AI Engineer / Document Intelligence Specialist
|
||||
|
||||
## 🎯 Context
|
||||
การเพิ่มความแม่นยำในการอ่านเอกสาร (OCR) และการค้นหาข้อมูลเชิงความหมาย (RAG) สำหรับเอกสารวิศวกรรมที่มีความซับซ้อน
|
||||
|
||||
## 🔍 Tuning Strategies
|
||||
1. **OCR Post-processing**: การใช้ AI ช่วยแก้ไขคำที่อ่านผิดจาก OCR (e.g. `O` เป็น `0`, `I` เป็น `1`)
|
||||
2. **Chunking Strategy**: แบ่งเนื้อหาตามหัวข้อหรือย่อหน้า (Semantic Chunking) แทนการแบ่งตามจำนวนตัวอักษร
|
||||
3. **Metadata Filtering**: การผสมผสาน Keyword Search กับ Vector Search เพื่อผลลัพธ์ที่แม่นยำที่สุด
|
||||
4. **Prompt Engineering for Extraction**: การออกแบบ Prompt ให้สกัดข้อมูล JSON จาก OCR text อย่างเสถียร
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[OCR/RAG OPTIMIZATION]
|
||||
Document Type: <e.g. Drawing Title Block, RFA Form>
|
||||
Problem: <e.g. อ่านเลขที่เอกสารผิด, ค้นหาข้อมูลไม่เจอ>
|
||||
Request: เสนอแนวทางการปรับปรุง Chunking หรือ Prompt เพื่อเพิ่ม Accuracy ของระบบ
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial OCR/RAG tuning prompt
|
||||
@@ -0,0 +1,28 @@
|
||||
// File: docs/ai-knowledge-base/prompts/codex/codex-bugfix.md
|
||||
# Bug Fix Prompt (Windsurf/Codex)
|
||||
|
||||
## ⭐ Role: Debugging Specialist
|
||||
|
||||
## 🎯 Objective
|
||||
วิเคราะห์และแก้ไข Bug ในระบบ DMS โดยเน้นความถูกต้องและไม่ส่งผลกระทบต่อส่วนอื่น (No Regressions)
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[DEBUG]
|
||||
Issue: <อธิบายอาการของ Bug และผลกระทบ>
|
||||
File: <path/to/file>
|
||||
Error Log: <error message จาก terminal หรือ browser>
|
||||
Steps to Reproduce: <ขั้นตอนในการทำให้เกิด bug>
|
||||
Request: วิเคราะห์หาสาเหตุตามลำดับความสำคัญ (Root Cause Analysis) และเสนอแนวทางการแก้ไขที่สอดคล้องกับ ADRs
|
||||
```
|
||||
|
||||
## 🔍 Instructions for AI
|
||||
1. ค้นหาจุดที่เกิด Error ใน Codebase
|
||||
2. ตรวจสอบความเกี่ยวข้องกับ Database Schema หรือ Permissions
|
||||
3. สร้าง Unit Test เพื่อจำลอง Bug (Red Test)
|
||||
4. แก้ไขโค้ดเพื่อให้ Test ผ่าน (Green Test)
|
||||
5. ตรวจสอบผลกระทบข้างเคียง (Side Effects)
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial bugfix prompt
|
||||
@@ -0,0 +1,28 @@
|
||||
// File: docs/ai-knowledge-base/prompts/codex/codex-feature.md
|
||||
# Feature Implementation Prompt (Windsurf/Codex)
|
||||
|
||||
## ⭐ Role: Senior Full Stack Developer (DMS Specialist)
|
||||
|
||||
## 🎯 Context
|
||||
ใช้เมื่อต้องการให้ AI เริ่มเขียนโค้ดสำหรับฟีเจอร์ใหม่ หลังจากที่มี Spec และ Plan แล้ว
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[IMPLEMENT FEATURE]
|
||||
Feature Name: <ชื่อฟีเจอร์>
|
||||
Spec Reference: <path/to/spec.md>
|
||||
Plan Reference: <path/to/plan.md>
|
||||
Current Task: <ระบุ Task จาก tasks.md>
|
||||
Request: เขียนโค้ดตามมาตรฐานที่กำหนดใน AGENTS.md โดยเน้นความถูกต้องของ Type Safety และการจัดการ Error
|
||||
```
|
||||
|
||||
## 🔍 Instructions for AI
|
||||
1. อ่าน Spec และ Plan ให้เข้าใจถ่องแท้
|
||||
2. ตรวจสอบ Schema ของ Database ก่อนเริ่มเขียน Entity/Service
|
||||
3. เขียน Logic ให้สอดคล้องกับ ADRs (UUIDv7, Idempotency, etc.)
|
||||
4. เขียน Unit Test ควบคู่ไปด้วยเสมอ
|
||||
5. ตรวจสอบ Forbidden Actions ก่อนส่งงาน
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial feature implementation prompt
|
||||
@@ -0,0 +1,26 @@
|
||||
// File: docs/ai-knowledge-base/prompts/codex/codex-review.md
|
||||
# Code Review Prompt (Windsurf/Codex)
|
||||
|
||||
## ⭐ Role: Senior Code Reviewer (DMS Specialist)
|
||||
|
||||
## 🎯 Context
|
||||
ตรวจสอบโค้ดในโครงการ LCBP3-DMS เพื่อความปลอดภัย, คุณภาพ และความถูกต้องตามมาตรฐานสถาปัตยกรรม
|
||||
|
||||
## 📝 Focus Areas
|
||||
1. **Security**: ตรวจสอบ CASL Guard, RBAC Check และการทำ Input Sanitization
|
||||
2. **UUID Strategy (ADR-019)**: มั่นใจว่าใช้ `publicId` และไม่มีการใช้ `parseInt()`
|
||||
3. **Database Consistency (ADR-009)**: ตรวจสอบว่าไม่มีการใช้ TypeORM Migrations
|
||||
4. **Performance**: ตรวจสอบ Query Optimization และการใช้ Cache
|
||||
5. **Forbidden Patterns**: ค้นหา `any`, `console.log` หรือการข้าม StorageService
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[CODE REVIEW]
|
||||
Files: <รายการไฟล์>
|
||||
Focus: <เลือก: security/performance/uuid/logic>
|
||||
Request: ตรวจสอบโค้ดตามมาตรฐานใน AGENTS.md และ ADRs ที่เกี่ยวข้อง พร้อมเสนอวิธี Refactor หากพบจุดบกพร่อง
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial code review prompt
|
||||
@@ -0,0 +1,29 @@
|
||||
// File: docs/ai-knowledge-base/prompts/core/coding-standards.md
|
||||
# Coding Standards & Best Practices
|
||||
|
||||
## ✅ General Guidelines
|
||||
- **English for Code**: ใช้ภาษาอังกฤษสำหรับตัวแปร, ชื่อฟังก์ชัน และ logic
|
||||
- **Thai for Comments**: ใช้ภาษาไทยสำหรับการอธิบาย code, JSDoc และ Documentation
|
||||
- **Strict Typing**: ห้ามใช้ `any` เด็ดขาด ให้ใช้ Interface หรือ Type เสมอ
|
||||
- **Single Export**: 1 ไฟล์ควร Export เพียง 1 สัญลักษณ์หลัก
|
||||
- **File Headers**: ทุกไฟล์ต้องมี `// File: path` และ `// Change Log`
|
||||
|
||||
## 🆔 Identifier Strategy (ADR-019)
|
||||
- **Database PK**: ใช้ `INT AUTO_INCREMENT` (ห้ามเปิดเผยผ่าน API)
|
||||
- **Public ID**: ใช้ `UUIDv7` สำหรับการอ้างอิงผ่าน API และ URL เท่านั้น
|
||||
- **Frontend**: ใช้ `publicId` เพียงอย่างเดียว ห้ามใช้ `parseInt()` กับ UUID
|
||||
|
||||
## 🛡️ Security & Integrity
|
||||
- **Idempotency**: ทุกการเขียนข้อมูล (POST/PUT/PATCH) ต้องรองรับ `Idempotency-Key`
|
||||
- **RBAC**: ตรวจสอบสิทธิ์ผ่าน CASL Guard เสมอ
|
||||
- **Data Isolation**: AI ห้ามเข้าถึง Database โดยตรง ต้องผ่าน API เท่านั้น
|
||||
- **Validation**: ใช้ Zod (Frontend) และ class-validator (Backend)
|
||||
|
||||
## 🏗️ Architecture
|
||||
- **Backend**: Thin Controller -> Service (Business Logic) -> Repository/Entity
|
||||
- **Frontend**: ใช้ Component จาก shadcn/ui และจัดการ State ด้วย TanStack Query
|
||||
- **Async Tasks**: งานที่ใช้เวลานานต้องส่งเข้า BullMQ เสมอ
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Consolidated coding standards from AGENTS.md
|
||||
@@ -0,0 +1,22 @@
|
||||
// File: docs/ai-knowledge-base/prompts/core/guardrails.md
|
||||
# AI Guardrails & Forbidden Actions
|
||||
|
||||
## 🚫 Forbidden Actions (Critical)
|
||||
| Action | Reason |
|
||||
| --- | --- |
|
||||
| **SQL Triggers** | ห้ามใช้สำหรับ Business Logic เพราะทดสอบยากและข้าม Audit Log |
|
||||
| **Direct DB/Storage Access** | AI ห้ามเข้าถึงโดยตรง ต้องผ่าน DMS API เท่านั้น |
|
||||
| **parseInt() on UUID** | ห้ามใช้ เพราะจะทำให้ข้อมูลผิดพลาด (e.g. 0195... กลายเป็น 19) |
|
||||
| **Exposing INT PK** | ห้ามเปิดเผย ID ที่เป็น Integer ผ่าน API ป้องกันการคาดเดาข้อมูล |
|
||||
| **console.log** | ห้ามมีใน Code ที่ Commit ให้ใช้ NestJS Logger แทน |
|
||||
| **any type** | ห้ามใช้เด็ดขาด เพื่อความปลอดภัยของระบบ |
|
||||
| **.env in Production** | ห้ามเก็บ Secret ใน .env ให้ใช้ Docker Environment แทน |
|
||||
|
||||
## 🛡️ Security Checks
|
||||
- ทุกครั้งที่สร้าง API ใหม่ ต้องมี `@UseGuards(CaslGuard)`
|
||||
- ทุกไฟล์ที่อัปโหลดต้องผ่านการสแกน ClamAV (ผ่าน StorageService)
|
||||
- ทุกการแก้ไข Schema ต้องแก้ที่ไฟล์ SQL โดยตรง (ห้ามใช้ TypeORM Migrations - ADR-009)
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial guardrails from AGENTS.md
|
||||
@@ -0,0 +1,26 @@
|
||||
// File: docs/ai-knowledge-base/prompts/core/master-prompt.md
|
||||
# Master Prompt: NAP-DMS (Integrated Team)
|
||||
|
||||
## ⭐ Role: The Orchestrator (Senior Team)
|
||||
ให้คุณรับบทเป็นทีมผู้เชี่ยวชาญที่ทำงานร่วมกัน:
|
||||
- **Solution Architect**: ออกแบบโครงสร้างภาพรวม
|
||||
- **Document Controller**: ดูแลความถูกต้องของเอกสารและสถานะ
|
||||
- **Backend Engineer**: พัฒนา API ที่ปลอดภัย (NestJS)
|
||||
- **Frontend Engineer**: พัฒนา UI ที่ใช้งานง่าย (Next.js)
|
||||
- **DevOps Engineer**: ดูแลการ Deploy และ Automation
|
||||
|
||||
## 🎯 Context
|
||||
ระบบคือ AI-powered Document Management System สำหรับโครงการ LCBP3 ซึ่งต้องรองรับกฎระเบียบที่เข้มงวดและความปลอดภัยระดับสูง
|
||||
|
||||
## 🏗️ Instructions
|
||||
1. ตรวจสอบ **Specs** และ **ADRs** ที่เกี่ยวข้องทุกครั้งก่อนตอบ
|
||||
2. ปฏิบัติตาม **Coding Standards** (No `any`, Thai comments, Explicit types)
|
||||
3. ป้องกัน **Race Conditions** และตรวจสอบ **RBAC** เสมอ
|
||||
4. หากพบความไม่ชัดเจน ให้ถามเพื่อยืนยันก่อนลงมือทำ
|
||||
|
||||
## 🚀 Activation
|
||||
"จากนี้ไป ทุกคำตอบของคุณต้องผ่านการกลั่นกรองจากบทบาททั้ง 5 นี้ และสอดคล้องกับมาตรฐานโครงการ 100%"
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial master prompt template
|
||||
@@ -0,0 +1,22 @@
|
||||
// File: docs/ai-knowledge-base/prompts/core/system-context.md
|
||||
# System Context: NAP-DMS (LCBP3)
|
||||
|
||||
## 🏗️ Project Overview
|
||||
NAP-DMS คือระบบจัดการเอกสาร (Document Management System) สำหรับโครงการก่อสร้างขนาดใหญ่ (LCBP3) โดยเน้นไปที่การควบคุมเอกสาร (Document Control), การจัดการแบบวาด (Drawing Management), และการไหลเวียนของเอกสารขออนุมัติ (RFA/Transmittal/Circulation)
|
||||
|
||||
## 🎯 Key Modules
|
||||
1. **Correspondence**: การจัดการจดหมายโต้ตอบระหว่างหน่วยงาน
|
||||
2. **RFA (Request for Approval)**: กระบวนการขออนุมัติวัสดุ/แบบวาด
|
||||
3. **Transmittal**: การส่งมอบเอกสารอย่างเป็นทางการ
|
||||
4. **Circulation**: การกระจายเอกสารภายในทีม
|
||||
5. **AI Intelligence**: การใช้ AI ในการจำแนกเอกสาร (Classification), สกัดข้อมูล (Extraction), และค้นหา (Semantic Search)
|
||||
|
||||
## 🔑 Technology Stack
|
||||
- **Backend**: NestJS, TypeScript, MariaDB (SQL), Redis (Redlock/BullMQ)
|
||||
- **Frontend**: Next.js (App Router), TanStack Query, React Hook Form, Zod, shadcn/ui
|
||||
- **AI**: Ollama (On-premises), Gemini (via API for non-sensitive tasks)
|
||||
- **Infrastructure**: Docker, Gitea Actions, QNAP Container Station
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial project context
|
||||
@@ -0,0 +1,34 @@
|
||||
// File: docs/ai-knowledge-base/prompts/dms/api-design.md
|
||||
# API Design Prompt (DMS Standard)
|
||||
|
||||
## ⭐ Role: Backend Architect
|
||||
|
||||
## 🎯 Objective
|
||||
ออกแบบ REST API ที่ปลอดภัย, มีประสิทธิภาพ และรองรับ Idempotency สำหรับระบบ DMS
|
||||
|
||||
## 📝 Instructions
|
||||
1. **Naming**: ใช้ kebab-case สำหรับ URL และ camelCase สำหรับ JSON field
|
||||
2. **Security**: ทุก Endpoint ต้องระบุ Decorator `@UseGuards(CaslGuard)` และ `@CheckPolicies(...)`
|
||||
3. **Idempotency**: สำหรับ POST/PATCH ต้องตรวจสอบ `Idempotency-Key` ใน Header
|
||||
4. **Validation**: ใช้ `Zod` สำหรับ Frontend และ `class-validator` ใน Backend DTOs
|
||||
5. **Standard Response**:
|
||||
- Success: `200 OK` หรือ `201 Created` พร้อมข้อมูล
|
||||
- Error: ปฏิบัติตาม ADR-007 (Error Handling Strategy)
|
||||
|
||||
## 📤 Output Format
|
||||
```typescript
|
||||
// Example Controller / DTO Definition
|
||||
@Controller('v1/documents')
|
||||
export class DocumentController {
|
||||
@Post()
|
||||
@UseGuards(CaslGuard)
|
||||
@CheckPolicies((ability) => ability.can(Action.Create, Document))
|
||||
async create(@Body() createDto: CreateDocumentDto, @Headers('idempotency-key') key: string) {
|
||||
// ... logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial API design standard
|
||||
@@ -0,0 +1,30 @@
|
||||
// File: docs/ai-knowledge-base/prompts/dms/bug-fix.md
|
||||
# Bug Fix Prompt (DMS Module)
|
||||
|
||||
## ⭐ Role: Debugging Specialist (DMS Expert)
|
||||
|
||||
## 🎯 Context
|
||||
ระบบ DMS มีความซับซ้อนเรื่องสถานะ (State) และสิทธิ์ (Permissions) การแก้ไข Bug ต้องระวังไม่ให้กระทบต่อ Workflow Logic
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[DEBUG DMS]
|
||||
Module: <e.g. RFA, Correspondence>
|
||||
Issue: <อธิบายปัญหา>
|
||||
Affected PublicId: <UUIDv7 ที่เกิดปัญหา>
|
||||
Error Message: <Paste log หรือ error จาก browser>
|
||||
Reference Docs:
|
||||
- specs/01-Requirements/01-06-edge-cases-and-rules.md
|
||||
- specs/06-Decision-Records/ADR-007-error-handling-strategy.md
|
||||
Request: วิเคราะห์หาสาเหตุ โดยตรวจสอบทั้ง Database State และ CASL Ability
|
||||
```
|
||||
|
||||
## 🔍 Investigation Checklist
|
||||
1. **Database Check**: ตรวจสอบสถานะจริงใน DB เทียบกับสถานะที่ควรจะเป็น
|
||||
2. **Permission Check**: ผู้ใช้ที่เกิดปัญหามีสิทธิ์ทำ Action นั้นหรือไม่?
|
||||
3. **Concurrency**: เกิด Race Condition หรือไม่? (เช็ค Redis locks)
|
||||
4. **Validation**: ติด Zod หรือ class-validator หรือไม่?
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial bug fix prompt for DMS
|
||||
@@ -0,0 +1,31 @@
|
||||
// File: docs/ai-knowledge-base/prompts/dms/db-schema.md
|
||||
# Database Schema Design Prompt
|
||||
|
||||
## ⭐ Role: Senior Database Administrator (MariaDB Specialist)
|
||||
|
||||
## 🎯 Context
|
||||
ออกแบบหรือแก้ไขตารางฐานข้อมูลสำหรับระบบ NAP-DMS โดยยึดตาม ADR-009 และ ADR-019
|
||||
|
||||
## 📝 Key Rules
|
||||
1. **No Migrations**: ห้ามสร้างไฟล์ Migration ให้เขียน SQL Script โดยตรง
|
||||
2. **Hybrid ID**:
|
||||
- `id INT AUTO_INCREMENT PRIMARY KEY` (Internal)
|
||||
- `publicId BINARY(16) UNIQUE` (External - UUIDv7)
|
||||
3. **Audit Fields**:
|
||||
- `createdBy INT` (FK to user internal id)
|
||||
- `updatedBy INT`
|
||||
- `createdAt TIMESTAMP`
|
||||
- `updatedAt TIMESTAMP`
|
||||
- `version INT DEFAULT 1` (For optimistic locking)
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[DB SCHEMA DESIGN]
|
||||
Feature: <ชื่อฟีเจอร์>
|
||||
Requirements: <รายละเอียดข้อมูลที่ต้องเก็บ>
|
||||
Request: ออกแบบตารางพร้อมความสัมพันธ์ (FK) และดัชนี (Index) ที่เหมาะสม โดยใช้มาตรฐาน Hybrid UUID
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial DB schema prompt standard
|
||||
@@ -0,0 +1,32 @@
|
||||
// File: docs/ai-knowledge-base/prompts/dms/feature-design.md
|
||||
# Feature Design Prompt (DMS Edition)
|
||||
|
||||
## ⭐ Role: Senior Full Stack Developer / Solution Architect
|
||||
|
||||
## 🎯 Context
|
||||
คุณกำลังออกแบบฟีเจอร์ใหม่สำหรับระบบ NAP-DMS โดยอ้างอิงจาก Business Rules ใน `specs/01-requirements/01-02-business-rules/`
|
||||
|
||||
## 📝 Input Template
|
||||
```
|
||||
[NEW FEATURE]
|
||||
Module: <module-name>
|
||||
Requirement: <อธิบาย User Story หรืออ้างอิงไฟล์ spec>
|
||||
Constraints: <ข้อจำกัดเพิ่มเติม เช่น สิทธิ์การเข้าถึง, ความเกี่ยวข้องกับ Module อื่น>
|
||||
```
|
||||
|
||||
## 🚀 Instructions
|
||||
1. ตรวจสอบ **Glossary** (`specs/00-overview/00-02-glossary.md`) เพื่อใช้คำศัพท์ให้ถูกต้อง
|
||||
2. วิเคราะห์ **Edge Cases** (`specs/01-Requirements/01-06-edge-cases-and-rules.md`)
|
||||
3. ออกแบบ **Data Model & Schema** ตามมาตรฐาน ADR-019 (Hybrid UUID)
|
||||
4. กำหนด **RBAC Matrix** สำหรับฟีเจอร์นี้
|
||||
5. ร่างลำดับการทำงาน (Workflow) และการแจ้งเตือน (Notifications)
|
||||
|
||||
## 📤 Output Format
|
||||
1. **Summary**: สรุปแนวทางการแก้ปัญหา
|
||||
2. **Database Schema**: คำสั่ง SQL สำหรับสร้าง/แก้ไขตาราง
|
||||
3. **API Contracts**: นิยาม DTO และ Endpoint
|
||||
4. **Implementation Plan**: ขั้นตอนการพัฒนาทีละ Step
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial template
|
||||
@@ -0,0 +1,27 @@
|
||||
// File: docs/ai-knowledge-base/prompts/dms/rbac-enforcement.md
|
||||
# RBAC Enforcement Prompt (DMS Security)
|
||||
|
||||
## ⭐ Role: Security Engineer / IAM Specialist
|
||||
|
||||
## 🎯 Context
|
||||
การบังคับใช้สิทธิ์การเข้าถึงข้อมูล (Access Control) ตามบทบาทหน้าที่ (Role-Based Access Control) โดยใช้ CASL ใน NestJS และ RBAC Matrix
|
||||
|
||||
## 🛡️ Enforcement Rules
|
||||
1. **CASL Guard**: ทุก API Controller ต้องประดับด้วย `@UseGuards(CaslGuard)`
|
||||
2. **Policy Definition**: ตรวจสอบสิทธิ์ในระดับ Action (Create, Read, Update, Delete, Manage) และ Subject (Entity)
|
||||
3. **Field Level Security**: บางฟิลด์อาจต้องซ่อนตามระดับสิทธิ์ (e.g. ข้อมูลราคา)
|
||||
4. **Project Isolation**: ตรวจสอบว่าผู้ใช้มีสิทธิ์เข้าถึงโครงการนั้นๆ จริงหรือไม่ (Project ID Check)
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[RBAC CHECK]
|
||||
Endpoint: <e.g. PATCH /v1/rfa/:publicId>
|
||||
User Role: <e.g. Consultant>
|
||||
Desired Action: <e.g. Approve RFA>
|
||||
Reference: specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md
|
||||
Request: วิเคราะห์และเขียนโค้ด CASL Policy สำหรับกรณีนี้
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial RBAC enforcement prompt
|
||||
@@ -0,0 +1,25 @@
|
||||
// File: docs/ai-knowledge-base/prompts/dms/refactor.md
|
||||
# Code Refactoring Prompt (DMS Standard)
|
||||
|
||||
## ⭐ Role: Senior Software Engineer (Refactoring Specialist)
|
||||
|
||||
## 🎯 Objective
|
||||
ปรับปรุงโครงสร้างโค้ดให้สะอาด (Clean Code), บำรุงรักษาง่าย (Maintainability) และสอดคล้องกับมาตรฐาน LCBP3
|
||||
|
||||
## 🏗️ Refactoring Principles
|
||||
1. **DRY (Don't Repeat Yourself)**: ย้าย Logic ที่ซ้ำกันไปไว้ใน Common Service หรือ Utility
|
||||
2. **SOLID**: แยกความรับผิดชอบของ Class และ Method ให้ชัดเจน
|
||||
3. **Type Safety**: กำจัด `any` และ `unknown` โดยใช้ Interface/Type ที่ถูกต้อง
|
||||
4. **Performance**: ลดการ Query ซ้ำซ้อน และใช้ Transaction เมื่อจำเป็น
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[REFACTOR]
|
||||
Target File: <path/to/file>
|
||||
Goal: <e.g. แยก logic ออกจาก controller, ปรับปรุง type safety>
|
||||
Request: ช่วยวิเคราะห์โค้ดเดิมและเสนอเวอร์ชัน Refactor ที่ยังคงรักษา functionality เดิมไว้ 100% พร้อมอธิบายเหตุผลของการเปลี่ยนแปลง
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial refactor prompt
|
||||
@@ -0,0 +1,27 @@
|
||||
// File: docs/ai-knowledge-base/prompts/dms/report-generation.md
|
||||
# Report Generation Prompt (DMS)
|
||||
|
||||
## ⭐ Role: Data Analyst / Backend Developer
|
||||
|
||||
## 🎯 Context
|
||||
การสร้างรายงาน (Reports) จากข้อมูลในระบบ DMS เช่น สรุปสถานะ RFA ประจำสัปดาห์ หรือรายงานความคืบหน้าของ Drawing
|
||||
|
||||
## 📊 Reporting Requirements
|
||||
1. **Data Source**: ดึงข้อมูลจากตารางหลัก (Correspondence, RFA, etc.) และตาราง History
|
||||
2. **Filters**: ต้องรองรับการกรองตาม Project, Discipline, Date Range และ Status
|
||||
3. **Export Formats**: รองรับ PDF (สำหรับเซ็นชื่อ) และ Excel (สำหรับวิเคราะห์ข้อมูล)
|
||||
4. **Performance**: ใช้ Aggregate Queries ที่มีประสิทธิภาพ และทำ Caching หากจำเป็น
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[REPORT DESIGN]
|
||||
Report Name: <ชื่อรายงาน>
|
||||
Columns: <รายการฟิลด์ที่ต้องการแสดง>
|
||||
Group By: <e.g. Discipline, Sub-contractor>
|
||||
Export Format: <Excel/PDF>
|
||||
Request: ออกแบบ Query และโครงสร้างข้อมูลสำหรับสร้างรายงานนี้
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial report generation prompt
|
||||
@@ -0,0 +1,26 @@
|
||||
// File: docs/ai-knowledge-base/prompts/dms/ui-flow.md
|
||||
# UI/UX Flow Design Prompt (DMS)
|
||||
|
||||
## ⭐ Role: UX/UI Designer (Enterprise Application)
|
||||
|
||||
## 🎯 Context
|
||||
ออกแบบหน้าจอและการไหลเวียนของงาน (User Journey) ในระบบ DMS ที่มีข้อมูลจำนวนมากและ Workflow ที่ซับซ้อน
|
||||
|
||||
## 🎨 Design Guidelines
|
||||
1. **Consistency**: ใช้ Component จาก `shadcn/ui` และ Palette สีตามมาตรฐานโครงการ
|
||||
2. **Efficiency**: ลดจำนวนการคลิกเพื่อเข้าถึงข้อมูลสำคัญ (e.g. Document Preview)
|
||||
3. **Feedback**: ต้องมี Loading states, Success/Error toasts และ Skeleton screens
|
||||
4. **Responsive**: รองรับทั้ง Desktop (หลัก) และ Tablet สำหรับงานหน้าไซต์
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[UI FLOW DESIGN]
|
||||
Feature: <ชื่อฟีเจอร์>
|
||||
User Role: <e.g. Document Control, Sub-contractor>
|
||||
Objective: <สิ่งที่ผู้ใช้ต้องการทำ>
|
||||
Request: ออกแบบ User Flow ตั้งแต่เริ่มต้นจนจบ พร้อมระบุ UI Components ที่ต้องใช้ในแต่ละหน้า
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial UI flow prompt
|
||||
@@ -0,0 +1,26 @@
|
||||
// File: docs/ai-knowledge-base/prompts/infra/network-troubleshoot.md
|
||||
# Network Troubleshooting Prompt (DMS Infra)
|
||||
|
||||
## ⭐ Role: Network Engineer (Omada Specialist)
|
||||
|
||||
## 🎯 Context
|
||||
การแก้ไขปัญหาเครือข่ายภายในโครงการที่ใช้ TP-Link Omada สำหรับการจัดการ VLAN และ Switch
|
||||
|
||||
## 🔍 Diagnosis Steps
|
||||
1. **Physical Link**: ตรวจสอบสถานะไฟที่ Port Switch และสายแลน
|
||||
2. **VLAN Tagging**: ตรวจสอบว่า Port ถูก Config เป็น Access หรือ Trunk และ VLAN ID ถูกต้องหรือไม่
|
||||
3. **DHCP Status**: ตรวจสอบว่า Client ได้รับ IP Address หรือไม่
|
||||
4. **Gateway Ping**: ทดสอบการเชื่อมต่อกับ Default Gateway และ Internet
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[NETWORK DEBUG]
|
||||
Device: <e.g. Switch-Floor-2, Omada Controller>
|
||||
Symptom: <e.g. ไม่สามารถเชื่อมต่อ Server ได้, VLAN 20 ใช้งานไม่ได้>
|
||||
Recent Changes: <การแก้ไขล่าสุดก่อนเกิดปัญหา>
|
||||
Request: ช่วยวิเคราะห์สาเหตุและเสนอขั้นตอนการแก้ไข (Step-by-step recovery)
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial network troubleshoot prompt
|
||||
@@ -0,0 +1,27 @@
|
||||
// File: docs/ai-knowledge-base/prompts/infra/server-debug.md
|
||||
# Server & Docker Debugging Prompt
|
||||
|
||||
## ⭐ Role: Systems Administrator / DevOps Engineer
|
||||
|
||||
## 🎯 Context
|
||||
การแก้ไขปัญหาที่เกี่ยวข้องกับ Linux Server, Docker Containers และการเชื่อมต่อฐานข้อมูล
|
||||
|
||||
## 🔍 Commands for Debugging
|
||||
- `docker ps`: ตรวจสอบสถานะ Container
|
||||
- `docker logs -f <container_name>`: ดู Log การทำงานแบบ Real-time
|
||||
- `df -h`: ตรวจสอบพื้นที่ว่างใน Disk
|
||||
- `free -m`: ตรวจสอบการใช้งาน RAM
|
||||
- `netstat -tulpn`: ตรวจสอบ Port ที่เปิดใช้งานอยู่
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[SERVER DEBUG]
|
||||
Service: <e.g. DMS-Backend, MariaDB>
|
||||
Problem: <e.g. Container Restart Loop, Connection Timeout>
|
||||
Error Output: <Paste log จาก docker logs>
|
||||
Request: วิเคราะห์หาสาเหตุ (e.g. Out of Memory, Disk Full, Env Config Error) และวิธีแก้ไข
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial server debug prompt
|
||||
@@ -0,0 +1,26 @@
|
||||
// File: docs/ai-knowledge-base/prompts/infra/vlan-change.md
|
||||
# VLAN Configuration Change Prompt
|
||||
|
||||
## ⭐ Role: Network Architect
|
||||
|
||||
## 🎯 Context
|
||||
ขั้นตอนการเพิ่ม หรือแก้ไข VLAN ภายในโครงการเพื่อความปลอดภัยและการแยกแยะการใช้งาน (Segmentation)
|
||||
|
||||
## 🏗️ Configuration Rules
|
||||
1. **Naming Standard**: ใช้ชื่อที่สื่อความหมาย (e.g. VLAN-10-SERVER, VLAN-20-CCTV)
|
||||
2. **Subnet Planning**: กำหนด Range IP ที่ไม่ซ้ำซ้อนกับ VLAN เดิม
|
||||
3. **ACL (Access Control List)**: กำหนดสิทธิ์การข้าม VLAN (Inter-VLAN Routing) ตามความจำเป็น
|
||||
4. **Port Profile**: สร้าง Profile ใน Omada เพื่อความง่ายในการนำไปใช้กับ Switch หลายตัว
|
||||
|
||||
## 🚀 Prompt Template
|
||||
```
|
||||
[VLAN CHANGE]
|
||||
Purpose: <e.g. แยกเครือข่ายสำหรับทีม Sub-contractor>
|
||||
VLAN ID: <e.g. 30>
|
||||
IP Range: <e.g. 192.168.30.0/24>
|
||||
Request: ออกแบบขั้นตอนการ Config ใน Omada Controller และ Switch Port
|
||||
```
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial VLAN change prompt
|
||||
@@ -0,0 +1,43 @@
|
||||
// File: docs/ai-knowledge-base/templates/api-spec.md
|
||||
# API Specification: [Endpoint Name]
|
||||
|
||||
## 📋 Metadata
|
||||
- **Version**: v1
|
||||
- **Module**: [e.g. RFA]
|
||||
- **Protocol**: REST (JSON)
|
||||
- **Status**: Draft / Proposed
|
||||
|
||||
## 🚀 Endpoint
|
||||
`METHOD /v1/[path]`
|
||||
|
||||
## 🛡️ Authentication & Authorization
|
||||
- **Auth Required**: Yes/No
|
||||
- **Roles**: [Admin, Consultant, etc.]
|
||||
- **CASL Action**: `Action.Create / Action.Read / ...`
|
||||
|
||||
## 📥 Request Parameters
|
||||
### Headers
|
||||
- `Idempotency-Key`: UUID (Required for Write actions)
|
||||
- `Authorization`: Bearer [token]
|
||||
|
||||
### Body (JSON)
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | String | Yes | Name of entity |
|
||||
|
||||
## 📤 Response (JSON)
|
||||
### Success (200/201)
|
||||
```json
|
||||
{
|
||||
"publicId": "...",
|
||||
"status": "success",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error (400/401/403/500)
|
||||
- ปฏิบัติตาม ADR-007
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial API spec template
|
||||
@@ -0,0 +1,32 @@
|
||||
// File: docs/ai-knowledge-base/templates/bug-report.md
|
||||
# Bug Report: [Short Title]
|
||||
|
||||
## 🚨 Issue Description
|
||||
[อธิบายปัญหาที่เกิดขึ้น]
|
||||
|
||||
## 🛠️ Environment
|
||||
- **Branch**: [main / develop / ...]
|
||||
- **Device**: [Desktop / Mobile]
|
||||
- **User Role**: [Admin / Document Control / ...]
|
||||
|
||||
## 🔄 Steps to Reproduce
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
|
||||
## ❌ Actual Result
|
||||
[สิ่งที่เกิดขึ้นจริง เช่น หน้าจอค้าง หรือ Error Message]
|
||||
|
||||
## ✅ Expected Result
|
||||
[สิ่งที่ควรจะเป็น]
|
||||
|
||||
## 🪵 Logs & Screenshots
|
||||
```
|
||||
[Paste Error Log Here]
|
||||
```
|
||||
![Link to screenshot]
|
||||
|
||||
---
|
||||
// Change Log:
|
||||
// - 2026-05-14: Initial template
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user