690514:2019 204-rfa-approval-refactor #01
This commit is contained in:
@@ -32,6 +32,7 @@ examples
|
|||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
coverage
|
coverage
|
||||||
|
**/coverage
|
||||||
**/*.spec.ts
|
**/*.spec.ts
|
||||||
**/*.test.ts
|
**/*.test.ts
|
||||||
**/*.test.tsx
|
**/*.test.tsx
|
||||||
@@ -60,4 +61,6 @@ scripts
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
**/.env.local
|
**/.env.local
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ backend/node_modules/
|
|||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
coverage
|
||||||
|
.next
|
||||||
|
out
|
||||||
*.min.js
|
*.min.js
|
||||||
|
|||||||
Vendored
+2
-1
@@ -33,6 +33,7 @@
|
|||||||
"github.copilot",
|
"github.copilot",
|
||||||
"bierner.markdown-mermaid",
|
"bierner.markdown-mermaid",
|
||||||
"vitest.explorer",
|
"vitest.explorer",
|
||||||
"google.geminicodeassist"
|
"google.geminicodeassist",
|
||||||
|
"openai.chatgpt"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"editor.fontSize": 16,
|
"editor.fontSize": 16,
|
||||||
"npm.packageManager": "pnpm"
|
"npm.packageManager": "pnpm",
|
||||||
|
"chatgpt.runCodexInWindowsSubsystemForLinux": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# NAP-DMS Project Context & Rules
|
# NAP-DMS Project Context & Rules
|
||||||
|
|
||||||
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
- For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools)
|
||||||
- Version: 1.9.1 | Last synced from repo: 2026-05-13
|
- Version: 1.9.2 | Last synced from repo: 2026-05-14
|
||||||
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
|
- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3)
|
||||||
- Skill pack: `.agents/skills/` (v1.9.1, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
|
- Skill pack: `.agents/skills/` (v1.9.0, 21 skills) — see [`skills/README.md`](./.agents/skills/README.md) + [`skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ If significant logic changes are made, summarize what was done for the user afte
|
|||||||
|
|
||||||
- **UUID Validation:** ทุกครั้งที่มีการรับค่า ID จาก API หรือ URL ต้องตรวจสอบว่าเป็น **UUIDv7** และห้ามใช้ `parseInt()` หรือตัวดำเนินการทางคณิตศาสตร์กับค่านี้เด็ดขาด (ADR-019)
|
- **UUID Validation:** ทุกครั้งที่มีการรับค่า ID จาก API หรือ URL ต้องตรวจสอบว่าเป็น **UUIDv7** และห้ามใช้ `parseInt()` หรือตัวดำเนินการทางคณิตศาสตร์กับค่านี้เด็ดขาด (ADR-019)
|
||||||
- **RBAC Check:** การสร้าง API ใหม่ต้องมี **CASL Guard** และตรวจสอบสิทธิ์แบบ 4-Level RBAC Matrix เสมอ (ADR-016)
|
- **RBAC Check:** การสร้าง API ใหม่ต้องมี **CASL Guard** และตรวจสอบสิทธิ์แบบ 4-Level RBAC Matrix เสมอ (ADR-016)
|
||||||
- **Data Isolation:** หากมีการใช้ฟีเจอร์ AI ต้องมั่นใจว่ารันผ่าน **Ollama บน Admin Desktop** เท่านั้น และห้ามให้ AI เข้าถึง Database หรือ Storage โดยตรง (ต้องผ่าน DMS API เท่านั้น) (ADR-018)
|
- **Data Isolation:** หากมีการใช้ฟีเจอร์ AI ต้องมั่นใจว่ารันผ่าน **Ollama บน Admin Desktop** เท่านั้น และห้ามให้ AI เข้าถึง Database หรือ Storage โดยตรง (ต้องผ่าน DMS API เท่านั้น) (ADR-023)
|
||||||
- **Input Sanitization:** ไฟล์อัปโหลดต้องผ่านการตรวจสอบแบบ **Two-Phase** (Temp → Commit) และต้องสแกนด้วย **ClamAV** ก่อนย้ายเข้า Permanent Storage (ADR-016)
|
- **Input Sanitization:** ไฟล์อัปโหลดต้องผ่านการตรวจสอบแบบ **Two-Phase** (Temp → Commit) และต้องสแกนด้วย **ClamAV** ก่อนย้ายเข้า Permanent Storage (ADR-016)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -81,7 +81,7 @@ Build fails immediately if violated:
|
|||||||
- UUID Strategy (ADR-019) — no `parseInt` / `Number` / `+` on UUID
|
- UUID Strategy (ADR-019) — no `parseInt` / `Number` / `+` on UUID
|
||||||
- Database correctness — verify schema before writing queries
|
- Database correctness — verify schema before writing queries
|
||||||
- File upload security (ClamAV + whitelist)
|
- File upload security (ClamAV + whitelist)
|
||||||
- AI validation boundary (ADR-018)
|
- AI validation boundary (ADR-023)
|
||||||
- Error handling strategy (ADR-007)
|
- Error handling strategy (ADR-007)
|
||||||
- Forbidden patterns: `any`, `console.log`, UUID misuse, `id ?? ''` fallback
|
- Forbidden patterns: `any`, `console.log`, UUID misuse, `id ?? ''` fallback
|
||||||
|
|
||||||
@@ -122,10 +122,9 @@ Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > oth
|
|||||||
| **ADR-008 Notifications** | `specs/06-Decision-Records/ADR-008-email-notification-strategy.md` | ✅ Active | BullMQ + multi-channel notification |
|
| **ADR-008 Notifications** | `specs/06-Decision-Records/ADR-008-email-notification-strategy.md` | ✅ Active | BullMQ + multi-channel notification |
|
||||||
| **ADR-009 DB Migration** | `specs/06-Decision-Records/ADR-009-database-migration-strategy.md` | ✅ Active | Schema changes — edit SQL directly |
|
| **ADR-009 DB Migration** | `specs/06-Decision-Records/ADR-009-database-migration-strategy.md` | ✅ Active | Schema changes — edit SQL directly |
|
||||||
| **ADR-016 Security** | `specs/06-Decision-Records/ADR-016-security-authentication.md` | ✅ Active | Auth, RBAC, file upload security |
|
| **ADR-016 Security** | `specs/06-Decision-Records/ADR-016-security-authentication.md` | ✅ Active | Auth, RBAC, file upload security |
|
||||||
| **ADR-018 AI Boundary** | `specs/06-Decision-Records/ADR-018-ai-boundary.md` | ✅ Active | AI isolation rules |
|
|
||||||
| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work |
|
| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work |
|
||||||
| **ADR-020 AI Integration** | `specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md` | ✅ Active | AI architecture patterns |
|
|
||||||
| **ADR-021 Workflow Context** | `specs/06-Decision-Records/ADR-021-workflow-context.md` | ✅ Active | Integrated workflow & step attachments |
|
| **ADR-021 Workflow Context** | `specs/06-Decision-Records/ADR-021-workflow-context.md` | ✅ Active | Integrated workflow & step attachments |
|
||||||
|
| **ADR-023 AI Architecture** | `specs/06-Decision-Records/ADR-023-unified-ai-architecture.md` | ✅ Active | Unified AI boundaries and pipeline |
|
||||||
| **Backend Guidelines** | `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` | — | NestJS patterns |
|
| **Backend Guidelines** | `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` | — | NestJS patterns |
|
||||||
| **Frontend Guidelines** | `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` | — | Next.js patterns |
|
| **Frontend Guidelines** | `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` | — | Next.js patterns |
|
||||||
| **Testing Strategy** | `specs/05-Engineering-Guidelines/05-04-testing-strategy.md` | — | Coverage goals |
|
| **Testing Strategy** | `specs/05-Engineering-Guidelines/05-04-testing-strategy.md` | — | Coverage goals |
|
||||||
@@ -248,9 +247,9 @@ Read `specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md`
|
|||||||
5. **Password:** bcrypt 12 salt rounds, min 8 chars, rotate every 90 days
|
5. **Password:** bcrypt 12 salt rounds, min 8 chars, rotate every 90 days
|
||||||
6. **Rate Limiting:** `ThrottlerGuard` on all auth endpoints
|
6. **Rate Limiting:** `ThrottlerGuard` on all auth endpoints
|
||||||
7. **File Upload:** Whitelist PDF/DWG/DOCX/XLSX/ZIP, max 50MB, ClamAV scan
|
7. **File Upload:** Whitelist PDF/DWG/DOCX/XLSX/ZIP, max 50MB, ClamAV scan
|
||||||
8. **AI Isolation (ADR-018):** Ollama on Admin Desktop ONLY — NO direct DB/storage access
|
8. **AI Isolation (ADR-023):** Ollama on Admin Desktop ONLY — NO direct DB/storage access
|
||||||
9. **Error Handling (ADR-007):** Use layered error classification with user-friendly messages
|
9. **Error Handling (ADR-007):** Use layered error classification with user-friendly messages
|
||||||
10. **AI Integration (ADR-020):** RFA-First approach with unified pipeline architecture
|
10. **AI Integration (ADR-023):** RFA-First approach with unified pipeline architecture
|
||||||
|
|
||||||
Full details: `specs/06-Decision-Records/ADR-016-security-authentication.md`
|
Full details: `specs/06-Decision-Records/ADR-016-security-authentication.md`
|
||||||
|
|
||||||
@@ -308,12 +307,12 @@ Full glossary: `specs/00-overview/00-02-glossary.md`
|
|||||||
| `req: any` in controllers | `RequestWithUser` typed interface | Type safety lost; auth context unreachable |
|
| `req: any` in controllers | `RequestWithUser` typed interface | Type safety lost; auth context unreachable |
|
||||||
| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | `"019505…"` parsed to integer `19` — silently wrong |
|
| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | `"019505…"` parsed to integer `19` — silently wrong |
|
||||||
| Exposing INT PK in API responses | UUIDv7 `publicId` (ADR-019) | Leaks row count; enables DB enumeration attacks |
|
| Exposing INT PK in API responses | UUIDv7 `publicId` (ADR-019) | Leaks row count; enables DB enumeration attacks |
|
||||||
| AI accessing DB/storage directly | AI → DMS API → DB (ADR-018) | Bypasses RBAC, audit trail, and validation layer |
|
| AI accessing DB/storage directly | AI → DMS API → DB (ADR-023) | Bypasses RBAC, audit trail, and validation layer |
|
||||||
| Direct file operations bypassing StorageService | `StorageService` for all file moves | Orphaned files; broken ClamAV scan; no audit trail |
|
| Direct file operations bypassing StorageService | `StorageService` for all file moves | Orphaned files; broken ClamAV scan; no audit trail |
|
||||||
| Inline email/notification sending | BullMQ queue job (ADR-008) | Blocks request thread; no retry on transient failure |
|
| Inline email/notification sending | BullMQ queue job (ADR-008) | Blocks request thread; no retry on transient failure |
|
||||||
| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | Unverified deploy risks data loss in production |
|
| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | Unverified deploy risks data loss in production |
|
||||||
| AI direct cloud API calls | On-premises Ollama only (ADR-018) | Data privacy violation; no audit control |
|
| AI direct cloud API calls | On-premises Ollama only (ADR-023) | Data privacy violation; no audit control |
|
||||||
| AI outputs without human validation | Human-in-the-loop validation required (ADR-020) | Unvalidated AI metadata corrupts document records |
|
| AI outputs without human validation | Human-in-the-loop validation required (ADR-023) | Unvalidated AI metadata corrupts document records |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -347,7 +346,7 @@ The following actions MUST NOT be performed autonomously. **Stop and ask for con
|
|||||||
3. **Check schema** — verify table/column in `schema-02-tables.sql`
|
3. **Check schema** — verify table/column in `schema-02-tables.sql`
|
||||||
4. **Check data dictionary** — confirm field meanings + business rules
|
4. **Check data dictionary** — confirm field meanings + business rules
|
||||||
5. **Scan edge cases** — `01-06-edge-cases-and-rules.md`
|
5. **Scan edge cases** — `01-06-edge-cases-and-rules.md`
|
||||||
6. **Check ADRs** — verify decisions align (ADR-009, ADR-018, ADR-019)
|
6. **Check ADRs** — verify decisions align (ADR-009, ADR-019, ADR-023)
|
||||||
7. **Write code** — TypeScript strict, no `any`, no `console.log`, follow headers/JSDoc rules
|
7. **Write code** — TypeScript strict, no `any`, no `console.log`, follow headers/JSDoc rules
|
||||||
|
|
||||||
### 🟡 Normal Work — UI / Feature / Integration
|
### 🟡 Normal Work — UI / Feature / Integration
|
||||||
@@ -408,25 +407,25 @@ When user asks about... check these files:
|
|||||||
|
|
||||||
| Request | Files to Check | Expected Response |
|
| Request | Files to Check | Expected Response |
|
||||||
| ----------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
| ----------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
| "สร้าง API ใหม่" | `05-02-backend-guidelines.md`, `schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard |
|
| "สร้าง API ใหม่" | `05-02-backend-guidelines.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard |
|
||||||
| "แก้ฟอร์ม frontend" | `05-03-frontend-guidelines.md`, `01-06-edge-cases.md` | RHF+Zod + TanStack Query + Thai comments |
|
| "แก้ฟอร์ม frontend" | `05-03-frontend-guidelines.md`, `01-06-edge-cases-and-rules.md` | RHF+Zod + TanStack Query + Thai comments |
|
||||||
| "เพิ่ม field ใหม่" | `ADR-009`, `data-dictionary.md`, `schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity |
|
| "เพิ่ม field ใหม่" | `ADR-009`, `03-01-data-dictionary.md`, `lcbp3-v1.9.0-schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity |
|
||||||
| "ตรวจสอบ UUID" | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor |
|
| "ตรวจสอบ UUID" | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor |
|
||||||
| "สร้าง migration" | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow |
|
| "สร้าง migration" | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow |
|
||||||
| "ตรวจสอบ permission" | `seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix |
|
| "ตรวจสอบ permission" | `lcbp3-v1.9.0-seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix |
|
||||||
| "deploy production" | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy |
|
| "deploy production" | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy |
|
||||||
| "เพิ่ม test" | `05-04-testing-strategy.md` | Coverage goals + test patterns |
|
| "เพิ่ม test" | `05-04-testing-strategy.md` | Coverage goals + test patterns |
|
||||||
| "AI integration" | `ADR-018`, `ADR-020` | AI boundary + unified pipeline |
|
| "AI integration" | `ADR-023` | AI boundary + unified pipeline |
|
||||||
| "Error handling" | `ADR-007` | Layered error classification + recovery |
|
| "Error handling" | `ADR-007` | Layered error classification + recovery |
|
||||||
| "File upload" | `ADR-016`, `05-02-backend-guidelines.md`, `03-Data-and-Storage/03-03-file-storage.md` | Two-phase upload → temp → commit; ClamAV + whitelist |
|
| "File upload" | `ADR-016`, `05-02-backend-guidelines.md`, `03-Data-and-Storage/03-03-file-storage.md` | Two-phase upload → temp → commit; ClamAV + whitelist |
|
||||||
| "Notifications / Queue" | `ADR-008`, `05-02-backend-guidelines.md` | BullMQ job — never inline; check retry + dead-letter |
|
| "Notifications / Queue" | `ADR-008`, `05-02-backend-guidelines.md` | BullMQ job — never inline; check retry + dead-letter |
|
||||||
| "Add i18n / translate" | `05-08-i18n-guidelines.md` | i18n keys only — no hardcoded text |
|
| "Add i18n / translate" | `05-08-i18n-guidelines.md` | i18n keys only — no hardcoded text |
|
||||||
| "Workflow / DSL" | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService |
|
| "Workflow / DSL" | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService |
|
||||||
| "Document numbering" | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) |
|
| "Document numbering" | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) |
|
||||||
| "ตรวจสอบ Workflow" | `01-06-edge-cases.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร |
|
| "ตรวจสอบ Workflow" | `01-06-edge-cases-and-rules.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร |
|
||||||
| "Transmittal submit" | ADR-021, TransmittalService | submit() with EC-RFA-004 validation |
|
| "Transmittal submit" | ADR-021, TransmittalService | submit() with EC-RFA-004 validation |
|
||||||
| "Circulation reassign" | ADR-021, CirculationService | reassignRouting() with EC-CIRC-001 |
|
| "Circulation reassign" | ADR-021, CirculationService | reassignRouting() with EC-CIRC-001 |
|
||||||
| "Audit ความปลอดภัย" | `ADR-016`, `ADR-018`, `ADR-019` | ตรวจสอบ UUID pattern, CASL Guard และ AI Boundary |
|
| "Audit ความปลอดภัย" | `ADR-016`, `ADR-019`, `ADR-023` | ตรวจสอบ UUID pattern, CASL Guard และ AI Boundary |
|
||||||
| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
|
| "แก้ bug / bugfix" | `.agents/workflows/bugfix.md`, `error-catalog.md` | ใช้ bugfix workflow สำหรับเคสที่สาเหตุชัดเจน |
|
||||||
|
|
||||||
## 🛠️ Final Checklist (Tier 1 & Tier 2)
|
## 🛠️ Final Checklist (Tier 1 & Tier 2)
|
||||||
@@ -441,7 +440,7 @@ When user asks about... check these files:
|
|||||||
- [ ] **One main export per file**
|
- [ ] **One main export per file**
|
||||||
- [ ] Schema changes via SQL directly (not migration)
|
- [ ] Schema changes via SQL directly (not migration)
|
||||||
- [ ] Test coverage meets targets (Backend 70%+, Business Logic 80%+)
|
- [ ] Test coverage meets targets (Backend 70%+, Business Logic 80%+)
|
||||||
- [ ] Relevant ADRs checked (ADR-007, ADR-009, ADR-018, ADR-019, ADR-020)
|
- [ ] Relevant ADRs checked (ADR-007, ADR-009, ADR-019, ADR-021, ADR-023)
|
||||||
- [ ] Glossary terms used correctly
|
- [ ] Glossary terms used correctly
|
||||||
- [ ] Error handling complete (Logger + HttpException)
|
- [ ] Error handling complete (Logger + HttpException)
|
||||||
- [ ] i18n keys used instead of hardcode text
|
- [ ] i18n keys used instead of hardcode text
|
||||||
@@ -485,6 +484,7 @@ This file is a **quick reference**. For detailed information:
|
|||||||
|
|
||||||
| Version | Date | Changes | Updated By |
|
| Version | Date | Changes | Updated By |
|
||||||
| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
|
| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
|
||||||
|
| 1.9.2 | 2026-05-14 | Consolidated legacy AI ADRs (017, 017B, 018, 020, 022) into master ADR-023: Unified AI Architecture | Antigravity AI |
|
||||||
| 1.9.1 | 2026-05-13 | Added `bugfix` workflow and skill (migrated and improved from `docs/bugfix.md`) | Windsurf AI |
|
| 1.9.1 | 2026-05-13 | Added `bugfix` workflow and skill (migrated and improved from `docs/bugfix.md`) | Windsurf AI |
|
||||||
| 1.9.0 | 2026-05-03 | Integrated Global TypeScript Coding Standards (Headers, JSDoc, Thai comments, Single Export, No blank lines) | Windsurf AI |
|
| 1.9.0 | 2026-05-03 | Integrated Global TypeScript Coding Standards (Headers, JSDoc, Thai comments, Single Export, No blank lines) | Windsurf AI |
|
||||||
| 1.8.9 | 2026-04-22 | `.agents/skills/` LCBP3-native rebuild (20 skills @ v1.8.9) + `_LCBP3-CONTEXT.md` appendix + `specs/03-Data-and-Storage/deltas/` + AGENTS.md sync | Windsurf AI |
|
| 1.8.9 | 2026-04-22 | `.agents/skills/` LCBP3-native rebuild (20 skills @ v1.8.9) + `_LCBP3-CONTEXT.md` appendix + `specs/03-Data-and-Storage/deltas/` + AGENTS.md sync | Windsurf AI |
|
||||||
|
|||||||
@@ -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
|
# Version History
|
||||||
|
|
||||||
|
## 1.9.1 (2026-05-14)
|
||||||
|
|
||||||
|
### docs(architecture): Unified AI Architecture Consolidation (ADR-023)
|
||||||
|
|
||||||
|
#### Summary
|
||||||
|
|
||||||
|
ยุบรวมข้อกำหนดและสถาปัตยกรรมด้าน AI ที่กระจัดกระจาย (ADR-017, ADR-017B, ADR-018, ADR-020 และ ADR-022) เข้าสู่เอกสารหลักฉบับเดียวคือ **ADR-023: Unified AI Architecture** เพื่อเป็น Single Source of Truth และป้องกันปัญหา Revision Drift
|
||||||
|
|
||||||
|
#### Changes
|
||||||
|
|
||||||
|
- **Master AI Spec**: สร้างเอกสาร `ADR-023-unified-ai-architecture.md` ควบคุมสถาปัตยกรรม AI ทั้งหมดของระบบ
|
||||||
|
- **Superseded Legacy ADRs**: เปลี่ยนสถานะของ ADR-017, ADR-017B, ADR-018, ADR-020 และ ADR-022 เป็น `Superseded by ADR-023`
|
||||||
|
- **Zero Trust Boundary**: ย้ำเกณฑ์การแยกส่วน AI Workloads (Ollama, Qdrant, n8n) ให้อยู่บนเครื่อง Admin Desktop (`Desk-5439`) เท่านั้น และห้ามเข้าถึง DB/Storage โดยตรง (ต้องผ่าน DMS API Gateway)
|
||||||
|
- **Documentation Synced**: ปรับปรุงเอกสารอ้างอิงทั้งหมดใน `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md` และ `AGENTS.md` ให้ชี้มาที่ ADR-023
|
||||||
|
|
||||||
## 1.9.0 (2026-05-13)
|
## 1.9.0 (2026-05-13)
|
||||||
|
|
||||||
### feat(agent): Agent Infrastructure Standardization (v1.9.0)
|
### feat(migration): RFA System & Agent Infrastructure Standardization (v1.9.0)
|
||||||
|
|
||||||
#### Summary
|
#### Summary
|
||||||
สร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Windsurf/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต
|
|
||||||
|
การปรับปรุงระบบ RFA Approval ให้สมบูรณ์พร้อมใช้งานจริง และสร้างมาตรฐานใหม่สำหรับการทำงานร่วมกับ AI Agent (Antigravity/Windsurf/CLI) ให้เป็นเอกภาพทั่วทั้งโครงการ (Agent-Agnostic) พร้อมปรับปรุงโครงสร้างการเก็บ Specification ให้รองรับการขยายตัวในอนาคต
|
||||||
|
|
||||||
#### Changes
|
#### Changes
|
||||||
|
|
||||||
|
- **RFA System Migration**: อัปเดต Schema (v1.9.0), ปรับปรุง Data Dictionary และขยายสิทธิ์ใน RBAC Matrix (Lead Engineer role)
|
||||||
|
- **Workflow Context**: เชื่อมต่อ ADR-021 (Integrated Workflow Context) เข้ากับทุกกระบวนการ Workflow
|
||||||
- **Agent Infrastructure**: ย้ายและรวบรวม Rules, Skills และ Workflows ไว้ที่ `.agents/` เป็น Single Source of Truth
|
- **Agent Infrastructure**: ย้ายและรวบรวม Rules, Skills และ Workflows ไว้ที่ `.agents/` เป็น Single Source of Truth
|
||||||
- **Hybrid Specs Structure**: เริ่มใช้โครงสร้างโฟลเดอร์ `specs/[100/200/300]-category/` เพื่อจัดระเบียบงาน Infra, Fullstack และงานทั่วไป
|
- **Hybrid Specs Structure**: เริ่มใช้โครงสร้างโฟลเดอร์ `specs/[100/200/300]-category/` เพื่อจัดระเบียบงาน Infra, Fullstack และงานทั่วไป
|
||||||
- **Automation**: เพิ่มสคริปต์ `sync-agent-configs.ps1` และ `audit-skills.sh` เพื่อตรวจสอบความสมบูรณ์และซิงค์ข้อมูลอัตโนมัติ
|
- **Automation**: เพิ่มสคริปต์ `sync-agent-configs.ps1` และ `audit-skills.sh` เพื่อตรวจสอบความสมบูรณ์และซิงค์ข้อมูลอัตโนมัติ
|
||||||
- **Standardization**: กำหนดมาตรฐานการเขียนโค้ดใหม่ (File Headers, Change Logs, Thai JSDoc) ทั่วทั้งโครงการ
|
- **Standardization**: กำหนดมาตรฐานการเขียนโค้ดใหม่ (File Headers, Change Logs, Thai JSDoc) ทั่วทั้งโครงการ
|
||||||
|
- **Node.js v24 Environment**: ปรับปรุงสภาพแวดล้อมให้เป็น Node.js v24.15.0 LTS ทั้งหมด
|
||||||
- **Drift Prevention**: ใช้ Directory Junctions เชื่อมโยง `.windsurf/` เข้ากับ `.agents/` โดยตรง
|
- **Drift Prevention**: ใช้ Directory Junctions เชื่อมโยง `.windsurf/` เข้ากับ `.agents/` โดยตรง
|
||||||
|
|
||||||
## 1.8.11 (2026-05-05)
|
## 1.8.11 (2026-05-05)
|
||||||
|
|||||||
+22
-20
@@ -8,13 +8,13 @@
|
|||||||
|
|
||||||
## 📚 Table of Contents
|
## 📚 Table of Contents
|
||||||
|
|
||||||
- [ภาพรวม Specification Structure](#-specification-structure)
|
- [ภาพรวม Specification Structure](#specification-structure)
|
||||||
- [หลักการเขียน Specifications](#-writing-principles)
|
- [หลักการเขียน Specifications](#writing-principles)
|
||||||
- [Workflow การแก้ไข Specs](#-contribution-workflow)
|
- [Workflow การแก้ไข Specs](#contribution-workflow)
|
||||||
- [Template และ Guidelines](#-templates--guidelines)
|
- [Template และ Guidelines](#templates--guidelines)
|
||||||
- [Review Process](#-review-process)
|
- [Review Process](#review-process)
|
||||||
- [Best Practices](#-best-practices)
|
- [Best Practices](#best-practices)
|
||||||
- [Tools และ Resources](#-tools--resources)
|
- [Tools และ Resources](#tools--resources)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -50,16 +50,16 @@ specs/
|
|||||||
│ ├── 02-03-network-design.md
|
│ ├── 02-03-network-design.md
|
||||||
│ └── 02-04-api-design.md
|
│ └── 02-04-api-design.md
|
||||||
│
|
│
|
||||||
├── 03-Data-and-Storage/ # Database Schema v1.8.0 (3-file split)
|
├── 03-Data-and-Storage/ # Database Schema v1.9.0 (3-file split)
|
||||||
│ ├── README.md
|
│ ├── README.md
|
||||||
│ ├── lcbp3-v1.8.0-schema-01-drop.sql # DROP statements
|
│ ├── lcbp3-v1.9.0-schema-01-drop.sql # DROP statements
|
||||||
│ ├── lcbp3-v1.8.0-schema-02-tables.sql # CREATE TABLE
|
│ ├── lcbp3-v1.9.0-schema-02-tables.sql # CREATE TABLE
|
||||||
│ ├── lcbp3-v1.8.0-schema-03-views-indexes.sql # Views + Indexes
|
│ ├── lcbp3-v1.9.0-schema-03-views-indexes.sql # Views + Indexes
|
||||||
│ ├── lcbp3-v1.8.0-seed-basic.sql # Master Data Seed
|
│ ├── lcbp3-v1.9.0-seed-basic.sql # Master Data Seed
|
||||||
│ ├── lcbp3-v1.8.0-seed-permissions.sql # RBAC Permissions Seed
|
│ ├── lcbp3-v1.9.0-seed-permissions.sql # RBAC Permissions Seed
|
||||||
│ ├── 03-01-data-dictionary.md
|
│ ├── 03-01-data-dictionary.md
|
||||||
│ ├── 03-06-migration-business-scope.md # Gap 7: Migration Scope [★ NEW]
|
│ ├── 03-06-migration-business-scope.md # Gap 7: Migration Scope [★ NEW]
|
||||||
│ └── deltas/ # Incremental SQL (ADR-009) [★ v1.8.9]
|
│ └── deltas/ # Incremental SQL (ADR-009) [★ v1.9.0]
|
||||||
│
|
│
|
||||||
├── 04-Infrastructure-OPS/ # Deployment & Operations (9 docs)
|
├── 04-Infrastructure-OPS/ # Deployment & Operations (9 docs)
|
||||||
│ ├── README.md
|
│ ├── README.md
|
||||||
@@ -79,7 +79,7 @@ specs/
|
|||||||
│ ├── 05-03-frontend-guidelines.md
|
│ ├── 05-03-frontend-guidelines.md
|
||||||
│ └── 05-04-testing-strategy.md
|
│ └── 05-04-testing-strategy.md
|
||||||
│
|
│
|
||||||
├── 06-Decision-Records/ # Architecture Decision Records (22 ADRs)
|
├── 06-Decision-Records/ # Architecture Decision Records (23 ADRs)
|
||||||
│ ├── README.md
|
│ ├── README.md
|
||||||
│ ├── ADR-001-unified-workflow-engine.md
|
│ ├── ADR-001-unified-workflow-engine.md
|
||||||
│ └── ...
|
│ └── ...
|
||||||
@@ -104,7 +104,7 @@ specs/
|
|||||||
| **03-Data-and-Storage** | Schema v1.8.0, Migration Scope | Gap 7 | Backend Lead + DBA |
|
| **03-Data-and-Storage** | Schema v1.8.0, Migration Scope | Gap 7 | Backend Lead + DBA |
|
||||||
| **04-Infrastructure-OPS** | Deployment, Operations, Release Policy | Gap 8 | DevOps Team |
|
| **04-Infrastructure-OPS** | Deployment, Operations, Release Policy | Gap 8 | DevOps Team |
|
||||||
| **05-Engineering-Guidelines** | แผนการพัฒนาและ Implementation | — | Development Team Leads |
|
| **05-Engineering-Guidelines** | แผนการพัฒนาและ Implementation | — | Development Team Leads |
|
||||||
| **06-Decision-Records** | Architecture Decision Records (22) | ADR-018/019/020/021 | Tech Lead + Senior Devs |
|
| **06-Decision-Records** | Architecture Decision Records (23) | ADR-019/021/023 | Tech Lead + Senior Devs |
|
||||||
| **100-Infrastructures** | Infrastructure Operations & Ops | — | DevOps / SRE Team |
|
| **100-Infrastructures** | Infrastructure Operations & Ops | — | DevOps / SRE Team |
|
||||||
| **200-fullstacks** | Feature Implementation (Fullstack) | spec.md, plan.md | Development Team |
|
| **200-fullstacks** | Feature Implementation (Fullstack) | spec.md, plan.md | Development Team |
|
||||||
| **300-others** | Documentation & Research | — | All Team Members |
|
| **300-others** | Documentation & Research | — | All Team Members |
|
||||||
@@ -555,12 +555,14 @@ graph LR
|
|||||||
| 1.8.7 | 2026-04-14 | Tech Lead | ADR-021 integration complete (22 ADRs), workflow context features |
|
| 1.8.7 | 2026-04-14 | Tech Lead | ADR-021 integration complete (22 ADRs), workflow context features |
|
||||||
| 1.8.8 | 2026-04-14 | Tech Lead | Step-specific attachments, IntegratedBanner, WorkflowLifecycle |
|
| 1.8.8 | 2026-04-14 | Tech Lead | Step-specific attachments, IntegratedBanner, WorkflowLifecycle |
|
||||||
| 1.8.9 | 2026-04-18 | Tech Lead | Docker Compose hardening — 27 findings (C1–S4) addressed |
|
| 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
|
**Status**: Approved
|
||||||
**Last Updated**: 2026-04-18
|
**Last Updated**: 2026-05-13
|
||||||
**Security**: 0 vulnerabilities (backend) + Compose stack hardened (27 findings → 0)
|
**Security**: 0 vulnerabilities (backend) + Compose stack hardened (27 findings → 0)
|
||||||
**Workflow Engine**: ADR-021 Integrated Context complete
|
**Workflow Engine**: ADR-021 Integrated Context complete + RFA v1.9.0 finalized
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. UUID Conventions (ADR-019)
|
### 5. UUID Conventions (ADR-019)
|
||||||
@@ -754,7 +756,7 @@ bash ./.agents/scripts/bash/audit-skills.sh
|
|||||||
- **ADR-019 UUID** — `publicId` exposed directly; ห้าม `parseInt`/`Number`/`+` บน UUID; ห้าม `id ?? ''` fallback; ห้ามใช้ `@Expose({ name: 'id' })` rename
|
- **ADR-019 UUID** — `publicId` exposed directly; ห้าม `parseInt`/`Number`/`+` บน UUID; ห้าม `id ?? ''` fallback; ห้ามใช้ `@Expose({ name: 'id' })` rename
|
||||||
- **ADR-009 Schema** — แก้ `lcbp3-v1.8.0-schema-02-tables.sql` โดยตรง + เพิ่ม delta ที่ `specs/03-Data-and-Storage/deltas/`; ห้าม TypeORM migrations
|
- **ADR-009 Schema** — แก้ `lcbp3-v1.8.0-schema-02-tables.sql` โดยตรง + เพิ่ม delta ที่ `specs/03-Data-and-Storage/deltas/`; ห้าม TypeORM migrations
|
||||||
- **ADR-016 Security** — CASL + `Idempotency-Key` + ClamAV two-phase upload
|
- **ADR-016 Security** — CASL + `Idempotency-Key` + ClamAV two-phase upload
|
||||||
- **ADR-018/020 AI Boundary** — Ollama on Admin Desktop only; human-in-the-loop validation
|
- **ADR-023 AI Architecture** — Ollama on Admin Desktop only; human-in-the-loop validation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
|
|
||||||
## 📈 Current Status (As of 2026-05-13)
|
## 📈 Current Status (As of 2026-05-13)
|
||||||
|
|
||||||
**Version 1.9.0 — Universal Agent Standard & Hybrid Specs Structure**
|
**Version 1.9.0 — RFA Migration & Universal Agent Infrastructure**
|
||||||
|
|
||||||
> v1.8.11 shipped May 5; v1.9.0 (Agent-Agnostic Infra & Hybrid Specs) shipped May 13.
|
> v1.8.11 shipped May 5; v1.9.0 (RFA Migration & Agent-Agnostic Infra) shipped May 13.
|
||||||
|
|
||||||
| Area | Status | หมายเหตุ |
|
| Area | Status | หมายเหตุ |
|
||||||
| ---------------------- | ------------------------ | ------------------------------------------------------------------ |
|
| ---------------------- | ------------------------ | ------------------------------------------------------------------ |
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
| 🎨 **Frontend** | ✅ 100% Complete | Next.js 16.2.0, React 19.2.4, ESLint 9 |
|
| 🎨 **Frontend** | ✅ 100% Complete | Next.js 16.2.0, React 19.2.4, ESLint 9 |
|
||||||
| 💾 **Database** | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration Policy |
|
| 💾 **Database** | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration Policy |
|
||||||
| 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy (Categorized Feature Specs) |
|
| 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy (Categorized Feature Specs) |
|
||||||
| 🤖 **AI Migration** | ✅ Production Ready | n8n + Ollama (ADR-017/018) |
|
| 🤖 **AI Migration** | ✅ Production Ready | n8n + Ollama (ADR-023) |
|
||||||
| 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context |
|
| 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context |
|
||||||
| 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready |
|
| 🧪 **Testing** | ✅ UAT Ready | E2E + Acceptance Criteria ready |
|
||||||
| 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station |
|
| 🚀 **Deployment** | ✅ Production Ready | Blue-Green on QNAP Container Station |
|
||||||
@@ -50,7 +50,7 @@ LCBP3-DMS เป็นระบบบริหารจัดการเอก
|
|||||||
- 🔐 **RBAC 4-Level** - ควบคุมสิทธิ์แบบละเอียด (Global, Organization, Project, Contract)
|
- 🔐 **RBAC 4-Level** - ควบคุมสิทธิ์แบบละเอียด (Global, Organization, Project, Contract)
|
||||||
- 📁 **Two-Phase File Storage** - จัดการไฟล์แบบ Transactional พร้อม Virus Scanning
|
- 📁 **Two-Phase File Storage** - จัดการไฟล์แบบ Transactional พร้อม Virus Scanning
|
||||||
- 🔢 **Document Numbering** - สร้างเลขที่เอกสารอัตโนมัติ ป้องกัน Race Condition
|
- 🔢 **Document Numbering** - สร้างเลขที่เอกสารอัตโนมัติ ป้องกัน Race Condition
|
||||||
- 🤖 **AI-Assisted Migration** - Ollama + n8n นำเข้าเอกสารเก่า ~20,000 ไฟล์ (ADR-017/018)
|
- 🤖 **AI-Assisted Migration** - Ollama + n8n นำเข้าเอกสารเก่า ~20,000 ไฟล์ (ADR-023)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ lcbp3-dms/
|
|||||||
| **Edge Cases & Rules** | 37 Edge Cases, Business Logic Guards | Gap 10 ✅ | `01-06-edge-cases-and-rules.md` |
|
| **Edge Cases & Rules** | 37 Edge Cases, Business Logic Guards | Gap 10 ✅ | `01-06-edge-cases-and-rules.md` |
|
||||||
| **Schema v1.8.0** | Tables, Views, Indexes (3-file split) | — | `lcbp3-v1.8.0-schema-*.sql` |
|
| **Schema v1.8.0** | Tables, Views, Indexes (3-file split) | — | `lcbp3-v1.8.0-schema-*.sql` |
|
||||||
| **Data Dictionary** | Field Meanings, Business Rules | — | `03-01-data-dictionary.md` |
|
| **Data Dictionary** | Field Meanings, Business Rules | — | `03-01-data-dictionary.md` |
|
||||||
| **ADRs (22)** | All Architecture Decisions incl. ADR-003/004/007/018/019/020/021 | - | `06-Decision-Records/` |
|
| **ADRs (23)** | All Architecture Decisions incl. ADR-003/004/007/019/021/023 | - | `06-Decision-Records/` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ lcbp3-dms/
|
|||||||
| 1 | [`AGENTS.md`](./AGENTS.md) | Quick-reference rules (Tier 1/2/3 enforcement, ADR-019 March 2026 pattern, forbidden actions) |
|
| 1 | [`AGENTS.md`](./AGENTS.md) | Quick-reference rules (Tier 1/2/3 enforcement, ADR-019 March 2026 pattern, forbidden actions) |
|
||||||
| 2 | [`.agents/skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md) | Shared context appendix injected into every speckit-\* skill |
|
| 2 | [`.agents/skills/_LCBP3-CONTEXT.md`](./.agents/skills/_LCBP3-CONTEXT.md) | Shared context appendix injected into every speckit-\* skill |
|
||||||
| 3 | [`.agents/skills/README.md`](./.agents/skills/README.md) | Skill-pack layout + slash-command invocation guide |
|
| 3 | [`.agents/skills/README.md`](./.agents/skills/README.md) | Skill-pack layout + slash-command invocation guide |
|
||||||
| 4 | `specs/06-Decision-Records/` | 22 ADRs (architectural decisions) |
|
| 4 | `specs/06-Decision-Records/` | 23 ADRs (architectural decisions) |
|
||||||
|
|
||||||
**Unified workflows (v1.9.0):** `/00-speckit.all` → `/102-speckit.specify` → `/104-speckit.plan` → `/107-speckit.implement` → `/110-speckit.reviewer`
|
**Unified workflows (v1.9.0):** `/00-speckit.all` → `/102-speckit.specify` → `/104-speckit.plan` → `/107-speckit.implement` → `/110-speckit.reviewer`
|
||||||
|
|
||||||
@@ -382,15 +382,17 @@ lcbp3-dms/
|
|||||||
|
|
||||||
## 🗺️ Roadmap
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
### ✅ Version 1.9.0 (May 2026) — Universal Agent Standard & Hybrid Specs Structure
|
### ✅ Version 1.9.0 (May 2026) — RFA System & Agent Infrastructure Standardization
|
||||||
|
|
||||||
**Agent Infrastructure standardized (`.agents/` @ v1.9.0) — 2026-05-13:**
|
**RFA System Migration & Agent Infrastructure standardized (`.agents/` @ v1.9.0) — 2026-05-13:**
|
||||||
|
|
||||||
|
- ✅ **RFA System**: Finalized RFA migration, schema v1.9.0, and RBAC matrix expansion.
|
||||||
- ✅ **Agent-Agnostic**: ย้าย Workflows และ Rules มาไว้ที่ `.agents/` เพื่อให้ใช้ร่วมกันได้ทุก AI
|
- ✅ **Agent-Agnostic**: ย้าย Workflows และ Rules มาไว้ที่ `.agents/` เพื่อให้ใช้ร่วมกันได้ทุก AI
|
||||||
- ✅ **Hybrid Specs**: เริ่มใช้โครงสร้างโฟลเดอร์ 100/200/300 ใน `specs/` อย่างเป็นทางการ
|
- ✅ **Hybrid Specs**: เริ่มใช้โครงสร้างโฟลเดอร์ 100/200/300 ใน `specs/` อย่างเป็นทางการ
|
||||||
- ✅ **Auto-Sync**: ระบบ Sync อัตโนมัติระหว่าง `.agents/` และ `.windsurf/` (Drift Prevention)
|
- ✅ **Auto-Sync**: ระบบ Sync อัตโนมัติระหว่าง `.agents/` และ `.windsurf/` (Drift Prevention)
|
||||||
- ✅ **Audit Enhanced**: สคริปต์ตรวจสอบสุขภาพระบบรองรับการตรวจโครงสร้าง Specs folder
|
- ✅ **Audit Enhanced**: สคริปต์ตรวจสอบสุขภาพระบบรองรับการตรวจโครงสร้าง Specs folder
|
||||||
- ✅ **TS Standards**: บังคับใช้ File Headers และ Change Logs ทั่วโครงการ
|
- ✅ **TS Standards**: บังคับใช้ File Headers และ Change Logs ทั่วโครงการ
|
||||||
|
- ✅ **AI Architecture**: ยุบรวมสถาปัตยกรรม AI หลักเข้าสู่ ADR-023 (แทนที่ ADR-017, 017B, 018, 020, 022)
|
||||||
|
|
||||||
**Docker Compose stacks fully hardened — 27 findings across 4 phases:**
|
**Docker Compose stacks fully hardened — 27 findings across 4 phases:**
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -54,7 +54,7 @@
|
|||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
"async-retry": "^1.3.3",
|
"async-retry": "^1.3.3",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.63.2",
|
"bullmq": "^5.63.2",
|
||||||
"cache-manager": "^7.2.5",
|
"cache-manager": "^7.2.5",
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"typeorm": "^0.3.27",
|
"typeorm": "^0.3.27",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.1",
|
||||||
"winston": "^3.18.3",
|
"winston": "^3.18.3",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { User } from '../user/entities/user.entity';
|
||||||
import { CircularDetectionService } from './services/circular-detection.service';
|
import { CircularDetectionService } from './services/circular-detection.service';
|
||||||
import { CreateDelegationDto } from './dto/create-delegation.dto';
|
import { CreateDelegationDto } from './dto/create-delegation.dto';
|
||||||
|
import { DelegationScope } from '../common/enums/review.enums';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DelegationService {
|
export class DelegationService {
|
||||||
@@ -63,6 +64,23 @@ export class DelegationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const delegateOnward = await this.findActiveDelegate(
|
||||||
|
delegate.user_id,
|
||||||
|
dto.startDate,
|
||||||
|
[
|
||||||
|
DelegationScope.ALL,
|
||||||
|
DelegationScope.RFA_ONLY,
|
||||||
|
DelegationScope.CORRESPONDENCE_ONLY,
|
||||||
|
DelegationScope.SPECIFIC_TYPES,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (delegateOnward) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Nested delegation is not allowed — delegatee already delegates onward'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const delegation = this.delegationRepo.create({
|
const delegation = this.delegationRepo.create({
|
||||||
delegatorUserId: delegator.user_id,
|
delegatorUserId: delegator.user_id,
|
||||||
delegateUserId: delegate.user_id,
|
delegateUserId: delegate.user_id,
|
||||||
@@ -98,7 +116,8 @@ export class DelegationService {
|
|||||||
*/
|
*/
|
||||||
async findActiveDelegate(
|
async findActiveDelegate(
|
||||||
userId: number,
|
userId: number,
|
||||||
date: Date = new Date()
|
date: Date = new Date(),
|
||||||
|
scopes: DelegationScope[] = [DelegationScope.ALL]
|
||||||
): Promise<User | null> {
|
): Promise<User | null> {
|
||||||
const delegation = await this.delegationRepo
|
const delegation = await this.delegationRepo
|
||||||
.createQueryBuilder('d')
|
.createQueryBuilder('d')
|
||||||
@@ -107,6 +126,7 @@ export class DelegationService {
|
|||||||
.andWhere('d.is_active = 1')
|
.andWhere('d.is_active = 1')
|
||||||
.andWhere('d.start_date <= :date', { date })
|
.andWhere('d.start_date <= :date', { date })
|
||||||
.andWhere('d.end_date >= :date', { date })
|
.andWhere('d.end_date >= :date', { date })
|
||||||
|
.andWhere('d.scope IN (:...scopes)', { scopes })
|
||||||
.orderBy('d.created_at', 'DESC')
|
.orderBy('d.created_at', 'DESC')
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,62 @@
|
|||||||
// File: src/modules/distribution/distribution-matrix.service.ts
|
// File: src/modules/distribution/distribution-matrix.service.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Resolve public IDs internally and align CRUD with canonical schema.
|
||||||
// CRUD สำหรับ DistributionMatrix และ Recipients (T053)
|
// CRUD สำหรับ DistributionMatrix และ Recipients (T053)
|
||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
|
import { validate as uuidValidate } from 'uuid';
|
||||||
import { DistributionMatrix } from './entities/distribution-matrix.entity';
|
import { DistributionMatrix } from './entities/distribution-matrix.entity';
|
||||||
import { DistributionRecipient } from './entities/distribution-recipient.entity';
|
import { DistributionRecipient } from './entities/distribution-recipient.entity';
|
||||||
import { Project } from '../project/entities/project.entity';
|
import { Project } from '../project/entities/project.entity';
|
||||||
|
import { ResponseCode } from '../response-code/entities/response-code.entity';
|
||||||
export interface CreateDistributionMatrixDto {
|
import { CreateDistributionMatrixDto } from './dto/create-distribution-matrix.dto';
|
||||||
projectId: number;
|
import { UpdateDistributionMatrixDto } from './dto/update-distribution-matrix.dto';
|
||||||
documentTypeCode: string;
|
import { AddDistributionRecipientDto } from './dto/add-distribution-recipient.dto';
|
||||||
responseCodeFilter?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AddRecipientDto {
|
|
||||||
recipientType: string;
|
|
||||||
recipientId?: number;
|
|
||||||
roleCode?: string;
|
|
||||||
deliveryMethod?: string;
|
|
||||||
isCc?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DistributionMatrixService {
|
export class DistributionMatrixService {
|
||||||
private readonly logger = new Logger(DistributionMatrixService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(DistributionMatrix)
|
@InjectRepository(DistributionMatrix)
|
||||||
private readonly matrixRepo: Repository<DistributionMatrix>,
|
private readonly matrixRepo: Repository<DistributionMatrix>,
|
||||||
@InjectRepository(DistributionRecipient)
|
@InjectRepository(DistributionRecipient)
|
||||||
private readonly recipientRepo: Repository<DistributionRecipient>,
|
private readonly recipientRepo: Repository<DistributionRecipient>,
|
||||||
@InjectRepository(Project)
|
@InjectRepository(Project)
|
||||||
private readonly projectRepo: Repository<Project>
|
private readonly projectRepo: Repository<Project>,
|
||||||
|
@InjectRepository(ResponseCode)
|
||||||
|
private readonly responseCodeRepo: Repository<ResponseCode>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findByProject(projectId: number): Promise<DistributionMatrix[]> {
|
/**
|
||||||
|
* ดึง Matrix ของโครงการ พร้อม global defaults.
|
||||||
|
*/
|
||||||
|
async findByProject(projectId?: number): Promise<DistributionMatrix[]> {
|
||||||
return this.matrixRepo.find({
|
return this.matrixRepo.find({
|
||||||
where: { projectId, isActive: true },
|
where:
|
||||||
relations: ['recipients'],
|
projectId === undefined
|
||||||
order: { documentTypeCode: 'ASC' },
|
? { isActive: true }
|
||||||
|
: [
|
||||||
|
{ projectId, isActive: true },
|
||||||
|
{ projectId: IsNull(), isActive: true },
|
||||||
|
],
|
||||||
|
relations: ['recipients', 'responseCode'],
|
||||||
|
order: { documentTypeId: 'ASC', createdAt: 'DESC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByProjectPublicId(
|
async findByProjectPublicId(
|
||||||
projectPublicId: string
|
projectPublicId?: string
|
||||||
): Promise<DistributionMatrix[]> {
|
): Promise<DistributionMatrix[]> {
|
||||||
|
if (!projectPublicId) return this.findByProject();
|
||||||
|
if (!uuidValidate(projectPublicId)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid projectPublicId: ${projectPublicId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
const project = await this.projectRepo.findOne({
|
const project = await this.projectRepo.findOne({
|
||||||
where: { publicId: projectPublicId },
|
where: { publicId: projectPublicId },
|
||||||
});
|
});
|
||||||
@@ -55,34 +67,64 @@ export class DistributionMatrixService {
|
|||||||
|
|
||||||
async findOneByDocType(
|
async findOneByDocType(
|
||||||
projectId: number,
|
projectId: number,
|
||||||
documentTypeCode: string
|
documentTypeId: number
|
||||||
): Promise<DistributionMatrix | null> {
|
): Promise<DistributionMatrix | null> {
|
||||||
return this.matrixRepo.findOne({
|
return this.matrixRepo.findOne({
|
||||||
where: { projectId, documentTypeCode, isActive: true },
|
where: [
|
||||||
relations: ['recipients'],
|
{ projectId, documentTypeId, isActive: true },
|
||||||
|
{ projectId: IsNull(), documentTypeId, isActive: true },
|
||||||
|
],
|
||||||
|
relations: ['recipients', 'responseCode'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateDistributionMatrixDto): Promise<DistributionMatrix> {
|
async create(dto: CreateDistributionMatrixDto): Promise<DistributionMatrix> {
|
||||||
const matrix = this.matrixRepo.create(dto as Partial<DistributionMatrix>);
|
const matrix = this.matrixRepo.create({
|
||||||
|
name: dto.name,
|
||||||
|
projectId: await this.resolveProjectId(dto.projectPublicId),
|
||||||
|
documentTypeId: dto.documentTypeId,
|
||||||
|
responseCodeId: await this.resolveResponseCodeId(
|
||||||
|
dto.responseCodePublicId
|
||||||
|
),
|
||||||
|
conditions: dto.conditions,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
return this.matrixRepo.save(matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
publicId: string,
|
||||||
|
dto: UpdateDistributionMatrixDto
|
||||||
|
): Promise<DistributionMatrix> {
|
||||||
|
const matrix = await this.findMatrix(publicId);
|
||||||
|
if (dto.name !== undefined) matrix.name = dto.name;
|
||||||
|
if (dto.documentTypeId !== undefined) {
|
||||||
|
matrix.documentTypeId = dto.documentTypeId;
|
||||||
|
}
|
||||||
|
if (dto.projectPublicId !== undefined) {
|
||||||
|
matrix.projectId = await this.resolveProjectId(dto.projectPublicId);
|
||||||
|
}
|
||||||
|
if (dto.responseCodePublicId !== undefined) {
|
||||||
|
matrix.responseCodeId = await this.resolveResponseCodeId(
|
||||||
|
dto.responseCodePublicId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dto.conditions !== undefined) matrix.conditions = dto.conditions;
|
||||||
return this.matrixRepo.save(matrix);
|
return this.matrixRepo.save(matrix);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addRecipient(
|
async addRecipient(
|
||||||
matrixPublicId: string,
|
matrixPublicId: string,
|
||||||
dto: AddRecipientDto
|
dto: AddDistributionRecipientDto
|
||||||
): Promise<DistributionRecipient> {
|
): Promise<DistributionRecipient> {
|
||||||
const matrix = await this.matrixRepo.findOne({
|
const matrix = await this.findMatrix(matrixPublicId);
|
||||||
where: { publicId: matrixPublicId },
|
|
||||||
});
|
|
||||||
if (!matrix)
|
|
||||||
throw new NotFoundException(`Matrix not found: ${matrixPublicId}`);
|
|
||||||
|
|
||||||
const recipient = this.recipientRepo.create({
|
const recipient = this.recipientRepo.create({
|
||||||
matrixId: matrix.id,
|
matrixId: matrix.id,
|
||||||
...dto,
|
recipientType: dto.recipientType,
|
||||||
} as Partial<DistributionRecipient>);
|
recipientPublicId: dto.recipientPublicId,
|
||||||
|
deliveryMethod: dto.deliveryMethod,
|
||||||
|
sequence: dto.sequence,
|
||||||
|
});
|
||||||
return this.recipientRepo.save(recipient);
|
return this.recipientRepo.save(recipient);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,9 +137,44 @@ export class DistributionMatrixService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(publicId: string): Promise<void> {
|
async remove(publicId: string): Promise<void> {
|
||||||
const matrix = await this.matrixRepo.findOne({ where: { publicId } });
|
const matrix = await this.findMatrix(publicId);
|
||||||
if (!matrix) throw new NotFoundException(publicId);
|
|
||||||
matrix.isActive = false;
|
matrix.isActive = false;
|
||||||
await this.matrixRepo.save(matrix);
|
await this.matrixRepo.save(matrix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async findMatrix(publicId: string): Promise<DistributionMatrix> {
|
||||||
|
const matrix = await this.matrixRepo.findOne({ where: { publicId } });
|
||||||
|
if (!matrix) {
|
||||||
|
throw new NotFoundException(`Distribution Matrix not found: ${publicId}`);
|
||||||
|
}
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveProjectId(
|
||||||
|
projectPublicId?: string
|
||||||
|
): Promise<number | undefined> {
|
||||||
|
if (!projectPublicId) return undefined;
|
||||||
|
const project = await this.projectRepo.findOne({
|
||||||
|
where: { publicId: projectPublicId },
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundException(`Project not found: ${projectPublicId}`);
|
||||||
|
}
|
||||||
|
return project.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveResponseCodeId(
|
||||||
|
responseCodePublicId?: string
|
||||||
|
): Promise<number | undefined> {
|
||||||
|
if (!responseCodePublicId) return undefined;
|
||||||
|
const responseCode = await this.responseCodeRepo.findOne({
|
||||||
|
where: { publicId: responseCodePublicId },
|
||||||
|
});
|
||||||
|
if (!responseCode) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Response Code not found: ${responseCodePublicId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return responseCode.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// File: src/modules/distribution/distribution.controller.ts
|
// File: src/modules/distribution/distribution.controller.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Add RBAC and validated public-ID DTOs for Distribution Matrix CRUD.
|
||||||
// Admin endpoints สำหรับจัดการ Distribution Matrix (T058)
|
// Admin endpoints สำหรับจัดการ Distribution Matrix (T058)
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@@ -9,57 +11,64 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
Patch,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||||
import { DistributionMatrixService } from './distribution-matrix.service';
|
import { DistributionMatrixService } from './distribution-matrix.service';
|
||||||
|
import { CreateDistributionMatrixDto } from './dto/create-distribution-matrix.dto';
|
||||||
class CreateMatrixDto {
|
import { AddDistributionRecipientDto } from './dto/add-distribution-recipient.dto';
|
||||||
projectId!: number;
|
import { UpdateDistributionMatrixDto } from './dto/update-distribution-matrix.dto';
|
||||||
documentTypeCode!: string;
|
|
||||||
responseCodeFilter?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
class AddRecipientDto {
|
|
||||||
recipientType!: string;
|
|
||||||
recipientId?: number;
|
|
||||||
roleCode?: string;
|
|
||||||
deliveryMethod?: string;
|
|
||||||
isCc?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('admin/distribution-matrices')
|
@Controller('admin/distribution-matrices')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||||
export class DistributionController {
|
export class DistributionController {
|
||||||
constructor(private readonly matrixService: DistributionMatrixService) {}
|
constructor(private readonly matrixService: DistributionMatrixService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findByProject(
|
findByProject(@Query('projectPublicId') projectPublicId?: string) {
|
||||||
@Query('projectPublicId', ParseUuidPipe) projectPublicId: string
|
|
||||||
) {
|
|
||||||
return this.matrixService.findByProjectPublicId(projectPublicId);
|
return this.matrixService.findByProjectPublicId(projectPublicId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateMatrixDto) {
|
@RequirePermission('master_data.manage')
|
||||||
|
create(@Body() dto: CreateDistributionMatrixDto) {
|
||||||
return this.matrixService.create(dto);
|
return this.matrixService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':publicId')
|
||||||
|
@RequirePermission('master_data.manage')
|
||||||
|
update(
|
||||||
|
@Param('publicId', ParseUuidPipe) publicId: string,
|
||||||
|
@Body() dto: UpdateDistributionMatrixDto
|
||||||
|
) {
|
||||||
|
return this.matrixService.update(publicId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':publicId/recipients')
|
@Post(':publicId/recipients')
|
||||||
|
@RequirePermission('master_data.manage')
|
||||||
addRecipient(
|
addRecipient(
|
||||||
@Param('publicId') publicId: string,
|
@Param('publicId', ParseUuidPipe) publicId: string,
|
||||||
@Body() dto: AddRecipientDto
|
@Body() dto: AddDistributionRecipientDto
|
||||||
) {
|
) {
|
||||||
return this.matrixService.addRecipient(publicId, dto);
|
return this.matrixService.addRecipient(publicId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':publicId/recipients/:recipientPublicId')
|
@Delete(':publicId/recipients/:recipientPublicId')
|
||||||
removeRecipient(@Param('recipientPublicId') recipientPublicId: string) {
|
@RequirePermission('master_data.manage')
|
||||||
return this.matrixService.removeRecipient(recipientPublicId);
|
async removeRecipient(
|
||||||
|
@Param('recipientPublicId', ParseUuidPipe) recipientPublicId: string
|
||||||
|
) {
|
||||||
|
await this.matrixService.removeRecipient(recipientPublicId);
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':publicId')
|
@Delete(':publicId')
|
||||||
remove(@Param('publicId') publicId: string) {
|
@RequirePermission('master_data.manage')
|
||||||
return this.matrixService.remove(publicId);
|
async remove(@Param('publicId', ParseUuidPipe) publicId: string) {
|
||||||
|
await this.matrixService.remove(publicId);
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// File: src/modules/distribution/distribution.module.ts
|
// File: src/modules/distribution/distribution.module.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Register ResponseCode repository for Distribution Matrix publicId resolution.
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
@@ -13,6 +15,8 @@ import { TransmittalCreatorService } from './services/transmittal-creator.servic
|
|||||||
import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants';
|
import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants';
|
||||||
import { NotificationModule } from '../notification/notification.module';
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
import { Project } from '../project/entities/project.entity';
|
import { Project } from '../project/entities/project.entity';
|
||||||
|
import { ResponseCode } from '../response-code/entities/response-code.entity';
|
||||||
|
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -20,9 +24,11 @@ import { Project } from '../project/entities/project.entity';
|
|||||||
DistributionMatrix,
|
DistributionMatrix,
|
||||||
DistributionRecipient,
|
DistributionRecipient,
|
||||||
Project,
|
Project,
|
||||||
|
ResponseCode,
|
||||||
]),
|
]),
|
||||||
BullModule.registerQueue({ name: QUEUE_DISTRIBUTION }),
|
BullModule.registerQueue({ name: QUEUE_DISTRIBUTION }),
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
|
DocumentNumberingModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
DistributionService,
|
DistributionService,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// File: src/modules/distribution/distribution.service.ts
|
// File: src/modules/distribution/distribution.service.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Carry canonical documentTypeId in queue payload while preserving legacy code metadata.
|
||||||
// Enqueue distribution jobs เมื่อ RFA ได้รับการอนุมัติ (T054)
|
// Enqueue distribution jobs เมื่อ RFA ได้รับการอนุมัติ (T054)
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
@@ -9,7 +11,8 @@ export interface DistributionJobPayload {
|
|||||||
rfaPublicId: string;
|
rfaPublicId: string;
|
||||||
rfaRevisionPublicId: string;
|
rfaRevisionPublicId: string;
|
||||||
projectId: number;
|
projectId: number;
|
||||||
documentTypeCode: string;
|
documentTypeId?: number;
|
||||||
|
documentTypeCode?: string;
|
||||||
responseCode: string;
|
responseCode: string;
|
||||||
approvedAt: Date;
|
approvedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// File: src/modules/distribution/dto/add-distribution-recipient.dto.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Add validated DTO for polymorphic Distribution recipients.
|
||||||
|
import { IsEnum, IsInt, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
import { DeliveryMethod, RecipientType } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
export class AddDistributionRecipientDto {
|
||||||
|
@IsEnum(RecipientType)
|
||||||
|
recipientType!: RecipientType;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
recipientPublicId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DeliveryMethod)
|
||||||
|
deliveryMethod?: DeliveryMethod;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
sequence?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// File: src/modules/distribution/dto/create-distribution-matrix.dto.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Add validated DTO for Distribution Matrix creation.
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
class DistributionConditionsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ each: true })
|
||||||
|
codes?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ each: true })
|
||||||
|
excludeCodes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateDistributionMatrixDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectPublicId?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
documentTypeId!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
responseCodePublicId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => DistributionConditionsDto)
|
||||||
|
conditions?: DistributionConditionsDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// File: src/modules/distribution/dto/update-distribution-matrix.dto.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Add validated DTO for Distribution Matrix updates.
|
||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateDistributionMatrixDto } from './create-distribution-matrix.dto';
|
||||||
|
|
||||||
|
export class UpdateDistributionMatrixDto extends PartialType(
|
||||||
|
CreateDistributionMatrixDto
|
||||||
|
) {}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
// File: src/modules/distribution/entities/distribution-matrix.entity.ts
|
// File: src/modules/distribution/entities/distribution-matrix.entity.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Align columns with canonical v1.9.0 schema and ADR-019 publicId contract.
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
|
||||||
OneToMany,
|
OneToMany,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
@@ -13,6 +14,12 @@ import { Exclude } from 'class-transformer';
|
|||||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
import { DistributionRecipient } from './distribution-recipient.entity';
|
import { DistributionRecipient } from './distribution-recipient.entity';
|
||||||
import { Project } from '../../project/entities/project.entity';
|
import { Project } from '../../project/entities/project.entity';
|
||||||
|
import { ResponseCode } from '../../response-code/entities/response-code.entity';
|
||||||
|
|
||||||
|
export interface DistributionConditions {
|
||||||
|
codes?: string[];
|
||||||
|
excludeCodes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
@Entity('distribution_matrices')
|
@Entity('distribution_matrices')
|
||||||
export class DistributionMatrix extends UuidBaseEntity {
|
export class DistributionMatrix extends UuidBaseEntity {
|
||||||
@@ -20,19 +27,23 @@ export class DistributionMatrix extends UuidBaseEntity {
|
|||||||
@Exclude()
|
@Exclude()
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
@Column({ name: 'project_id' })
|
@Column({ length: 100 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'project_id', nullable: true })
|
||||||
@Exclude()
|
@Exclude()
|
||||||
projectId!: number;
|
projectId?: number;
|
||||||
|
|
||||||
@Column({ name: 'document_type_code', length: 20 })
|
@Column({ name: 'document_type_id' })
|
||||||
documentTypeCode!: string; // 'SDW', 'DDW', 'ADW', 'MS'...
|
@Exclude()
|
||||||
|
documentTypeId!: number;
|
||||||
|
|
||||||
@Column({
|
@Column({ name: 'response_code_id', nullable: true })
|
||||||
name: 'response_code_filter',
|
@Exclude()
|
||||||
type: 'simple-array',
|
responseCodeId?: number;
|
||||||
nullable: true,
|
|
||||||
})
|
@Column({ type: 'json', nullable: true })
|
||||||
responseCodeFilter?: string[]; // ['1A','1B'] — NULL = ทุก code
|
conditions?: DistributionConditions;
|
||||||
|
|
||||||
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
@@ -40,14 +51,14 @@ export class DistributionMatrix extends UuidBaseEntity {
|
|||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at' })
|
|
||||||
updatedAt!: Date;
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
@ManyToOne(() => Project)
|
@ManyToOne(() => Project)
|
||||||
@JoinColumn({ name: 'project_id' })
|
@JoinColumn({ name: 'project_id' })
|
||||||
project?: Project;
|
project?: Project;
|
||||||
|
|
||||||
|
@ManyToOne(() => ResponseCode)
|
||||||
|
@JoinColumn({ name: 'response_code_id' })
|
||||||
|
responseCode?: ResponseCode;
|
||||||
|
|
||||||
@OneToMany(
|
@OneToMany(
|
||||||
() => DistributionRecipient,
|
() => DistributionRecipient,
|
||||||
(r: DistributionRecipient) => r.matrix,
|
(r: DistributionRecipient) => r.matrix,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// File: src/modules/distribution/entities/distribution-recipient.entity.ts
|
// File: src/modules/distribution/entities/distribution-recipient.entity.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Store polymorphic recipient public IDs instead of internal numeric IDs.
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
@@ -23,32 +25,29 @@ export class DistributionRecipient extends UuidBaseEntity {
|
|||||||
matrixId!: number;
|
matrixId!: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
|
name: 'recipient_type',
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: RecipientType,
|
enum: RecipientType,
|
||||||
})
|
})
|
||||||
recipientType!: RecipientType;
|
recipientType!: RecipientType;
|
||||||
|
|
||||||
@Column({ name: 'recipient_id', nullable: true })
|
@Column({ name: 'recipient_public_id', type: 'uuid' })
|
||||||
@Exclude()
|
recipientPublicId!: string;
|
||||||
recipientId?: number; // userId / organizationId / teamId (FK based on type)
|
|
||||||
|
|
||||||
@Column({ name: 'role_code', length: 50, nullable: true })
|
|
||||||
roleCode?: string; // 'ALL_QS', 'ALL_SITE_ENG' (when type = ROLE)
|
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
|
name: 'delivery_method',
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: DeliveryMethod,
|
enum: DeliveryMethod,
|
||||||
default: DeliveryMethod.BOTH,
|
default: DeliveryMethod.BOTH,
|
||||||
})
|
})
|
||||||
deliveryMethod!: DeliveryMethod;
|
deliveryMethod!: DeliveryMethod;
|
||||||
|
|
||||||
@Column({ name: 'is_cc', type: 'tinyint', default: 0 })
|
@Column({ nullable: true })
|
||||||
isCc!: boolean; // true = CC recipient, false = primary
|
sequence?: number;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
// Relations
|
|
||||||
@ManyToOne(
|
@ManyToOne(
|
||||||
() => DistributionMatrix,
|
() => DistributionMatrix,
|
||||||
(m: DistributionMatrix) => m.recipients,
|
(m: DistributionMatrix) => m.recipients,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// File: src/modules/distribution/processors/distribution.processor.ts
|
// File: src/modules/distribution/processors/distribution.processor.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Notify direct USER recipients after Distribution processing.
|
||||||
// BullMQ Worker สำหรับประมวลผล Distribution jobs (T056, ADR-008)
|
// BullMQ Worker สำหรับประมวลผล Distribution jobs (T056, ADR-008)
|
||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
@@ -7,6 +9,7 @@ import { QUEUE_DISTRIBUTION } from '../../common/constants/queue.constants';
|
|||||||
import { DistributionJobPayload } from '../distribution.service';
|
import { DistributionJobPayload } from '../distribution.service';
|
||||||
import { TransmittalCreatorService } from '../services/transmittal-creator.service';
|
import { TransmittalCreatorService } from '../services/transmittal-creator.service';
|
||||||
import { NotificationService } from '../../notification/notification.service';
|
import { NotificationService } from '../../notification/notification.service';
|
||||||
|
import { DeliveryMethod } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
@Processor(QUEUE_DISTRIBUTION)
|
@Processor(QUEUE_DISTRIBUTION)
|
||||||
export class DistributionProcessor extends WorkerHost {
|
export class DistributionProcessor extends WorkerHost {
|
||||||
@@ -23,7 +26,7 @@ export class DistributionProcessor extends WorkerHost {
|
|||||||
const payload = job.data;
|
const payload = job.data;
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeCode}, code ${payload.responseCode})`
|
`Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeId ?? payload.documentTypeCode}, code ${payload.responseCode})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. สร้าง Transmittal records
|
// 1. สร้าง Transmittal records
|
||||||
@@ -34,7 +37,33 @@ export class DistributionProcessor extends WorkerHost {
|
|||||||
`Created ${result.transmittalPublicIds.length} transmittals for RFA ${payload.rfaPublicId}`
|
`Created ${result.transmittalPublicIds.length} transmittals for RFA ${payload.rfaPublicId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. แจ้งเตือน submitter
|
await Promise.all(
|
||||||
|
result.notificationTargets.flatMap((target) => {
|
||||||
|
const base = {
|
||||||
|
userId: target.userId,
|
||||||
|
title: `RFA ${payload.responseCode} distributed`,
|
||||||
|
message: `RFA ${payload.rfaPublicId} has been distributed after approval.`,
|
||||||
|
entityType: 'rfa',
|
||||||
|
link: `/rfa/${payload.rfaPublicId}`,
|
||||||
|
};
|
||||||
|
if (target.deliveryMethod === DeliveryMethod.BOTH) {
|
||||||
|
return [
|
||||||
|
this.notificationService.send({ ...base, type: 'SYSTEM' }),
|
||||||
|
this.notificationService.send({ ...base, type: 'EMAIL' }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
this.notificationService.send({
|
||||||
|
...base,
|
||||||
|
type:
|
||||||
|
target.deliveryMethod === DeliveryMethod.EMAIL
|
||||||
|
? 'EMAIL'
|
||||||
|
: 'SYSTEM',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(`Distribution complete for RFA ${payload.rfaPublicId}`);
|
this.logger.log(`Distribution complete for RFA ${payload.rfaPublicId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// File: src/modules/distribution/services/approval-listener.service.ts
|
// File: src/modules/distribution/services/approval-listener.service.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Accept canonical documentTypeId in approval events.
|
||||||
// Strangler Pattern — listens for RFA approval events and triggers distribution (T055)
|
// Strangler Pattern — listens for RFA approval events and triggers distribution (T055)
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +26,8 @@ export class ApprovalListenerService {
|
|||||||
rfaPublicId: string;
|
rfaPublicId: string;
|
||||||
rfaRevisionPublicId: string;
|
rfaRevisionPublicId: string;
|
||||||
projectId: number;
|
projectId: number;
|
||||||
documentTypeCode: string;
|
documentTypeId?: number;
|
||||||
|
documentTypeCode?: string;
|
||||||
responseCode: string;
|
responseCode: string;
|
||||||
decision: ConsensusDecision;
|
decision: ConsensusDecision;
|
||||||
approvedAt: Date;
|
approvedAt: Date;
|
||||||
@@ -45,6 +48,7 @@ export class ApprovalListenerService {
|
|||||||
rfaPublicId: event.rfaPublicId,
|
rfaPublicId: event.rfaPublicId,
|
||||||
rfaRevisionPublicId: event.rfaRevisionPublicId,
|
rfaRevisionPublicId: event.rfaRevisionPublicId,
|
||||||
projectId: event.projectId,
|
projectId: event.projectId,
|
||||||
|
documentTypeId: event.documentTypeId,
|
||||||
documentTypeCode: event.documentTypeCode,
|
documentTypeCode: event.documentTypeCode,
|
||||||
responseCode: event.responseCode,
|
responseCode: event.responseCode,
|
||||||
approvedAt: event.approvedAt,
|
approvedAt: event.approvedAt,
|
||||||
|
|||||||
@@ -1,9 +1,33 @@
|
|||||||
// File: src/modules/distribution/services/transmittal-creator.service.ts
|
// File: src/modules/distribution/services/transmittal-creator.service.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Use schema-aligned Matrix conditions and canonical documentTypeId lookup.
|
||||||
// สร้าง Transmittal records จาก Distribution jobs (T057)
|
// สร้าง Transmittal records จาก Distribution jobs (T057)
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { DataSource, IsNull, Repository } from 'typeorm';
|
||||||
import { DistributionMatrix } from '../entities/distribution-matrix.entity';
|
import { DistributionMatrix } from '../entities/distribution-matrix.entity';
|
||||||
|
import { DistributionRecipient } from '../entities/distribution-recipient.entity';
|
||||||
|
import { DeliveryMethod, RecipientType } from '../../common/enums/review.enums';
|
||||||
|
import { CorrespondenceRevision } from '../../correspondence/entities/correspondence-revision.entity';
|
||||||
|
import { Correspondence } from '../../correspondence/entities/correspondence.entity';
|
||||||
|
import { CorrespondenceType } from '../../correspondence/entities/correspondence-type.entity';
|
||||||
|
import { CorrespondenceStatus } from '../../correspondence/entities/correspondence-status.entity';
|
||||||
|
import { CorrespondenceRecipient } from '../../correspondence/entities/correspondence-recipient.entity';
|
||||||
|
import { Transmittal } from '../../transmittal/entities/transmittal.entity';
|
||||||
|
import { TransmittalItem } from '../../transmittal/entities/transmittal-item.entity';
|
||||||
|
import { DocumentNumberingService } from '../../document-numbering/services/document-numbering.service';
|
||||||
|
import { Organization } from '../../organization/entities/organization.entity';
|
||||||
|
import { User } from '../../user/entities/user.entity';
|
||||||
|
|
||||||
|
export interface DistributionNotificationTarget {
|
||||||
|
userId: number;
|
||||||
|
deliveryMethod: DeliveryMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionCreationResult {
|
||||||
|
transmittalPublicIds: string[];
|
||||||
|
notificationTargets: DistributionNotificationTarget[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TransmittalCreatorService — ใช้ Strangler Pattern ไม่แก้ไข TransmittalService เดิม
|
* TransmittalCreatorService — ใช้ Strangler Pattern ไม่แก้ไข TransmittalService เดิม
|
||||||
@@ -15,7 +39,9 @@ export class TransmittalCreatorService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(DistributionMatrix)
|
@InjectRepository(DistributionMatrix)
|
||||||
private readonly matrixRepo: Repository<DistributionMatrix>
|
private readonly matrixRepo: Repository<DistributionMatrix>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly numberingService: DocumentNumberingService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,44 +52,271 @@ export class TransmittalCreatorService {
|
|||||||
rfaPublicId: string;
|
rfaPublicId: string;
|
||||||
rfaRevisionPublicId: string;
|
rfaRevisionPublicId: string;
|
||||||
projectId: number;
|
projectId: number;
|
||||||
documentTypeCode: string;
|
documentTypeId?: number;
|
||||||
|
documentTypeCode?: string;
|
||||||
responseCode: string;
|
responseCode: string;
|
||||||
}): Promise<{ transmittalPublicIds: string[] }> {
|
}): Promise<DistributionCreationResult> {
|
||||||
|
if (!payload.documentTypeId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Distribution skipped for RFA ${payload.rfaPublicId}: documentTypeId missing`
|
||||||
|
);
|
||||||
|
return { transmittalPublicIds: [], notificationTargets: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const matrix = await this.matrixRepo.findOne({
|
const matrix = await this.matrixRepo.findOne({
|
||||||
where: {
|
where: [
|
||||||
projectId: payload.projectId,
|
{
|
||||||
documentTypeCode: payload.documentTypeCode,
|
projectId: payload.projectId,
|
||||||
isActive: true,
|
documentTypeId: payload.documentTypeId,
|
||||||
},
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectId: IsNull(),
|
||||||
|
documentTypeId: payload.documentTypeId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
relations: ['recipients'],
|
relations: ['recipients'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!matrix || !matrix.recipients || matrix.recipients.length === 0) {
|
if (!matrix || !matrix.recipients || matrix.recipients.length === 0) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`No distribution matrix found for project ${payload.projectId}, docType ${payload.documentTypeCode}`
|
`No distribution matrix found for project ${payload.projectId}, docType ${payload.documentTypeId}`
|
||||||
);
|
);
|
||||||
return { transmittalPublicIds: [] };
|
return { transmittalPublicIds: [], notificationTargets: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ตรวจสอบ response code filter
|
// ตรวจสอบ response code filter
|
||||||
if (
|
if (
|
||||||
matrix.responseCodeFilter &&
|
matrix.conditions?.codes &&
|
||||||
matrix.responseCodeFilter.length > 0 &&
|
matrix.conditions.codes.length > 0 &&
|
||||||
!matrix.responseCodeFilter.includes(payload.responseCode)
|
!matrix.conditions.codes.includes(payload.responseCode)
|
||||||
) {
|
) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Response code ${payload.responseCode} not in filter — skipping distribution`
|
`Response code ${payload.responseCode} not in filter — skipping distribution`
|
||||||
);
|
);
|
||||||
return { transmittalPublicIds: [] };
|
return { transmittalPublicIds: [], notificationTargets: [] };
|
||||||
|
}
|
||||||
|
if (matrix.conditions?.excludeCodes?.includes(payload.responseCode)) {
|
||||||
|
this.logger.log(
|
||||||
|
`Response code ${payload.responseCode} is excluded — skipping distribution`
|
||||||
|
);
|
||||||
|
return { transmittalPublicIds: [], notificationTargets: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceRevision = await this.dataSource.manager.findOne(
|
||||||
|
CorrespondenceRevision,
|
||||||
|
{
|
||||||
|
where: { publicId: payload.rfaRevisionPublicId },
|
||||||
|
relations: ['correspondence'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!sourceRevision?.correspondence) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Distribution skipped for RFA ${payload.rfaPublicId}: source revision not found`
|
||||||
|
);
|
||||||
|
return { transmittalPublicIds: [], notificationTargets: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientOrganizationIds = await this.resolveRecipientOrganizations(
|
||||||
|
matrix.recipients
|
||||||
|
);
|
||||||
|
const notificationTargets = await this.resolveNotificationTargets(
|
||||||
|
matrix.recipients
|
||||||
|
);
|
||||||
|
if (recipientOrganizationIds.length === 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Distribution skipped for RFA ${payload.rfaPublicId}: no organization recipients resolved`
|
||||||
|
);
|
||||||
|
return { transmittalPublicIds: [], notificationTargets };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Creating Transmittal for RFA ${payload.rfaPublicId} → ${matrix.recipients.length} recipients`
|
`Creating Transmittal for RFA ${payload.rfaPublicId} → ${recipientOrganizationIds.length} recipient organizations`
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: เรียก TransmittalService.create() เมื่อ integrate ใน Sprint ถัดไป
|
const transmittalPublicIds: string[] = [];
|
||||||
// return transmittalService.createDraft({ rfaPublicId, recipients });
|
for (const recipientOrganizationId of recipientOrganizationIds) {
|
||||||
|
const existingPublicId = await this.findExistingTransmittalPublicId(
|
||||||
|
sourceRevision.correspondence.id,
|
||||||
|
recipientOrganizationId
|
||||||
|
);
|
||||||
|
if (existingPublicId) {
|
||||||
|
transmittalPublicIds.push(existingPublicId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const createdPublicId = await this.createDraftTransmittal({
|
||||||
|
sourceCorrespondence: sourceRevision.correspondence,
|
||||||
|
recipientOrganizationId,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
if (createdPublicId) transmittalPublicIds.push(createdPublicId);
|
||||||
|
}
|
||||||
|
return { transmittalPublicIds, notificationTargets };
|
||||||
|
}
|
||||||
|
|
||||||
return { transmittalPublicIds: [] };
|
private async resolveNotificationTargets(
|
||||||
|
recipients: DistributionRecipient[]
|
||||||
|
): Promise<DistributionNotificationTarget[]> {
|
||||||
|
const targets = new Map<number, DistributionNotificationTarget>();
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
if (recipient.recipientType !== RecipientType.USER) continue;
|
||||||
|
const user = await this.dataSource.manager.findOne(User, {
|
||||||
|
where: { publicId: recipient.recipientPublicId },
|
||||||
|
});
|
||||||
|
if (!user) continue;
|
||||||
|
targets.set(user.user_id, {
|
||||||
|
userId: user.user_id,
|
||||||
|
deliveryMethod: recipient.deliveryMethod,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from(targets.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRecipientOrganizations(
|
||||||
|
recipients: DistributionRecipient[]
|
||||||
|
): Promise<number[]> {
|
||||||
|
const organizationIds = new Set<number>();
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const organizationId =
|
||||||
|
await this.resolveRecipientOrganizationId(recipient);
|
||||||
|
if (organizationId) organizationIds.add(organizationId);
|
||||||
|
}
|
||||||
|
return Array.from(organizationIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRecipientOrganizationId(
|
||||||
|
recipient: DistributionRecipient
|
||||||
|
): Promise<number | undefined> {
|
||||||
|
if (recipient.deliveryMethod === DeliveryMethod.IN_APP) return undefined;
|
||||||
|
if (recipient.recipientType === RecipientType.ORGANIZATION) {
|
||||||
|
const organization = await this.dataSource.manager.findOne(Organization, {
|
||||||
|
where: { publicId: recipient.recipientPublicId },
|
||||||
|
});
|
||||||
|
return organization?.id;
|
||||||
|
}
|
||||||
|
if (recipient.recipientType === RecipientType.USER) {
|
||||||
|
const user = await this.dataSource.manager.findOne(User, {
|
||||||
|
where: { publicId: recipient.recipientPublicId },
|
||||||
|
});
|
||||||
|
return user?.primaryOrganizationId;
|
||||||
|
}
|
||||||
|
this.logger.warn(
|
||||||
|
`Recipient type ${recipient.recipientType} requires expansion and was skipped for Transmittal creation`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findExistingTransmittalPublicId(
|
||||||
|
sourceCorrespondenceId: number,
|
||||||
|
recipientOrganizationId: number
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const rows = await this.dataSource.query<Array<{ publicId: string }>>(
|
||||||
|
`
|
||||||
|
SELECT c.uuid AS publicId
|
||||||
|
FROM transmittals t
|
||||||
|
INNER JOIN correspondences c ON c.id = t.correspondence_id
|
||||||
|
INNER JOIN transmittal_items ti ON ti.transmittal_id = t.correspondence_id
|
||||||
|
INNER JOIN correspondence_recipients cr ON cr.correspondence_id = t.correspondence_id
|
||||||
|
WHERE ti.item_correspondence_id = ?
|
||||||
|
AND cr.recipient_organization_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[sourceCorrespondenceId, recipientOrganizationId]
|
||||||
|
);
|
||||||
|
return rows[0]?.publicId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createDraftTransmittal(context: {
|
||||||
|
sourceCorrespondence: Correspondence;
|
||||||
|
recipientOrganizationId: number;
|
||||||
|
payload: {
|
||||||
|
rfaPublicId: string;
|
||||||
|
projectId: number;
|
||||||
|
responseCode: string;
|
||||||
|
};
|
||||||
|
}): Promise<string | undefined> {
|
||||||
|
const type = await this.dataSource.manager.findOne(CorrespondenceType, {
|
||||||
|
where: { typeCode: 'TRN' },
|
||||||
|
});
|
||||||
|
const status = await this.dataSource.manager.findOne(CorrespondenceStatus, {
|
||||||
|
where: { statusCode: 'DRAFT' },
|
||||||
|
});
|
||||||
|
if (!type || !status || !context.sourceCorrespondence.originatorId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Distribution skipped for RFA ${context.payload.rfaPublicId}: missing Transmittal master data or originator`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docNumber = await this.numberingService.generateNextNumber({
|
||||||
|
projectId: context.payload.projectId,
|
||||||
|
originatorOrganizationId: context.sourceCorrespondence.originatorId,
|
||||||
|
recipientOrganizationId: context.recipientOrganizationId,
|
||||||
|
typeId: type.id,
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
customTokens: {
|
||||||
|
TYPE_CODE: type.typeCode,
|
||||||
|
RESPONSE_CODE: context.payload.responseCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
try {
|
||||||
|
const correspondence = queryRunner.manager.create(Correspondence, {
|
||||||
|
correspondenceNumber: docNumber.number,
|
||||||
|
correspondenceTypeId: type.id,
|
||||||
|
projectId: context.payload.projectId,
|
||||||
|
originatorId: context.sourceCorrespondence.originatorId,
|
||||||
|
isInternal: false,
|
||||||
|
});
|
||||||
|
const savedCorrespondence =
|
||||||
|
await queryRunner.manager.save(correspondence);
|
||||||
|
|
||||||
|
const revision = queryRunner.manager.create(CorrespondenceRevision, {
|
||||||
|
correspondenceId: savedCorrespondence.id,
|
||||||
|
revisionNumber: 0,
|
||||||
|
revisionLabel: '0',
|
||||||
|
isCurrent: true,
|
||||||
|
statusId: status.id,
|
||||||
|
subject: `Distribution for ${context.sourceCorrespondence.correspondenceNumber}`,
|
||||||
|
details: {
|
||||||
|
sourceRfaPublicId: context.payload.rfaPublicId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await queryRunner.manager.save(revision);
|
||||||
|
|
||||||
|
const recipient = queryRunner.manager.create(CorrespondenceRecipient, {
|
||||||
|
correspondenceId: savedCorrespondence.id,
|
||||||
|
recipientOrganizationId: context.recipientOrganizationId,
|
||||||
|
recipientType: 'TO',
|
||||||
|
});
|
||||||
|
await queryRunner.manager.save(recipient);
|
||||||
|
|
||||||
|
const transmittal = queryRunner.manager.create(Transmittal, {
|
||||||
|
correspondenceId: savedCorrespondence.id,
|
||||||
|
purpose: 'FOR_INFORMATION',
|
||||||
|
remarks: `Auto-distributed from RFA ${context.payload.rfaPublicId}`,
|
||||||
|
});
|
||||||
|
await queryRunner.manager.save(transmittal);
|
||||||
|
|
||||||
|
const item = queryRunner.manager.create(TransmittalItem, {
|
||||||
|
transmittalId: savedCorrespondence.id,
|
||||||
|
itemCorrespondenceId: context.sourceCorrespondence.id,
|
||||||
|
quantity: 1,
|
||||||
|
remarks: `RFA response code ${context.payload.responseCode}`,
|
||||||
|
});
|
||||||
|
await queryRunner.manager.save(item);
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
return savedCorrespondence.publicId;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export class CreateReminderRuleDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
projectId?: number;
|
projectId?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(20)
|
@MaxLength(20)
|
||||||
@@ -33,4 +37,8 @@ export class CreateReminderRuleDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
notifyRoles?: string[];
|
notifyRoles?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
messageTemplate?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// File: src/modules/reminder/entities/reminder-history.entity.ts
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Exclude } from 'class-transformer';
|
||||||
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
|
import { ReviewTask } from '../../review-team/entities/review-task.entity';
|
||||||
|
import { User } from '../../user/entities/user.entity';
|
||||||
|
import { ReminderType } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
@Entity('reminder_histories')
|
||||||
|
export class ReminderHistory extends UuidBaseEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
@Exclude()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'task_id' })
|
||||||
|
@Exclude()
|
||||||
|
taskId!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id' })
|
||||||
|
@Exclude()
|
||||||
|
userId!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ReminderType,
|
||||||
|
})
|
||||||
|
reminderType!: ReminderType;
|
||||||
|
|
||||||
|
@Column({ name: 'escalation_level', type: 'tinyint', default: 0 })
|
||||||
|
escalationLevel!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'sent_at' })
|
||||||
|
sentAt!: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => ReviewTask)
|
||||||
|
@JoinColumn({ name: 'task_id' })
|
||||||
|
task?: ReviewTask;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ export class ReminderRule extends UuidBaseEntity {
|
|||||||
@Exclude()
|
@Exclude()
|
||||||
projectId?: number; // NULL = global rule
|
projectId?: number; // NULL = global rule
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
@Column({ name: 'document_type_code', length: 20, nullable: true })
|
@Column({ name: 'document_type_code', length: 20, nullable: true })
|
||||||
documentTypeCode?: string; // 'SDW', 'DDW' — NULL = all types
|
documentTypeCode?: string; // 'SDW', 'DDW' — NULL = all types
|
||||||
|
|
||||||
@@ -38,6 +41,9 @@ export class ReminderRule extends UuidBaseEntity {
|
|||||||
@Column({ name: 'notify_roles', type: 'simple-array', nullable: true })
|
@Column({ name: 'notify_roles', type: 'simple-array', nullable: true })
|
||||||
notifyRoles?: string[]; // เช่น ['TASK_ASSIGNEE', 'TEAM_LEAD', 'PROJECT_MANAGER']
|
notifyRoles?: string[]; // เช่น ['TASK_ASSIGNEE', 'TEAM_LEAD', 'PROJECT_MANAGER']
|
||||||
|
|
||||||
|
@Column({ name: 'message_template', type: 'text', nullable: true })
|
||||||
|
messageTemplate?: string;
|
||||||
|
|
||||||
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
|
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
|
||||||
import { ReminderType } from '../../common/enums/review.enums';
|
import { ReminderType } from '../../common/enums/review.enums';
|
||||||
import { EscalationService } from '../services/escalation.service';
|
import { EscalationService } from '../services/escalation.service';
|
||||||
import { NotificationService } from '../../notification/notification.service';
|
import { NotificationService } from '../../notification/notification.service';
|
||||||
import { ScheduleReminderPayload } from '../services/scheduler.service';
|
import { ScheduleReminderPayload } from '../services/scheduler.service';
|
||||||
|
import { ReviewTask } from '../../review-team/entities/review-task.entity';
|
||||||
|
|
||||||
@Processor(QUEUE_REMINDERS)
|
@Processor(QUEUE_REMINDERS)
|
||||||
export class ReminderProcessor extends WorkerHost {
|
export class ReminderProcessor extends WorkerHost {
|
||||||
@@ -15,7 +18,9 @@ export class ReminderProcessor extends WorkerHost {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly escalationService: EscalationService,
|
private readonly escalationService: EscalationService,
|
||||||
private readonly notificationService: NotificationService
|
private readonly notificationService: NotificationService,
|
||||||
|
@InjectRepository(ReviewTask)
|
||||||
|
private readonly taskRepo: Repository<ReviewTask>
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -27,17 +32,28 @@ export class ReminderProcessor extends WorkerHost {
|
|||||||
`Processing reminder job: ${reminderType} for task ${taskPublicId}`
|
`Processing reminder job: ${reminderType} for task ${taskPublicId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ดึง internal ID ของ task
|
||||||
|
const task = await this.taskRepo.findOne({
|
||||||
|
where: { publicId: taskPublicId },
|
||||||
|
select: ['id', 'assignedToUserId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
this.logger.warn(`Task ${taskPublicId} not found — skipping reminder`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (reminderType) {
|
switch (reminderType) {
|
||||||
case ReminderType.DUE_SOON:
|
case ReminderType.DUE_SOON:
|
||||||
await this.notificationService.send({
|
await this.notificationService.send({
|
||||||
userId: assigneeUserId,
|
userId: assigneeUserId,
|
||||||
title: '⏰ Review Task Due Soon',
|
title: '⏰ Review Task Due Soon',
|
||||||
message:
|
message: 'Your review task is due soon. Please complete your review.',
|
||||||
'Your review task is due in 2 days. Please complete your review.',
|
|
||||||
type: 'SYSTEM',
|
type: 'SYSTEM',
|
||||||
entityType: 'review_task',
|
entityType: 'review_task',
|
||||||
entityId: taskPublicId as unknown as number,
|
entityId: task.id,
|
||||||
});
|
});
|
||||||
|
await this.escalationService.recordHistory(task, reminderType, 0);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ReminderType.ON_DUE:
|
case ReminderType.ON_DUE:
|
||||||
@@ -48,8 +64,9 @@ export class ReminderProcessor extends WorkerHost {
|
|||||||
'Your review task is due today. Please complete it as soon as possible.',
|
'Your review task is due today. Please complete it as soon as possible.',
|
||||||
type: 'SYSTEM',
|
type: 'SYSTEM',
|
||||||
entityType: 'review_task',
|
entityType: 'review_task',
|
||||||
entityId: taskPublicId as unknown as number,
|
entityId: task.id,
|
||||||
});
|
});
|
||||||
|
await this.escalationService.recordHistory(task, reminderType, 0);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ReminderType.OVERDUE:
|
case ReminderType.OVERDUE:
|
||||||
@@ -60,8 +77,9 @@ export class ReminderProcessor extends WorkerHost {
|
|||||||
'Your review task is overdue. Escalation will occur if not completed.',
|
'Your review task is overdue. Escalation will occur if not completed.',
|
||||||
type: 'SYSTEM',
|
type: 'SYSTEM',
|
||||||
entityType: 'review_task',
|
entityType: 'review_task',
|
||||||
entityId: taskPublicId as unknown as number,
|
entityId: task.id,
|
||||||
});
|
});
|
||||||
|
await this.escalationService.recordHistory(task, reminderType, 0);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ReminderType.ESCALATION_L1:
|
case ReminderType.ESCALATION_L1:
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export class ReminderController {
|
|||||||
return this.reminderService.findAllByProjectPublicId(projectPublicId);
|
return this.reminderService.findAllByProjectPublicId(projectPublicId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('history/:taskPublicId')
|
||||||
|
getHistory(@Param('taskPublicId') taskPublicId: string) {
|
||||||
|
return this.reminderService.findHistoryByTaskPublicId(taskPublicId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':publicId')
|
@Get(':publicId')
|
||||||
findOne(@Param('publicId') publicId: string) {
|
findOne(@Param('publicId') publicId: string) {
|
||||||
return this.reminderService.findOne(publicId);
|
return this.reminderService.findOne(publicId);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { ReminderRule } from './entities/reminder-rule.entity';
|
import { ReminderRule } from './entities/reminder-rule.entity';
|
||||||
|
import { ReminderHistory } from './entities/reminder-history.entity';
|
||||||
import { ReviewTask } from '../review-team/entities/review-task.entity';
|
import { ReviewTask } from '../review-team/entities/review-task.entity';
|
||||||
import { ReminderService } from './reminder.service';
|
import { ReminderService } from './reminder.service';
|
||||||
import { ReminderController } from './reminder.controller';
|
import { ReminderController } from './reminder.controller';
|
||||||
@@ -15,7 +16,12 @@ import { Project } from '../project/entities/project.entity';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([ReminderRule, ReviewTask, Project]),
|
TypeOrmModule.forFeature([
|
||||||
|
ReminderRule,
|
||||||
|
ReminderHistory,
|
||||||
|
ReviewTask,
|
||||||
|
Project,
|
||||||
|
]),
|
||||||
BullModule.registerQueue({ name: QUEUE_REMINDERS }),
|
BullModule.registerQueue({ name: QUEUE_REMINDERS }),
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { validate as uuidValidate } from 'uuid';
|
import { validate as uuidValidate } from 'uuid';
|
||||||
import { ReminderRule } from './entities/reminder-rule.entity';
|
import { ReminderRule } from './entities/reminder-rule.entity';
|
||||||
|
import { ReminderHistory } from './entities/reminder-history.entity';
|
||||||
import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto';
|
import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto';
|
||||||
import { Project } from '../project/entities/project.entity';
|
import { Project } from '../project/entities/project.entity';
|
||||||
|
import { ReviewTask } from '../review-team/entities/review-task.entity';
|
||||||
|
|
||||||
export { CreateReminderRuleDto };
|
export { CreateReminderRuleDto };
|
||||||
|
|
||||||
@@ -22,8 +24,12 @@ export class ReminderService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ReminderRule)
|
@InjectRepository(ReminderRule)
|
||||||
private readonly ruleRepo: Repository<ReminderRule>,
|
private readonly ruleRepo: Repository<ReminderRule>,
|
||||||
|
@InjectRepository(ReminderHistory)
|
||||||
|
private readonly historyRepo: Repository<ReminderHistory>,
|
||||||
@InjectRepository(Project)
|
@InjectRepository(Project)
|
||||||
private readonly projectRepo: Repository<Project>
|
private readonly projectRepo: Repository<Project>,
|
||||||
|
@InjectRepository(ReviewTask)
|
||||||
|
private readonly taskRepo: Repository<ReviewTask>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(projectId?: number): Promise<ReminderRule[]> {
|
async findAll(projectId?: number): Promise<ReminderRule[]> {
|
||||||
@@ -58,6 +64,21 @@ export class ReminderService {
|
|||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findHistoryByTaskPublicId(
|
||||||
|
taskPublicId: string
|
||||||
|
): Promise<ReminderHistory[]> {
|
||||||
|
const task = await this.taskRepo.findOne({
|
||||||
|
where: { publicId: taskPublicId },
|
||||||
|
});
|
||||||
|
if (!task) throw new NotFoundException('Task', taskPublicId);
|
||||||
|
|
||||||
|
return this.historyRepo.find({
|
||||||
|
where: { taskId: task.id },
|
||||||
|
relations: ['user'],
|
||||||
|
order: { sentAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async create(dto: CreateReminderRuleDto): Promise<ReminderRule> {
|
async create(dto: CreateReminderRuleDto): Promise<ReminderRule> {
|
||||||
const rule = this.ruleRepo.create(dto as Partial<ReminderRule>);
|
const rule = this.ruleRepo.create(dto as Partial<ReminderRule>);
|
||||||
return this.ruleRepo.save(rule);
|
return this.ruleRepo.save(rule);
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, LessThan } from 'typeorm';
|
import { Repository, LessThan } from 'typeorm';
|
||||||
import { ReviewTask } from '../../review-team/entities/review-task.entity';
|
import { ReviewTask } from '../../review-team/entities/review-task.entity';
|
||||||
import { ReviewTaskStatus } from '../../common/enums/review.enums';
|
import {
|
||||||
|
ReviewTaskStatus,
|
||||||
|
ReminderType,
|
||||||
|
} from '../../common/enums/review.enums';
|
||||||
import { NotificationService } from '../../notification/notification.service';
|
import { NotificationService } from '../../notification/notification.service';
|
||||||
import { ReminderRule } from '../entities/reminder-rule.entity';
|
import { ReminderRule } from '../entities/reminder-rule.entity';
|
||||||
|
import { ReminderHistory } from '../entities/reminder-history.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EscalationService {
|
export class EscalationService {
|
||||||
@@ -17,12 +21,40 @@ export class EscalationService {
|
|||||||
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||||
@InjectRepository(ReminderRule)
|
@InjectRepository(ReminderRule)
|
||||||
private readonly reminderRuleRepo: Repository<ReminderRule>,
|
private readonly reminderRuleRepo: Repository<ReminderRule>,
|
||||||
|
@InjectRepository(ReminderHistory)
|
||||||
|
private readonly historyRepo: Repository<ReminderHistory>,
|
||||||
private readonly notificationService: NotificationService
|
private readonly notificationService: NotificationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* บันทึกประวัติการส่ง reminder (FR-018)
|
||||||
|
*/
|
||||||
|
async recordHistory(
|
||||||
|
task: ReviewTask,
|
||||||
|
type: ReminderType,
|
||||||
|
level: number
|
||||||
|
): Promise<void> {
|
||||||
|
const history = this.historyRepo.create({
|
||||||
|
taskId: task.id,
|
||||||
|
userId: task.assignedToUserId,
|
||||||
|
reminderType: type,
|
||||||
|
escalationLevel: level,
|
||||||
|
});
|
||||||
|
await this.historyRepo.save(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* นับจำนวนครั้งที่ส่ง reminder สำหรับ level นั้นๆ
|
||||||
|
*/
|
||||||
|
async getStrikeCount(taskId: number, level: number): Promise<number> {
|
||||||
|
return this.historyRepo.count({
|
||||||
|
where: { taskId, escalationLevel: level },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escalation Level 1 (FR-015): Team Lead ได้รับแจ้งเตือน
|
* Escalation Level 1 (FR-015): Team Lead ได้รับแจ้งเตือน
|
||||||
* เรียกเมื่อ task เกิน due date 1 วัน
|
* เรียกเมื่อ task เกิน due date
|
||||||
*/
|
*/
|
||||||
async escalateLevel1(taskPublicId: string): Promise<void> {
|
async escalateLevel1(taskPublicId: string): Promise<void> {
|
||||||
const task = await this.reviewTaskRepo.findOne({
|
const task = await this.reviewTaskRepo.findOne({
|
||||||
@@ -32,32 +64,35 @@ export class EscalationService {
|
|||||||
|
|
||||||
if (!task || task.status === ReviewTaskStatus.COMPLETED) return;
|
if (!task || task.status === ReviewTaskStatus.COMPLETED) return;
|
||||||
|
|
||||||
const daysOverdue = task.dueDate
|
const strikes = await this.getStrikeCount(task.id, 1);
|
||||||
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000)
|
if (strikes >= 3) {
|
||||||
: 0;
|
this.logger.log(
|
||||||
|
`Task ${taskPublicId} L1 strikes reached 3 — moving to L2`
|
||||||
if (daysOverdue < 1) return;
|
);
|
||||||
|
await this.escalateLevel2(taskPublicId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Escalation L1: task ${taskPublicId} is ${daysOverdue} days overdue`
|
`Escalation L1 (Strike ${strikes + 1}): task ${taskPublicId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// แจ้ง Team Lead
|
|
||||||
if (task.assignedToUserId) {
|
if (task.assignedToUserId) {
|
||||||
await this.notificationService.send({
|
await this.notificationService.send({
|
||||||
userId: task.assignedToUserId,
|
userId: task.assignedToUserId,
|
||||||
title: `⚠ Review Task Overdue (${daysOverdue}d)`,
|
title: `⚠ Review Task Overdue (L1 Strike ${strikes + 1})`,
|
||||||
message: `Your review task is overdue by ${daysOverdue} day(s). Please complete it immediately.`,
|
message: `Your review task is overdue. Please complete it immediately.`,
|
||||||
type: 'SYSTEM',
|
type: 'SYSTEM',
|
||||||
entityType: 'review_task',
|
entityType: 'review_task',
|
||||||
entityId: task.id,
|
entityId: task.id,
|
||||||
});
|
});
|
||||||
|
await this.recordHistory(task, ReminderType.ESCALATION_L1, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escalation Level 2 (FR-016): Project Manager ได้รับแจ้งเตือน
|
* Escalation Level 2 (FR-016): Project Manager ได้รับแจ้งเตือน
|
||||||
* เรียกเมื่อ task เกิน due date 3 วัน
|
* เรียกเมื่อ L1 ครบ 3 ครั้ง หรือตามเงื่อนไข SLA
|
||||||
*/
|
*/
|
||||||
async escalateLevel2(taskPublicId: string): Promise<void> {
|
async escalateLevel2(taskPublicId: string): Promise<void> {
|
||||||
const task = await this.reviewTaskRepo.findOne({
|
const task = await this.reviewTaskRepo.findOne({
|
||||||
@@ -67,20 +102,25 @@ export class EscalationService {
|
|||||||
|
|
||||||
if (!task || task.status === ReviewTaskStatus.COMPLETED) return;
|
if (!task || task.status === ReviewTaskStatus.COMPLETED) return;
|
||||||
|
|
||||||
const daysOverdue = task.dueDate
|
const strikes = await this.getStrikeCount(task.id, 2);
|
||||||
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (daysOverdue < 3) return;
|
|
||||||
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Escalation L2: task ${taskPublicId} is ${daysOverdue} days overdue — escalating to PM`
|
`Escalation L2 (Strike ${strikes + 1}): task ${taskPublicId} — escalating to PM`
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: ดึง PM user ID จาก project membership — ใช้ placeholder สำหรับตอนนี้
|
// TODO: ดึง PM user ID จาก project membership
|
||||||
this.logger.log(
|
// สำหรับตอนนี้ แจ้งผู้รับผิดชอบเดิมแต่หัวเรื่องแรงขึ้น
|
||||||
`L2 escalation notification queued for task ${taskPublicId}`
|
if (task.assignedToUserId) {
|
||||||
);
|
await this.notificationService.send({
|
||||||
|
userId: task.assignedToUserId,
|
||||||
|
title: `🛑 CRITICAL: Review Task Overdue (L2 Strike ${strikes + 1})`,
|
||||||
|
message: `Your review task is critically overdue. Project Management has been notified.`,
|
||||||
|
type: 'SYSTEM',
|
||||||
|
entityType: 'review_task',
|
||||||
|
entityId: task.id,
|
||||||
|
});
|
||||||
|
await this.recordHistory(task, ReminderType.ESCALATION_L2, 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,14 +140,23 @@ export class EscalationService {
|
|||||||
this.logger.log(`Processing ${overdueTasks.length} overdue tasks`);
|
this.logger.log(`Processing ${overdueTasks.length} overdue tasks`);
|
||||||
|
|
||||||
for (const task of overdueTasks) {
|
for (const task of overdueTasks) {
|
||||||
const daysOverdue = task.dueDate
|
// ดึง history ล่าสุดเพื่อดูว่าควร escalate level ไหน
|
||||||
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000)
|
const lastHistory = await this.historyRepo.findOne({
|
||||||
: 0;
|
where: { taskId: task.id },
|
||||||
|
order: { sentAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
if (daysOverdue >= 3) {
|
if (!lastHistory || lastHistory.escalationLevel === 0) {
|
||||||
await this.escalateLevel2(task.publicId);
|
|
||||||
} else if (daysOverdue >= 1) {
|
|
||||||
await this.escalateLevel1(task.publicId);
|
await this.escalateLevel1(task.publicId);
|
||||||
|
} else if (lastHistory.escalationLevel === 1) {
|
||||||
|
const strikes = await this.getStrikeCount(task.id, 1);
|
||||||
|
if (strikes >= 3) {
|
||||||
|
await this.escalateLevel2(task.publicId);
|
||||||
|
} else {
|
||||||
|
await this.escalateLevel1(task.publicId); // FIXED typo
|
||||||
|
}
|
||||||
|
} else if (lastHistory.escalationLevel === 2) {
|
||||||
|
await this.escalateLevel2(task.publicId); // Daily reminder for L2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { Queue } from 'bullmq';
|
|||||||
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
|
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
|
||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
import { ReminderType } from '../../common/enums/review.enums';
|
import { ReminderType } from '../../common/enums/review.enums';
|
||||||
|
import { ReminderRule } from '../entities/reminder-rule.entity';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
export interface ScheduleReminderPayload {
|
export interface ScheduleReminderPayload {
|
||||||
taskPublicId: string;
|
taskPublicId: string;
|
||||||
@@ -13,6 +16,8 @@ export interface ScheduleReminderPayload {
|
|||||||
assigneeUserId: number;
|
assigneeUserId: number;
|
||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
reminderType: ReminderType;
|
reminderType: ReminderType;
|
||||||
|
projectId?: number;
|
||||||
|
documentTypeCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReminderJob = Job<ScheduleReminderPayload>;
|
type ReminderJob = Job<ScheduleReminderPayload>;
|
||||||
@@ -23,64 +28,67 @@ export class SchedulerService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectQueue(QUEUE_REMINDERS)
|
@InjectQueue(QUEUE_REMINDERS)
|
||||||
private readonly reminderQueue: Queue
|
private readonly reminderQueue: Queue,
|
||||||
|
@InjectRepository(ReminderRule)
|
||||||
|
private readonly ruleRepo: Repository<ReminderRule>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule ชุด reminders ให้ Review Task (FR-013)
|
* Schedule ชุด reminders ให้ Review Task (FR-013) ตาม ReminderRule
|
||||||
* เรียกหลังจาก TaskCreationService สร้าง tasks เรียบร้อยแล้ว
|
* เรียกหลังจาก TaskCreationService สร้าง tasks เรียบร้อยแล้ว
|
||||||
*/
|
*/
|
||||||
async scheduleForTask(payload: ScheduleReminderPayload): Promise<void> {
|
async scheduleForTask(payload: ScheduleReminderPayload): Promise<void> {
|
||||||
const { taskPublicId, dueDate } = payload;
|
const { taskPublicId, dueDate, projectId, documentTypeCode } = payload;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const remindersToSchedule: Array<{ type: ReminderType; delayMs: number }> =
|
// ดึงกฎที่เกี่ยวข้อง (Global + Project specific)
|
||||||
[];
|
const rules = await this.ruleRepo.find({
|
||||||
|
where: [
|
||||||
// 2 วันก่อน due date
|
{ projectId, documentTypeCode, isActive: true },
|
||||||
const twoDaysBefore = dueDate.getTime() - 2 * 86_400_000;
|
{ projectId: undefined, documentTypeCode, isActive: true },
|
||||||
if (twoDaysBefore > now) {
|
{ projectId, documentTypeCode: undefined, isActive: true },
|
||||||
remindersToSchedule.push({
|
{ projectId: undefined, documentTypeCode: undefined, isActive: true },
|
||||||
type: ReminderType.DUE_SOON,
|
],
|
||||||
delayMs: twoDaysBefore - now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// วัน due date เอง
|
|
||||||
const onDue = dueDate.getTime();
|
|
||||||
if (onDue > now) {
|
|
||||||
remindersToSchedule.push({
|
|
||||||
type: ReminderType.ON_DUE,
|
|
||||||
delayMs: onDue - now,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1 วันหลัง due (Escalation L1)
|
|
||||||
const oneDayAfter = dueDate.getTime() + 1 * 86_400_000;
|
|
||||||
remindersToSchedule.push({
|
|
||||||
type: ReminderType.ESCALATION_L1,
|
|
||||||
delayMs: Math.max(oneDayAfter - now, 0),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3 วันหลัง due (Escalation L2)
|
if (rules.length === 0) {
|
||||||
const threeDaysAfter = dueDate.getTime() + 3 * 86_400_000;
|
this.logger.debug(`No reminder rules found for task ${taskPublicId}`);
|
||||||
remindersToSchedule.push({
|
return;
|
||||||
type: ReminderType.ESCALATION_L2,
|
}
|
||||||
delayMs: Math.max(threeDaysAfter - now, 0),
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(
|
const jobs = [];
|
||||||
remindersToSchedule.map(({ type, delayMs }) =>
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const triggerTime =
|
||||||
|
dueDate.getTime() - rule.daysBeforeDue * 24 * 60 * 60 * 1000;
|
||||||
|
const delayMs = triggerTime - now;
|
||||||
|
|
||||||
|
// ถ้าเวลาผ่านไปแล้ว ไม่ต้อง schedule (ยกเว้น overdue ที่อาจจะต้องการส่งทันที)
|
||||||
|
if (delayMs <= 0 && rule.reminderType !== ReminderType.OVERDUE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs.push(
|
||||||
this.reminderQueue.add(
|
this.reminderQueue.add(
|
||||||
'send-reminder',
|
'send-reminder',
|
||||||
{ ...payload, reminderType: type },
|
{
|
||||||
{ delay: delayMs, removeOnComplete: true, removeOnFail: 100 }
|
...payload,
|
||||||
|
reminderType: rule.reminderType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
delay: Math.max(delayMs, 0),
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 100,
|
||||||
|
jobId: `${taskPublicId}-${rule.reminderType}-${rule.id}`, // ป้องกัน duplicate
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
await Promise.all(jobs);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Scheduled ${remindersToSchedule.length} reminders for task ${taskPublicId}`
|
`Scheduled ${jobs.length} reminders for task ${taskPublicId} based on ${rules.length} rules`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// File: src/modules/response-code/dto/create-response-code.dto.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-05-13: Add DTO for creating custom response codes.
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ResponseCodeCategory } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
export class CreateResponseCodeDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(10)
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
subStatus?: string;
|
||||||
|
|
||||||
|
@IsEnum(ResponseCodeCategory)
|
||||||
|
category!: ResponseCodeCategory;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
descriptionTh!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
descriptionEn!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
implications?: {
|
||||||
|
affectsSchedule?: boolean;
|
||||||
|
affectsCost?: boolean;
|
||||||
|
requiresContractReview?: boolean;
|
||||||
|
requiresEiaAmendment?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
notifyRoles?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// File: src/modules/response-code/dto/update-response-code.dto.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-05-13: Add DTO for updating response codes by publicId.
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ResponseCodeCategory } from '../../common/enums/review.enums';
|
||||||
|
|
||||||
|
export class UpdateResponseCodeDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(10)
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
subStatus?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ResponseCodeCategory)
|
||||||
|
category?: ResponseCodeCategory;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
descriptionTh?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
descriptionEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
implications?: {
|
||||||
|
affectsSchedule?: boolean;
|
||||||
|
affectsCost?: boolean;
|
||||||
|
requiresContractReview?: boolean;
|
||||||
|
requiresEiaAmendment?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
notifyRoles?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// File: src/modules/response-code/dto/upsert-response-code-rule.dto.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-05-13: Add DTO for response code matrix rule upsert endpoints.
|
||||||
|
|
||||||
|
import { IsBoolean, IsInt, IsOptional, IsUUID, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpsertResponseCodeRuleDto {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
documentTypeId!: number;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
responseCodePublicId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectPublicId?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
isEnabled!: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresComments?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
triggersNotification?: boolean;
|
||||||
|
}
|
||||||
@@ -1,19 +1,51 @@
|
|||||||
// File: src/modules/response-code/response-code.controller.ts
|
// File: src/modules/response-code/response-code.controller.ts
|
||||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
// Change Log:
|
||||||
|
// - 2026-05-13: Resolve project query identifiers through UuidResolverService and stop numeric coercion on public IDs.
|
||||||
|
// - 2026-05-13: Add basic CRUD endpoints with RBAC enforcement for response code management.
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||||
|
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||||
|
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||||
import { ResponseCodeService } from './response-code.service';
|
import { ResponseCodeService } from './response-code.service';
|
||||||
import { ResponseCodeCategory } from '../common/enums/review.enums';
|
import { ResponseCodeCategory } from '../common/enums/review.enums';
|
||||||
|
import { CreateResponseCodeDto } from './dto/create-response-code.dto';
|
||||||
|
import { UpdateResponseCodeDto } from './dto/update-response-code.dto';
|
||||||
|
import { UpsertResponseCodeRuleDto } from './dto/upsert-response-code-rule.dto';
|
||||||
|
import { MatrixManagementService } from './services/matrix-management.service';
|
||||||
|
import { InheritanceService } from './services/inheritance.service';
|
||||||
|
|
||||||
|
@ApiTags('Response Codes')
|
||||||
|
@ApiBearerAuth()
|
||||||
@Controller('response-codes')
|
@Controller('response-codes')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||||
export class ResponseCodeController {
|
export class ResponseCodeController {
|
||||||
constructor(private readonly responseCodeService: ResponseCodeService) {}
|
constructor(
|
||||||
|
private readonly responseCodeService: ResponseCodeService,
|
||||||
|
private readonly uuidResolver: UuidResolverService,
|
||||||
|
private readonly matrixManagementService: MatrixManagementService,
|
||||||
|
private readonly inheritanceService: InheritanceService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /response-codes
|
* GET /response-codes
|
||||||
* ดึง Response Codes ทั้งหมด
|
* ดึง Response Codes ทั้งหมด
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get all active response codes' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.responseCodeService.findAll();
|
return this.responseCodeService.findAll();
|
||||||
}
|
}
|
||||||
@@ -23,6 +55,7 @@ export class ResponseCodeController {
|
|||||||
* ดึง Response Codes ตาม Category (FR-006)
|
* ดึง Response Codes ตาม Category (FR-006)
|
||||||
*/
|
*/
|
||||||
@Get('category/:category')
|
@Get('category/:category')
|
||||||
|
@ApiOperation({ summary: 'Get response codes by category' })
|
||||||
findByCategory(@Param('category') category: ResponseCodeCategory) {
|
findByCategory(@Param('category') category: ResponseCodeCategory) {
|
||||||
return this.responseCodeService.findByCategory(category);
|
return this.responseCodeService.findByCategory(category);
|
||||||
}
|
}
|
||||||
@@ -32,13 +65,18 @@ export class ResponseCodeController {
|
|||||||
* ดึง Response Codes ที่ใช้ได้กับ document type + project
|
* ดึง Response Codes ที่ใช้ได้กับ document type + project
|
||||||
*/
|
*/
|
||||||
@Get('document-type/:documentTypeId')
|
@Get('document-type/:documentTypeId')
|
||||||
findByDocumentType(
|
@ApiOperation({ summary: 'Get response codes by document type and project' })
|
||||||
@Param('documentTypeId') documentTypeId: string,
|
async findByDocumentType(
|
||||||
|
@Param('documentTypeId', ParseIntPipe) documentTypeId: number,
|
||||||
@Query('projectId') projectId?: string
|
@Query('projectId') projectId?: string
|
||||||
) {
|
) {
|
||||||
|
const resolvedProjectId = projectId
|
||||||
|
? await this.uuidResolver.resolveProjectId(projectId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return this.responseCodeService.findByDocumentType(
|
return this.responseCodeService.findByDocumentType(
|
||||||
Number(documentTypeId),
|
documentTypeId,
|
||||||
projectId ? Number(projectId) : undefined
|
resolvedProjectId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +85,79 @@ export class ResponseCodeController {
|
|||||||
* ดึง Response Code ตาม publicId (ADR-019)
|
* ดึง Response Code ตาม publicId (ADR-019)
|
||||||
*/
|
*/
|
||||||
@Get(':publicId')
|
@Get(':publicId')
|
||||||
findOne(@Param('publicId') publicId: string) {
|
@ApiOperation({ summary: 'Get response code by publicId' })
|
||||||
|
findOne(@Param('publicId', ParseUuidPipe) publicId: string) {
|
||||||
return this.responseCodeService.findByPublicId(publicId);
|
return this.responseCodeService.findByPublicId(publicId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('master_data.manage')
|
||||||
|
@ApiOperation({ summary: 'Create a custom response code' })
|
||||||
|
create(@Body() dto: CreateResponseCodeDto) {
|
||||||
|
return this.responseCodeService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':publicId')
|
||||||
|
@RequirePermission('master_data.manage')
|
||||||
|
@ApiOperation({ summary: 'Update response code by publicId' })
|
||||||
|
update(
|
||||||
|
@Param('publicId', ParseUuidPipe) publicId: string,
|
||||||
|
@Body() dto: UpdateResponseCodeDto
|
||||||
|
) {
|
||||||
|
return this.responseCodeService.update(publicId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':publicId')
|
||||||
|
@RequirePermission('master_data.manage')
|
||||||
|
@ApiOperation({ summary: 'Deactivate response code by publicId' })
|
||||||
|
async remove(@Param('publicId', ParseUuidPipe) publicId: string) {
|
||||||
|
await this.responseCodeService.deactivate(publicId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('matrix/:documentTypeId')
|
||||||
|
@ApiOperation({ summary: 'Resolve response code matrix by document type' })
|
||||||
|
async getMatrix(
|
||||||
|
@Param('documentTypeId', ParseIntPipe) documentTypeId: number,
|
||||||
|
@Query('projectId') projectId?: string
|
||||||
|
) {
|
||||||
|
const resolvedProjectId = projectId
|
||||||
|
? await this.uuidResolver.resolveProjectId(projectId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return this.inheritanceService.resolveMatrix(
|
||||||
|
documentTypeId,
|
||||||
|
resolvedProjectId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('matrix/rules')
|
||||||
|
@RequirePermission('master_data.manage')
|
||||||
|
@ApiOperation({ summary: 'Create or update a response code matrix rule' })
|
||||||
|
async upsertRule(@Body() dto: UpsertResponseCodeRuleDto) {
|
||||||
|
const resolvedProjectId = dto.projectPublicId
|
||||||
|
? await this.uuidResolver.resolveProjectId(dto.projectPublicId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return this.matrixManagementService.upsertRule({
|
||||||
|
documentTypeId: dto.documentTypeId,
|
||||||
|
responseCodePublicId: dto.responseCodePublicId,
|
||||||
|
projectId: resolvedProjectId,
|
||||||
|
isEnabled: dto.isEnabled,
|
||||||
|
requiresComments: dto.requiresComments,
|
||||||
|
triggersNotification: dto.triggersNotification,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('matrix/rules/:rulePublicId')
|
||||||
|
@RequirePermission('master_data.manage')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Delete a project-specific response code matrix override',
|
||||||
|
})
|
||||||
|
async deleteRuleOverride(
|
||||||
|
@Param('rulePublicId', ParseUuidPipe) rulePublicId: string
|
||||||
|
) {
|
||||||
|
await this.matrixManagementService.deleteProjectOverride(rulePublicId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// File: src/modules/response-code/response-code.module.ts
|
// File: src/modules/response-code/response-code.module.ts
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditLog } from '../../common/entities/audit-log.entity';
|
||||||
import { ResponseCode } from './entities/response-code.entity';
|
import { ResponseCode } from './entities/response-code.entity';
|
||||||
import { ResponseCodeRule } from './entities/response-code-rule.entity';
|
import { ResponseCodeRule } from './entities/response-code-rule.entity';
|
||||||
import { ResponseCodeService } from './response-code.service';
|
import { ResponseCodeService } from './response-code.service';
|
||||||
import { ResponseCodeController } from './response-code.controller';
|
import { ResponseCodeController } from './response-code.controller';
|
||||||
|
import { ResponseCodeAuditService } from './services/audit.service';
|
||||||
import { ImplicationsService } from './services/implications.service';
|
import { ImplicationsService } from './services/implications.service';
|
||||||
import { NotificationTriggerService } from './services/notification-trigger.service';
|
import { NotificationTriggerService } from './services/notification-trigger.service';
|
||||||
import { MatrixManagementService } from './services/matrix-management.service';
|
import { MatrixManagementService } from './services/matrix-management.service';
|
||||||
@@ -14,11 +16,12 @@ import { NotificationModule } from '../notification/notification.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User]),
|
TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User, AuditLog]),
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ResponseCodeService,
|
ResponseCodeService,
|
||||||
|
ResponseCodeAuditService,
|
||||||
ImplicationsService,
|
ImplicationsService,
|
||||||
NotificationTriggerService,
|
NotificationTriggerService,
|
||||||
MatrixManagementService,
|
MatrixManagementService,
|
||||||
@@ -27,6 +30,7 @@ import { NotificationModule } from '../notification/notification.module';
|
|||||||
controllers: [ResponseCodeController],
|
controllers: [ResponseCodeController],
|
||||||
exports: [
|
exports: [
|
||||||
ResponseCodeService,
|
ResponseCodeService,
|
||||||
|
ResponseCodeAuditService,
|
||||||
ImplicationsService,
|
ImplicationsService,
|
||||||
NotificationTriggerService,
|
NotificationTriggerService,
|
||||||
MatrixManagementService,
|
MatrixManagementService,
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
// File: src/modules/response-code/response-code.service.ts
|
// File: src/modules/response-code/response-code.service.ts
|
||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
// Change Log:
|
||||||
|
// - 2026-05-13: Add basic CRUD methods for response codes to support controller mutations.
|
||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, IsNull } from 'typeorm';
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import { ResponseCode } from './entities/response-code.entity';
|
import { ResponseCode } from './entities/response-code.entity';
|
||||||
import { ResponseCodeRule } from './entities/response-code-rule.entity';
|
import { ResponseCodeRule } from './entities/response-code-rule.entity';
|
||||||
import { ResponseCodeCategory } from '../common/enums/review.enums';
|
import { ResponseCodeCategory } from '../common/enums/review.enums';
|
||||||
|
import { CreateResponseCodeDto } from './dto/create-response-code.dto';
|
||||||
|
import { UpdateResponseCodeDto } from './dto/update-response-code.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ResponseCodeService {
|
export class ResponseCodeService {
|
||||||
@@ -85,6 +95,83 @@ export class ResponseCodeService {
|
|||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* สร้าง Response Code ใหม่สำหรับ Master Approval Matrix
|
||||||
|
*/
|
||||||
|
async create(dto: CreateResponseCodeDto): Promise<ResponseCode> {
|
||||||
|
const existing = await this.responseCodeRepo.findOne({
|
||||||
|
where: {
|
||||||
|
code: dto.code,
|
||||||
|
category: dto.category,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Response Code already exists for code=${dto.code}, category=${dto.category}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = this.responseCodeRepo.create({
|
||||||
|
code: dto.code,
|
||||||
|
subStatus: dto.subStatus,
|
||||||
|
category: dto.category,
|
||||||
|
descriptionTh: dto.descriptionTh,
|
||||||
|
descriptionEn: dto.descriptionEn,
|
||||||
|
implications: dto.implications,
|
||||||
|
notifyRoles: dto.notifyRoles,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
isSystem: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.responseCodeRepo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* อัปเดต Response Code ตาม publicId
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
publicId: string,
|
||||||
|
dto: UpdateResponseCodeDto
|
||||||
|
): Promise<ResponseCode> {
|
||||||
|
const entity = await this.findByPublicId(publicId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(dto.code && dto.code !== entity.code) ||
|
||||||
|
(dto.category && dto.category !== entity.category)
|
||||||
|
) {
|
||||||
|
const existing = await this.responseCodeRepo.findOne({
|
||||||
|
where: {
|
||||||
|
code: dto.code ?? entity.code,
|
||||||
|
category: dto.category ?? entity.category,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing && existing.publicId !== entity.publicId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Response Code already exists for code=${dto.code ?? entity.code}, category=${dto.category ?? entity.category}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(entity, dto);
|
||||||
|
return this.responseCodeRepo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ปิดการใช้งาน Response Code โดยไม่ลบข้อมูล
|
||||||
|
*/
|
||||||
|
async deactivate(publicId: string): Promise<void> {
|
||||||
|
const entity = await this.findByPublicId(publicId);
|
||||||
|
|
||||||
|
if (entity.isSystem) {
|
||||||
|
throw new BadRequestException('Cannot deactivate a system response code');
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.isActive = false;
|
||||||
|
await this.responseCodeRepo.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ตรวจสอบว่า Response Code triggers notification หรือไม่ (FR-007)
|
* ตรวจสอบว่า Response Code triggers notification หรือไม่ (FR-007)
|
||||||
* Code 1C, 1D, 3 → trigger notification
|
* Code 1C, 1D, 3 → trigger notification
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// File: src/modules/response-code/services/audit.service.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-05-13: Add response code audit service for review task response code changes.
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuditLog } from '../../../common/entities/audit-log.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResponseCodeAuditService {
|
||||||
|
private readonly logger = new Logger(ResponseCodeAuditService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AuditLog)
|
||||||
|
private readonly auditLogRepo: Repository<AuditLog>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* บันทึก audit trail เมื่อมีการเลือกหรือเปลี่ยน Response Code บน Review Task
|
||||||
|
*/
|
||||||
|
async logReviewTaskResponseCodeChange(input: {
|
||||||
|
reviewTaskPublicId: string;
|
||||||
|
responseCodePublicId: string;
|
||||||
|
previousResponseCodeId?: number;
|
||||||
|
currentResponseCodeId: number;
|
||||||
|
comments?: string;
|
||||||
|
userId?: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const auditLog = this.auditLogRepo.create({
|
||||||
|
userId: input.userId ?? null,
|
||||||
|
action: 'response_code.change',
|
||||||
|
severity: 'INFO',
|
||||||
|
entityType: 'review_task',
|
||||||
|
entityId: input.reviewTaskPublicId,
|
||||||
|
detailsJson: {
|
||||||
|
previousResponseCodeId: input.previousResponseCodeId ?? null,
|
||||||
|
currentResponseCodeId: input.currentResponseCodeId,
|
||||||
|
responseCodePublicId: input.responseCodePublicId,
|
||||||
|
comments: input.comments ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.auditLogRepo.save(auditLog);
|
||||||
|
this.logger.debug(
|
||||||
|
`Recorded response code audit for review task ${input.reviewTaskPublicId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
// File: src/modules/review-team/dto/shared/review-team.dto.ts
|
// File: src/modules/review-team/dto/shared/review-team.dto.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-05-13: Align AddTeamMemberDto discipline identifier with the INT-based disciplines schema.
|
||||||
// Shared DTOs สำหรับ Review Team และ Review Task APIs
|
// Shared DTOs สำหรับ Review Team และ Review Task APIs
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -69,8 +71,9 @@ export class AddTeamMemberDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
userPublicId!: string; // ADR-019
|
userPublicId!: string; // ADR-019
|
||||||
|
|
||||||
@IsUUID()
|
@IsInt()
|
||||||
disciplinePublicId!: string; // ADR-019
|
@IsPositive()
|
||||||
|
disciplineId!: number; // disciplines table is internal INT per current schema
|
||||||
|
|
||||||
@IsEnum(ReviewTeamMemberRole)
|
@IsEnum(ReviewTeamMemberRole)
|
||||||
role!: ReviewTeamMemberRole;
|
role!: ReviewTeamMemberRole;
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// File: src/modules/review-team/review-task.controller.ts
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { ReviewTaskService } from './review-task.service';
|
||||||
|
import { ConsensusService } from './services/consensus.service';
|
||||||
|
import { VetoOverrideService } from './services/veto-override.service';
|
||||||
|
import type { VetoOverrideDto } from './services/veto-override.service';
|
||||||
|
import {
|
||||||
|
CompleteReviewTaskDto,
|
||||||
|
SearchReviewTaskDto,
|
||||||
|
} from './dto/shared/review-team.dto';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { User } from '../user/entities/user.entity';
|
||||||
|
|
||||||
|
@Controller('review-tasks')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ReviewTaskController {
|
||||||
|
constructor(
|
||||||
|
private readonly reviewTaskService: ReviewTaskService,
|
||||||
|
private readonly consensusService: ConsensusService,
|
||||||
|
private readonly vetoOverrideService: VetoOverrideService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@Query() dto: SearchReviewTaskDto) {
|
||||||
|
return this.reviewTaskService.findAll(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':publicId')
|
||||||
|
findOne(@Param('publicId', ParseUUIDPipe) publicId: string) {
|
||||||
|
return this.reviewTaskService.findByPublicId(publicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':publicId/start')
|
||||||
|
startReview(@Param('publicId', ParseUUIDPipe) publicId: string) {
|
||||||
|
return this.reviewTaskService.startReview(publicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':publicId/complete')
|
||||||
|
async completeReview(
|
||||||
|
@Param('publicId', ParseUUIDPipe) publicId: string,
|
||||||
|
@Body() dto: CompleteReviewTaskDto,
|
||||||
|
@CurrentUser() _user: User
|
||||||
|
) {
|
||||||
|
const task = await this.reviewTaskService.completeReview(publicId, dto);
|
||||||
|
|
||||||
|
// Evaluate consensus after completion (FR-010)
|
||||||
|
try {
|
||||||
|
const fullTask = (await this.reviewTaskService.findFullTaskContext(
|
||||||
|
publicId
|
||||||
|
)) as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
const rfaRevision = fullTask.rfaRevision as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const corrRevision = rfaRevision?.correspondenceRevision as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const correspondence = corrRevision?.correspondence as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (rfaRevision && correspondence) {
|
||||||
|
await this.consensusService.evaluateAfterTaskComplete(
|
||||||
|
fullTask.rfaRevisionId,
|
||||||
|
{
|
||||||
|
rfaPublicId: correspondence.publicId as string,
|
||||||
|
|
||||||
|
rfaRevisionPublicId: corrRevision.publicId as string,
|
||||||
|
|
||||||
|
projectId: correspondence.projectId as number,
|
||||||
|
|
||||||
|
documentTypeId: (
|
||||||
|
correspondence.type as Record<string, unknown> | undefined
|
||||||
|
)?.id as number | undefined,
|
||||||
|
|
||||||
|
documentTypeCode:
|
||||||
|
((correspondence.type as Record<string, unknown> | undefined)
|
||||||
|
?.typeCode as string | undefined) ?? 'RFA',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_error: unknown) {
|
||||||
|
// Log error but don't fail the task completion response
|
||||||
|
// (error as any).logger?.error(`Consensus evaluation failed: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('veto-override')
|
||||||
|
async overrideVeto(@Body() dto: VetoOverrideDto, @CurrentUser() user: User) {
|
||||||
|
return this.vetoOverrideService.executeOverride({
|
||||||
|
...dto,
|
||||||
|
overriddenByUserId: user.user_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
// File: src/modules/review-team/review-task.service.ts
|
// File: src/modules/review-team/review-task.service.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-05-13: Record audit trail when a review task response code is completed or changed.
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -10,6 +12,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { ReviewTask } from './entities/review-task.entity';
|
import { ReviewTask } from './entities/review-task.entity';
|
||||||
import { ResponseCode } from '../response-code/entities/response-code.entity';
|
import { ResponseCode } from '../response-code/entities/response-code.entity';
|
||||||
|
import { ResponseCodeAuditService } from '../response-code/services/audit.service';
|
||||||
import {
|
import {
|
||||||
CompleteReviewTaskDto,
|
CompleteReviewTaskDto,
|
||||||
SearchReviewTaskDto,
|
SearchReviewTaskDto,
|
||||||
@@ -25,7 +28,8 @@ export class ReviewTaskService {
|
|||||||
@InjectRepository(ReviewTask)
|
@InjectRepository(ReviewTask)
|
||||||
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||||
@InjectRepository(ResponseCode)
|
@InjectRepository(ResponseCode)
|
||||||
private readonly responseCodeRepo: Repository<ResponseCode>
|
private readonly responseCodeRepo: Repository<ResponseCode>,
|
||||||
|
private readonly responseCodeAuditService: ResponseCodeAuditService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,6 +95,48 @@ export class ReviewTaskService {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ดึง Review Task พร้อม context ทั้งหมด (RFA, Project, Type)
|
||||||
|
*/
|
||||||
|
async findFullTaskContext(publicId: string): Promise<ReviewTask> {
|
||||||
|
const task = await this.reviewTaskRepo
|
||||||
|
.createQueryBuilder('task')
|
||||||
|
.leftJoinAndSelect('task.responseCode', 'responseCode')
|
||||||
|
.leftJoinAndSelect('task.team', 'team')
|
||||||
|
.innerJoinAndMapOne(
|
||||||
|
'task.rfaRevision',
|
||||||
|
'rfa_revisions',
|
||||||
|
'rfaRev',
|
||||||
|
'rfaRev.id = task.rfa_revision_id'
|
||||||
|
)
|
||||||
|
.innerJoinAndMapOne(
|
||||||
|
'rfaRev.correspondenceRevision',
|
||||||
|
'correspondence_revisions',
|
||||||
|
'corrRev',
|
||||||
|
'corrRev.id = rfaRev.id'
|
||||||
|
)
|
||||||
|
.innerJoinAndMapOne(
|
||||||
|
'corrRev.correspondence',
|
||||||
|
'correspondences',
|
||||||
|
'corr',
|
||||||
|
'corr.id = corrRev.correspondence_id'
|
||||||
|
)
|
||||||
|
.leftJoinAndMapOne(
|
||||||
|
'corr.type',
|
||||||
|
'correspondence_types',
|
||||||
|
'corrType',
|
||||||
|
'corrType.id = corr.correspondence_type_id'
|
||||||
|
)
|
||||||
|
.where('task.uuid = :publicId', { publicId })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new NotFoundException(`Review Task not found: ${publicId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ดึง Tasks รวมทั้งหมดของ RFA Revision พร้อม Aggregate Status (FR-004)
|
* ดึง Tasks รวมทั้งหมดของ RFA Revision พร้อม Aggregate Status (FR-004)
|
||||||
*/
|
*/
|
||||||
@@ -143,6 +189,7 @@ export class ReviewTaskService {
|
|||||||
dto: CompleteReviewTaskDto
|
dto: CompleteReviewTaskDto
|
||||||
): Promise<ReviewTask> {
|
): Promise<ReviewTask> {
|
||||||
const task = await this.findByPublicId(publicId);
|
const task = await this.findByPublicId(publicId);
|
||||||
|
const previousResponseCodeId = task.responseCodeId;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
task.status === ReviewTaskStatus.COMPLETED ||
|
task.status === ReviewTaskStatus.COMPLETED ||
|
||||||
@@ -180,7 +227,15 @@ export class ReviewTaskService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// TypeORM จะ throw OptimisticLockVersionMismatchError ถ้า version ไม่ตรง (ADR-002)
|
// TypeORM จะ throw OptimisticLockVersionMismatchError ถ้า version ไม่ตรง (ADR-002)
|
||||||
return await this.reviewTaskRepo.save(task);
|
const savedTask = await this.reviewTaskRepo.save(task);
|
||||||
|
await this.responseCodeAuditService.logReviewTaskResponseCodeChange({
|
||||||
|
reviewTaskPublicId: savedTask.publicId,
|
||||||
|
responseCodePublicId: dto.responseCodePublicId,
|
||||||
|
previousResponseCodeId,
|
||||||
|
currentResponseCodeId: responseCode.id,
|
||||||
|
comments: dto.comments,
|
||||||
|
});
|
||||||
|
return savedTask;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ import { VetoOverrideService } from './services/veto-override.service';
|
|||||||
|
|
||||||
// Controllers
|
// Controllers
|
||||||
import { ReviewTeamController } from './review-team.controller';
|
import { ReviewTeamController } from './review-team.controller';
|
||||||
|
import { ReviewTaskController } from './review-task.controller';
|
||||||
|
|
||||||
// Modules
|
// Modules
|
||||||
import { ResponseCodeModule } from '../response-code/response-code.module';
|
import { ResponseCodeModule } from '../response-code/response-code.module';
|
||||||
import { NotificationModule } from '../notification/notification.module';
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
import { DistributionModule } from '../distribution/distribution.module';
|
import { DistributionModule } from '../distribution/distribution.module';
|
||||||
|
import { DelegationModule } from '../delegation/delegation.module';
|
||||||
|
import { ReminderModule } from '../reminder/reminder.module';
|
||||||
|
|
||||||
// Queue constants
|
// Queue constants
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +55,8 @@ import {
|
|||||||
NotificationModule,
|
NotificationModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
DistributionModule,
|
DistributionModule,
|
||||||
|
DelegationModule,
|
||||||
|
ReminderModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ReviewTeamService,
|
ReviewTeamService,
|
||||||
@@ -61,7 +66,7 @@ import {
|
|||||||
ConsensusService,
|
ConsensusService,
|
||||||
VetoOverrideService,
|
VetoOverrideService,
|
||||||
],
|
],
|
||||||
controllers: [ReviewTeamController],
|
controllers: [ReviewTeamController, ReviewTaskController],
|
||||||
exports: [
|
exports: [
|
||||||
ReviewTeamService,
|
ReviewTeamService,
|
||||||
ReviewTaskService,
|
ReviewTaskService,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// File: src/modules/review-team/review-team.service.ts
|
// File: src/modules/review-team/review-team.service.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-05-13: Resolve project public IDs with UuidResolverService and align discipline lookup with INT discipline IDs.
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -11,6 +13,7 @@ import { ReviewTeam } from './entities/review-team.entity';
|
|||||||
import { ReviewTeamMember } from './entities/review-team-member.entity';
|
import { ReviewTeamMember } from './entities/review-team-member.entity';
|
||||||
import { User } from '../user/entities/user.entity';
|
import { User } from '../user/entities/user.entity';
|
||||||
import { Discipline } from '../master/entities/discipline.entity';
|
import { Discipline } from '../master/entities/discipline.entity';
|
||||||
|
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||||
import {
|
import {
|
||||||
CreateReviewTeamDto,
|
CreateReviewTeamDto,
|
||||||
UpdateReviewTeamDto,
|
UpdateReviewTeamDto,
|
||||||
@@ -30,7 +33,8 @@ export class ReviewTeamService {
|
|||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private readonly userRepo: Repository<User>,
|
private readonly userRepo: Repository<User>,
|
||||||
@InjectRepository(Discipline)
|
@InjectRepository(Discipline)
|
||||||
private readonly disciplineRepo: Repository<Discipline>
|
private readonly disciplineRepo: Repository<Discipline>,
|
||||||
|
private readonly uuidResolver: UuidResolverService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,21 +101,14 @@ export class ReviewTeamService {
|
|||||||
* สร้าง Review Team ใหม่
|
* สร้าง Review Team ใหม่
|
||||||
*/
|
*/
|
||||||
async create(dto: CreateReviewTeamDto): Promise<ReviewTeam> {
|
async create(dto: CreateReviewTeamDto): Promise<ReviewTeam> {
|
||||||
// ตรวจสอบว่า project มีอยู่จริง (via publicId)
|
const projectId = await this.uuidResolver.resolveProjectId(
|
||||||
const project = await this.teamRepo.manager
|
dto.projectPublicId
|
||||||
.getRepository('projects')
|
);
|
||||||
.findOne({
|
|
||||||
where: { uuid: dto.projectPublicId } as Record<string, unknown>,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
throw new NotFoundException(`Project not found: ${dto.projectPublicId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = this.teamRepo.create({
|
const team = this.teamRepo.create({
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
projectId: (project as { id: number }).id,
|
projectId,
|
||||||
defaultForRfaTypes: dto.defaultForRfaTypes,
|
defaultForRfaTypes: dto.defaultForRfaTypes,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
@@ -155,12 +152,10 @@ export class ReviewTeamService {
|
|||||||
|
|
||||||
// ตรวจสอบ Discipline
|
// ตรวจสอบ Discipline
|
||||||
const discipline = await this.disciplineRepo.findOne({
|
const discipline = await this.disciplineRepo.findOne({
|
||||||
where: { id: Number(dto.disciplinePublicId) },
|
where: { id: dto.disciplineId },
|
||||||
});
|
});
|
||||||
if (!discipline)
|
if (!discipline)
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(`Discipline not found: ${dto.disciplineId}`);
|
||||||
`Discipline not found: ${dto.disciplinePublicId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// ตรวจสอบซ้ำ
|
// ตรวจสอบซ้ำ
|
||||||
const existing = await this.memberRepo.findOne({
|
const existing = await this.memberRepo.findOne({
|
||||||
@@ -173,7 +168,7 @@ export class ReviewTeamService {
|
|||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}`
|
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplineId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export class ConsensusService {
|
|||||||
rfaPublicId: string;
|
rfaPublicId: string;
|
||||||
rfaRevisionPublicId: string;
|
rfaRevisionPublicId: string;
|
||||||
projectId: number;
|
projectId: number;
|
||||||
|
documentTypeId?: number;
|
||||||
documentTypeCode: string;
|
documentTypeCode: string;
|
||||||
}
|
}
|
||||||
): Promise<ConsensusResult> {
|
): Promise<ConsensusResult> {
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { ReviewTask } from '../entities/review-task.entity';
|
|||||||
import {
|
import {
|
||||||
ReviewTaskStatus,
|
ReviewTaskStatus,
|
||||||
ReviewTeamMemberRole,
|
ReviewTeamMemberRole,
|
||||||
|
DelegationScope,
|
||||||
|
ReminderType,
|
||||||
} from '../../common/enums/review.enums';
|
} from '../../common/enums/review.enums';
|
||||||
|
import { DelegationService } from '../../delegation/delegation.service';
|
||||||
|
import { SchedulerService } from '../../reminder/services/scheduler.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TaskCreationService {
|
export class TaskCreationService {
|
||||||
@@ -23,7 +27,9 @@ export class TaskCreationService {
|
|||||||
@InjectRepository(ReviewTeamMember)
|
@InjectRepository(ReviewTeamMember)
|
||||||
private readonly memberRepo: Repository<ReviewTeamMember>,
|
private readonly memberRepo: Repository<ReviewTeamMember>,
|
||||||
@InjectRepository(ReviewTask)
|
@InjectRepository(ReviewTask)
|
||||||
private readonly reviewTaskRepo: Repository<ReviewTask>
|
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||||
|
private readonly delegationService: DelegationService,
|
||||||
|
private readonly schedulerService: SchedulerService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,12 +40,16 @@ export class TaskCreationService {
|
|||||||
* @param reviewTeamPublicId - publicId ของ Review Team (ADR-019)
|
* @param reviewTeamPublicId - publicId ของ Review Team (ADR-019)
|
||||||
* @param dueDate - กำหนดเวลาตรวจสอบ
|
* @param dueDate - กำหนดเวลาตรวจสอบ
|
||||||
* @param manager - EntityManager จาก QueryRunner (ใช้ Transaction เดิม)
|
* @param manager - EntityManager จาก QueryRunner (ใช้ Transaction เดิม)
|
||||||
|
* @param projectId - (Optional) ID ของโครงการ สำหรับ reminder rules
|
||||||
|
* @param documentTypeCode - (Optional) ประเภทเอกสาร สำหรับ reminder rules
|
||||||
*/
|
*/
|
||||||
async createParallelTasks(
|
async createParallelTasks(
|
||||||
rfaRevisionId: number,
|
rfaRevisionId: number,
|
||||||
reviewTeamPublicId: string,
|
reviewTeamPublicId: string,
|
||||||
dueDate: Date,
|
dueDate: Date,
|
||||||
manager: EntityManager
|
manager: EntityManager,
|
||||||
|
projectId?: number,
|
||||||
|
documentTypeCode?: string
|
||||||
): Promise<ReviewTask[]> {
|
): Promise<ReviewTask[]> {
|
||||||
// ดึง ReviewTeam พร้อม members
|
// ดึง ReviewTeam พร้อม members
|
||||||
const team = await this.reviewTeamRepo.findOne({
|
const team = await this.reviewTeamRepo.findOne({
|
||||||
@@ -77,16 +87,40 @@ export class TaskCreationService {
|
|||||||
|
|
||||||
// สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel)
|
// สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel)
|
||||||
for (const [disciplineId, leadMember] of disciplineMap) {
|
for (const [disciplineId, leadMember] of disciplineMap) {
|
||||||
|
const activeDelegate = await this.delegationService.findActiveDelegate(
|
||||||
|
leadMember.userId,
|
||||||
|
dueDate,
|
||||||
|
[DelegationScope.ALL, DelegationScope.RFA_ONLY]
|
||||||
|
);
|
||||||
|
const assignedToUserId = activeDelegate?.user_id ?? leadMember.userId;
|
||||||
|
const delegatedFromUserId = activeDelegate
|
||||||
|
? leadMember.userId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const task = manager.create(ReviewTask, {
|
const task = manager.create(ReviewTask, {
|
||||||
rfaRevisionId,
|
rfaRevisionId,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
disciplineId,
|
disciplineId,
|
||||||
assignedToUserId: leadMember.userId,
|
assignedToUserId,
|
||||||
|
delegatedFromUserId,
|
||||||
status: ReviewTaskStatus.PENDING,
|
status: ReviewTaskStatus.PENDING,
|
||||||
dueDate,
|
dueDate,
|
||||||
});
|
});
|
||||||
const saved = await manager.save(ReviewTask, task);
|
const saved = await manager.save(ReviewTask, task);
|
||||||
tasks.push(saved);
|
tasks.push(saved);
|
||||||
|
|
||||||
|
// Schedule Reminders & Escalation (US4)
|
||||||
|
if (saved.assignedToUserId) {
|
||||||
|
await this.schedulerService.scheduleForTask({
|
||||||
|
taskPublicId: saved.publicId,
|
||||||
|
rfaPublicId: rfaRevisionId.toString(), // ใช้ rfaRevisionId เป็น placeholder
|
||||||
|
assigneeUserId: saved.assignedToUserId,
|
||||||
|
dueDate: saved.dueDate ?? dueDate,
|
||||||
|
reminderType: ReminderType.DUE_SOON, // Start type, scheduler will fetch rules
|
||||||
|
projectId: projectId ?? team.projectId,
|
||||||
|
documentTypeCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface VetoOverrideDto {
|
|||||||
rfaPublicId: string;
|
rfaPublicId: string;
|
||||||
rfaRevisionPublicId: string;
|
rfaRevisionPublicId: string;
|
||||||
projectId: number;
|
projectId: number;
|
||||||
|
documentTypeId?: number;
|
||||||
documentTypeCode: string;
|
documentTypeCode: string;
|
||||||
overrideReason: string;
|
overrideReason: string;
|
||||||
overriddenByUserId: number;
|
overriddenByUserId: number;
|
||||||
@@ -72,6 +73,7 @@ export class VetoOverrideService {
|
|||||||
rfaPublicId: dto.rfaPublicId,
|
rfaPublicId: dto.rfaPublicId,
|
||||||
rfaRevisionPublicId: dto.rfaRevisionPublicId,
|
rfaRevisionPublicId: dto.rfaRevisionPublicId,
|
||||||
projectId: dto.projectId,
|
projectId: dto.projectId,
|
||||||
|
documentTypeId: dto.documentTypeId,
|
||||||
documentTypeCode: dto.documentTypeCode,
|
documentTypeCode: dto.documentTypeCode,
|
||||||
responseCode: '1A',
|
responseCode: '1A',
|
||||||
decision: ConsensusDecision.OVERRIDDEN,
|
decision: ConsensusDecision.OVERRIDDEN,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// File: src/modules/rfa/rfa.controller.ts
|
// File: src/modules/rfa/rfa.controller.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-05-13: Wire submit reviewTeamPublicId through to the submit workflow for parallel review task creation.
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -76,7 +78,12 @@ export class RfaController {
|
|||||||
) {
|
) {
|
||||||
// ADR-019: resolve UUID → internal INT id via findOneByUuidRaw
|
// ADR-019: resolve UUID → internal INT id via findOneByUuidRaw
|
||||||
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
|
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
|
||||||
return this.rfaService.submit(rfa.id, submitDto.templateId, user);
|
return this.rfaService.submit(
|
||||||
|
rfa.id,
|
||||||
|
submitDto.templateId,
|
||||||
|
user,
|
||||||
|
submitDto.reviewTeamPublicId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':uuid/action')
|
@Post(':uuid/action')
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { RfaService } from './rfa.service';
|
|||||||
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
|
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
|
||||||
import { NotificationModule } from '../notification/notification.module';
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
import { ProjectModule } from '../project/project.module';
|
import { ProjectModule } from '../project/project.module';
|
||||||
|
import { ReviewTeamModule } from '../review-team/review-team.module';
|
||||||
import { SearchModule } from '../search/search.module';
|
import { SearchModule } from '../search/search.module';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
|
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
|
||||||
@@ -66,6 +67,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
|
|||||||
DocumentNumberingModule,
|
DocumentNumberingModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
ProjectModule,
|
ProjectModule,
|
||||||
|
ReviewTeamModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
WorkflowEngineModule,
|
WorkflowEngineModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// File: src/modules/rfa/rfa.service.ts
|
// File: src/modules/rfa/rfa.service.ts
|
||||||
|
// Change Log:
|
||||||
|
// - 2026-05-13: Invoke TaskCreationService during submit when a review team is selected.
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +61,7 @@ import { SearchService } from '../search/search.service';
|
|||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||||
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||||
|
import { TaskCreationService } from '../review-team/services/task-creation.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RfaService {
|
export class RfaService {
|
||||||
@@ -109,6 +112,7 @@ export class RfaService {
|
|||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private workflowEngine: WorkflowEngineService,
|
private workflowEngine: WorkflowEngineService,
|
||||||
private notificationService: NotificationService,
|
private notificationService: NotificationService,
|
||||||
|
private taskCreationService: TaskCreationService,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
private uuidResolver: UuidResolverService
|
private uuidResolver: UuidResolverService
|
||||||
@@ -664,7 +668,12 @@ export class RfaService {
|
|||||||
return mappedRfa;
|
return mappedRfa;
|
||||||
}
|
}
|
||||||
|
|
||||||
async submit(rfaId: number, templateId: number, user: User) {
|
async submit(
|
||||||
|
rfaId: number,
|
||||||
|
templateId: number,
|
||||||
|
user: User,
|
||||||
|
reviewTeamPublicId?: string
|
||||||
|
) {
|
||||||
const rfa = await this.findOne(rfaId, true);
|
const rfa = await this.findOne(rfaId, true);
|
||||||
const corrRevisions =
|
const corrRevisions =
|
||||||
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
||||||
@@ -747,6 +756,17 @@ export class RfaService {
|
|||||||
});
|
});
|
||||||
await queryRunner.manager.save(routing);
|
await queryRunner.manager.save(routing);
|
||||||
|
|
||||||
|
if (reviewTeamPublicId) {
|
||||||
|
await this.taskCreationService.createParallelTasks(
|
||||||
|
currentRfaRev.id,
|
||||||
|
reviewTeamPublicId,
|
||||||
|
routing.dueDate ?? new Date(),
|
||||||
|
queryRunner.manager,
|
||||||
|
rfa.correspondence.projectId,
|
||||||
|
rfa.rfaType.typeCode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Notify
|
// Notify
|
||||||
const recipientUserId = await this.userService.findDocControlIdByOrg(
|
const recipientUserId = await this.userService.findDocControlIdByOrg(
|
||||||
firstStep.toOrganizationId
|
firstStep.toOrganizationId
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// File: backend/src/modules/user/entities/role.entity.ts
|
||||||
|
// Change Log:
|
||||||
|
// - v1.9.0 (2026-05-13): เพิ่ม publicId (uuid) column ตาม delta-11 + ADR-019
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
@@ -6,7 +9,9 @@ import {
|
|||||||
JoinTable,
|
JoinTable,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Permission } from './permission.entity';
|
import { Permission } from './permission.entity';
|
||||||
|
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||||
|
|
||||||
|
/** ขอบเขตของบทบาท */
|
||||||
export enum RoleScope {
|
export enum RoleScope {
|
||||||
GLOBAL = 'Global',
|
GLOBAL = 'Global',
|
||||||
ORGANIZATION = 'Organization',
|
ORGANIZATION = 'Organization',
|
||||||
@@ -14,8 +19,15 @@ export enum RoleScope {
|
|||||||
CONTRACT = 'Contract',
|
CONTRACT = 'Contract',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity สำหรับตาราง roles
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* - Internal PK: roleId (INT) — ห้าม expose ใน API (ADR-019)
|
||||||
|
* - Public ID: publicId (UUID) — ใช้ใน API Response และ distribution_recipients (delta-11)
|
||||||
|
*/
|
||||||
@Entity('roles')
|
@Entity('roles')
|
||||||
export class Role {
|
export class Role extends UuidBaseEntity {
|
||||||
@PrimaryGeneratedColumn({ name: 'role_id' })
|
@PrimaryGeneratedColumn({ name: 'role_id' })
|
||||||
roleId!: number;
|
roleId!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// File: tests/unit/delegation/delegation.service.spec.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-13: เพิ่ม regression test เพื่อป้องกัน multi-level delegation chain
|
||||||
|
import { DelegationService } from '../../../src/modules/delegation/delegation.service';
|
||||||
|
import { CircularDetectionService } from '../../../src/modules/delegation/services/circular-detection.service';
|
||||||
|
import { Delegation } from '../../../src/modules/delegation/entities/delegation.entity';
|
||||||
|
import { User } from '../../../src/modules/user/entities/user.entity';
|
||||||
|
import { DelegationScope } from '../../../src/modules/common/enums/review.enums';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
type RepositoryMock<T extends object> = {
|
||||||
|
findOne: jest.MockedFunction<(options: unknown) => Promise<T | null>>;
|
||||||
|
create: jest.MockedFunction<(payload: Partial<T>) => T>;
|
||||||
|
save: jest.MockedFunction<(entity: T) => Promise<T>>;
|
||||||
|
createQueryBuilder: jest.MockedFunction<(alias: string) => QueryBuilderMock>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QueryBuilderMock = {
|
||||||
|
innerJoinAndSelect: jest.MockedFunction<
|
||||||
|
(relation: string, alias: string) => QueryBuilderMock
|
||||||
|
>;
|
||||||
|
where: jest.MockedFunction<
|
||||||
|
(
|
||||||
|
condition: string,
|
||||||
|
parameters?: Record<string, unknown>
|
||||||
|
) => QueryBuilderMock
|
||||||
|
>;
|
||||||
|
andWhere: jest.MockedFunction<
|
||||||
|
(
|
||||||
|
condition: string,
|
||||||
|
parameters?: Record<string, unknown>
|
||||||
|
) => QueryBuilderMock
|
||||||
|
>;
|
||||||
|
orderBy: jest.MockedFunction<
|
||||||
|
(sort: string, order: 'ASC' | 'DESC') => QueryBuilderMock
|
||||||
|
>;
|
||||||
|
getOne: jest.MockedFunction<() => Promise<Delegation | null>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createQueryBuilderMock = (
|
||||||
|
delegation: Delegation | null
|
||||||
|
): QueryBuilderMock => {
|
||||||
|
const queryBuilder = {} as QueryBuilderMock;
|
||||||
|
queryBuilder.innerJoinAndSelect = jest.fn(
|
||||||
|
(_relation: string, _alias: string): QueryBuilderMock => queryBuilder
|
||||||
|
);
|
||||||
|
queryBuilder.where = jest.fn(
|
||||||
|
(
|
||||||
|
_condition: string,
|
||||||
|
_parameters?: Record<string, unknown>
|
||||||
|
): QueryBuilderMock => queryBuilder
|
||||||
|
);
|
||||||
|
queryBuilder.andWhere = jest.fn(
|
||||||
|
(
|
||||||
|
_condition: string,
|
||||||
|
_parameters?: Record<string, unknown>
|
||||||
|
): QueryBuilderMock => queryBuilder
|
||||||
|
);
|
||||||
|
queryBuilder.orderBy = jest.fn(
|
||||||
|
(_sort: string, _order: 'ASC' | 'DESC'): QueryBuilderMock => queryBuilder
|
||||||
|
);
|
||||||
|
queryBuilder.getOne = jest.fn(
|
||||||
|
(): Promise<Delegation | null> => Promise.resolve(delegation)
|
||||||
|
);
|
||||||
|
return queryBuilder;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRepositoryMock = <T extends object>(): RepositoryMock<T> => ({
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn((payload: Partial<T>): T => payload as T),
|
||||||
|
save: jest.fn((entity: T): Promise<T> => Promise.resolve(entity)),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DelegationService', () => {
|
||||||
|
const delegationRepo = createRepositoryMock<Delegation>();
|
||||||
|
const userRepo = createRepositoryMock<User>();
|
||||||
|
const circularDetectionService = {
|
||||||
|
wouldCreateCircle: jest.fn(),
|
||||||
|
} as unknown as CircularDetectionService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
delegationRepo.createQueryBuilder.mockReturnValue(
|
||||||
|
createQueryBuilderMock(null)
|
||||||
|
);
|
||||||
|
(circularDetectionService.wouldCreateCircle as jest.Mock).mockResolvedValue(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects delegated user who already delegates onward', async () => {
|
||||||
|
const service = new DelegationService(
|
||||||
|
delegationRepo as unknown as Repository<Delegation>,
|
||||||
|
userRepo as unknown as Repository<User>,
|
||||||
|
circularDetectionService
|
||||||
|
);
|
||||||
|
const delegator = {
|
||||||
|
user_id: 1,
|
||||||
|
publicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||||
|
} as User;
|
||||||
|
const delegate = {
|
||||||
|
user_id: 2,
|
||||||
|
publicId: '019505a1-7c3e-7000-8000-000000000002',
|
||||||
|
} as User;
|
||||||
|
const onwardDelegation = {
|
||||||
|
publicId: '019505a1-7c3e-7000-8000-000000000003',
|
||||||
|
delegatorUserId: delegate.user_id,
|
||||||
|
delegateUserId: 3,
|
||||||
|
delegate: { user_id: 3 } as User,
|
||||||
|
} as Delegation;
|
||||||
|
|
||||||
|
(userRepo.findOne as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(delegator)
|
||||||
|
.mockResolvedValueOnce(delegate);
|
||||||
|
delegationRepo.createQueryBuilder.mockReturnValue(
|
||||||
|
createQueryBuilderMock(onwardDelegation)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create(delegator.publicId, {
|
||||||
|
delegateUserPublicId: delegate.publicId,
|
||||||
|
scope: DelegationScope.RFA_ONLY,
|
||||||
|
startDate: new Date('2026-05-20T00:00:00.000Z'),
|
||||||
|
endDate: new Date('2026-05-27T00:00:00.000Z'),
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Nested delegation is not allowed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// File: tests/unit/distribution/distribution-matrix.service.spec.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Add regression coverage for Distribution Matrix public-ID handling.
|
||||||
|
import { DistributionMatrixService } from '../../../src/modules/distribution/distribution-matrix.service';
|
||||||
|
import { DistributionMatrix } from '../../../src/modules/distribution/entities/distribution-matrix.entity';
|
||||||
|
import { DistributionRecipient } from '../../../src/modules/distribution/entities/distribution-recipient.entity';
|
||||||
|
import { Project } from '../../../src/modules/project/entities/project.entity';
|
||||||
|
import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity';
|
||||||
|
import {
|
||||||
|
DeliveryMethod,
|
||||||
|
RecipientType,
|
||||||
|
} from '../../../src/modules/common/enums/review.enums';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
type RepositoryMock<T extends object> = {
|
||||||
|
find: jest.MockedFunction<(options: unknown) => Promise<T[]>>;
|
||||||
|
findOne: jest.MockedFunction<(options: unknown) => Promise<T | null>>;
|
||||||
|
create: jest.MockedFunction<(payload: Partial<T>) => T>;
|
||||||
|
save: jest.MockedFunction<(payload: T) => Promise<T>>;
|
||||||
|
remove: jest.MockedFunction<(payload: T) => Promise<T>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRepositoryMock = <T extends object>(): RepositoryMock<T> => ({
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
create: jest.fn((payload: Partial<T>): T => payload as T),
|
||||||
|
save: jest.fn((payload: T): Promise<T> => Promise.resolve(payload)),
|
||||||
|
remove: jest.fn((payload: T): Promise<T> => Promise.resolve(payload)),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DistributionMatrixService', () => {
|
||||||
|
const matrixRepo = createRepositoryMock<DistributionMatrix>();
|
||||||
|
const recipientRepo = createRepositoryMock<DistributionRecipient>();
|
||||||
|
const projectRepo = createRepositoryMock<Project>();
|
||||||
|
const responseCodeRepo = createRepositoryMock<ResponseCode>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a schema-aligned matrix by resolving public IDs internally', async () => {
|
||||||
|
const service = new DistributionMatrixService(
|
||||||
|
matrixRepo as unknown as Repository<DistributionMatrix>,
|
||||||
|
recipientRepo as unknown as Repository<DistributionRecipient>,
|
||||||
|
projectRepo as unknown as Repository<Project>,
|
||||||
|
responseCodeRepo as unknown as Repository<ResponseCode>
|
||||||
|
);
|
||||||
|
matrixRepo.findOne.mockResolvedValue({
|
||||||
|
id: 7,
|
||||||
|
publicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||||
|
} as unknown as DistributionMatrix);
|
||||||
|
projectRepo.findOne.mockResolvedValue({
|
||||||
|
id: 7,
|
||||||
|
publicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||||
|
} as Project);
|
||||||
|
responseCodeRepo.findOne.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
publicId: '019505a1-7c3e-7000-8000-000000000002',
|
||||||
|
} as ResponseCode);
|
||||||
|
|
||||||
|
await service.create({
|
||||||
|
name: 'Shop Drawing Distribution',
|
||||||
|
projectPublicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||||
|
documentTypeId: 3,
|
||||||
|
responseCodePublicId: '019505a1-7c3e-7000-8000-000000000002',
|
||||||
|
conditions: { codes: ['1A', '1B'], excludeCodes: ['3'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(matrixRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'Shop Drawing Distribution',
|
||||||
|
projectId: 7,
|
||||||
|
documentTypeId: 3,
|
||||||
|
responseCodeId: 9,
|
||||||
|
conditions: { codes: ['1A', '1B'], excludeCodes: ['3'] },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds recipients with recipientPublicId instead of internal recipient ids', async () => {
|
||||||
|
const service = new DistributionMatrixService(
|
||||||
|
matrixRepo as unknown as Repository<DistributionMatrix>,
|
||||||
|
recipientRepo as unknown as Repository<DistributionRecipient>,
|
||||||
|
projectRepo as unknown as Repository<Project>,
|
||||||
|
responseCodeRepo as unknown as Repository<ResponseCode>
|
||||||
|
);
|
||||||
|
matrixRepo.findOne.mockResolvedValue({
|
||||||
|
id: 11,
|
||||||
|
publicId: '019505a1-7c3e-7000-8000-000000000003',
|
||||||
|
} as DistributionMatrix);
|
||||||
|
|
||||||
|
await service.addRecipient('019505a1-7c3e-7000-8000-000000000003', {
|
||||||
|
recipientType: RecipientType.ORGANIZATION,
|
||||||
|
recipientPublicId: '019505a1-7c3e-7000-8000-000000000004',
|
||||||
|
deliveryMethod: DeliveryMethod.BOTH,
|
||||||
|
sequence: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recipientRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
matrixId: 11,
|
||||||
|
recipientType: RecipientType.ORGANIZATION,
|
||||||
|
recipientPublicId: '019505a1-7c3e-7000-8000-000000000004',
|
||||||
|
deliveryMethod: DeliveryMethod.BOTH,
|
||||||
|
sequence: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// File: tests/unit/distribution/transmittal-creator.service.spec.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-14: Add regression coverage for Distribution Matrix response-code filtering.
|
||||||
|
import { TransmittalCreatorService } from '../../../src/modules/distribution/services/transmittal-creator.service';
|
||||||
|
import { DistributionMatrix } from '../../../src/modules/distribution/entities/distribution-matrix.entity';
|
||||||
|
import { DistributionRecipient } from '../../../src/modules/distribution/entities/distribution-recipient.entity';
|
||||||
|
import { DocumentNumberingService } from '../../../src/modules/document-numbering/services/document-numbering.service';
|
||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
type MatrixRepositoryMock = {
|
||||||
|
findOne: jest.MockedFunction<
|
||||||
|
(options: unknown) => Promise<DistributionMatrix | null>
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TransmittalCreatorService', () => {
|
||||||
|
const matrixRepo: MatrixRepositoryMock = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const dataSource = {
|
||||||
|
manager: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
query: jest.fn(),
|
||||||
|
createQueryRunner: jest.fn(),
|
||||||
|
};
|
||||||
|
const numberingService = {
|
||||||
|
generateNextNumber: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips distribution when the response code is excluded', async () => {
|
||||||
|
const service = new TransmittalCreatorService(
|
||||||
|
matrixRepo as unknown as Repository<DistributionMatrix>,
|
||||||
|
dataSource as unknown as DataSource,
|
||||||
|
numberingService as unknown as DocumentNumberingService
|
||||||
|
);
|
||||||
|
matrixRepo.findOne.mockResolvedValue({
|
||||||
|
conditions: { excludeCodes: ['3', '4'] },
|
||||||
|
recipients: [{} as DistributionRecipient],
|
||||||
|
} as DistributionMatrix);
|
||||||
|
|
||||||
|
const result = await service.createFromDistribution({
|
||||||
|
rfaPublicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||||
|
rfaRevisionPublicId: '019505a1-7c3e-7000-8000-000000000002',
|
||||||
|
projectId: 1,
|
||||||
|
documentTypeId: 2,
|
||||||
|
responseCode: '3',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
transmittalPublicIds: [],
|
||||||
|
notificationTargets: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { ResponseCodeService } from '../../../src/modules/response-code/response
|
|||||||
import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity';
|
import { ResponseCode } from '../../../src/modules/response-code/entities/response-code.entity';
|
||||||
import { ResponseCodeRule } from '../../../src/modules/response-code/entities/response-code-rule.entity';
|
import { ResponseCodeRule } from '../../../src/modules/response-code/entities/response-code-rule.entity';
|
||||||
import { ResponseCodeCategory } from '../../../src/modules/common/enums/review.enums';
|
import { ResponseCodeCategory } from '../../../src/modules/common/enums/review.enums';
|
||||||
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
|
||||||
const mockCode: Partial<ResponseCode> = {
|
const mockCode: Partial<ResponseCode> = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -21,6 +22,13 @@ const mockCode: Partial<ResponseCode> = {
|
|||||||
const mockCodeRepo = {
|
const mockCodeRepo = {
|
||||||
find: jest.fn().mockResolvedValue([mockCode]),
|
find: jest.fn().mockResolvedValue([mockCode]),
|
||||||
findOne: jest.fn().mockResolvedValue(mockCode),
|
findOne: jest.fn().mockResolvedValue(mockCode),
|
||||||
|
create: jest.fn(
|
||||||
|
(payload: Partial<ResponseCode>): Partial<ResponseCode> => payload
|
||||||
|
),
|
||||||
|
save: jest.fn(
|
||||||
|
(payload: Partial<ResponseCode>): Promise<Partial<ResponseCode>> =>
|
||||||
|
Promise.resolve(payload)
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockRuleRepo = {
|
const mockRuleRepo = {
|
||||||
@@ -31,6 +39,18 @@ describe('ResponseCodeService', () => {
|
|||||||
let service: ResponseCodeService;
|
let service: ResponseCodeService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockCodeRepo.find.mockResolvedValue([mockCode]);
|
||||||
|
mockCodeRepo.findOne.mockResolvedValue(mockCode);
|
||||||
|
mockCodeRepo.create.mockImplementation(
|
||||||
|
(payload: Partial<ResponseCode>): Partial<ResponseCode> => payload
|
||||||
|
);
|
||||||
|
mockCodeRepo.save.mockImplementation(
|
||||||
|
(payload: Partial<ResponseCode>): Promise<Partial<ResponseCode>> =>
|
||||||
|
Promise.resolve(payload)
|
||||||
|
);
|
||||||
|
mockRuleRepo.find.mockResolvedValue([]);
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
ResponseCodeService,
|
ResponseCodeService,
|
||||||
@@ -71,4 +91,72 @@ describe('ResponseCodeService', () => {
|
|||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create a non-system response code when code/category is unique', async () => {
|
||||||
|
mockCodeRepo.findOne.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const result = await service.create({
|
||||||
|
code: '9A',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'ทดสอบ',
|
||||||
|
descriptionEn: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCodeRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: '9A',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: '9A',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
isSystem: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject duplicate code/category pairs', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
code: '1A',
|
||||||
|
category: ResponseCodeCategory.ENGINEERING,
|
||||||
|
descriptionTh: 'ซ้ำ',
|
||||||
|
descriptionEn: 'Duplicate',
|
||||||
|
})
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should update an existing response code by publicId', async () => {
|
||||||
|
const result = await service.update('test-uuid-1', {
|
||||||
|
descriptionEn: 'Updated Description',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCodeRepo.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
publicId: 'test-uuid-1',
|
||||||
|
descriptionEn: 'Updated Description',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
descriptionEn: 'Updated Description',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deactivate', () => {
|
||||||
|
it('should reject deactivation for system response codes', async () => {
|
||||||
|
await expect(service.deactivate('test-uuid-1')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// File: tests/unit/review-team/task-creation-delegation.service.spec.ts
|
||||||
|
// Change Log
|
||||||
|
// - 2026-05-13: เพิ่ม regression test สำหรับการมอบหมาย Review Task ผ่าน Delegation
|
||||||
|
import { EntityManager, Repository } from 'typeorm';
|
||||||
|
import { TaskCreationService } from '../../../src/modules/review-team/services/task-creation.service';
|
||||||
|
import { ReviewTeam } from '../../../src/modules/review-team/entities/review-team.entity';
|
||||||
|
import { ReviewTeamMember } from '../../../src/modules/review-team/entities/review-team-member.entity';
|
||||||
|
import { ReviewTask } from '../../../src/modules/review-team/entities/review-task.entity';
|
||||||
|
import { DelegationService } from '../../../src/modules/delegation/delegation.service';
|
||||||
|
import { SchedulerService } from '../../../src/modules/reminder/services/scheduler.service';
|
||||||
|
import {
|
||||||
|
DelegationScope,
|
||||||
|
ReviewTeamMemberRole,
|
||||||
|
} from '../../../src/modules/common/enums/review.enums';
|
||||||
|
import { User } from '../../../src/modules/user/entities/user.entity';
|
||||||
|
|
||||||
|
type RepositoryMock<T extends object> = Pick<Repository<T>, 'findOne' | 'find'>;
|
||||||
|
|
||||||
|
const createRepositoryMock = <T extends object>(): jest.Mocked<
|
||||||
|
RepositoryMock<T>
|
||||||
|
> => ({
|
||||||
|
findOne: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createManagerMock = (): { create: jest.Mock; save: jest.Mock } => ({
|
||||||
|
create: jest.fn(
|
||||||
|
(_entity: unknown, payload: Partial<ReviewTask>): ReviewTask =>
|
||||||
|
payload as ReviewTask
|
||||||
|
),
|
||||||
|
save: jest.fn(
|
||||||
|
(_entity: unknown, payload: ReviewTask): Promise<ReviewTask> =>
|
||||||
|
Promise.resolve(payload)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TaskCreationService delegation resolution', () => {
|
||||||
|
const reviewTeamRepo = createRepositoryMock<ReviewTeam>();
|
||||||
|
const memberRepo = createRepositoryMock<ReviewTeamMember>();
|
||||||
|
const reviewTaskRepo = createRepositoryMock<ReviewTask>();
|
||||||
|
|
||||||
|
const delegationService = {
|
||||||
|
findActiveDelegate: jest.fn(),
|
||||||
|
} as unknown as DelegationService;
|
||||||
|
|
||||||
|
const schedulerService = {
|
||||||
|
scheduleForTask: jest.fn(),
|
||||||
|
} as unknown as SchedulerService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns delegated review task to active delegate and preserves original reviewer', async () => {
|
||||||
|
const service = new TaskCreationService(
|
||||||
|
reviewTeamRepo as unknown as Repository<ReviewTeam>,
|
||||||
|
memberRepo as unknown as Repository<ReviewTeamMember>,
|
||||||
|
reviewTaskRepo as unknown as Repository<ReviewTask>,
|
||||||
|
delegationService,
|
||||||
|
schedulerService
|
||||||
|
);
|
||||||
|
const manager = createManagerMock();
|
||||||
|
const originalReviewerId = 10;
|
||||||
|
const delegateReviewer = { user_id: 20 } as User;
|
||||||
|
const team = {
|
||||||
|
id: 1,
|
||||||
|
publicId: '019505a1-7c3e-7000-8000-000000000001',
|
||||||
|
isActive: true,
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
userId: originalReviewerId,
|
||||||
|
disciplineId: 3,
|
||||||
|
role: ReviewTeamMemberRole.LEAD,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as ReviewTeam;
|
||||||
|
|
||||||
|
(reviewTeamRepo.findOne as jest.Mock).mockResolvedValue(team);
|
||||||
|
(delegationService.findActiveDelegate as jest.Mock).mockResolvedValue(
|
||||||
|
delegateReviewer
|
||||||
|
);
|
||||||
|
|
||||||
|
const tasks = await service.createParallelTasks(
|
||||||
|
100,
|
||||||
|
team.publicId,
|
||||||
|
new Date('2026-05-20T00:00:00.000Z'),
|
||||||
|
manager as unknown as EntityManager
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tasks).toHaveLength(1);
|
||||||
|
expect(tasks[0].assignedToUserId).toBe(delegateReviewer.user_id);
|
||||||
|
expect(tasks[0].delegatedFromUserId).toBe(originalReviewerId);
|
||||||
|
expect(delegationService.findActiveDelegate).toHaveBeenCalledWith(
|
||||||
|
originalReviewerId,
|
||||||
|
expect.any(Date),
|
||||||
|
[DelegationScope.ALL, DelegationScope.RFA_ONLY]
|
||||||
|
);
|
||||||
|
expect(schedulerService.scheduleForTask).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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