251123:2300 Update T1

This commit is contained in:
2025-11-24 08:15:15 +07:00
parent 23006898d9
commit 9360d78ea6
81 changed files with 4232 additions and 347 deletions

View File

@@ -0,0 +1,6 @@
---
trigger: always_on
glob:
description:
---

View File

@@ -0,0 +1,397 @@
# “Phase 6A + Technical Design Document : Workflow DSL (Mini-Language)”**
ออกแบบสำหรับระบบ Workflow Engine กลางของโครงการ
**ไม่มีโค้ดผูกกับ Framework** เพื่อให้สามารถนำไป Implement ใน NestJS หรือ Microservice ใด ๆ ได้
---
## 📌 **Phase 6A Workflow DSL Implementation Plan**
### 🎯 เป้าหมายของ Phase 6A
ใน Phase นี้ จะเริ่มสร้าง “Workflow DSL (Domain-Specific Language)” สำหรับนิยามกฎการเดินงาน (Workflow Transition Rules) ให้สามารถ:
* แยก **Business Workflow Logic** ออกจาก Source Code
* แก้ไขกฎ Workflow ได้โดย **ไม่ต้องแก้โค้ดและไม่ต้อง Deploy ใหม่**
* รองรับ Document หลายประเภท เช่น
* Correspondence
* RFA
* Internal Circulation
* Document Transmittal
* รองรับ Multi-step routing, skip, reject, rollback, parallel assignments
* สามารถนำไปใช้งานทั้งใน
* Backend (NestJS)
* Frontend (UI Driven)
* External Microservices
---
### 📅 ระยะเวลา
**1 สัปดาห์ (หลัง Phase 6.5)**
---
### 🧩 Output ของ Phase 6A
* DSL Specification (Grammar)
* JSON Schema for Workflow Definition
* Workflow Rule Interpreter (Parser + Executor)
* Validation Engine (Compile-time and Runtime)
* Storage (DB Table / Registry)
* Execution API:
| Action | Description |
| -------------------------------- | ------------------------------- |
| compile() | ตรวจ DSL → สร้าง Execution Tree |
| evaluate(state, action, context) | ประมวลผลและส่งสถานะใหม่ |
| preview(state) | คำนวณ Next Possible Transitions |
| validate() | ตรวจว่า DSL ถูกต้อง |
---
## 📘 **Technical Specification Workflow DSL**
---
### 1⃣ Requirements
#### Functional Requirements
* นิยาม Workflow เป็นภาษาคล้าย State Machine
* แต่ละเอกสารมี **State, Actions, Entry/Exit Events**
* สามารถมี:
* Required approvals
* Conditional transition
* Auto-transition
* Parallel approval
* Return/rollback
####
* Running time: < 20ms ต่อคำสั่ง
* Hot reload ไม่ต้อง Compile ใหม่ทั้ง Backend
* DSL ต้อง Debug ได้ง่าย
* ต้อง Versioned
* ต้องรองรับ Audit 100%
---
### 2⃣ DSL Format (Human Friendly)
```yaml
workflow: RFA
version: 1.0
states:
- name: DRAFT
initial: true
on:
SUBMIT:
to: IN_REVIEW
require:
- role: ENGINEER
events:
- notify: reviewer
- name: IN_REVIEW
on:
APPROVE:
to: APPROVED
REJECT:
to: DRAFT
events:
- notify: creator
- name: APPROVED
terminal: true
```
---
### 3⃣ Compiled Execution Model (Normalized JSON)
```json
{
"workflow": "RFA",
"version": "1.0",
"states": {
"DRAFT": {
"initial": true,
"transitions": {
"SUBMIT": {
"to": "IN_REVIEW",
"requirements": [
{ "role": "ENGINEER" }
],
"events": [
{ "type": "notify", "target": "reviewer" }
]
}
}
},
"IN_REVIEW": {
"transitions": {
"APPROVE": { "to": "APPROVED" },
"REJECT": {
"to": "DRAFT",
"events": [
{ "type": "notify", "target": "creator" }
]
}
}
},
"APPROVED": {
"terminal": true
}
}
}
```
Frontend และ Backend สามารถแชร์ JSON นี้ร่วมกันได้
---
### 4⃣ DSL Grammar Definition (EBNF)
```ebnf
workflow = "workflow" ":" identifier ;
version = "version" ":" number ;
states = "states:" state_list ;
state_list = { state } ;
state = "- name:" identifier
[ "initial:" boolean ]
[ "terminal:" boolean ]
[ "on:" transition_list ] ;
transition_list = { transition } ;
transition = action ":"
indent "to:" identifier
[ indent "require:" requirements ]
[ indent "events:" event_list ] ;
requirements = "- role:" identifier | "- user:" identifier ;
event_list = { event } ;
event = "- notify:" identifier ;
```
---
### 5⃣ Validation Rules (Compile-Time)
#### 5.1 State Rules
* ต้องมีอย่างน้อย 1 state ที่ `initial: true`
* หาก `terminal: true` → ต้องไม่มี transition ต่อไป
#### 5.2 Transition Rules
ตรวจสอบว่า:
* `to` ชี้ไปยัง state ที่มีอยู่
* `require.role` ต้องเป็น role ที่ระบบรู้จัก
* Action name ต้องเป็น **UPPER_CASE**
#### 5.3 Version Safety
* ทุกชุด Workflow DSL ต้องขึ้นกับ version
* แก้ไขต้องสร้าง version ใหม่
* ไม่ overwrite version เก่า
* “Document ที่กำลังอยู่ใน step เดิมยังต้องใช้กฎเดิมได้”
---
### 6⃣ Runtime Validation Rules
เมื่อ execute(action):
```
input: current_state, action, context
1) ตรวจว่า state มี transition "action"
2) ตรวจว่าผู้ใช้มีสิทธิ์ตาม require[]
3) Compute next state
4) Execute events[]
5) Return next_state
```
---
### 7⃣ Context Model
```ts
interface WorkflowContext {
userId: string;
roles: string[];
documentId: string;
now: Date;
payload?: any;
}
```
---
### 8⃣ Execution API (Abstract)
```ts
class WorkflowEngine {
load(dsl: string | object): CompiledWorkflow
compile(dsl: string | object): CompiledWorkflow
evaluate(state: string, action: string, context: WorkflowContext): EvalResult
getAvailableActions(state: string, context: WorkflowContext): string[]
}
```
---
### 9⃣ Interpreter Execution Flow
```mermaid
flowchart TD
A[Receive Action] --> B[Load Compiled Workflow]
B --> C[Check allowed actions]
C -->|Invalid| E[Return Error]
C --> D[Evaluate Requirements]
D --> F[Transition to Next State]
F --> G[Run Events]
G --> H[Return Success]
```
---
### 🔟 Events System
รองรับ event หลายประเภท:
| event.type | ตัวอย่าง |
| ----------- | ------------------------- |
| notify | ส่ง Email/Line |
| assign | เปลี่ยนผู้รับผิดชอบ |
| webhook | ยิง Webhook ไปยังระบบอื่น |
| auto_action | ทำ action ซ้ำโดยอัตโนมัติ |
---
### 11⃣ Database Schema
#### Table: `workflow_definition`
| Field | Type | Description |
| ------------- | ----------- | --------------------- |
| id | UUID | PK |
| workflow_code | varchar(50) | เช่น `RFA`, `CORR` |
| version | int | Version number |
| dsl | JSON | YAML/JSON DSL เก็บดิบ |
| compiled | JSON | JSON ที่ Compile แล้ว |
| created_at | timestamp | |
| is_active | boolean | ใช้อยู่หรือไม่ |
#### Table: `workflow_history`
เก็บ audit แบบ immutable append-only
| Field | Description |
| ----------- | --------------- |
| workflow | Document Type |
| document_id | เอกสารไหน |
| from_state | เดิม |
| to_state | ใหม่ |
| action | คำสั่ง |
| user | ใครเป็นคนทำ |
| timestamp | เวลา |
| metadata | เหตุการณ์อื่น ๆ |
---
### 12⃣ Error Codes
| Code | Meaning |
| ----------------------- | ---------------------- |
| WF_NO_TRANSITION | Action นี้ไม่ถูกต้อง |
| WF_RESTRICTED | User ไม่มีสิทธิ์ |
| WF_MISSING_REQUIREMENTS | ไม่ผ่านเงื่อนไข |
| WF_STATE_NOT_FOUND | ไม่มี state ที่อ้างอิง |
| WF_SYNTAX_ERROR | DSL ผิดรูปแบบ |
---
### 13⃣ Testing Strategy
#### Unit Tests
* Parse DSL → JSON
* Invalid syntax → throw error
* Invalid transitions → throw error
#### Integration Tests
* Evaluate() ผ่าน 20+ cases
* RFA ย้อนกลับ
* Approve chain
* Parallel review
#### Load Tests
* 1,000 documents running workflow
* Evaluate < 20ms ต่อ action
---
### 14⃣ Deployment Strategy
#### Hot Reload Options
* DSL stored in DB
* Cache in Redis
* Touched timestamp triggers:
```
invalidate cache → recompile
```
#### No downtime required
---
### 15⃣ Microservice-Ready
DSL Engine แยกเป็น:
* `workflow-engine-core` → Pure JS library
* `workflow-service` → NestJS module
* API public:
```
POST /workflow/evaluate
GET /workflow/preview
POST /workflow/compile
```
ภายหลังสามารถนำไปวางบน:
* Kubernetes
* Worker Node
* API Gateway
---
## 🎉 Summary
### สิ่งที่ Phase 6A เพิ่มเข้าในระบบ
✔ Workflow DSL ที่แก้ไขได้โดยไม่ต้อง Deploy
✔ Parser + Validator + Runtime Interpreter
✔ Database storage + Versioning
✔ Execution API สำหรับ Backend และ Frontend
✔ รองรับ Business Workflow ซับซ้อนทั้งหมด
✔ Ready สำหรับ Microservice model ในอนาคต

View File

@@ -127,7 +127,7 @@ src/
- **[ ] T1.1 CommonModule - Base Infrastructure**
- [ ] สร้าง Base Entity (id, created\_at, updated\_at, deleted\_at)
- [ ] สร้าง Base Entity (id, created_at, updated_at, deleted_at)
- [ ] สร้าง Global Exception Filter (ไม่เปิดเผย sensitive information)
- [ ] สร้าง Response Transform Interceptor
- [ ] สร้าง Audit Log Interceptor
@@ -150,7 +150,7 @@ src/
- [ ] สร้าง JWT Strategy (Passport)
- [ ] สร้าง JwtAuthGuard
- [ ] สร้าง Controllers:
- [ ] POST /auth/login → { access\_token, refresh\_token }
- [ ] POST /auth/login → { access_token, refresh_token }
- [ ] POST /auth/register → Create User (Admin only)
- [ ] POST /auth/refresh → Refresh token
- [ ] POST /auth/logout → Revoke token
@@ -238,8 +238,8 @@ src/
- [ ] Generate checksum (SHA-256)
- [ ] **Cleanup Job:** สร้าง Cron Job ลบไฟล์ใน `temp/` ที่ค้างเกิน 24 ชม. **โดยตรวจสอบจากคอลัมน์ `expires_at` ในตาราง `attachments`**
- [ ] สร้าง Controller:
- [ ] POST /files/upload → { temp\_id } (Protected)
- [ ] POST /files/commit → { attachment\_id, url } (Protected)
- [ ] POST /files/upload → { temp_id } (Protected)
- [ ] POST /files/commit → { attachment_id, url } (Protected)
- [ ] GET /files/:id/download → File Stream (Protected + Expiration)
- [ ] **Security:** Access Control - ตรวจสอบสิทธิ์ผ่าน Junction Table
- [ ] **Deliverable:** อัปโหลด/ดาวน์โหลดไฟล์ได้อย่างปลอดภัย แบบ Transactional
@@ -259,7 +259,7 @@ src/
4. Release Redis Lock
5. Retry on Failure ด้วย exponential backoff
- [ ] Fallback mechanism เมื่อการขอเลขล้มเหลว
- [ ] Format ตาม Template: {ORG\_CODE}-{TYPE\_CODE}-{YEAR\_SHORT}-{SEQ:4}
- [ ] Format ตาม Template: {ORG_CODE}-{TYPE_CODE}-{YEAR_SHORT}-{SEQ:4}
- **ไม่มี Controller** (Internal Service เท่านั้น)
- [ ] **Security:** Implement audit log ทุกครั้งที่มีการ generate เลขที่
- [ ] **Deliverable:** Service สร้างเลขที่เอกสารได้ถูกต้องและปลอดภัย ไม่มี Race Condition
@@ -503,7 +503,7 @@ src/
- [ ] Implement Caching (Master Data, User Permissions, Search Results)
- [ ] Database Optimization (Review Indexes, Query Optimization, Pagination)
- [ ] **Deliverable:** Response Time \< 200ms (90th percentile)
- [ ] **Deliverable:** Response Time < 200ms (90th percentile)
-----

478
5_Backend_Folder_V1_4_3.md Normal file
View File

@@ -0,0 +1,478 @@
# โครงสร้างโฟลเดอร์และไฟล์ทั้งหมดสำหรับ **Backend (NestJS)** ตามแผนงาน **LCBP3-DMS v1.4.3** ตั้งแต่ Phase 0 ถึง Phase 6 (T0-T6.2) ที่ได้ดำเนินการไปแล้ว
โครงสร้างนี้ออกแบบตามหลัก **Domain-Driven Design** และ **Modular Architecture** ที่ระบุไว้ในแผนพัฒนา
---
## 📂 **backend/** (Backend Application)
* [x] `.env` (สำหรับ Local Dev เท่านั้น ห้าม commit)
* [x] `.gitignore`
* [x] `docker-compose.yml` (Configuration หลักสำหรับ Deploy)
* [x] `docker-compose.override.yml` (สำหรับ Inject Secrets ตอน Dev)
* [x] `package.json`
* [x] `pnpm-lock.yaml`
* [x] `tsconfig.json`
* [x] `nest-cli.json`
* [x] `README.md`
---
## 📂 **backend/src/** (Source Code)
### **📄 Entry Points**
* [x] `main.ts` (Application Bootstrap, Swagger, Global Pipes)
* [x] `app.module.ts` (Root Module ที่รวมทุก Modules เข้าด้วยกัน)
### **📁 src/common/** (Shared Resources)
* [x] **common.module.ts**
* **auth/**
* **dto/**
* [x] **login.dto.ts**
* [x] **register.dto.ts**
* [x] **auth.controller.spec.ts**
* [x] **auth.controller.ts**
* [x] **auth.module.ts**
* [x] **auth.service.spec.ts**
* [x] **auth.service.ts**
* **config/** (Configuration Service)
* [x] **env.validation.ts**
* **decorators/**
* [x] **audit.decorator.ts**
* [x] `current-user.decorator.ts`
* [x] `require-permission.decorator.ts`
* **entities/**
* [x] **audit-log.entity.ts**
* [x] **base.entity.ts**
* **exceptions/**
* [x] `http-exception.filter.ts` (Global Filter)
* **file-storage/** (Two-Phase Storage System)
* **entities/**
* [x] **attachment.entity.ts**
* [x] **file-storage.controller.spec.ts**
* [x] **file-storage.controller.ts**
* [x] **file-storage.module.ts**
* [x] **file-storage.service.spec.ts**
* [x] `file-storage.service.ts` (Upload, Scan Virus, Commit)
* [x] `guards/`
* [x] `jwt-auth.guard.ts`
* [x] **jwt.strategy.ts**
* [x] `rbac.guard.ts` (ตรวจสอบสิทธิ์ 4 ระดับ)
* **interceptors/**
* [x] `audit-log.interceptor.ts` (เก็บ Log ลง DB)
* [x] `transform.interceptor.ts` (Standard Response Format)
* **resilience/** (Circuit Breaker & Retry)
### **📁 src/modules/** (Feature Modules)
1. **user/** (User Management & RBAC)
* [x] `dto/`
* [x] **assign-user-role.dto.ts**
* [x] `create-user.dto.ts`
* [x] `update-user.dto.ts`
* [x] `entities/`
* [x] `user.entity.ts`
* [x] `role.entity.ts`
* [x] `permission.entity.ts`
* [x] `user-preference.entity.ts`
* [x] **user-assignment.service.ts**
* [x] `user.controller.ts`
* [x] `user.module.ts`
* [x] `user.service.ts`
* [x] **user.service.spec.ts**
2. **project/** (Project Structure)
* [x] `dto/`
* [x] **create-project.dto.ts**
* [x] `search-project.dto.ts`
* [x] `update-project.dto.ts`
* [x] `entities/`
* [x] `contract-organization.entity.ts`
* [x] `contract.entity.ts`
* [x] `organization.entity.ts`
* [x] `project-organization.entity.ts` (Junction)
* [x] **project.entity.ts**
* [x] **project.controller.spec.ts**
* [x] `project.controller.ts`
* [x] `project.module.ts`
* [x] **project.service.spec.ts**
* [x] `project.service.ts`
3. **correspondence/** (Core Document System)
* [x] `dto/`
* [x] `add-reference.dto.ts`
* [x] `create-correspondence.dto.ts`
* [x] `search-correspondence.dto.ts`
* [x] **submit-correspondence.dto.ts**
* [x] `workflow-action.dto.ts`
* [x] `entities/`
* [x] `correspondence-reference.entity.ts`
* [x] `correspondence-revision.entity.ts`
* [x] `correspondence-routing.entity.ts` (Unified Workflow)
* [x] `correspondence-status.entity.ts`
* [x] `correspondence-type.entity.ts`
* [x] `correspondence.entity.ts`
* [x] **routing-template-step.entity.ts**
* [x] `routing-template.entity.ts`
* [x] **correspondence.controller.spec.ts**
* [x] `correspondence.controller.ts`
* [x] `correspondence.module.ts`
* [x] **correspondence.service.spec.ts**
* [x] `correspondence.service.ts` (Impersonation & Workflow Logic)
4. **drawing/** (Contract & Shop Drawings)
* [x] `dto/`
* [x] `create-contract-drawing.dto.ts`
* [x] `create-shop-drawing-revision.dto.ts`
* [x] `create-shop-drawing.dto.ts`
* [x] `search-contract-drawing.dto.ts`
* [x] `search-shop-drawing.dto.ts`
* [x] `update-contract-drawing.dto.ts`
* [x] `entities/`
* [x] `contract-drawing-sub-category.entity.ts`
* [x] `contract-drawing-volume.entity.ts`
* [x] `contract-drawing.entity.ts`
* [x] `shop-drawing-main-category.entity.ts`
* [x] `shop-drawing-revision.entity.ts`
* [x] `shop-drawing-sub-category.entity.ts`
* [x] `shop-drawing.entity.ts`
* [x] `contract-drawing.controller.ts`
* [x] `contract-drawing.service.ts`
* [x] `drawing-master-data.controller.ts`
* [x] `drawing-master-data.service.ts`
* [x] `drawing.module.ts`
* [x] `shop-drawing.controller.ts`
* [x] `shop-drawing.service.ts`
4. **rfa/** (Request for Approval & Advanced Workflow)
* [x] `dto/`
* [x] `create-rfa.dto.ts`
* [x] `search-rfa.dto.ts`
* [x] `update-rfa.dto.ts`
* [x] `entities/`
* [x] `rfa-approve-code.entity.ts`
* [x] `rfa-item.entity.ts`
* [x] `rfa-revision.entity.ts`
* [x] `rfa-status-code.entity.ts`
* [x] `rfa-type.entity.ts`
* [x] `rfa-workflow-template-step.entity.ts`
* [x] `rfa-workflow-template.entity.ts`
* [x] `rfa-workflow.entity.ts`
* [x] `rfa.entity.ts`
* [x] `rfa.controller.ts`
* [x] `rfa.module.ts`
* [x] `rfa.service.ts` (Unified Workflow Integration)
5. **circulation/** (Internal Routing)
* [x] `dto/`
* [x] `create-circulation.dto.ts`
* [x] `update-circulation-routing.dto.ts`
* [x] `search-circulation.dto.ts`
* [x] `entities/`
* [x] `circulation-routing.entity.ts`
* [x] `circulation-status-code.entity.ts`
* [x] `circulation.entity.ts`
* [x] `circulation.controller.ts`
* [x] `circulation.module.ts`
* [x] `circulation.service.ts`
6. **transmittal/** (Document Forwarding)
* [x] `dto/`
* [x] `create-transmittal.dto.ts`
* [x] `search-transmittal.dto.ts`
* [x] **update-transmittal.dto.ts**
* [x] `entities/`
* [x] `transmittal-item.entity.ts`
* [x] `transmittal.entity.ts`
* [x] `transmittal.controller.ts`
* [x] `transmittal.module.ts`
* [x] `transmittal.service.ts`
7. **notification/** (System Alerts)
* [x] `dto/`
* [x] `create-notification.dto.ts`
* [x] `search-notification.dto.ts`
* [x] `entities/`
* [x] `notification.entity.ts`
* [x] `notification-cleanup.service.ts` (Cron Job)
* [x] `notification.controller.ts`
* [x] `notification.gateway.ts`
* [x] `notification.module.ts` (Real-time WebSocket)
* [x] `notification.processor.ts` (Consumer/Worker for Email & Line)
* [x] `notification.service.ts` (Producer)
8. **search/** (Elasticsearch)
* [x] `dto/search-query.dto.ts`
* [x] `search.controller.ts`
* [x] `search.module.ts`
* [x] `search.service.ts` (Indexing & Searching)
9. **document-numbering/** (Internal Service)
* [x] `entities/`
* [x] `document-number-format.entity.ts`
* [x] `document-number-counter.entity.ts`
* [x] `document-numbering.module.ts`
* [x] **document-numbering.service.spec.ts**
* [x] `document-numbering.service.ts` (Double-Lock Mechanism)
10. **workflow-engine/** (Unified Logic)
* [x] `interfaces/workflow.interface.ts`
* [x] `workflow-engine.module.ts`
* [x] **workflow-engine.service.spec.ts**
* [x] `workflow-engine.service.ts` (State Machine Logic)
11. **json-schema/** (Validation)
* [x] `dto/`
* [x] `create-json-schema.dto.ts`+
* [x] `search-json-schema.dto.ts`
* [x] `update-json-schema.dto.ts`
* [x] `entities/`
* [x] `json-schema.entity.ts`
* [x] **json-schema.controller.spec.ts**
* [x] **json-schema.controller.ts**
* [x] `json-schema.module.ts`
* [x] **json-schema.service.spec.ts**
* [x] `json-schema.service.ts`
## **Folder Structure ของ Backend (NestJS)** ที่
---
### 📁 Backend Folder Structure (LCBP3-DMS v1.4.3)
```text
backend/
├── .env # Environment variables for local development only (not committed)
├── .gitignore # Git ignore rules
├── docker-compose.yml # Main deployment container configuration
├── docker-compose.override.yml # Dev-time secret/environment injection
├── package.json # Node dependencies and NPM scripts
├── pnpm-lock.yaml # Dependency lock file for pnpm
├── tsconfig.json # TypeScript compiler configuration
├── nest-cli.json # NestJS project configuration
├── README.md # Project documentation
└── src/
├── main.ts # Application bootstrap and initialization
├── app.module.ts # Root application module
├── common/ # 🛠️ Shared framework resources used across modules
│ ├── common.module.ts # Registers shared providers
│ │
│ ├── auth/ # 🛡️ Authentication module
│ │ ├── dto/
│ │ │ ├── login.dto.ts # Login request payload
│ │ │ └── register.dto.ts # Registration payload
│ │ ├── auth.module.ts # Auth DI module
│ │ ├── auth.controller.ts # Auth REST endpoints
│ │ ├── auth.controller.spec.ts # Unit tests for controller
│ │ ├── auth.service.ts # Authentication logic
│ │ └── auth.service.spec.ts # Unit test for service
│ │
│ ├── config/ # 📄 Configuration
│ │ └── env.validation.ts # Zod/Joi validation for environment variables
│ │
│ ├── decorators/ # 📡 Decorators for common use cases
│ │ ├── audit.decorator.ts # Enables audit logging for a method
│ │ ├── current-user.decorator.ts # Extracts logged-in user from request
│ │ └── require-permission.decorator.ts # Declares RBAC permission requirement
│ │
│ ├── entities/ # 📚 Database entities
│ │ ├── audit-log.entity.ts # Audit log database entity
│ │ └── base.entity.ts # Base abstraction containing core columns
│ │
│ ├── exceptions/ # 🛡️ Global exception trap/formatter
│ │ └── http-exception.filter.ts # Global exception trap/formatter
│ │
│ ├── file-storage/ # 📂 Two-Phase document storage (upload → scan → commit)
│ │ ├── entities/
│ │ │ └── attachment.entity.ts # Represents stored file metadata
│ │ ├── file-storage.controller.ts # Upload/download endpoints
│ │ ├── file-storage.controller.spec.ts # Unit tests
│ │ ├── file-storage.module.ts # Module DI bindings
│ │ ├── file-storage.service.ts # File handling logic
│ │ └── file-storage.service.spec.ts
│ │
│ ├── guards/ # 🛡️ JWT authentication guard
│ │ ├── jwt-auth.guard.ts # JWT authentication guard
│ │ ├── jwt.strategy.ts # JWT strategy configuration
│ │ └── rbac.guard.ts # Role-based access control enforcement
│ │
│ ├── interceptors/ # 📡 Interceptors for common use cases
│ │ ├── audit-log.interceptor.ts # Automatically logs certain operations
│ │ └── transform.interceptor.ts # Standardized response formatting
│ │
│ └── resilience/ # 🛡️ Circuit-breaker / retry logic (if implemented)
├── modules/ # 📦 Module-specific resources
│ ├── user/ # 👤 User + RBAC module
│ │ ├── dto/
│ │ │ ├── assign-user-role.dto.ts # Assign roles to users
│ │ │ ├── create-user.dto.ts
│ │ │ └── update-user.dto.ts
│ │ ├── entities/
│ │ │ ├── user.entity.ts # User table definition
│ │ │ ├── role.entity.ts # Role definition
│ │ │ ├── permission.entity.ts # Permission entity
│ │ │ └── user-preference.entity.ts # User preference settings
│ │ ├── user.controller.ts # REST endpoints
│ │ ├── user.service.ts # Business logic
│ │ ├── user.service.spec.ts # Unit tests
│ │ └── user.module.ts # Module DI container
│ │
│ ├── project/ # 🏢 Project/Organization/Contract structure
│ │ ├── dto/
│ │ │ ├── create-project.dto.ts
│ │ │ ├── search-project.dto.ts
│ │ │ └── update-project.dto.ts
│ │ ├── entities/
│ │ │ ├── project.entity.ts
│ │ │ ├── contract.entity.ts
│ │ │ ├── organization.entity.ts
│ │ │ ├── project-organization.entity.ts
│ │ │ └── contract-organization.entity.ts
│ │ ├── project.controller.ts
│ │ ├── project.controller.spec.ts
│ │ └── project.service.ts
│ ├── correspondence/ # ✉️ Formal letters with routing workflow
│ │ ├── dto/
│ │ │ ├── add-reference.dto.ts
│ │ │ ├── create-correspondence.dto.ts
│ │ │ ├── search-correspondence.dto.ts
│ │ │ ├── submit-correspondence.dto.ts
│ │ │ └── workflow-action.dto.ts
│ │ ├── entities/
│ │ │ ├── correspondence.entity.ts
│ │ │ ├── correspondence-revision.entity.ts
│ │ │ ├── correspondence-routing.entity.ts
│ │ │ ├── correspondence-status.entity.ts
│ │ │ ├── correspondence-type.entity.ts
│ │ │ ├── correspondence-reference.entity.ts
│ │ │ ├── routing-template.entity.ts
│ │ │ └── routing-template-step.entity.ts
│ │ ├── correspondence.controller.ts
│ │ ├── correspondence.controller.spec.ts
│ │ └── correspondence.service.ts
│ ├── drawing/ # 📐Contract & Shop drawing tracking
│ │ ├── dto/
│ │ │ ├── create-contract-drawing.dto.ts
│ │ │ ├── create-shop-drawing.dto.ts
│ │ │ ├── create-shop-drawing-revision.dto.ts
│ │ │ ├── search-contract-drawing.dto.ts
│ │ │ ├── search-shop-drawing.dto.ts
│ │ │ └── update-contract-drawing.dto.ts
│ │ ├── entities/
│ │ │ ├── contract-drawing.entity.ts
│ │ │ ├── contract-drawing-volume.entity.ts
│ │ │ ├── contract-drawing-sub-category.entity.ts
│ │ │ ├── shop-drawing.entity.ts
│ │ │ ├── shop-drawing-revision.entity.ts
│ │ │ ├── shop-drawing-main-category.entity.ts
│ │ │ └── shop-drawing-sub-category.entity.ts
│ │ ├── drawing.module.ts
│ │ ├── contract-drawing.controller.ts
│ │ ├── contract-drawing.service.ts
│ │ ├── drawing-master-data.controller.ts
│ │ ├── drawing-master-data.service.ts
│ │ ├── shop-drawing.controller.ts
│ │ └── shop-drawing.service.ts
│ ├── rfa/ # ✅ Request for Approval (multi-step workflow)
│ │ ├── dto/
│ │ │ ├── create-rfa.dto.ts
│ │ │ ├── search-rfa.dto.ts
│ │ │ └── update-rfa.dto.ts
│ │ ├── entities/
│ │ │ ├── rfa.entity.ts
│ │ │ ├── rfa-revision.entity.ts
│ │ │ ├── rfa-item.entity.ts
│ │ │ ├── rfa-type.entity.ts
│ │ │ ├── rfa-status-code.entity.ts
│ │ │ ├── rfa-approve-code.entity.ts
│ │ │ ├── rfa-workflow.entity.ts
│ │ │ ├── rfa-workflow-template.entity.ts
│ │ │ └── rfa-workflow-template-step.entity.ts
│ │ ├── rfa.controller.ts
│ │ ├── rfa.module.ts
│ │ └── rfa.service.ts
│ ├── circulation/ # 🔄 Internal routing workflow
│ │ ├── dto/
│ │ │ ├── create-circulation.dto.ts
│ │ │ ├── update-circulation-routing.dto.ts
│ │ │ └── search-circulation.dto.ts
│ │ ├── entities/
│ │ │ ├── circulation.entity.ts
│ │ │ ├── circulation-routing.entity.ts
│ │ │ └── circulation-status-code.entity.ts
│ │ ├── circulation.controller.ts
│ │ ├── circulation.module.ts
│ │ └── circulation.service.ts
│ ├── transmittal/ # 📤 Document forwarding
│ │ ├── dto/
│ │ │ ├── create-transmittal.dto.ts
│ │ │ ├── search-transmittal.dto.ts
│ │ │ └── update-transmittal.dto.ts
│ │ ├── entities/
│ │ │ ├── transmittal.entity.ts
│ │ │ └── transmittal-item.entity.ts
│ │ ├── transmittal.controller.ts
│ │ ├── transmittal.module.ts
│ │ └── transmittal.service.ts
│ ├── notification/ # 🔔 Real-Time notification system
│ │ ├── dto/
│ │ │ ├── create-notification.dto.ts
│ │ │ └── search-notification.dto.ts
│ │ ├── entities/
│ │ │ └── notification.entity.ts
│ │ ├── notification.module.ts # WebSocket + Processor registration
│ │ ├── notification.controller.ts
│ │ ├── notification.gateway.ts # WebSocket gateway
│ │ ├── notification.processor.ts # Message consumer (e.g. mail worker)
│ │ ├── notification.service.ts
│ │ └── notification-cleanup.service.ts # Cron-based cleanup job
│ ├── search/ # 🔍 Elasticsearch integration
│ │ ├── dto/
│ │ │ └── search-query.dto.ts
│ │ ├── search.module.ts
│ │ ├── search.controller.ts
│ │ └── search.service.ts # Indexing/search logic
│ ├── document-numbering/ # 🔢 Auto-increment controlled ID generation
│ │ ├── entities/
│ │ │ ├── document-number-format.entity.ts
│ │ │ └── document-number-counter.entity.ts
│ │ ├── document-numbering.module.ts
│ │ ├── document-numbering.service.ts
│ │ └── document-numbering.service.spec.ts
│ ├── workflow-engine/ # ⚙️ Unified state-machine workflow engine
│ │ ├── interfaces/
│ │ │ └── workflow.interface.ts
│ │ ├── workflow-engine.module.ts
│ │ ├── workflow-engine.service.ts
│ │ └── workflow-engine.service.spec.ts
│ └── json-schema/ # 📋 Dynamic request schema validation
│ ├── dto/
│ │ ├── create-json-schema.dto.ts
│ │ ├── update-json-schema.dto.ts
│ │ └── search-json-schema.dto.ts
│ ├── entities/
│ │ └── json-schema.entity.ts
│ ├── json-schema.module.ts
│ ├── json-schema.controller.ts
│ ├── json-schema.controller.spec.ts
│ ├── json-schema.service.ts
│ └── json-schema.service.spec.ts
```
---

436
T0-T6.2.md Normal file
View File

@@ -0,0 +1,436 @@
# โครงสร้างโฟลเดอร์และไฟล์ทั้งหมดสำหรับ **Backend (NestJS)** ตามแผนงาน **LCBP3-DMS v1.4.3** ตั้งแต่ Phase 0 ถึง Phase 6 (T0-T6.2) ที่ได้ดำเนินการไปแล้ว
โครงสร้างนี้ออกแบบตามหลัก **Domain-Driven Design** และ **Modular Architecture** ที่ระบุไว้ในแผนพัฒนา
---
## 📂 **backend/** (Backend Application)
* [x] `.env` (สำหรับ Local Dev เท่านั้น ห้าม commit)
* [x] `.gitignore`
* [x] `docker-compose.yml` (Configuration หลักสำหรับ Deploy)
* [x] `docker-compose.override.yml` (สำหรับ Inject Secrets ตอน Dev)
* [x] `package.json`
* [x] `pnpm-lock.yaml`
* [x] `tsconfig.json`
* [x] `nest-cli.json`
* [x] `README.md`
---
## 📂 **backend/src/** (Source Code)
### **📄 Entry Points**
* [x] `main.ts` (Application Bootstrap, Swagger, Global Pipes)
* [x] `app.module.ts` (Root Module ที่รวมทุก Modules เข้าด้วยกัน)
### **📁 src/common/** (Shared Resources)
* [x] **common.module.ts**
* **auth/**
* **dto/**
* [x] **login.dto.ts**
* [x] **register.dto.ts**
* [x] **auth.controller.spec.ts**
* [x] **auth.controller.ts**
* [x] **auth.module.ts**
* [x] **auth.service.spec.ts**
* [x] **auth.service.ts**
* **config/** (Configuration Service)
* [x] **env.validation.ts**
* **decorators/**
* [x] **audit.decorator.ts**
* [x] `current-user.decorator.ts`
* [x] `require-permission.decorator.ts`
* **entities/**
* [x] **audit-log.entity.ts**
* [x] **base.entity.ts**
* **exceptions/**
* [x] `http-exception.filter.ts` (Global Filter)
* **file-storage/** (Two-Phase Storage System)
* **entities/**
* [x] **attachment.entity.ts**
* [x] **file-storage.controller.spec.ts**
* [x] **file-storage.controller.ts**
* [x] **file-storage.module.ts**
* [x] **file-storage.service.spec.ts**
* [x] `file-storage.service.ts` (Upload, Scan Virus, Commit)
* [x] `guards/`
* [x] `jwt-auth.guard.ts`
* [x] **jwt.strategy.ts**
* [x] `rbac.guard.ts` (ตรวจสอบสิทธิ์ 4 ระดับ)
* **interceptors/**
* [x] `audit-log.interceptor.ts` (เก็บ Log ลง DB)
* [x] `transform.interceptor.ts` (Standard Response Format)
* **resilience/** (Circuit Breaker & Retry)
### **📁 src/modules/** (Feature Modules)
1. **user/** (User Management & RBAC)
* [x] `dto/`
* [x] **assign-user-role.dto.ts**
* [x] `create-user.dto.ts`
* [x] `update-user.dto.ts`
* [x] `entities/`
* [x] `user.entity.ts`
* [x] `role.entity.ts`
* [x] `permission.entity.ts`
* [x] `user-preference.entity.ts`
* [x] **user-assignment.service.ts**
* [x] `user.controller.ts`
* [x] `user.module.ts`
* [x] `user.service.ts`
* [x] **user.service.spec.ts**
2. **project/** (Project Structure)
* [x] `dto/`
* [x] **create-project.dto.ts**
* [x] `search-project.dto.ts`
* [x] `update-project.dto.ts`
* [x] `entities/`
* [x] `contract-organization.entity.ts`
* [x] `contract.entity.ts`
* [x] `organization.entity.ts`
* [x] `project-organization.entity.ts` (Junction)
* [x] **project.entity.ts**
* [x] **project.controller.spec.ts**
* [x] `project.controller.ts`
* [x] `project.module.ts`
* [x] **project.service.spec.ts**
* [x] `project.service.ts`
3. **correspondence/** (Core Document System)
* [x] `dto/`
* [x] `add-reference.dto.ts`
* [x] `create-correspondence.dto.ts`
* [x] `search-correspondence.dto.ts`
* [x] **submit-correspondence.dto.ts**
* [x] `workflow-action.dto.ts`
* [x] `entities/`
* [x] `correspondence-reference.entity.ts`
* [x] `correspondence-revision.entity.ts`
* [x] `correspondence-routing.entity.ts` (Unified Workflow)
* [x] `correspondence-status.entity.ts`
* [x] `correspondence-type.entity.ts`
* [x] `correspondence.entity.ts`
* [x] **routing-template-step.entity.ts**
* [x] `routing-template.entity.ts`
* [x] **correspondence.controller.spec.ts**
* [x] `correspondence.controller.ts`
* [x] `correspondence.module.ts`
* [x] **correspondence.service.spec.ts**
* [x] `correspondence.service.ts` (Impersonation & Workflow Logic)
4. **drawing/** (Contract & Shop Drawings)
* [x] `dto/`
* [x] `create-contract-drawing.dto.ts`
* [x] `create-shop-drawing-revision.dto.ts`
* [x] `create-shop-drawing.dto.ts`
* [x] `search-contract-drawing.dto.ts`
* [x] `search-shop-drawing.dto.ts`
* [x] `update-contract-drawing.dto.ts`
* [x] `entities/`
* [x] `contract-drawing-sub-category.entity.ts`
* [x] `contract-drawing-volume.entity.ts`
* [x] `contract-drawing.entity.ts`
* [x] `shop-drawing-main-category.entity.ts`
* [x] `shop-drawing-revision.entity.ts`
* [x] `shop-drawing-sub-category.entity.ts`
* [x] `shop-drawing.entity.ts`
* [x] `contract-drawing.controller.ts`
* [x] `contract-drawing.service.ts`
* [x] `drawing-master-data.controller.ts`
* [x] `drawing-master-data.service.ts`
* [x] `drawing.module.ts`
* [x] `shop-drawing.controller.ts`
* [x] `shop-drawing.service.ts`
4. **rfa/** (Request for Approval & Advanced Workflow)
* [x] `dto/`
* [x] `create-rfa.dto.ts`
* [x] `search-rfa.dto.ts`
* [x] `update-rfa.dto.ts`
* [x] `entities/`
* [x] `rfa-approve-code.entity.ts`
* [x] `rfa-item.entity.ts`
* [x] `rfa-revision.entity.ts`
* [x] `rfa-status-code.entity.ts`
* [x] `rfa-type.entity.ts`
* [x] `rfa-workflow-template-step.entity.ts`
* [x] `rfa-workflow-template.entity.ts`
* [x] `rfa-workflow.entity.ts`
* [x] `rfa.entity.ts`
* [x] `rfa.controller.ts`
* [x] `rfa.module.ts`
* [x] `rfa.service.ts` (Unified Workflow Integration)
5. **circulation/** (Internal Routing)
* [x] `dto/`
* [x] `create-circulation.dto.ts`
* [x] `update-circulation-routing.dto.ts`
* [x] `search-circulation.dto.ts`
* [x] `entities/`
* [x] `circulation-routing.entity.ts`
* [x] `circulation-status-code.entity.ts`
* [x] `circulation.entity.ts`
* [x] `circulation.controller.ts`
* [x] `circulation.module.ts`
* [x] `circulation.service.ts`
6. **transmittal/** (Document Forwarding)
* [x] `dto/`
* [x] `create-transmittal.dto.ts`
* [x] `search-transmittal.dto.ts`
* [x] **update-transmittal.dto.ts**
* [x] `entities/`
* [x] `transmittal-item.entity.ts`
* [x] `transmittal.entity.ts`
* [x] `transmittal.controller.ts`
* [x] `transmittal.module.ts`
* [x] `transmittal.service.ts`
7. **notification/** (System Alerts)
* [x] `dto/`
* [x] `create-notification.dto.ts`
* [x] `search-notification.dto.ts`
* [x] `entities/`
* [x] `notification.entity.ts`
* [x] `notification-cleanup.service.ts` (Cron Job)
* [x] `notification.controller.ts`
* [x] `notification.gateway.ts`
* [x] `notification.module.ts` (Real-time WebSocket)
* [x] `notification.processor.ts` (Consumer/Worker for Email & Line)
* [x] `notification.service.ts` (Producer)
8. **search/** (Elasticsearch)
* [x] `dto/search-query.dto.ts`
* [x] `search.controller.ts`
* [x] `search.module.ts`
* [x] `search.service.ts` (Indexing & Searching)
9. **document-numbering/** (Internal Service)
* [x] `entities/`
* [x] `document-number-format.entity.ts`
* [x] `document-number-counter.entity.ts`
* [x] `document-numbering.module.ts`
* [x] **document-numbering.service.spec.ts**
* [x] `document-numbering.service.ts` (Double-Lock Mechanism)
10. **workflow-engine/** (Unified Logic)
* [x] `interfaces/workflow.interface.ts`
* [x] `workflow-engine.module.ts`
* [x] **workflow-engine.service.spec.ts**
* [x] `workflow-engine.service.ts` (State Machine Logic)
11. **json-schema/** (Validation)
* [x] `dto/`
* [x] `create-json-schema.dto.ts`+
* [x] `search-json-schema.dto.ts`
* [x] `update-json-schema.dto.ts`
* [x] `entities/`
* [x] `json-schema.entity.ts`
* [x] **json-schema.controller.spec.ts**
* [x] **json-schema.controller.ts**
* [x] `json-schema.module.ts`
* [x] **json-schema.service.spec.ts**
* [x] `json-schema.service.ts`
---
นี่คือโครงสร้าง **Folder Structure** ของโปรเจกต์ Backend (NestJS) ฉบับสมบูรณ์ ที่รวบรวมจาก **Requirements**, **FullStackJS Guidelines**, **Data Dictionary** และสิ่งที่เราได้ **Implement ไปแล้วตั้งแต่ Phase 0 ถึง Phase 6.2** ครับ
โครงสร้างนี้เป็นแบบ **Domain-Driven Modular Architecture** ครับ
```text
lcbp3-backend/
├── .env # Environment variables (Local Dev)
├── .gitignore
├── docker-compose.yml # Main config for services (DB, Redis, ES, App)
├── docker-compose.override.yml # Secrets injection for Dev
├── nest-cli.json
├── package.json
├── pnpm-lock.yaml
├── README.md
├── tsconfig.json
└── src/
├── main.ts # Entry point (Swagger, Helmet, Global Pipes)
├── app.module.ts # Root Module
├── common/ # 🛠️ Shared Resources
│ ├── auth/
│ │ ├── guards/
│ │ │ ├── jwt-auth.guard.ts
│ │ │ └── rbac.guard.ts
│ │ └── strategies/
│ │ └── jwt.strategy.ts
│ ├── config/ # Config Service
│ ├── decorators/
│ │ ├── current-user.decorator.ts
│ │ └── require-permission.decorator.ts
│ ├── exceptions/
│ │ └── http-exception.filter.ts
│ ├── file-storage/ # 📂 Two-Phase Storage System
│ │ ├── dto/
│ │ ├── entities/
│ │ │ └── attachment.entity.ts
│ │ ├── file-storage.module.ts
│ │ └── file-storage.service.ts
│ ├── interceptors/
│ │ ├── audit-log.interceptor.ts
│ │ └── transform.interceptor.ts
│ └── resilience/ # Circuit Breaker & Retry logic
└── modules/ # 📦 Feature Modules
├── user/ # 👤 User & RBAC
│ ├── dto/
│ │ ├── create-user.dto.ts
│ │ └── update-user.dto.ts
│ ├── entities/
│ │ ├── permission.entity.ts
│ │ ├── role.entity.ts
│ │ ├── user-preference.entity.ts
│ │ └── user.entity.ts
│ ├── user.controller.ts
│ ├── user.module.ts
│ └── user.service.ts
├── project/ # 🏢 Projects & Organizations
│ ├── dto/
│ │ ├── create-project.dto.ts
│ │ ├── search-project.dto.ts
│ │ └── update-project.dto.ts
│ ├── entities/
│ │ ├── contract-organization.entity.ts
│ │ ├── contract.entity.ts
│ │ ├── organization.entity.ts
│ │ ├── project-organization.entity.ts
│ │ └── project.entity.ts
│ ├── project.controller.ts
│ ├── project.module.ts
│ └── project.service.ts
├── correspondence/ # ✉️ Core Document
│ ├── dto/
│ │ ├── add-reference.dto.ts
│ │ ├── create-correspondence.dto.ts
│ │ ├── search-correspondence.dto.ts
│ │ └── workflow-action.dto.ts
│ ├── entities/
│ │ ├── correspondence-reference.entity.ts
│ │ ├── correspondence-revision.entity.ts
│ │ ├── correspondence-routing.entity.ts
│ │ ├── correspondence-status.entity.ts
│ │ ├── correspondence-type.entity.ts
│ │ ├── correspondence.entity.ts
│ │ └── routing-template.entity.ts
│ ├── correspondence.controller.ts
│ ├── correspondence.module.ts
│ └── correspondence.service.ts
├── drawing/ # 📐 Shop & Contract Drawings
│ ├── dto/
│ │ ├── create-contract-drawing.dto.ts
│ │ ├── create-shop-drawing-revision.dto.ts
│ │ ├── create-shop-drawing.dto.ts
│ │ ├── search-contract-drawing.dto.ts
│ │ ├── search-shop-drawing.dto.ts
│ │ └── update-contract-drawing.dto.ts
│ ├── entities/
│ │ ├── contract-drawing-sub-category.entity.ts
│ │ ├── contract-drawing-volume.entity.ts
│ │ ├── contract-drawing.entity.ts
│ │ ├── shop-drawing-main-category.entity.ts
│ │ ├── shop-drawing-revision.entity.ts
│ │ ├── shop-drawing-sub-category.entity.ts
│ │ └── shop-drawing.entity.ts
│ ├── contract-drawing.controller.ts
│ ├── contract-drawing.service.ts
│ ├── drawing-master-data.controller.ts
│ ├── drawing-master-data.service.ts
│ ├── drawing.module.ts
│ ├── shop-drawing.controller.ts
│ └── shop-drawing.service.ts
├── rfa/ # ✅ Request for Approval
│ ├── dto/
│ │ ├── create-rfa.dto.ts
│ │ ├── search-rfa.dto.ts
│ │ └── update-rfa.dto.ts
│ ├── entities/
│ │ ├── rfa-approve-code.entity.ts
│ │ ├── rfa-item.entity.ts
│ │ ├── rfa-revision.entity.ts
│ │ ├── rfa-status-code.entity.ts
│ │ ├── rfa-type.entity.ts
│ │ ├── rfa-workflow-template-step.entity.ts
│ │ ├── rfa-workflow-template.entity.ts
│ │ ├── rfa-workflow.entity.ts
│ │ └── rfa.entity.ts
│ ├── rfa.controller.ts
│ ├── rfa.module.ts
│ └── rfa.service.ts
├── circulation/ # 🔄 Internal Routing
│ ├── dto/
│ │ ├── create-circulation.dto.ts
│ │ ├── search-circulation.dto.ts
│ │ └── update-circulation-routing.dto.ts
│ ├── entities/
│ │ ├── circulation-routing.entity.ts
│ │ ├── circulation-status-code.entity.ts
│ │ └── circulation.entity.ts
│ ├── circulation.controller.ts
│ ├── circulation.module.ts
│ └── circulation.service.ts
├── transmittal/ # 📤 Outgoing Documents
│ ├── dto/
│ │ ├── create-transmittal.dto.ts
│ │ └── search-transmittal.dto.ts
│ ├── entities/
│ │ ├── transmittal-item.entity.ts
│ │ └── transmittal.entity.ts
│ ├── transmittal.controller.ts
│ ├── transmittal.module.ts
│ └── transmittal.service.ts
├── notification/ # 🔔 Real-time & Queue
│ ├── dto/
│ │ ├── create-notification.dto.ts
│ │ └── search-notification.dto.ts
│ ├── entities/
│ │ └── notification.entity.ts
│ ├── notification-cleanup.service.ts
│ ├── notification.controller.ts
│ ├── notification.gateway.ts
│ ├── notification.module.ts
│ ├── notification.processor.ts
│ └── notification.service.ts
├── search/ # 🔍 Elasticsearch
│ ├── dto/
│ │ └── search-query.dto.ts
│ ├── search.controller.ts
│ ├── search.module.ts
│ └── search.service.ts
├── document-numbering/ # 🔢 Internal Numbering Service
│ ├── entities/
│ │ ├── document-number-counter.entity.ts
│ │ └── document-number-format.entity.ts
│ ├── document-numbering.module.ts
│ └── document-numbering.service.ts
├── workflow-engine/ # ⚙️ Unified Logic
│ ├── interfaces/
│ │ └── workflow.interface.ts
│ ├── workflow-engine.module.ts
│ └── workflow-engine.service.ts
└── json-schema/ # 📋 Validation Logic
├── json-schema.module.ts
└── json-schema.service.ts
```

View File

@@ -0,0 +1,76 @@
version: '3.8'
services:
# ---------------------------------------------------------------------------
# Redis Service
# ใช้สำหรับ: Caching ข้อมูล, Session Store และ Message Queue (สำหรับ NestJS/BullMQ)
# ---------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: lcbp3_redis
restart: always
command: redis-server --save 60 1 --loglevel warning --requirepass "${REDIS_PASSWORD:-redis_password}"
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- lcbp3_net
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis_password}", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ---------------------------------------------------------------------------
# Elasticsearch Service
# ใช้สำหรับ: Full-text Search และการวิเคราะห์ข้อมูล (Database Analysis)
# ---------------------------------------------------------------------------
elasticsearch:
image: elasticsearch:8.11.1
container_name: lcbp3_elasticsearch
restart: always
environment:
- node.name=lcbp3_es01
- cluster.name=lcbp3_es_cluster
- discovery.type=single-node # รันแบบ Node เดียวสำหรับ Dev/Phase 0
- bootstrap.memory_lock=true # ล็อคหน่วยความจำเพื่อประสิทธิภาพ
- "ES_JAVA_OPTS=-Xms512m -Xmx512m" # กำหนด Heap Size (ปรับเพิ่มได้ตาม Resource เครื่อง)
- xpack.security.enabled=false # ปิด Security ชั่วคราวสำหรับ Phase 0 (ควรเปิดใน Production)
- xpack.security.http.ssl.enabled=false
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- es_data:/usr/share/elasticsearch/data
ports:
- "9200:9200"
networks:
- lcbp3_net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
# ---------------------------------------------------------------------------
# Volumes Configuration
# การจัดการพื้นที่จัดเก็บข้อมูลแบบ Persistent Data
# ---------------------------------------------------------------------------
volumes:
redis_data:
driver: local
name: lcbp3_redis_vol
es_data:
driver: local
name: lcbp3_es_vol
# ---------------------------------------------------------------------------
# Networks Configuration
# เครือข่ายสำหรับเชื่อมต่อ Container ภายใน
# ---------------------------------------------------------------------------
networks:
lcbp3_net:
driver: bridge
name: lcbp3_network

View File

@@ -41,10 +41,24 @@ services:
networks:
- lcbp3-net
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.1
container_name: lcbp3-elasticsearch
environment:
- discovery.type=single-node
- xpack.security.enabled=false # ปิด security เพื่อความง่ายใน Dev (Prod ต้องเปิด)
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ports:
- "9200:9200"
volumes:
- esdata:/usr/share/elasticsearch/data
networks:
- lcbp3-net
volumes:
db_data:
redis_data: # เพิ่ม Volume
esdata:
networks:
lcbp3-net:
driver: bridge

View File

@@ -21,8 +21,9 @@
},
"dependencies": {
"@casl/ability": "^6.7.3",
"@elastic/elasticsearch": "^9.2.0",
"@elastic/elasticsearch": "^8.11.1",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
@@ -43,6 +44,8 @@
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"bullmq": "^5.63.2",
"cache-manager": "^7.2.5",
"cache-manager-redis-yet": "^5.1.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"fs-extra": "^11.3.2",
@@ -69,6 +72,7 @@
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/cache-manager": "^5.0.0",
"@types/express": "^5.0.0",
"@types/fs-extra": "^11.0.4",
"@types/ioredis": "^5.0.0",

641
backend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,73 @@
// File: src/app.module.ts
// บันทึกการแก้ไข: เพิ่ม CacheModule (Redis), Config สำหรับ Idempotency และ Maintenance Mode (T1.1)
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; // <--- เพิ่ม Import นี้ T2.4
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq'; // Import BullModule
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; // <--- เพิ่ม Import นี้ T2.4
import { BullModule } from '@nestjs/bullmq';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-yet';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { envValidationSchema } from './common/config/env.validation.js'; // สังเกต .js สำหรับ ESM
// import { CommonModule } from './common/common.module';
import { envValidationSchema } from './common/config/env.validation.js';
import redisConfig from './common/config/redis.config';
// Entities & Interceptors
import { AuditLog } from './common/entities/audit-log.entity';
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
// ✅ Import Guard ใหม่สำหรับ Maintenance Mode
import { MaintenanceModeGuard } from './common/guards/maintenance-mode.guard';
// import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor'; // ✅ เตรียมไว้ใช้ (ถ้าต้องการ Global)
// Modules
import { UserModule } from './modules/user/user.module';
import { ProjectModule } from './modules/project/project.module';
import { FileStorageModule } from './common/file-storage/file-storage.module.js';
import { DocumentNumberingModule } from './modules/document-numbering/document-numbering.module';
import { AuthModule } from './common/auth/auth.module.js'; // <--- เพิ่ม Import นี้ T2.4
import { AuthModule } from './common/auth/auth.module.js';
import { JsonSchemaModule } from './modules/json-schema/json-schema.module.js';
import { WorkflowEngineModule } from './modules/workflow-engine/workflow-engine.module';
import { CorrespondenceModule } from './modules/correspondence/correspondence.module';
@Module({
imports: [
// 1. Setup Config Module พร้อม Validation
ConfigModule.forRoot({
isGlobal: true, // เรียกใช้ได้ทั่วทั้ง App ไม่ต้อง import ซ้ำ
envFilePath: '.env', // อ่านไฟล์ .env (สำหรับ Dev)
validationSchema: envValidationSchema, // ใช้ Schema ที่เราสร้างเพื่อตรวจสอบ
isGlobal: true,
envFilePath: '.env',
load: [redisConfig],
validationSchema: envValidationSchema,
validationOptions: {
// ถ้ามีค่าไหนไม่ผ่าน Validation ให้ Error และหยุดทำงานทันที
abortEarly: true,
},
}),
// 🛡️ T2.4 1. Setup Throttler Module (Rate Limiting)
// 🛡️ Setup Throttler Module (Rate Limiting)
ThrottlerModule.forRoot([
{
ttl: 60000, // 60 วินาที (Time to Live)
limit: 100, // ยิงได้สูงสุด 100 ครั้ง (Global Default)
ttl: 60000, // 60 วินาที
limit: 100, // ยิงได้สูงสุด 100 ครั้ง
},
]),
// 💾 Setup Cache Module (Redis)
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
store: await redisStore({
socket: {
host: configService.get<string>('redis.host'),
port: configService.get<number>('redis.port'),
},
ttl: configService.get<number>('redis.ttl'),
}),
}),
inject: [ConfigService],
}),
// 2. Setup TypeORM (MariaDB)
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
@@ -49,15 +80,14 @@ import { CorrespondenceModule } from './modules/correspondence/correspondence.mo
password: configService.get<string>('DB_PASSWORD'),
database: configService.get<string>('DB_DATABASE'),
autoLoadEntities: true,
// synchronize: true เฉพาะตอน Dev เท่านั้น ห้ามใช้บน Prod
// synchronize: configService.get<string>('NODE_ENV') === 'development',
// แก้บรรทัดนี้เป็น false ครับ
// เพราะเราใช้ SQL Script สร้าง DB แล้ว ไม่ต้องการให้ TypeORM มาแก้ Structure อัตโนมัติ
synchronize: false, // เราใช้ false ตามที่ตกลงกัน
synchronize: false, // Production Ready: false
}),
}),
// 3. BullMQ (Redis) Setup [NEW]
// ✅ 4. Register AuditLog Entity (Global Scope)
TypeOrmModule.forFeature([AuditLog]),
// 3. BullMQ (Redis) Setup
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
@@ -69,24 +99,46 @@ import { CorrespondenceModule } from './modules/correspondence/correspondence.mo
},
}),
}),
// Feature Modules
AuthModule,
// CommonModule,
UserModule,
ProjectModule,
FileStorageModule,
DocumentNumberingModule,
JsonSchemaModule,
WorkflowEngineModule,
CorrespondenceModule, // <--- เพิ่ม
CorrespondenceModule,
],
controllers: [AppController],
providers: [
AppService,
// 🛡️ 2. Register Global Guard
// 🛡️ 1. Register Global Guard (Rate Limit) - ทำงานก่อนเพื่อน
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
// 🚧 2. Maintenance Mode Guard - ทำงานต่อมา เพื่อ Block การเข้าถึงถ้าระบบปิดอยู่
{
provide: APP_GUARD,
useClass: MaintenanceModeGuard,
},
// 📝 3. Register Global Interceptor (Audit Log)
{
provide: APP_INTERCEPTOR,
useClass: AuditLogInterceptor,
},
// 🔄 4. Register Idempotency (Uncomment เมื่อต้องการบังคับใช้ Global)
// {
// provide: APP_INTERCEPTOR,
// useClass: IdempotencyInterceptor,
// },
],
})
export class AppModule {}
/*วิธีใช้งาน
เมื่อต้องการเปิด Maintenance Mode ให้ Admin (หรือคุณ) ยิงคำสั่งเข้า Redis หรือสร้าง API เพื่อ Set ค่า: SET system:maintenance_mode true (หรือ "true")
ระบบจะตอบกลับด้วย 503 Service Unavailable ทันที ยกเว้น Controller ที่คุณใส่ @BypassMaintenance() ไว้ครับ
*/

View File

@@ -1,18 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AutController } from './aut.controller';
describe('AutController', () => {
let controller: AutController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AutController],
}).compile();
controller = module.get<AutController>(AutController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -1,4 +0,0 @@
import { Controller } from '@nestjs/common';
@Controller('aut')
export class AutController {}

View File

@@ -1,17 +1,34 @@
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler'; // <--- ✅ เพิ่มบรรทัดนี้ครับ
import { AuthService } from './auth.service.js';
import { LoginDto } from './dto/login.dto.js'; // <--- Import DTO
import { RegisterDto } from './dto/register.dto.js'; // <--- Import DTO
// File: src/common/auth/auth.controller.ts
// บันทึกการแก้ไข: เพิ่ม Endpoints ให้ครบตามแผน T1.2 (Refresh, Logout, Profile)
import {
Controller,
Post,
Body,
Get,
UseGuards,
UnauthorizedException,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service.js';
import { LoginDto } from './dto/login.dto.js';
import { RegisterDto } from './dto/register.dto.js';
import { JwtAuthGuard } from './guards/jwt-auth.guard.js';
import { JwtRefreshGuard } from './guards/jwt-refresh.guard.js'; // ต้องสร้าง Guard นี้
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; // (ถ้าใช้ Swagger)
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
// เพิ่มความเข้มงวดให้ Login (กัน Brute Force)
@Throttle({ default: { limit: 10, ttl: 60000 } }) // 🔒 ให้ลองได้แค่ 5 ครั้ง ใน 1 นาที
// เปลี่ยน @Body() req เป็น @Body() loginDto: LoginDto
@Throttle({ default: { limit: 5, ttl: 60000 } }) // เข้มงวด: 5 ครั้ง/นาที
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'เข้าสู่ระบบเพื่อรับ Access & Refresh Token' })
async login(@Body() loginDto: LoginDto) {
const user = await this.authService.validateUser(
loginDto.username,
@@ -26,15 +43,38 @@ export class AuthController {
}
@Post('register-admin')
// เปลี่ยน @Body() req เป็น @Body() registerDto: RegisterDto
@UseGuards(JwtAuthGuard) // ควรป้องกัน Route นี้ให้เฉพาะ Superadmin
@ApiBearerAuth()
@ApiOperation({ summary: 'สร้างบัญชีผู้ใช้ใหม่ (Admin Only)' })
async register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
}
/*ตัวอย่าง: ยกเว้นการนับ (เช่น Health Check)
import { SkipThrottle } from '@nestjs/throttler';
@SkipThrottle()
@Get('health')
check() { ... }
*/
@UseGuards(JwtRefreshGuard)
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'ขอ Access Token ใหม่ด้วย Refresh Token' })
async refresh(@Request() req) {
// req.user จะมาจาก JwtRefreshStrategy
return this.authService.refreshToken(req.user.sub, req.user.refreshToken);
}
@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'ออกจากระบบ (Revoke Token)' })
async logout(@Request() req) {
// ดึง Token จาก Header Authorization: Bearer <token>
const token = req.headers.authorization?.split(' ')[1];
return this.authService.logout(req.user.sub, token);
}
@UseGuards(JwtAuthGuard)
@Get('profile')
@ApiBearerAuth()
@ApiOperation({ summary: 'ดึงข้อมูลผู้ใช้ปัจจุบัน' })
getProfile(@Request() req) {
return req.user;
}
}

View File

@@ -1,3 +1,6 @@
// File: src/common/auth/auth.module.ts
// บันทึกการแก้ไข: ลงทะเบียน Refresh Strategy และแก้ไข Config
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
@@ -5,7 +8,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service.js';
import { AuthController } from './auth.controller.js';
import { UserModule } from '../../modules/user/user.module.js';
import { JwtStrategy } from '../guards/jwt.strategy.js';
import { JwtStrategy } from './strategies/jwt.strategy.js';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy.js';
@Module({
imports: [
@@ -17,14 +21,17 @@ import { JwtStrategy } from '../guards/jwt.strategy.js';
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
// Cast เป็น any เพื่อแก้ปัญหา Type ไม่ตรงกับ Library
expiresIn: (configService.get<string>('JWT_EXPIRATION') ||
'8h') as any,
// ใช้ Template String หรือค่า Default ที่ปลอดภัย
expiresIn: configService.get<string>('JWT_EXPIRATION') || '15m',
},
}),
}),
],
providers: [AuthService, JwtStrategy],
providers: [
AuthService,
JwtStrategy,
JwtRefreshStrategy, // ✅ เพิ่ม Strategy สำหรับ Refresh Token
],
controllers: [AuthController],
exports: [AuthService],
})

View File

@@ -1,16 +1,31 @@
import { Injectable } from '@nestjs/common';
// File: src/common/auth/auth.service.ts
// บันทึกการแก้ไข: เพิ่ม Refresh Token, Logout (Redis Blacklist) และ Profile ตาม T1.2
import {
Injectable,
UnauthorizedException,
Inject,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import * as bcrypt from 'bcrypt';
import { UserService } from '../../modules/user/user.service.js';
import { RegisterDto } from './dto/register.dto.js'; // Import DTO
import { RegisterDto } from './dto/register.dto.js';
import { User } from '../../modules/user/entities/user.entity.js';
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
private configService: ConfigService,
@Inject(CACHE_MANAGER) private cacheManager: Cache, // ใช้ Redis สำหรับ Blacklist
) {}
// 1. ตรวจสอบ Username/Password
async validateUser(username: string, pass: string): Promise<any> {
const user = await this.userService.findOneByUsername(username);
if (user && (await bcrypt.compare(pass, user.password))) {
@@ -21,21 +36,91 @@ export class AuthService {
return null;
}
// 2. Login: สร้าง Access & Refresh Token
async login(user: any) {
const payload = { username: user.username, sub: user.user_id };
const payload = {
username: user.username,
sub: user.user_id,
scope: 'Global', // ตัวอย่าง: ใส่ Scope เริ่มต้น หรือดึงจาก Role
};
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_SECRET'),
expiresIn: this.configService.get<string>('JWT_EXPIRATION') || '15m',
}),
this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn:
this.configService.get<string>('JWT_REFRESH_EXPIRATION') || '7d',
}),
]);
return {
access_token: this.jwtService.sign(payload),
access_token: accessToken,
refresh_token: refreshToken,
user: user, // ส่งข้อมูล user กลับไปให้ Frontend ใช้แสดงผลเบื้องต้น
};
}
// 3. Register (สำหรับ Admin)
async register(userDto: RegisterDto) {
// ตรวจสอบว่ามี user อยู่แล้วหรือไม่
const existingUser = await this.userService.findOneByUsername(
userDto.username,
);
if (existingUser) {
throw new BadRequestException('Username already exists');
}
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(userDto.password, salt);
// ใช้ค่าจาก DTO ที่ Validate มาแล้ว
return this.userService.create({
...userDto,
password: hashedPassword,
});
}
// 4. Refresh Token: ออก Token ใหม่
async refreshToken(userId: number, refreshToken: string) {
// ตรวจสอบความถูกต้องของ Refresh Token (ถ้าใช้ DB เก็บ Refresh Token ก็เช็คตรงนี้)
// ในที่นี้เราเชื่อใจ Signature ของ JWT Refresh Secret
const user = await this.userService.findOne(userId);
if (!user) throw new UnauthorizedException('User not found');
// สร้าง Access Token ใหม่
const payload = { username: user.username, sub: user.user_id };
const accessToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_SECRET'),
expiresIn: this.configService.get<string>('JWT_EXPIRATION') || '15m',
});
return {
access_token: accessToken,
// refresh_token: refreshToken, // จะส่งเดิมกลับ หรือ Rotate ใหม่ก็ได้ (แนะนำ Rotate เพื่อความปลอดภัยสูงสุด)
};
}
// 5. Logout: นำ Token เข้า Blacklist ใน Redis
async logout(userId: number, accessToken: string) {
// หาเวลาที่เหลือของ Token เพื่อตั้ง TTL ใน Redis
try {
const decoded = this.jwtService.decode(accessToken);
if (decoded && decoded.exp) {
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
// Key pattern: blacklist:token:{token_string}
await this.cacheManager.set(
`blacklist:token:${accessToken}`,
true,
ttl * 1000,
);
}
}
} catch (error) {
// Ignore decoding error
}
return { message: 'Logged out successfully' };
}
}

View File

@@ -0,0 +1,32 @@
// File: src/common/auth/strategies/jwt-refresh.strategy.ts
// บันทึกการแก้ไข: Strategy สำหรับ Refresh Token (T1.2)
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(
Strategy,
'jwt-refresh',
) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
// ใช้ Secret แยกต่างหากสำหรับ Refresh Token
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET'),
passReqToCallback: true,
});
}
async validate(req: Request, payload: any) {
const refreshToken = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
return {
...payload,
refreshToken,
};
}
}

View File

@@ -0,0 +1,63 @@
// File: src/common/auth/strategies/jwt.strategy.ts
// บันทึกการแก้ไข: ปรับปรุง JwtStrategy ให้ตรวจสอบ Blacklist (Redis) และสถานะ User (T1.2)
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException, Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CACHE_MANAGER } from '@nestjs/cache-manager'; // ✅ ใช้สำหรับ Blacklist
import { Cache } from 'cache-manager';
import { Request } from 'express';
import { UserService } from '../../../modules/user/user.service.js';
// Interface สำหรับ Payload ใน Token
export interface JwtPayload {
sub: number;
username: string;
scope?: string; // เพิ่ม Scope ถ้ามีการใช้
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private userService: UserService,
@Inject(CACHE_MANAGER) private cacheManager: Cache, // ✅ Inject Redis Cache
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
passReqToCallback: true, // ✅ จำเป็นต้องใช้ เพื่อดึง Raw Token มาเช็ค Blacklist
});
}
async validate(req: Request, payload: JwtPayload) {
// 1. ดึง Token ออกมาเพื่อตรวจสอบใน Blacklist
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
// 2. ตรวจสอบว่า Token นี้อยู่ใน Redis Blacklist หรือไม่ (กรณี Logout ไปแล้ว)
const isBlacklisted = await this.cacheManager.get(
`blacklist:token:${token}`,
);
if (isBlacklisted) {
throw new UnauthorizedException('Token has been revoked (Logged out)');
}
// 3. ค้นหา User จาก Database
const user = await this.userService.findOne(payload.sub);
// 4. ตรวจสอบความถูกต้องของ User
if (!user) {
throw new UnauthorizedException('User not found');
}
// 5. (Optional) ตรวจสอบว่า User ยัง Active อยู่หรือไม่
if (user.is_active === false || user.is_active === 0) {
throw new UnauthorizedException('User account is inactive');
}
// คืนค่า User เพื่อนำไปใส่ใน req.user
return user;
}
}

View File

@@ -1,9 +1,31 @@
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { AutController } from './aut/aut.controller';
// File: src/common/common.module.ts
// บันทึกการแก้ไข: Module รวม Infrastructure พื้นฐาน (T1.1)
import { Module, Global } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CryptoService } from './services/crypto.service';
import { RequestContextService } from './services/request-context.service';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { HttpExceptionFilter } from './exceptions/http-exception.filter';
import { TransformInterceptor } from './interceptors/transform.interceptor';
// import { IdempotencyInterceptor } from './interceptors/idempotency.interceptor'; // นำเข้าถ้าต้องการใช้ Global
@Global() // ทำให้ Module นี้ใช้ได้ทั่วทั้งแอปโดยไม่ต้อง Import ซ้ำ
@Module({
imports: [AuthModule],
controllers: [AutController]
imports: [ConfigModule],
providers: [
CryptoService,
RequestContextService,
// Register Global Filter & Interceptor ที่นี่ หรือใน AppModule ก็ได้
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
{
provide: APP_INTERCEPTOR,
useClass: TransformInterceptor,
},
],
exports: [CryptoService, RequestContextService],
})
export class CommonModule {}

View File

@@ -0,0 +1,11 @@
// File: src/common/config/redis.config.ts
// บันทึกการแก้ไข: สร้าง Config สำหรับ Redis (T0.2)
import { registerAs } from '@nestjs/config';
export default registerAs('redis', () => ({
host: process.env.REDIS_HOST || 'cache', // Default เป็นชื่อ Service ใน Docker
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
ttl: parseInt(process.env.REDIS_TTL, 10) || 3600, // Default TTL 1 ชั่วโมง
// password: process.env.REDIS_PASSWORD, // เปิดใช้ถ้ามี Password
}));

View File

@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
export const AUDIT_KEY = 'audit';
export interface AuditMetadata {
action: string; // ชื่อการกระทำ (เช่น 'rfa.create', 'user.login')
entityType?: string; // ชื่อ Entity (เช่น 'rfa', 'user') - ถ้าไม่ระบุอาจจะพยายามเดา
}
export const Audit = (action: string, entityType?: string) =>
SetMetadata(AUDIT_KEY, { action, entityType });

View File

@@ -0,0 +1,10 @@
// File: src/common/decorators/bypass-maintenance.decorator.ts
// บันทึกการแก้ไข: ใช้สำหรับยกเว้นการตรวจสอบ Maintenance Mode (T1.1)
import { SetMetadata } from '@nestjs/common';
export const BYPASS_MAINTENANCE_KEY = 'bypass_maintenance';
// ใช้ @BypassMaintenance() บน Controller หรือ Method ที่ต้องการให้ทำงานได้แม้ปิดระบบ
export const BypassMaintenance = () =>
SetMetadata(BYPASS_MAINTENANCE_KEY, true);

View File

@@ -0,0 +1,7 @@
// File: src/common/decorators/idempotency.decorator.ts
// ใช้สำหรับบังคับว่า Controller นี้ต้องมี Idempotency Key (Optional Enhancement)
import { SetMetadata } from '@nestjs/common';
export const IDEMPOTENCY_KEY = 'idempotency_required';
export const RequireIdempotency = () => SetMetadata(IDEMPOTENCY_KEY, true);

View File

@@ -0,0 +1,56 @@
// File: src/common/entities/audit-log.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../modules/user/entities/user.entity';
@Entity('audit_logs')
export class AuditLog {
@PrimaryGeneratedColumn({ name: 'audit_id', type: 'bigint' })
auditId!: string;
@Column({ name: 'request_id', nullable: true })
requestId?: string;
// ✅ ต้องมีบรรทัดนี้ (TypeORM ต้องการเพื่อ Map Column)
@Column({ name: 'user_id', nullable: true })
userId?: number | null; // ✅ เพิ่ม | null เพื่อรองรับค่า null
@Column({ length: 100 })
action!: string;
@Column({
type: 'enum',
enum: ['INFO', 'WARN', 'ERROR', 'CRITICAL'],
default: 'INFO',
})
severity!: string;
@Column({ name: 'entity_type', length: 50, nullable: true })
entityType?: string;
@Column({ name: 'entity_id', length: 50, nullable: true })
entityId?: string;
@Column({ name: 'details_json', type: 'json', nullable: true })
detailsJson?: any;
@Column({ name: 'ip_address', length: 45, nullable: true })
ipAddress?: string;
@Column({ name: 'user_agent', length: 255, nullable: true })
userAgent?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
// Relations
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user?: User;
}

View File

@@ -1,3 +1,6 @@
// File: src/common/exceptions/http-exception.filter.ts
// บันทึกการแก้ไข: ปรับปรุง Global Filter ให้จัดการ Error ปลอดภัยสำหรับ Production และ Log ละเอียดใน Dev (T1.1)
import {
ExceptionFilter,
Catch,
@@ -17,34 +20,65 @@ export class HttpExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// 1. หา Status Code
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// 2. หา Error Response Body ต้นฉบับ
const exceptionResponse =
exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';
: { message: 'Internal server error' };
// จัดรูปแบบ Error Message
const message =
// จัดรูปแบบ Error Message ให้เป็น Object เสมอ
let errorBody: any =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || exceptionResponse;
// 👇👇 เพิ่มบรรทัดนี้ครับ (สำคัญมาก!) 👇👇
console.error('💥 REAL ERROR:', exception);
? { message: exceptionResponse }
: exceptionResponse;
// Log Error (สำคัญมากสำหรับการ Debug แต่ไม่ส่งให้ Client เห็นทั้งหมด)
this.logger.error(
`Http Status: ${status} Error Message: ${JSON.stringify(message)}`,
);
// 3. 📝 Logging Strategy (แยกตามความรุนแรง)
if (status >= 500) {
// 💥 Critical Error: Log stack trace เต็มๆ
this.logger.error(
`💥 HTTP ${status} Error on ${request.method} ${request.url}`,
exception instanceof Error
? exception.stack
: JSON.stringify(exception),
);
response.status(status).json({
// 👇👇 สิ่งที่คุณต้องการ: Log ดิบๆ ให้เห็นชัดใน Docker Console 👇👇
console.error('💥 REAL CRITICAL ERROR:', exception);
} else {
// ⚠️ Client Error (400, 401, 403, 404): Log แค่ Warning พอ ไม่ต้อง Stack Trace
this.logger.warn(
`⚠️ HTTP ${status} Error on ${request.method} ${request.url}: ${JSON.stringify(errorBody.message || errorBody)}`,
);
}
// 4. 🔒 Security & Response Formatting
// กรณี Production และเป็น Error 500 -> ต้องซ่อนรายละเอียดความผิดพลาดของ Server
if (status === 500 && process.env.NODE_ENV === 'production') {
errorBody = {
message: 'Internal server error',
// อาจเพิ่ม reference code เพื่อให้ user แจ้ง support ได้ เช่น code: 'ERR-500'
};
}
// 5. Construct Final Response
const responseBody = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: status === 500 ? 'Internal server error' : message, // ซ่อน Detail กรณี 500
});
...errorBody, // Spread message, error, validation details
};
// 🛠️ Development Mode: แถม Stack Trace ไปให้ Frontend Debug ง่ายขึ้น
if (process.env.NODE_ENV !== 'production' && exception instanceof Error) {
responseBody.stack = exception.stack;
}
response.status(status).json(responseBody);
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}

View File

@@ -1,34 +0,0 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
// Interface สำหรับ Payload ใน Token
interface JwtPayload {
sub: number;
username: string;
}
import { UserService } from '../../modules/user/user.service.js';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private userService: UserService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET')!,
});
}
async validate(payload: JwtPayload) {
const user = await this.userService.findOne(payload.sub);
if (!user) {
throw new Error('User not found');
}
return user;
}
}

View File

@@ -0,0 +1,71 @@
// File: src/common/guards/maintenance-mode.guard.ts
// บันทึกการแก้ไข: ตรวจสอบ Flag ใน Redis เพื่อ Block API ระหว่างปรับปรุงระบบ (T1.1)
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
ServiceUnavailableException,
Logger,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { BYPASS_MAINTENANCE_KEY } from '../decorators/bypass-maintenance.decorator';
@Injectable()
export class MaintenanceModeGuard implements CanActivate {
private readonly logger = new Logger(MaintenanceModeGuard.name);
// Key ที่ใช้เก็บสถานะใน Redis (Admin จะเป็นคน Toggle ค่านี้)
private readonly MAINTENANCE_KEY = 'system:maintenance_mode';
constructor(
private reflector: Reflector,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. ตรวจสอบว่า Route นี้ได้รับการยกเว้นหรือไม่ (Bypass)
const isBypassed = this.reflector.getAllAndOverride<boolean>(
BYPASS_MAINTENANCE_KEY,
[context.getHandler(), context.getClass()],
);
if (isBypassed) {
return true;
}
// 2. ตรวจสอบสถานะจาก Redis
try {
const isMaintenanceOn = await this.cacheManager.get(this.MAINTENANCE_KEY);
// ถ้า Redis มีค่าเป็น true หรือ string "true" ให้ Block
if (isMaintenanceOn === true || isMaintenanceOn === 'true') {
// (Optional) 3. ตรวจสอบ Backdoor Header สำหรับ Admin (ถ้าต้องการ Bypass ฉุกเฉิน)
const request = context.switchToHttp().getRequest();
// const bypassToken = request.headers['x-maintenance-bypass'];
// if (bypassToken === process.env.ADMIN_SECRET) return true;
this.logger.warn(
`Blocked request to ${request.url} due to Maintenance Mode`,
);
throw new ServiceUnavailableException({
statusCode: 503,
message: 'ระบบกำลังปิดปรับปรุงชั่วคราว กรุณาลองใหม่ในภายหลัง',
error: 'Service Unavailable',
});
}
} catch (error) {
// กรณี Redis ล่ม หรือ Error อื่นๆ ให้ยอมให้ผ่านไปก่อน (Fail Open) หรือ Block (Fail Closed) ตามนโยบาย
// ในที่นี้เลือก Fail Open เพื่อไม่ให้ระบบล่มตาม Redis
if (error instanceof ServiceUnavailableException) {
throw error;
}
this.logger.error('Error checking maintenance mode', error);
}
return true;
}
}

View File

@@ -0,0 +1,80 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
Logger,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Request } from 'express';
import { AuditLog } from '../entities/audit-log.entity';
import { AUDIT_KEY, AuditMetadata } from '../decorators/audit.decorator';
import { User } from '../../modules/user/entities/user.entity';
@Injectable()
export class AuditLogInterceptor implements NestInterceptor {
private readonly logger = new Logger(AuditLogInterceptor.name);
constructor(
private reflector: Reflector,
@InjectRepository(AuditLog)
private auditLogRepo: Repository<AuditLog>,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const auditMetadata = this.reflector.getAllAndOverride<AuditMetadata>(
AUDIT_KEY,
[context.getHandler(), context.getClass()],
);
if (!auditMetadata) {
return next.handle();
}
const request = context.switchToHttp().getRequest<Request>();
const user = (request as any).user as User;
const rawIp = request.ip || request.socket.remoteAddress;
const ip = Array.isArray(rawIp) ? rawIp[0] : rawIp;
const userAgent = request.get('user-agent');
return next.handle().pipe(
tap(async (data) => {
try {
let entityId = null;
if (data && typeof data === 'object') {
if ('id' in data) entityId = String(data.id);
else if ('audit_id' in data) entityId = String(data.audit_id);
else if ('user_id' in data) entityId = String(data.user_id);
}
if (!entityId && request.params.id) {
entityId = String(request.params.id);
}
// ✅ FIX: ใช้ user?.user_id || null
const auditLog = this.auditLogRepo.create({
userId: user ? user.user_id : null,
action: auditMetadata.action,
entityType: auditMetadata.entityType,
entityId: entityId,
ipAddress: ip,
userAgent: userAgent,
severity: 'INFO',
} as unknown as AuditLog); // ✨ Trick: Cast ผ่าน unknown เพื่อล้าง Error ถ้า TS ยังไม่อัปเดต
await this.auditLogRepo.save(auditLog);
} catch (error) {
this.logger.error(
`Failed to create audit log for ${auditMetadata.action}: ${(error as Error).message}`,
);
}
}),
);
}
}

View File

@@ -0,0 +1,74 @@
// File: src/common/interceptors/idempotency.interceptor.ts
// บันทึกการแก้ไข: สร้าง IdempotencyInterceptor เพื่อป้องกันการทำรายการซ้ำ (T1.1)
import {
CallHandler,
ExecutionContext,
Inject,
Injectable,
NestInterceptor,
ConflictException,
Logger,
} from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
private readonly logger = new Logger(IdempotencyInterceptor.name);
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest<Request>();
const method = request.method;
// 1. ตรวจสอบว่าควรใช้ Idempotency หรือไม่ (เฉพาะ POST, PUT, DELETE)
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
return next.handle();
}
// 2. ดึง Idempotency-Key จาก Header
const idempotencyKey = request.headers['idempotency-key'] as string;
// ถ้าไม่มี Key ส่งมา ให้ทำงานปกติ (หรือจะบังคับให้ Error ก็ได้ ตาม Policy)
if (!idempotencyKey) {
// หมายเหตุ: ในระบบที่ Strict อาจจะ throw BadRequestException ถ้าไม่มี Key สำหรับ Transaction สำคัญ
return next.handle();
}
const cacheKey = `idempotency:${idempotencyKey}`;
// 3. ตรวจสอบใน Redis ว่า Key นี้เคยถูกประมวลผลหรือยัง
const cachedResponse = await this.cacheManager.get(cacheKey);
if (cachedResponse) {
this.logger.warn(
`Idempotency key detected: ${idempotencyKey}. Returning cached response.`,
);
// ถ้ามี ให้คืนค่าเดิมกลับไปเลย (เสมือนว่าทำรายการสำเร็จแล้ว)
return of(cachedResponse);
}
// 4. ถ้ายังไม่มี ให้ประมวลผลต่อ และบันทึกผลลัพธ์ลง Redis
return next.handle().pipe(
tap(async (response) => {
try {
// บันทึก Response ลง Cache (TTL 24 ชั่วโมง หรือตามความเหมาะสม)
await this.cacheManager.set(cacheKey, response, 86400 * 1000);
} catch (err) {
this.logger.error(
`Failed to cache idempotency key ${idempotencyKey}`,
err.stack,
);
}
}),
);
}
}

View File

@@ -0,0 +1,40 @@
// File: src/common/services/crypto.service.ts
// บันทึกการแก้ไข: Encryption/Decryption Utility (T1.1)
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
@Injectable()
export class CryptoService {
private readonly algorithm = 'aes-256-cbc';
private readonly key: Buffer;
private readonly ivLength = 16;
constructor(private configService: ConfigService) {
// Key ต้องมีขนาด 32 bytes (256 bits)
const secret =
this.configService.get<string>('APP_SECRET_KEY') ||
'default-secret-key-32-chars-long!';
this.key = crypto.scryptSync(secret, 'salt', 32);
}
encrypt(text: string): string {
const iv = crypto.randomBytes(this.ivLength);
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return `${iv.toString('hex')}:${encrypted}`;
}
decrypt(text: string): string {
const [ivHex, encryptedHex] = text.split(':');
if (!ivHex || !encryptedHex) return text;
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}

View File

@@ -0,0 +1,35 @@
// File: src/common/services/request-context.service.ts
// บันทึกการแก้ไข: เก็บ Context ระหว่าง Request (User, TraceID) (T1.1)
import { Injectable, Scope } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';
@Injectable({ scope: Scope.DEFAULT })
export class RequestContextService {
private static readonly cls = new AsyncLocalStorage<Map<string, any>>();
static run(fn: () => void) {
this.cls.run(new Map(), fn);
}
static set(key: string, value: any) {
const store = this.cls.getStore();
if (store) {
store.set(key, value);
}
}
static get<T>(key: string): T | undefined {
const store = this.cls.getStore();
return store?.get(key);
}
// Helper methods
static get currentUserId(): number | null {
return this.get('user_id') || null;
}
static get requestId(): string | null {
return this.get('request_id') || null;
}
}

View File

@@ -1,66 +1,81 @@
// File: src/main.ts
// บันทึกการแก้ไข: ปรับปรุง main.ts ให้สมบูรณ์ เชื่อมต่อกับ Global Filters/Interceptors และ ConfigService (T1.1)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe, Logger } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; // ✅ เพิ่ม Import Swagger
import { json, urlencoded } from 'express'; // ✅ เพิ่ม Import Body Parser
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { json, urlencoded } from 'express';
import helmet from 'helmet';
// Import ของเดิมของคุณ
import { TransformInterceptor } from './common/interceptors/transform.interceptor.js';
import { HttpExceptionFilter } from './common/exceptions/http-exception.filter.js';
// Import Custom Interceptors & Filters ที่สร้างใน T1.1
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { HttpExceptionFilter } from './common/exceptions/http-exception.filter';
async function bootstrap() {
// 1. Create App
const app = await NestFactory.create(AppModule);
// ดึง ConfigService เพื่อใช้ดึงค่า Environment Variables
const configService = app.get(ConfigService);
const logger = new Logger('Bootstrap');
// 🛡️ 1. Security (Helmet & CORS)
// 🛡️ 2. Security (Helmet & CORS)
app.use(helmet());
// ตั้งค่า CORS (ใน Production ควรระบุ origin ให้ชัดเจนจาก Config)
app.enableCors({
origin: true, // หรือระบุเช่น ['https://lcbp3.np-dms.work']
origin: true, // หรือ configService.get('CORS_ORIGIN')
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true,
});
// 📁 2. Body Parser Limits (รองรับ File Upload 50MB)
// 📁 3. Body Parser Limits (รองรับ File Upload 50MB ตาม Requirements)
app.use(json({ limit: '50mb' }));
app.use(urlencoded({ extended: true, limit: '50mb' }));
// 🌐 3. Global Prefix (เช่น /api/v1)
// 🌐 4. Global Prefix
app.setGlobalPrefix('api');
// ⚙️ 4. Global Pipes & Interceptors (ของเดิม)
// ⚙️ 5. Global Pipes & Interceptors & Filters
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // ตัด field ส่วนเกินทิ้ง
whitelist: true, // ตัด field ส่วนเกินทิ้ง (Security)
transform: true, // แปลง Type อัตโนมัติ (เช่น string -> number)
forbidNonWhitelisted: true, // แจ้ง Error ถ้าส่ง field แปลกปลอมมา
transformOptions: {
enableImplicitConversion: true, // ช่วยแปลง Type ใน Query Params
},
}),
);
// ลงทะเบียน Global Interceptor และ Filter ที่เราสร้างไว้
app.useGlobalInterceptors(new TransformInterceptor());
app.useGlobalFilters(new HttpExceptionFilter());
// 📘 5. Swagger Configuration (ส่วนที่ขาดไป)
const config = new DocumentBuilder()
// 📘 6. Swagger Configuration
const swaggerConfig = new DocumentBuilder()
.setTitle('LCBP3 DMS API')
.setDescription('Document Management System API Documentation')
.setVersion('1.4.3')
.addBearerAuth() // เพิ่มปุ่มใส่ Token (รูปกุญแจ)
.build();
const document = SwaggerModule.createDocument(app, config);
const document = SwaggerModule.createDocument(app, swaggerConfig);
// ตั้งค่าให้เข้าถึงได้ที่ /docs
// ตั้งค่าให้เข้าถึง Swagger ได้ที่ /docs
SwaggerModule.setup('docs', app, document, {
swaggerOptions: {
persistAuthorization: true, // จำ Token ไว้ไม่ต้องใส่ใหม่เวลารีเฟรช
persistAuthorization: true, // จำ Token ไว้ไม่ต้องใส่ใหม่เวลารีเฟรชหน้าจอ
},
});
// 🚀 6. Start Server
const port = process.env.PORT || 3000;
// 🚀 7. Start Server
const port = configService.get<number>('PORT') || 3000;
await app.listen(port);
logger.log(`Application is running on: http://localhost:${port}/api`);
logger.log(`Swagger UI is available at: http://localhost:${port}/docs`);
logger.log(`Application is running on: ${await app.getUrl()}/api`);
logger.log(`Swagger UI is available at: ${await app.getUrl()}/docs`);
}
bootstrap();

View File

@@ -0,0 +1,65 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
ParseIntPipe,
UseGuards,
Patch,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { CirculationService } from './circulation.service';
import { CreateCirculationDto } from './dto/create-circulation.dto';
import { UpdateCirculationRoutingDto } from './dto/update-circulation-routing.dto';
import { SearchCirculationDto } from './dto/search-circulation.dto';
import { User } from '../user/entities/user.entity';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Audit } from '../../common/decorators/audit.decorator'; // Import
@ApiTags('Circulations')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('circulations')
export class CirculationController {
constructor(private readonly circulationService: CirculationService) {}
@Post()
@ApiOperation({ summary: 'Create internal circulation' })
@RequirePermission('circulation.create') // สิทธิ์ ID 41
@Audit('circulation.create', 'circulation') // ✅ แปะตรงนี้
create(@Body() createDto: CreateCirculationDto, @CurrentUser() user: User) {
return this.circulationService.create(createDto, user);
}
@Get()
@ApiOperation({ summary: 'List circulations in my organization' })
@RequirePermission('document.view')
findAll(@Query() searchDto: SearchCirculationDto, @CurrentUser() user: User) {
return this.circulationService.findAll(searchDto, user);
}
@Get(':id')
@ApiOperation({ summary: 'Get circulation details' })
@RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.circulationService.findOne(id);
}
@Patch('routings/:id')
@ApiOperation({ summary: 'Update my routing task (Complete/Reject)' })
@RequirePermission('circulation.respond') // สิทธิ์ ID 42
updateRouting(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateCirculationRoutingDto,
@CurrentUser() user: User,
) {
return this.circulationService.updateRoutingStatus(id, updateDto, user);
}
}

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Circulation } from './entities/circulation.entity';
import { CirculationRouting } from './entities/circulation-routing.entity';
import { CirculationStatusCode } from './entities/circulation-status-code.entity';
import { CirculationService } from './circulation.service';
import { CirculationController } from './circulation.controller';
import { UserModule } from '../user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([
Circulation,
CirculationRouting,
CirculationStatusCode,
]),
UserModule,
],
controllers: [CirculationController],
providers: [CirculationService],
exports: [CirculationService],
})
export class CirculationModule {}

View File

@@ -1,11 +1,18 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Repository, DataSource, Not } from 'typeorm'; // เพิ่ม Not
import { Circulation } from './entities/circulation.entity';
import { CirculationRouting } from './entities/circulation-routing.entity';
import { User } from '../user/entities/user.entity';
import { CreateCirculationDto } from './dto/create-circulation.dto'; // ต้องสร้าง DTO นี้
import { CreateCirculationDto } from './dto/create-circulation.dto';
import { UpdateCirculationRoutingDto } from './dto/update-circulation-routing.dto'; // Import ใหม่
import { SearchCirculationDto } from './dto/search-circulation.dto'; // Import ใหม่
@Injectable()
export class CirculationService {
@@ -18,13 +25,16 @@ export class CirculationService {
) {}
async create(createDto: CreateCirculationDto, user: User) {
if (!user.primaryOrganizationId) {
throw new BadRequestException('User must belong to an organization');
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1. Create Master Circulation
// TODO: Generate Circulation No. logic here (Simple format)
// Generate No. (Mock Logic) -> ควรใช้ NumberingService จริงในอนาคต
const circulationNo = `CIR-${Date.now()}`;
const circulation = queryRunner.manager.create(Circulation, {
@@ -37,13 +47,12 @@ export class CirculationService {
});
const savedCirculation = await queryRunner.manager.save(circulation);
// 2. Create Routings (Assignees)
if (createDto.assigneeIds && createDto.assigneeIds.length > 0) {
const routings = createDto.assigneeIds.map((userId, index) =>
queryRunner.manager.create(CirculationRouting, {
circulationId: savedCirculation.id,
stepNumber: index + 1,
organizationId: user.primaryOrganizationId, // Internal routing
organizationId: user.primaryOrganizationId,
assignedTo: userId,
status: 'PENDING',
}),
@@ -61,23 +70,84 @@ export class CirculationService {
}
}
async findAll(searchDto: SearchCirculationDto, user: User) {
const { search, status, page = 1, limit = 20 } = searchDto;
const query = this.circulationRepo
.createQueryBuilder('c')
.leftJoinAndSelect('c.creator', 'creator')
.where('c.organizationId = :orgId', {
orgId: user.primaryOrganizationId,
});
if (status) {
query.andWhere('c.statusCode = :status', { status });
}
if (search) {
query.andWhere(
'(c.circulationNo LIKE :search OR c.subject LIKE :search)',
{ search: `%${search}%` },
);
}
query
.orderBy('c.createdAt', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [data, total] = await query.getManyAndCount();
return { data, meta: { total, page, limit } };
}
async findOne(id: number) {
const circulation = await this.circulationRepo.findOne({
where: { id },
relations: ['routings', 'routings.assignee', 'correspondence'],
relations: ['routings', 'routings.assignee', 'correspondence', 'creator'],
order: { routings: { stepNumber: 'ASC' } },
});
if (!circulation) throw new NotFoundException('Circulation not found');
return circulation;
}
// Method update status (Complete task)
// ✅ Logic อัปเดตสถานะและปิดงาน
async updateRoutingStatus(
routingId: number,
status: string,
comments: string,
dto: UpdateCirculationRoutingDto,
user: User,
) {
// Logic to update routing status
// and Check if all routings are completed -> Close Circulation
const routing = await this.routingRepo.findOne({
where: { id: routingId },
relations: ['circulation'],
});
if (!routing) throw new NotFoundException('Routing task not found');
// Check Permission: คนทำต้องเป็นเจ้าของ Task
if (routing.assignedTo !== user.user_id) {
throw new ForbiddenException('You are not assigned to this task');
}
// Update Routing
routing.status = dto.status;
routing.comments = dto.comments;
routing.completedAt = new Date();
await this.routingRepo.save(routing);
// Check: ถ้าทุกคนทำเสร็จแล้ว ให้ปิดใบเวียน (Master)
const pendingCount = await this.routingRepo.count({
where: {
circulationId: routing.circulationId,
status: 'PENDING', // หรือ status ที่ยังไม่เสร็จ
},
});
if (pendingCount === 0) {
await this.circulationRepo.update(routing.circulationId, {
statusCode: 'COMPLETED',
closedAt: new Date(),
});
}
return routing;
}
}

View File

@@ -4,6 +4,7 @@ import {
IsNotEmpty,
IsArray,
IsOptional,
ArrayMinSize, // ✅ เพิ่ม
} from 'class-validator';
export class CreateCirculationDto {
@@ -17,7 +18,7 @@ export class CreateCirculationDto {
@IsArray()
@IsInt({ each: true })
@IsNotEmpty()
@ArrayMinSize(1) // ✅ ต้องมีผู้รับอย่างน้อย 1 คน
assigneeIds!: number[]; // รายชื่อ User ID ที่ต้องการส่งให้ (ผู้รับผิดชอบ)
@IsString()

View File

@@ -1,34 +0,0 @@
import {
IsInt,
IsString,
IsOptional,
IsArray,
IsNotEmpty,
IsEnum,
} from 'class-validator';
export enum TransmittalPurpose {
FOR_APPROVAL = 'FOR_APPROVAL',
FOR_INFORMATION = 'FOR_INFORMATION',
FOR_REVIEW = 'FOR_REVIEW',
OTHER = 'OTHER',
}
export class CreateTransmittalDto {
@IsInt()
@IsNotEmpty()
projectId!: number; // จำเป็นสำหรับการออกเลขที่เอกสาร
@IsEnum(TransmittalPurpose)
@IsOptional()
purpose?: TransmittalPurpose; // วัตถุประสงค์
@IsString()
@IsOptional()
remarks?: string; // หมายเหตุ
@IsArray()
@IsInt({ each: true })
@IsNotEmpty()
itemIds!: number[]; // ID ของเอกสาร (Correspondence IDs) ที่จะแนบไปใน Transmittal
}

View File

@@ -0,0 +1,22 @@
import { IsInt, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
export class SearchCirculationDto {
@IsOptional()
@IsString()
search?: string; // ค้นหาจาก Subject หรือ No.
@IsOptional()
@IsString()
status?: string; // OPEN, COMPLETED
@IsOptional()
@IsInt()
@Type(() => Number)
page: number = 1;
@IsOptional()
@IsInt()
@Type(() => Number)
limit: number = 20;
}

View File

@@ -0,0 +1,16 @@
import { IsString, IsOptional, IsEnum } from 'class-validator';
export enum CirculationAction {
COMPLETED = 'COMPLETED',
REJECTED = 'REJECTED',
// IN_PROGRESS อาจจะไม่ต้องส่งมา เพราะเป็น auto state ตอนเริ่มดู
}
export class UpdateCirculationRoutingDto {
@IsEnum(CirculationAction)
status!: string; // สถานะที่ต้องการอัปเดต
@IsString()
@IsOptional()
comments?: string; // ความคิดเห็นเพิ่มเติม
}

View File

@@ -21,6 +21,8 @@ import { WorkflowActionDto } from './dto/workflow-action.dto.js';
import { AddReferenceDto } from './dto/add-reference.dto.js';
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto.js';
import { Query, Delete } from '@nestjs/common'; // เพิ่ม Query, Delete
import { Audit } from '../../common/decorators/audit.decorator'; // Import
@Controller('correspondences')
@UseGuards(JwtAuthGuard, RbacGuard)
export class CorrespondenceController {
@@ -38,6 +40,7 @@ export class CorrespondenceController {
@Post()
@RequirePermission('correspondence.create') // 🔒 ต้องมีสิทธิ์สร้าง
@Audit('correspondence.create', 'correspondence') // ✅ แปะตรงนี้
create(@Body() createDto: CreateCorrespondenceDto, @Request() req: any) {
return this.correspondenceService.create(createDto, req.user);
}
@@ -52,6 +55,7 @@ export class CorrespondenceController {
// ✅ เพิ่ม Endpoint นี้ครับ
@Post(':id/submit')
@RequirePermission('correspondence.create') // หรือจะสร้าง Permission ใหม่ 'workflow.submit' ก็ได้
@Audit('correspondence.create', 'correspondence') // ✅ แปะตรงนี้
submit(
@Param('id', ParseIntPipe) id: number,
@Body() submitDto: SubmitCorrespondenceDto,

View File

@@ -16,6 +16,7 @@ import { JsonSchemaModule } from '../json-schema/json-schema.module.js'; // ต
import { UserModule } from '../user/user.module.js'; // <--- 1. Import UserModule
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module.js'; // <--- ✅ เพิ่มบรรทัดนี้ครับ
import { CorrespondenceReference } from './entities/correspondence-reference.entity.js';
import { SearchModule } from '../search/search.module'; // ✅ 1. เพิ่ม Import SearchModule
@Module({
imports: [
@@ -33,6 +34,7 @@ import { CorrespondenceReference } from './entities/correspondence-reference.ent
JsonSchemaModule, // Import เพื่อ Validate JSON
UserModule, // <--- 2. ใส่ UserModule ใน imports เพื่อให้ RbacGuard ทำงานได้
WorkflowEngineModule, // <--- Import WorkflowEngine
SearchModule, // ✅ 2. ใส่ SearchModule ที่นี่
],
controllers: [CorrespondenceController],
providers: [CorrespondenceService],

View File

@@ -35,7 +35,7 @@ import { DocumentNumberingService } from '../document-numbering/document-numberi
import { JsonSchemaService } from '../json-schema/json-schema.service.js';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service.js';
import { UserService } from '../user/user.service.js';
import { SearchService } from '../search/search.service'; // Import SearchService
@Injectable()
export class CorrespondenceService {
private readonly logger = new Logger(CorrespondenceService.name);
@@ -61,6 +61,7 @@ export class CorrespondenceService {
private workflowEngine: WorkflowEngineService,
private userService: UserService,
private dataSource: DataSource,
private searchService: SearchService, // Inject
) {}
/**
@@ -182,7 +183,18 @@ export class CorrespondenceService {
});
await queryRunner.manager.save(revision);
await queryRunner.commitTransaction();
await queryRunner.commitTransaction(); // Transaction จบแล้ว ข้อมูลชัวร์แล้ว
// 🔥 Fire & Forget: ไม่ต้อง await ผลลัพธ์เพื่อความเร็ว (หรือใช้ Queue ก็ได้)
this.searchService.indexDocument({
id: savedCorr.id,
type: 'correspondence',
docNumber: docNumber,
title: createDto.title,
description: createDto.description,
status: 'DRAFT',
projectId: createDto.projectId,
createdAt: new Date(),
});
return {
...savedCorr,

View File

@@ -28,7 +28,7 @@ import { ContractDrawingController } from './contract-drawing.controller';
import { DrawingMasterDataController } from './drawing-master-data.controller';
// Modules
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
import { UserModule } from '../user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([
@@ -47,6 +47,7 @@ import { FileStorageModule } from '../../common/file-storage/file-storage.module
Attachment,
]),
FileStorageModule,
UserModule,
],
providers: [
ShopDrawingService,

View File

@@ -20,6 +20,7 @@ import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity';
import { Audit } from '../../common/decorators/audit.decorator'; // Import
@ApiTags('Shop Drawings')
@ApiBearerAuth()
@@ -31,6 +32,7 @@ export class ShopDrawingController {
@Post()
@ApiOperation({ summary: 'Create new Shop Drawing with initial revision' })
@RequirePermission('drawing.create') // อ้างอิง Permission จาก Seed
@Audit('drawing.create', 'shop_drawing') // ✅ แปะตรงนี้
create(@Body() createDto: CreateShopDrawingDto, @CurrentUser() user: User) {
return this.shopDrawingService.create(createDto, user);
}
@@ -52,6 +54,7 @@ export class ShopDrawingController {
@Post(':id/revisions')
@ApiOperation({ summary: 'Add new revision to existing Shop Drawing' })
@RequirePermission('drawing.create') // หรือ drawing.edit ตาม Logic องค์กร
@Audit('drawing.create', 'shop_drawing') // ✅ แปะตรงนี้
createRevision(
@Param('id', ParseIntPipe) id: number,
@Body() createRevisionDto: CreateShopDrawingRevisionDto,

View File

@@ -43,10 +43,9 @@ export class ShopDrawingService {
/**
* สร้าง Shop Drawing ใหม่ พร้อม Revision แรก (Rev 0)
* ทำงานภายใต้ Database Transaction เดียวกัน
*/
async create(createDto: CreateShopDrawingDto, user: User) {
// 1. ตรวจสอบเลขที่แบบซ้ำ (Unique Check)
// 1. Check Duplicate
const exists = await this.shopDrawingRepo.findOne({
where: { drawingNumber: createDto.drawingNumber },
});
@@ -61,7 +60,7 @@ export class ShopDrawingService {
await queryRunner.startTransaction();
try {
// 2. เตรียมข้อมูล Relations (Contract Drawings & Attachments)
// 2. Prepare Relations
let contractDrawings: ContractDrawing[] = [];
if (createDto.contractDrawingIds?.length) {
contractDrawings = await this.contractDrawingRepo.findBy({
@@ -76,7 +75,7 @@ export class ShopDrawingService {
});
}
// 3. สร้าง Master Shop Drawing
// 3. Create Master Shop Drawing
const shopDrawing = queryRunner.manager.create(ShopDrawing, {
projectId: createDto.projectId,
drawingNumber: createDto.drawingNumber,
@@ -87,23 +86,22 @@ export class ShopDrawingService {
});
const savedShopDrawing = await queryRunner.manager.save(shopDrawing);
// 4. สร้าง First Revision (Rev 0)
// 4. Create First Revision (Rev 0)
const revision = queryRunner.manager.create(ShopDrawingRevision, {
shopDrawingId: savedShopDrawing.id,
revisionNumber: 0, // เริ่มต้นที่ 0 เสมอ
revisionNumber: 0,
revisionLabel: createDto.revisionLabel || '0',
revisionDate: createDto.revisionDate
? new Date(createDto.revisionDate)
: new Date(),
description: createDto.description,
contractDrawings: contractDrawings, // ผูก M:N Relation
attachments: attachments, // ผูก M:N Relation
contractDrawings: contractDrawings,
attachments: attachments,
});
await queryRunner.manager.save(revision);
// 5. Commit Files (ย้ายไฟล์จาก Temp -> Permanent)
// 5. Commit Files
if (createDto.attachmentIds?.length) {
// ✅ FIX: ใช้ commitFiles และแปลง number[] -> string[]
await this.fileStorageService.commit(
createDto.attachmentIds.map(String),
);
@@ -111,13 +109,13 @@ export class ShopDrawingService {
await queryRunner.commitTransaction();
// ✅ FIX: Return ข้อมูลของ ShopDrawing และ Revision (ไม่ใช่ savedCorr หรือ docNumber)
return {
...savedShopDrawing,
currentRevision: revision,
};
} catch (err) {
await queryRunner.rollbackTransaction();
// ✅ FIX: Cast err เป็น Error
this.logger.error(
`Failed to create shop drawing: ${(err as Error).message}`,
);
@@ -128,14 +126,12 @@ export class ShopDrawingService {
}
/**
* เพิ่ม Revision ใหม่ให้กับ Shop Drawing เดิม (Add Revision)
* เช่น Rev 0 -> Rev A
* เพิ่ม Revision ใหม่ (Add Revision)
*/
async createRevision(
shopDrawingId: number,
createDto: CreateShopDrawingRevisionDto,
) {
// 1. ตรวจสอบว่ามี Master Drawing อยู่จริง
const shopDrawing = await this.shopDrawingRepo.findOneBy({
id: shopDrawingId,
});
@@ -143,7 +139,6 @@ export class ShopDrawingService {
throw new NotFoundException('Shop Drawing not found');
}
// 2. ตรวจสอบ Label ซ้ำใน Drawing เดียวกัน
const exists = await this.revisionRepo.findOne({
where: { shopDrawingId, revisionLabel: createDto.revisionLabel },
});
@@ -158,7 +153,6 @@ export class ShopDrawingService {
await queryRunner.startTransaction();
try {
// 3. เตรียม Relations
let contractDrawings: ContractDrawing[] = [];
if (createDto.contractDrawingIds?.length) {
contractDrawings = await this.contractDrawingRepo.findBy({
@@ -173,14 +167,12 @@ export class ShopDrawingService {
});
}
// 4. หา Revision Number ล่าสุดเพื่อ +1 (Running Number ภายใน)
const latestRev = await this.revisionRepo.findOne({
where: { shopDrawingId },
order: { revisionNumber: 'DESC' },
});
const nextRevNum = (latestRev?.revisionNumber ?? -1) + 1;
// 5. บันทึก Revision ใหม่
const revision = queryRunner.manager.create(ShopDrawingRevision, {
shopDrawingId,
revisionNumber: nextRevNum,
@@ -194,9 +186,7 @@ export class ShopDrawingService {
});
await queryRunner.manager.save(revision);
// 6. Commit Files
if (createDto.attachmentIds?.length) {
// ✅ FIX: ใช้ commitFiles และแปลง number[] -> string[]
await this.fileStorageService.commit(
createDto.attachmentIds.map(String),
);
@@ -206,7 +196,6 @@ export class ShopDrawingService {
return revision;
} catch (err) {
await queryRunner.rollbackTransaction();
// ✅ FIX: Cast err เป็น Error
this.logger.error(`Failed to create revision: ${(err as Error).message}`);
throw err;
} finally {
@@ -215,8 +204,7 @@ export class ShopDrawingService {
}
/**
* ค้นหา Shop Drawing (Search & Filter)
* รองรับการค้นหาด้วย Text และกรองตาม Category
* ค้นหา Shop Drawing
*/
async findAll(searchDto: SearchShopDrawingDto) {
const {
@@ -260,7 +248,7 @@ export class ShopDrawingService {
const [items, total] = await query.getManyAndCount();
// Transform Data: เลือก Revision ล่าสุดมาแสดงเป็น currentRevision
// Transform Data
const transformedItems = items.map((item) => {
item.revisions.sort((a, b) => b.revisionNumber - a.revisionNumber);
const currentRevision = item.revisions[0];
@@ -283,7 +271,7 @@ export class ShopDrawingService {
}
/**
* ดูรายละเอียด Shop Drawing (Get One)
* ดูรายละเอียด Shop Drawing
*/
async findOne(id: number) {
const shopDrawing = await this.shopDrawingRepo.findOne({
@@ -308,7 +296,7 @@ export class ShopDrawingService {
}
/**
* ลบ Shop Drawing (Soft Delete)
* ลบ Shop Drawing
*/
async remove(id: number, user: User) {
const shopDrawing = await this.findOne(id);

View File

@@ -0,0 +1,26 @@
import {
IsString,
IsNotEmpty,
IsInt,
IsOptional,
IsBoolean,
IsObject,
} from 'class-validator';
export class CreateJsonSchemaDto {
@IsString()
@IsNotEmpty()
schemaCode!: string; // รหัส Schema (ต้องไม่ซ้ำ เช่น 'RFA_DWG_V1')
@IsInt()
@IsOptional()
version?: number; // เวอร์ชัน (Default: 1)
@IsObject()
@IsNotEmpty()
schemaDefinition!: Record<string, any>; // โครงสร้าง JSON Schema (Standard Format)
@IsBoolean()
@IsOptional()
isActive?: boolean; // สถานะการใช้งาน
}

View File

@@ -0,0 +1,27 @@
import { IsString, IsOptional, IsBoolean, IsInt } from 'class-validator';
import { Type, Transform } from 'class-transformer';
export class SearchJsonSchemaDto {
@IsString()
@IsOptional()
search?: string; // ค้นหาจาก schemaCode
@IsBoolean()
@IsOptional()
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
})
isActive?: boolean;
@IsOptional()
@IsInt()
@Type(() => Number)
page: number = 1;
@IsOptional()
@IsInt()
@Type(() => Number)
limit: number = 20;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateJsonSchemaDto } from './create-json-schema.dto';
export class UpdateJsonSchemaDto extends PartialType(CreateJsonSchemaDto) {}

View File

@@ -1,25 +1,36 @@
import { Controller, Post, Body, Param, UseGuards } from '@nestjs/common';
import { JsonSchemaService } from './json-schema.service.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RbacGuard } from '../../common/guards/rbac.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@Controller('json-schemas')
import { JsonSchemaService } from './json-schema.service';
// ✅ FIX: Import DTO
import { CreateJsonSchemaDto } from './dto/create-json-schema.dto';
// ✅ FIX: แก้ไข Path ของ Guards
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('JSON Schemas') // ✅ Add Swagger Tag
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('json-schemas')
export class JsonSchemaController {
constructor(private readonly schemaService: JsonSchemaService) {}
@Post(':code')
@RequirePermission('system.manage_all') // เฉพาะ Superadmin หรือผู้มีสิทธิ์จัดการ System
create(@Param('code') code: string, @Body() definition: any) {
return this.schemaService.createOrUpdate(code, definition);
@Post()
@ApiOperation({ summary: 'Create or Update JSON Schema' })
@RequirePermission('system.manage_all') // Admin Only
create(@Body() createDto: CreateJsonSchemaDto) {
return this.schemaService.createOrUpdate(
createDto.schemaCode,
createDto.schemaDefinition,
);
}
// Endpoint สำหรับ Test Validate (Optional)
@Post(':code/validate')
@ApiOperation({ summary: 'Test validation against a schema' })
@RequirePermission('document.view')
async validate(@Param('code') code: string, @Body() data: any) {
const isValid = await this.schemaService.validate(code, data);
return { valid: isValid };
return { valid: isValid, message: 'Validation passed' };
}
}

View File

@@ -1,17 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JsonSchemaService } from './json-schema.service.js';
import { JsonSchemaController } from './json-schema.controller.js';
import { JsonSchema } from './entities/json-schema.entity.js';
import { UserModule } from '../user/user.module.js'; // <--- 1. Import UserModule
import { JsonSchemaService } from './json-schema.service';
import { JsonSchemaController } from './json-schema.controller';
import { JsonSchema } from './entities/json-schema.entity';
import { UserModule } from '../user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([JsonSchema]),
UserModule, // <--- 2. ใส่ UserModule ใน imports
],
imports: [TypeOrmModule.forFeature([JsonSchema]), UserModule],
controllers: [JsonSchemaController],
providers: [JsonSchemaService],
exports: [JsonSchemaService], // Export ให้ Module อื่นเรียกใช้ .validate()
exports: [JsonSchemaService],
})
export class JsonSchemaModule {}

View File

@@ -3,31 +3,32 @@ import {
OnModuleInit,
BadRequestException,
NotFoundException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { JsonSchema } from './entities/json-schema.entity.js';
import { JsonSchema } from './entities/json-schema.entity'; // ลบ .js
@Injectable()
export class JsonSchemaService implements OnModuleInit {
private ajv: Ajv;
// Cache ตัว Validator ที่ Compile แล้ว เพื่อประสิทธิภาพ
private validators = new Map<string, any>();
private readonly logger = new Logger(JsonSchemaService.name);
constructor(
@InjectRepository(JsonSchema)
private schemaRepo: Repository<JsonSchema>,
) {
// ตั้งค่า AJV
this.ajv = new Ajv({ allErrors: true, strict: false }); // strict: false เพื่อยืดหยุ่นกับ custom keywords
addFormats(this.ajv); // รองรับ format เช่น email, date-time
this.ajv = new Ajv({ allErrors: true, strict: false });
addFormats(this.ajv);
}
onModuleInit() {
// (Optional) โหลด Schema ทั้งหมดมา Cache ตอนเริ่ม App ก็ได้
// แต่ตอนนี้ใช้วิธี Lazy Load (โหลดเมื่อใช้) ไปก่อน
async onModuleInit() {
// Pre-load schemas (Optional for performance)
// const schemas = await this.schemaRepo.find({ where: { isActive: true } });
// schemas.forEach(s => this.createValidator(s.schemaCode, s.schemaDefinition));
}
/**
@@ -36,7 +37,6 @@ export class JsonSchemaService implements OnModuleInit {
async validate(schemaCode: string, data: any): Promise<boolean> {
let validate = this.validators.get(schemaCode);
// ถ้ายังไม่มีใน Cache หรือต้องการตัวล่าสุด ให้ดึงจาก DB
if (!validate) {
const schema = await this.schemaRepo.findOne({
where: { schemaCode, isActive: true },
@@ -59,19 +59,21 @@ export class JsonSchemaService implements OnModuleInit {
const valid = validate(data);
if (!valid) {
// รวบรวม Error ทั้งหมดส่งกลับไป
const errors = validate.errors
?.map((e: any) => `${e.instancePath} ${e.message}`)
.join(', ');
// โยน Error กลับไปเพื่อให้ Controller/Service ปลายทางจัดการ
throw new BadRequestException(`JSON Validation Failed: ${errors}`);
}
return true;
}
// ฟังก์ชันสำหรับสร้าง/อัปเดต Schema (สำหรับ Admin)
async createOrUpdate(schemaCode: string, definition: any) {
// ตรวจสอบก่อนว่า Definition เป็น JSON Schema ที่ถูกต้องไหม
/**
* สร้างหรืออัปเดต Schema
*/
async createOrUpdate(schemaCode: string, definition: Record<string, any>) {
// 1. ตรวจสอบว่า Definition เป็น JSON Schema ที่ถูกต้องไหม
try {
this.ajv.compile(definition);
} catch (error: any) {
@@ -80,6 +82,7 @@ export class JsonSchemaService implements OnModuleInit {
);
}
// 2. บันทึกลง DB
let schema = await this.schemaRepo.findOne({ where: { schemaCode } });
if (schema) {
@@ -93,9 +96,12 @@ export class JsonSchemaService implements OnModuleInit {
});
}
// Clear Cache เก่า
this.validators.delete(schemaCode);
const savedSchema = await this.schemaRepo.save(schema);
return this.schemaRepo.save(schema);
// 3. Clear Cache เพื่อให้ครั้งหน้าโหลดตัวใหม่
this.validators.delete(schemaCode);
this.logger.log(`Schema '${schemaCode}' updated (v${savedSchema.version})`);
return savedSchema;
}
}

View File

@@ -0,0 +1,15 @@
import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator';
export class CreateProjectDto {
@IsString()
@IsNotEmpty()
projectCode!: string; // รหัสโครงการ (เช่น LCBP3)
@IsString()
@IsNotEmpty()
projectName!: string; // ชื่อโครงการ
@IsBoolean()
@IsOptional()
isActive?: boolean; // สถานะการใช้งาน (Default: true)
}

View File

@@ -0,0 +1,27 @@
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
import { Type, Transform } from 'class-transformer';
export class SearchProjectDto {
@IsString()
@IsOptional()
search?: string; // ค้นหาจาก Project Code หรือ Name
@IsOptional()
@IsBoolean()
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
})
isActive?: boolean; // กรองตามสถานะ Active
@IsOptional()
@IsInt()
@Type(() => Number)
page: number = 1;
@IsOptional()
@IsInt()
@Type(() => Number)
limit: number = 20;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateProjectDto } from './create-project.dto';
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}

View File

@@ -1,4 +1,75 @@
import { Controller } from '@nestjs/common';
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@Controller('project')
export class ProjectController {}
import { ProjectService } from './project.service.js';
import { CreateProjectDto } from './dto/create-project.dto.js';
import { UpdateProjectDto } from './dto/update-project.dto.js';
import { SearchProjectDto } from './dto/search-project.dto.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RbacGuard } from '../../common/guards/rbac.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
@ApiTags('Projects')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('projects') // แนะนำให้ใช้ plural noun (projects)
export class ProjectController {
constructor(private readonly projectService: ProjectService) {}
@Post()
@ApiOperation({ summary: 'Create new Project' })
@RequirePermission('project.create')
create(@Body() createDto: CreateProjectDto) {
return this.projectService.create(createDto);
}
@Get()
@ApiOperation({ summary: 'Search Projects' })
@RequirePermission('project.view')
findAll(@Query() searchDto: SearchProjectDto) {
return this.projectService.findAll(searchDto);
}
@Get('organizations')
@ApiOperation({ summary: 'List All Organizations (Master Data)' })
// @RequirePermission('organization.view') // หรือเปิดให้ดูได้ทั่วไปถ้าจำเป็น
findAllOrgs() {
return this.projectService.findAllOrganizations();
}
@Get(':id')
@ApiOperation({ summary: 'Get Project Details' })
@RequirePermission('project.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.projectService.findOne(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update Project' })
@RequirePermission('project.edit')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateProjectDto,
) {
return this.projectService.update(id, updateDto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete Project (Soft Delete)' })
@RequirePermission('project.delete')
remove(@Param('id', ParseIntPipe) id: number) {
return this.projectService.remove(id);
}
}

View File

@@ -7,7 +7,8 @@ import { Organization } from './entities/organization.entity.js';
import { Contract } from './entities/contract.entity.js';
import { ProjectOrganization } from './entities/project-organization.entity.js'; // เพิ่ม
import { ContractOrganization } from './entities/contract-organization.entity.js'; // เพิ่ม
// Modules
import { UserModule } from '../user/user.module'; // ✅ 1. Import UserModule
@Module({
imports: [
TypeOrmModule.forFeature([
@@ -17,6 +18,7 @@ import { ContractOrganization } from './entities/contract-organization.entity.js
ProjectOrganization, // ลงทะเบียน
ContractOrganization, // ลงทะเบียน
]),
UserModule, // ✅ 2. เพิ่ม UserModule เข้าไปใน imports
],
controllers: [ProjectController],
providers: [ProjectService],

View File

@@ -1,11 +1,25 @@
import { Injectable } from '@nestjs/common';
import {
Injectable,
NotFoundException,
ConflictException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, Like } from 'typeorm';
// Entities
import { Project } from './entities/project.entity.js';
import { Organization } from './entities/organization.entity.js';
// DTOs
import { CreateProjectDto } from './dto/create-project.dto.js';
import { UpdateProjectDto } from './dto/update-project.dto.js';
import { SearchProjectDto } from './dto/search-project.dto.js';
@Injectable()
export class ProjectService {
private readonly logger = new Logger(ProjectService.name);
constructor(
@InjectRepository(Project)
private projectRepository: Repository<Project>,
@@ -13,13 +27,92 @@ export class ProjectService {
private organizationRepository: Repository<Organization>,
) {}
// ดึงรายการ Project ทั้งหมด
async findAllProjects() {
return this.projectRepository.find();
// --- CRUD Operations ---
async create(createDto: CreateProjectDto) {
// 1. เช็คชื่อ/รหัสซ้ำ (ถ้าจำเป็น)
const existing = await this.projectRepository.findOne({
where: { projectCode: createDto.projectCode },
});
if (existing) {
throw new ConflictException(
`Project Code "${createDto.projectCode}" already exists`,
);
}
// 2. สร้าง Project
const project = this.projectRepository.create(createDto);
return this.projectRepository.save(project);
}
// ดึงรายการ Organization ทั้งหมด
async findAll(searchDto: SearchProjectDto) {
const { search, isActive, page = 1, limit = 20 } = searchDto;
const skip = (page - 1) * limit;
// สร้าง Query Builder
const query = this.projectRepository.createQueryBuilder('project');
if (isActive !== undefined) {
query.andWhere('project.isActive = :isActive', { isActive });
}
if (search) {
query.andWhere(
'(project.projectCode LIKE :search OR project.projectName LIKE :search)',
{ search: `%${search}%` },
);
}
query.orderBy('project.created_at', 'DESC');
query.skip(skip).take(limit);
const [items, total] = await query.getManyAndCount();
return {
data: items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findOne(id: number) {
const project = await this.projectRepository.findOne({
where: { id },
relations: ['contracts'], // ดึงสัญญาที่เกี่ยวข้องมาด้วย
});
if (!project) {
throw new NotFoundException(`Project ID ${id} not found`);
}
return project;
}
async update(id: number, updateDto: UpdateProjectDto) {
const project = await this.findOne(id);
// Merge ข้อมูลใหม่ใส่ข้อมูลเดิม
this.projectRepository.merge(project, updateDto);
return this.projectRepository.save(project);
}
async remove(id: number) {
const project = await this.findOne(id);
// ใช้ Soft Delete
return this.projectRepository.softRemove(project);
}
// --- Organization Helper ---
async findAllOrganizations() {
return this.organizationRepository.find();
return this.organizationRepository.find({
where: { isActive: true },
order: { organizationCode: 'ASC' },
});
}
}

View File

@@ -15,6 +15,7 @@ import { RfaStatusCode } from './rfa-status-code.entity';
import { RfaApproveCode } from './rfa-approve-code.entity';
import { User } from '../../user/entities/user.entity';
import { RfaItem } from './rfa-item.entity';
import { RfaWorkflow } from './rfa-workflow.entity'; // Import เพิ่ม
@Entity('rfa_revisions')
@Unique(['rfaId', 'revisionNumber'])
@@ -96,4 +97,10 @@ export class RfaRevision {
// Items (Shop Drawings inside this RFA)
@OneToMany(() => RfaItem, (item) => item.rfaRevision, { cascade: true })
items!: RfaItem[];
// Workflows
@OneToMany(() => RfaWorkflow, (workflow) => workflow.rfaRevision, {
cascade: true,
})
workflows!: RfaWorkflow[];
}

View File

@@ -0,0 +1,64 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { RfaWorkflowTemplate } from './rfa-workflow-template.entity';
import { Organization } from '../../project/entities/organization.entity';
import { Role } from '../../user/entities/role.entity';
// ✅ 1. สร้าง Enum เพื่อให้ Type Safe
export enum RfaActionType {
REVIEW = 'REVIEW',
APPROVE = 'APPROVE',
ACKNOWLEDGE = 'ACKNOWLEDGE',
}
@Entity('rfa_workflow_template_steps')
export class RfaWorkflowTemplateStep {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'template_id' })
templateId!: number;
@Column({ name: 'step_number' })
stepNumber!: number;
@Column({ name: 'organization_id' })
organizationId!: number;
@Column({ name: 'role_id', nullable: true })
roleId?: number;
@Column({
name: 'action_type',
type: 'enum',
enum: RfaActionType, // ✅ 2. ใช้ Enum ตรงนี้
nullable: true,
})
actionType?: RfaActionType; // ✅ 3. เปลี่ยน type จาก string เป็น Enum
@Column({ name: 'duration_days', nullable: true })
durationDays?: number;
@Column({ name: 'is_optional', default: false })
isOptional!: boolean;
// Relations
@ManyToOne(() => RfaWorkflowTemplate, (template) => template.steps, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'template_id' })
template!: RfaWorkflowTemplate;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'organization_id' })
organization!: Organization;
@ManyToOne(() => Role)
@JoinColumn({ name: 'role_id' })
role?: Role;
}

View File

@@ -0,0 +1,39 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { RfaWorkflowTemplateStep } from './rfa-workflow-template-step.entity';
@Entity('rfa_workflow_templates')
export class RfaWorkflowTemplate {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'template_name', length: 100 })
templateName!: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'is_active', default: true })
isActive!: boolean;
@Column({ type: 'json', nullable: true })
workflowConfig?: Record<string, any>; // Configuration เพิ่มเติม
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
// Relations
@OneToMany(() => RfaWorkflowTemplateStep, (step) => step.template, {
cascade: true,
})
steps!: RfaWorkflowTemplateStep[];
}

View File

@@ -0,0 +1,73 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { RfaRevision } from './rfa-revision.entity';
import { Organization } from '../../project/entities/organization.entity';
import { User } from '../../user/entities/user.entity';
@Entity('rfa_workflows')
export class RfaWorkflow {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'rfa_revision_id' })
rfaRevisionId!: number;
@Column({ name: 'step_number' })
stepNumber!: number;
@Column({ name: 'organization_id' })
organizationId!: number;
@Column({ name: 'assigned_to', nullable: true })
assignedTo?: number;
@Column({
name: 'action_type',
type: 'enum',
enum: ['REVIEW', 'APPROVE', 'ACKNOWLEDGE'],
nullable: true,
})
actionType?: string;
@Column({
type: 'enum',
enum: ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'REJECTED'],
nullable: true,
})
status?: string;
@Column({ type: 'text', nullable: true })
comments?: string;
@Column({ name: 'completed_at', type: 'datetime', nullable: true })
completedAt?: Date;
@Column({ type: 'json', nullable: true })
stateContext?: Record<string, any>; // เก็บ Snapshot ข้อมูล ณ ขณะนั้น
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
// Relations
@ManyToOne(() => RfaRevision, (rev) => rev.workflows, { onDelete: 'CASCADE' }) // ต้องไปเพิ่ม Property workflows ใน RfaRevision ด้วย
@JoinColumn({ name: 'rfa_revision_id' })
rfaRevision!: RfaRevision;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'organization_id' })
organization!: Organization;
@ManyToOne(() => User)
@JoinColumn({ name: 'assigned_to' })
assignee?: User;
}

View File

@@ -18,6 +18,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Audit } from '../../common/decorators/audit.decorator'; // Import
@ApiTags('RFA (Request for Approval)')
@ApiBearerAuth()
@@ -29,6 +30,7 @@ export class RfaController {
@Post()
@ApiOperation({ summary: 'Create new RFA (Draft)' })
@RequirePermission('rfa.create') // สิทธิ์ ID 37
@Audit('rfa.create', 'rfa') // ✅ แปะตรงนี้
create(@Body() createDto: CreateRfaDto, @CurrentUser() user: User) {
return this.rfaService.create(createDto, user);
}

View File

@@ -21,6 +21,12 @@ import { RfaController } from './rfa.controller';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { UserModule } from '../user/user.module';
// ... imports
import { RfaWorkflow } from './entities/rfa-workflow.entity';
import { RfaWorkflowTemplate } from './entities/rfa-workflow-template.entity';
import { RfaWorkflowTemplateStep } from './entities/rfa-workflow-template-step.entity';
import { SearchModule } from '../search/search.module'; // ✅ เพิ่ม
@Module({
imports: [
TypeOrmModule.forFeature([
@@ -32,9 +38,14 @@ import { UserModule } from '../user/user.module';
RfaApproveCode,
Correspondence,
ShopDrawingRevision,
// ... (ตัวเดิม)
RfaWorkflow,
RfaWorkflowTemplate,
RfaWorkflowTemplateStep,
]),
DocumentNumberingModule,
UserModule,
SearchModule,
],
providers: [RfaService],
controllers: [RfaController],

View File

@@ -34,6 +34,7 @@ import { DocumentNumberingService } from '../document-numbering/document-numberi
import { UserService } from '../user/user.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
import { NotificationService } from '../notification/notification.service';
import { SearchService } from '../search/search.service'; // Import SearchService
@Injectable()
export class RfaService {
@@ -66,6 +67,7 @@ export class RfaService {
private workflowEngine: WorkflowEngineService,
private notificationService: NotificationService,
private dataSource: DataSource,
private searchService: SearchService, // Inject
) {}
/**
@@ -166,6 +168,17 @@ export class RfaService {
}
await queryRunner.commitTransaction();
// 🔥 Fire & Forget: ไม่ต้อง await ผลลัพธ์เพื่อความเร็ว (หรือใช้ Queue ก็ได้)
this.searchService.indexDocument({
id: savedCorr.id,
type: 'correspondence',
docNumber: docNumber,
title: createDto.title,
description: createDto.description,
status: 'DRAFT',
projectId: createDto.projectId,
createdAt: new Date(),
});
return {
...savedRfa,

View File

@@ -0,0 +1,27 @@
import { IsString, IsOptional, IsInt, IsNotEmpty } from 'class-validator';
import { Type } from 'class-transformer';
export class SearchQueryDto {
@IsString()
@IsOptional()
q?: string; // คำค้นหา (Query)
@IsString()
@IsOptional()
type?: string; // กรองประเภท: 'rfa', 'correspondence', 'drawing'
@IsInt()
@Type(() => Number)
@IsOptional()
projectId?: number;
@IsInt()
@Type(() => Number)
@IsOptional()
page: number = 1;
@IsInt()
@Type(() => Number)
@IsOptional()
limit: number = 20;
}

View File

@@ -0,0 +1,22 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { SearchService } from './search.service';
import { SearchQueryDto } from './dto/search-query.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('Search')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('search')
export class SearchController {
constructor(private readonly searchService: SearchService) {}
@Get()
@ApiOperation({ summary: 'Advanced Search across all documents' })
@RequirePermission('search.advanced') // สิทธิ์ ID 48
search(@Query() queryDto: SearchQueryDto) {
return this.searchService.search(queryDto);
}
}

View File

@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { ElasticsearchModule } from '@nestjs/elasticsearch';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SearchService } from './search.service';
import { SearchController } from './search.controller';
import { UserModule } from '../user/user.module'; // ✅ 1. Import UserModule
@Module({
imports: [
ConfigModule,
// ✅ 2. เพิ่ม UserModule เข้าไปใน imports
UserModule,
ElasticsearchModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
node:
configService.get<string>('ELASTICSEARCH_NODE') ||
'http://localhost:9200',
auth: {
username: configService.get<string>('ELASTICSEARCH_USERNAME') || '',
password: configService.get<string>('ELASTICSEARCH_PASSWORD') || '',
},
}),
inject: [ConfigService],
}),
],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],
})
export class SearchModule {}

View File

@@ -0,0 +1,152 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ElasticsearchService } from '@nestjs/elasticsearch';
import { ConfigService } from '@nestjs/config';
import { SearchQueryDto } from './dto/search-query.dto';
@Injectable()
export class SearchService implements OnModuleInit {
private readonly logger = new Logger(SearchService.name);
private readonly indexName = 'dms_documents';
constructor(
private readonly esService: ElasticsearchService,
private readonly configService: ConfigService,
) {}
async onModuleInit() {
await this.createIndexIfNotExists();
}
/**
* สร้าง Index และกำหนด Mapping (Schema)
*/
private async createIndexIfNotExists() {
try {
const indexExists = await this.esService.indices.exists({
index: this.indexName,
});
if (!indexExists) {
// ✅ FIX: Cast 'body' เป็น any เพื่อแก้ปัญหา Type Mismatch ของ Library
await this.esService.indices.create({
index: this.indexName,
body: {
mappings: {
properties: {
id: { type: 'integer' },
type: { type: 'keyword' }, // correspondence, rfa, drawing
docNumber: { type: 'text' },
title: { type: 'text', analyzer: 'standard' },
description: { type: 'text', analyzer: 'standard' },
status: { type: 'keyword' },
projectId: { type: 'integer' },
createdAt: { type: 'date' },
tags: { type: 'text' },
},
},
} as any,
});
this.logger.log(`Elasticsearch index '${this.indexName}' created.`);
}
} catch (error) {
this.logger.error(`Failed to create index: ${(error as Error).message}`);
}
}
/**
* Index เอกสาร (Create/Update)
*/
async indexDocument(doc: any) {
try {
return await this.esService.index({
index: this.indexName,
id: `${doc.type}_${doc.id}`, // Unique ID: rfa_101
document: doc, // ✅ Library รุ่นใหม่ใช้ 'document' แทน 'body' ในบางเวอร์ชัน
});
} catch (error) {
this.logger.error(
`Failed to index document: ${(error as Error).message}`,
);
}
}
/**
* ลบเอกสารออกจาก Index
*/
async removeDocument(type: string, id: number) {
try {
await this.esService.delete({
index: this.indexName,
id: `${type}_${id}`,
});
} catch (error) {
this.logger.error(
`Failed to remove document: ${(error as Error).message}`,
);
}
}
/**
* ค้นหาเอกสาร (Full-text Search)
*/
async search(queryDto: SearchQueryDto) {
const { q, type, projectId, page = 1, limit = 20 } = queryDto;
const from = (page - 1) * limit;
const mustQueries: any[] = [];
// 1. Full-text search logic
if (q) {
mustQueries.push({
multi_match: {
query: q,
fields: ['title^3', 'docNumber^2', 'description', 'tags'], // Boost ความสำคัญ
fuzziness: 'AUTO',
},
});
} else {
mustQueries.push({ match_all: {} });
}
// 2. Filter logic
const filterQueries: any[] = [];
if (type) filterQueries.push({ term: { type } });
if (projectId) filterQueries.push({ term: { projectId } });
try {
const result = await this.esService.search({
index: this.indexName,
from,
size: limit,
// ✅ ส่ง Query Structure โดยตรง
query: {
bool: {
must: mustQueries,
filter: filterQueries,
},
},
sort: [{ createdAt: { order: 'desc' } }],
});
// 3. Format Result
const hits = result.hits.hits;
const total =
typeof result.hits.total === 'number'
? result.hits.total
: result.hits.total?.value || 0;
return {
data: hits.map((hit) => hit._source),
meta: {
total,
page,
limit,
took: result.took,
},
};
} catch (error) {
this.logger.error(`Search failed: ${(error as Error).message}`);
return { data: [], meta: { total: 0, page, limit, took: 0 } };
}
}
}

View File

@@ -19,6 +19,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Audit } from '../../common/decorators/audit.decorator'; // Import
@ApiTags('Transmittals')
@ApiBearerAuth()
@@ -30,24 +31,23 @@ export class TransmittalController {
@Post()
@ApiOperation({ summary: 'Create new Transmittal' })
@RequirePermission('transmittal.create') // สิทธิ์ ID 40
@Audit('transmittal.create', 'transmittal') // ✅ แปะตรงนี้
create(@Body() createDto: CreateTransmittalDto, @CurrentUser() user: User) {
return this.transmittalService.create(createDto, user);
}
// เพิ่ม Endpoint พื้นฐานสำหรับการค้นหา (Optional)
/*
@Get()
@ApiOperation({ summary: 'Search Transmittals' })
@RequirePermission('document.view')
findAll(@Query() searchDto: SearchTransmittalDto) {
// return this.transmittalService.findAll(searchDto);
// return this.transmittalService.findAll(searchDto);
}
@Get(':id')
@ApiOperation({ summary: 'Get Transmittal details' })
@RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) {
// return this.transmittalService.findOne(id);
// return this.transmittalService.findOne(id);
}
*/
}

View File

@@ -6,11 +6,12 @@ import { Correspondence } from '../correspondence/entities/correspondence.entity
import { TransmittalService } from './transmittal.service';
import { TransmittalController } from './transmittal.controller';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { UserModule } from '../user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([Transmittal, TransmittalItem, Correspondence]),
DocumentNumberingModule,
UserModule,
],
controllers: [TransmittalController],
providers: [TransmittalService],

View File

@@ -12,6 +12,7 @@ import { Correspondence } from '../correspondence/entities/correspondence.entity
import { CreateTransmittalDto } from './dto/create-transmittal.dto'; // ต้องสร้าง DTO
import { User } from '../user/entities/user.entity';
import { DocumentNumberingService } from '../document-numbering/document-numbering.service';
import { SearchService } from '../search/search.service'; // Import SearchService
@Injectable()
export class TransmittalService {
@@ -24,6 +25,7 @@ export class TransmittalService {
private correspondenceRepo: Repository<Correspondence>,
private numberingService: DocumentNumberingService,
private dataSource: DataSource,
private searchService: SearchService, // Inject
) {}
async create(createDto: CreateTransmittalDto, user: User) {

View File

@@ -0,0 +1,42 @@
// File: src/modules/user/dto/update-preference.dto.ts
// บันทึกการแก้ไข: DTO สำหรับตรวจสอบข้อมูลการอัปเดต User Preferences (T1.3)
import { IsBoolean, IsOptional, IsString, IsIn } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger'; // ใช้สำหรับสร้าง API Documentation (Swagger)
export class UpdatePreferenceDto {
@ApiPropertyOptional({
description: 'รับการแจ้งเตือนทางอีเมลหรือไม่',
default: true,
})
@IsOptional()
@IsBoolean()
notifyEmail?: boolean;
@ApiPropertyOptional({
description: 'รับการแจ้งเตือนทาง LINE หรือไม่',
default: true,
})
@IsOptional()
@IsBoolean()
notifyLine?: boolean;
@ApiPropertyOptional({
description:
'รับการแจ้งเตือนแบบรวม (Digest) แทน Real-time เพื่อลดจำนวนข้อความ',
default: false,
})
@IsOptional()
@IsBoolean()
digestMode?: boolean;
@ApiPropertyOptional({
description: 'ธีมของหน้าจอ (light, dark, หรือ system)',
default: 'light',
enum: ['light', 'dark', 'system'],
})
@IsOptional()
@IsString()
@IsIn(['light', 'dark', 'system']) // บังคับว่าต้องเป็นค่าใดค่าหนึ่งในนี้เท่านั้น
uiTheme?: string;
}

View File

@@ -1,4 +1,21 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto.js';
// File: src/modules/user/dto/update-preference.dto.ts
import { IsBoolean, IsOptional, IsString, IsIn } from 'class-validator';
export class UpdateUserDto extends PartialType(CreateUserDto) {}
export class UpdatePreferenceDto {
@IsOptional()
@IsBoolean()
notifyEmail?: boolean;
@IsOptional()
@IsBoolean()
notifyLine?: boolean;
@IsOptional()
@IsBoolean()
digestMode?: boolean;
@IsOptional()
@IsString()
@IsIn(['light', 'dark', 'system'])
uiTheme?: string;
}

View File

@@ -0,0 +1,27 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('permissions')
export class Permission {
@PrimaryGeneratedColumn({ name: 'permission_id' })
permissionId!: number;
@Column({ name: 'permission_name', length: 100, unique: true })
permissionName!: string; // e.g., 'rfa.create'
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ length: 50, nullable: true })
module?: string; // e.g., 'rfa', 'user'
@Column({
name: 'scope_level',
type: 'enum',
enum: ['GLOBAL', 'ORG', 'PROJECT'],
nullable: true,
})
scopeLevel?: string;
@Column({ name: 'is_active', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,29 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
export enum RoleScope {
GLOBAL = 'Global',
ORGANIZATION = 'Organization',
PROJECT = 'Project',
CONTRACT = 'Contract',
}
@Entity('roles')
export class Role {
@PrimaryGeneratedColumn({ name: 'role_id' })
roleId!: number;
@Column({ name: 'role_name', length: 100 })
roleName!: string;
@Column({
type: 'enum',
enum: RoleScope,
})
scope!: RoleScope;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'is_system', default: false })
isSystem!: boolean;
}

View File

@@ -1,3 +1,6 @@
// File: src/modules/user/entities/user-assignment.entity.ts
// บันทึกการแก้ไข: Entity สำหรับการมอบหมาย Role ให้กับ User ตาม Scope (T1.3, RBAC 4-Level)
import {
Entity,
Column,
@@ -6,8 +9,11 @@ import {
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { User } from './user.entity.js';
// Import Role, Org, Project, Contract entities...
import { User } from './user.entity';
import { Role } from './role.entity';
import { Organization } from '../../project/entities/organization.entity'; // ปรับ Path ให้ตรงกับ ProjectModule
import { Project } from '../../project/entities/project.entity'; // ปรับ Path ให้ตรงกับ ProjectModule
import { Contract } from '../../project/entities/contract.entity'; // ปรับ Path ให้ตรงกับ ProjectModule
@Entity('user_assignments')
export class UserAssignment {
@@ -20,6 +26,7 @@ export class UserAssignment {
@Column({ name: 'role_id' })
roleId!: number;
// --- Scopes (เลือกได้เพียง 1 หรือเป็น NULL ทั้งหมดสำหรับ Global) ---
@Column({ name: 'organization_id', nullable: true })
organizationId?: number;
@@ -35,8 +42,29 @@ export class UserAssignment {
@CreateDateColumn({ name: 'assigned_at' })
assignedAt!: Date;
// Relation กลับไปหา User (เจ้าของสิทธิ์)
@ManyToOne(() => User)
// --- Relations ---
@ManyToOne(() => User, (user) => user.assignments, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: User;
user!: User;
@ManyToOne(() => Role, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'role_id' })
role!: Role;
@ManyToOne(() => Organization, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'organization_id' })
organization?: Organization;
@ManyToOne(() => Project, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'project_id' })
project?: Project;
@ManyToOne(() => Contract, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'assigned_by_user_id' })
assignedBy?: User;
}

View File

@@ -1,7 +1,10 @@
// File: src/modules/user/entities/user-preference.entity.ts
// บันทึกการแก้ไข: Entity สำหรับเก็บการตั้งค่าส่วนตัวของผู้ใช้ แยกจากตาราง Users (T1.3)
import {
Entity,
PrimaryColumn,
Column,
PrimaryColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
@@ -10,7 +13,6 @@ import { User } from './user.entity';
@Entity('user_preferences')
export class UserPreference {
// ใช้ user_id เป็น Primary Key และ Foreign Key ในตัวเดียวกัน (1:1 Relation)
@PrimaryColumn({ name: 'user_id' })
userId!: number;
@@ -20,18 +22,17 @@ export class UserPreference {
@Column({ name: 'notify_line', default: true })
notifyLine!: boolean;
@Column({ name: 'digest_mode', default: true })
@Column({ name: 'digest_mode', default: false })
digestMode!: boolean; // รับแจ้งเตือนแบบรวม (Digest) แทน Real-time
@Column({ name: 'ui_theme', length: 20, default: 'light' })
@Column({ name: 'ui_theme', default: 'light', length: 20 })
uiTheme!: string;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
// --- Relations ---
@OneToOne(() => User)
// --- Relation ---
@OneToOne(() => User, (user) => user.preference, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
}

View File

@@ -1,3 +1,6 @@
// File: src/modules/user/entities/user.entity.ts
// บันทึกการแก้ไข: เพิ่ม Relations กับ UserAssignment และ UserPreference (T1.3)
import {
Entity,
Column,
@@ -5,10 +8,14 @@ import {
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
ManyToOne, // <--- เพิ่มตรงนี้
JoinColumn, // <--- เพิ่มตรงนี้
ManyToOne,
OneToMany,
OneToOne,
JoinColumn,
} from 'typeorm';
import { Organization } from '../../project/entities/organization.entity.js'; // อย่าลืม import Organization
import { Organization } from '../../project/entities/organization.entity'; // Adjust path as needed
import { UserAssignment } from './user-assignment.entity';
import { UserPreference } from './user-preference.entity';
@Entity('users')
export class User {
@@ -18,7 +25,7 @@ export class User {
@Column({ unique: true, length: 50 })
username!: string;
@Column({ name: 'password_hash' })
@Column({ name: 'password_hash', select: false }) // ไม่ Select Password โดย Default
password!: string;
@Column({ unique: true, length: 100 })
@@ -33,15 +40,26 @@ export class User {
@Column({ name: 'is_active', default: true })
isActive!: boolean;
// Relation กับ Organization
@Column({ name: 'line_id', nullable: true, length: 100 })
lineId?: string;
// Relation กับ Organization (สังกัดหลัก)
@Column({ name: 'primary_organization_id', nullable: true })
primaryOrganizationId?: number;
@ManyToOne(() => Organization)
@ManyToOne(() => Organization, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'primary_organization_id' })
organization?: Organization;
// Base Entity Fields (ที่เราแยกมาเขียนเองเพราะเรื่อง deleted_at)
// Relation กับ Assignments (RBAC)
@OneToMany(() => UserAssignment, (assignment) => assignment.user)
assignments?: UserAssignment[];
// Relation กับ Preferences (1:1)
@OneToOne(() => UserPreference, (pref) => pref.user, { cascade: true })
preference?: UserPreference;
// Base Entity Fields
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;

View File

@@ -0,0 +1,47 @@
// File: src/modules/user/user-preference.service.ts
// บันทึกการแก้ไข: Service จัดการการตั้งค่าส่วนตัว (T1.3)
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserPreference } from './entities/user-preference.entity';
import { UpdatePreferenceDto } from './dto/update-preference.dto';
@Injectable()
export class UserPreferenceService {
constructor(
@InjectRepository(UserPreference)
private prefRepo: Repository<UserPreference>,
) {}
// ดึง Preference ของ User (ถ้าไม่มีให้สร้าง Default)
async findByUser(userId: number): Promise<UserPreference> {
let pref = await this.prefRepo.findOne({ where: { userId } });
if (!pref) {
pref = this.prefRepo.create({
userId,
notifyEmail: true,
notifyLine: true,
digestMode: false,
uiTheme: 'light',
});
await this.prefRepo.save(pref);
}
return pref;
}
// อัปเดต Preference
async update(
userId: number,
dto: UpdatePreferenceDto,
): Promise<UserPreference> {
const pref = await this.findByUser(userId);
// Merge ข้อมูลใหม่
this.prefRepo.merge(pref, dto);
return this.prefRepo.save(pref);
}
}

View File

@@ -1,3 +1,6 @@
// File: src/modules/user/user.controller.ts
// บันทึกการแก้ไข: เพิ่ม Endpoints สำหรับ User Preferences (T1.3)
import {
Controller,
Get,
@@ -8,47 +11,86 @@ import {
Delete,
UseGuards,
ParseIntPipe,
Request, // <--- อย่าลืม Import Request
} from '@nestjs/common';
import { UserService } from './user.service.js';
import { CreateUserDto } from './dto/create-user.dto.js';
import { UpdateUserDto } from './dto/update-user.dto.js';
import { AssignRoleDto } from './dto/assign-role.dto.js'; // <--- Import DTO
import { UserAssignmentService } from './user-assignment.service.js'; // <--- Import Service ใหม่
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RbacGuard } from '../../common/guards/rbac.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
import { UserService } from './user.service';
import { UserAssignmentService } from './user-assignment.service';
import { UserPreferenceService } from './user-preference.service'; // ✅ เพิ่ม
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { AssignRoleDto } from './dto/assign-role.dto';
import { UpdatePreferenceDto } from './dto/update-preference.dto'; // ✅ เพิ่ม DTO
import { JwtAuthGuard } from '../../common/auth/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard'; // สมมติว่ามีแล้ว ถ้ายังไม่มีให้คอมเมนต์ไว้ก่อน
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from './entities/user.entity';
@ApiTags('Users')
@ApiBearerAuth()
@Controller('users')
@UseGuards(JwtAuthGuard, RbacGuard)
@UseGuards(JwtAuthGuard, RbacGuard) // RbacGuard จะเช็ค permission
export class UserController {
constructor(
private readonly userService: UserService,
private readonly assignmentService: UserAssignmentService, // <--- ✅ Inject Service เข้ามา
private readonly assignmentService: UserAssignmentService,
private readonly preferenceService: UserPreferenceService, // ✅ Inject Service
) {}
// --- User CRUD ---
// --- User Preferences (Me) ---
// ต้องวางไว้ก่อน :id เพื่อไม่ให้ route ชนกัน
@Get('me/preferences')
@ApiOperation({ summary: 'Get my preferences' })
@UseGuards(JwtAuthGuard) // Bypass RBAC check for self
getMyPreferences(@CurrentUser() user: User) {
return this.preferenceService.findByUser(user.user_id);
}
@Patch('me/preferences')
@ApiOperation({ summary: 'Update my preferences' })
@UseGuards(JwtAuthGuard) // Bypass RBAC check for self
updateMyPreferences(
@CurrentUser() user: User,
@Body() dto: UpdatePreferenceDto,
) {
return this.preferenceService.update(user.user_id, dto);
}
@Get('me/permissions')
@ApiOperation({ summary: 'Get my permissions' })
@UseGuards(JwtAuthGuard)
getMyPermissions(@CurrentUser() user: User) {
return this.userService.getUserPermissions(user.user_id);
}
// --- User CRUD (Admin) ---
@Post()
@ApiOperation({ summary: 'Create new user' })
@RequirePermission('user.create')
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
@Get()
@ApiOperation({ summary: 'List all users' })
@RequirePermission('user.view')
findAll() {
return this.userService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get user details' })
@RequirePermission('user.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.userService.findOne(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update user' })
@RequirePermission('user.edit')
update(
@Param('id', ParseIntPipe) id: number,
@@ -58,6 +100,7 @@ export class UserController {
}
@Delete(':id')
@ApiOperation({ summary: 'Delete user (Soft delete)' })
@RequirePermission('user.delete')
remove(@Param('id', ParseIntPipe) id: number) {
return this.userService.remove(id);
@@ -65,14 +108,10 @@ export class UserController {
// --- Role Assignment ---
@Post('assign-role') // <--- ✅ ต้องมี @ เสมอครับ
@Post('assign-role')
@ApiOperation({ summary: 'Assign role to user' })
@RequirePermission('permission.assign')
assignRole(@Body() dto: AssignRoleDto, @Request() req: any) {
return this.assignmentService.assignRole(dto, req.user);
}
@Get('me/permissions')
@UseGuards(JwtAuthGuard) // No RbacGuard here to avoid circular dependency check issues
getMyPermissions(@Request() req: any) {
return this.userService.getUserPermissions(req.user.user_id);
assignRole(@Body() dto: AssignRoleDto, @CurrentUser() user: User) {
return this.assignmentService.assignRole(dto, user);
}
}

View File

@@ -1,24 +1,42 @@
// File: src/modules/user/user.module.ts
// บันทึกการแก้ไข: รวม UserPreferenceService และ RoleService (T1.3)
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service.js';
import { UserController } from './user.controller.js'; // 1. Import Controller
import { User } from './entities/user.entity.js';
import { UserAssignmentService } from './user-assignment.service.js';
import { UserAssignment } from './entities/user-assignment.entity.js';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { UserAssignmentService } from './user-assignment.service';
import { UserPreferenceService } from './user-preference.service'; // ✅ เพิ่ม
// Entities
import { User } from './entities/user.entity';
import { UserAssignment } from './entities/user-assignment.entity';
import { UserPreference } from './entities/user-preference.entity';
import { Role } from './entities/role.entity';
import { Permission } from './entities/permission.entity';
@Module({
imports: [
// 3. ลงทะเบียน Entity ทั้ง User และ UserAssignment
TypeOrmModule.forFeature([User, UserAssignment]),
], // 2. เพิ่มบรรทัดนี้ เพื่อบอก NestJS ว่ามี Controller นี้อยู่
// ลงทะเบียน Entity ให้ครบ
TypeOrmModule.forFeature([
User,
UserAssignment,
UserPreference,
Role,
Permission,
]),
],
controllers: [UserController],
providers: [
UserService,
UserAssignmentService, // <--- 4. ลงทะเบียน Service เป็น Provider
UserAssignmentService,
UserPreferenceService, // ✅ เพิ่ม Provider
],
exports: [
UserService,
UserAssignmentService, // <--- 5. Export เผื่อที่อื่นใช้
], // Export ให้ AuthModule เรียกใช้ได้
UserAssignmentService,
UserPreferenceService, // Export ให้ Module อื่นใช้ (เช่น Notification)
],
})
export class UserModule {}