251123:2300 Update T1
This commit is contained in:
6
.agent/rules/coding-standards.md
Normal file
6
.agent/rules/coding-standards.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
trigger: always_on
|
||||
glob:
|
||||
description:
|
||||
---
|
||||
|
||||
397
2_Backend_Plan_Phase6A_V1_4_3.md
Normal file
397
2_Backend_Plan_Phase6A_V1_4_3.md
Normal 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 ในอนาคต
|
||||
|
||||
@@ -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
478
5_Backend_Folder_V1_4_3.md
Normal 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
436
T0-T6.2.md
Normal 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
|
||||
```
|
||||
76
backend/Infrastructure Setup.yml
Normal file
76
backend/Infrastructure Setup.yml
Normal 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
|
||||
@@ -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
|
||||
@@ -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
641
backend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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() ไว้ครับ
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller('aut')
|
||||
export class AutController {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
32
backend/src/common/auth/strategies/jwt-refresh.strategy.ts
Normal file
32
backend/src/common/auth/strategies/jwt-refresh.strategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
63
backend/src/common/auth/strategies/jwt.strategy.ts
Normal file
63
backend/src/common/auth/strategies/jwt.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
11
backend/src/common/config/redis.config.ts
Normal file
11
backend/src/common/config/redis.config.ts
Normal 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
|
||||
}));
|
||||
11
backend/src/common/decorators/audit.decorator.ts
Normal file
11
backend/src/common/decorators/audit.decorator.ts
Normal 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 });
|
||||
@@ -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);
|
||||
7
backend/src/common/decorators/idempotency.decorator.ts
Normal file
7
backend/src/common/decorators/idempotency.decorator.ts
Normal 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);
|
||||
56
backend/src/common/entities/audit-log.entity.ts
Normal file
56
backend/src/common/entities/audit-log.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
5
backend/src/common/guards/jwt-refresh.guard.ts
Normal file
5
backend/src/common/guards/jwt-refresh.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
71
backend/src/common/guards/maintenance-mode.guard.ts
Normal file
71
backend/src/common/guards/maintenance-mode.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
80
backend/src/common/interceptors/audit-log.interceptor.ts
Normal file
80
backend/src/common/interceptors/audit-log.interceptor.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
backend/src/common/interceptors/idempotency.interceptor.ts
Normal file
74
backend/src/common/interceptors/idempotency.interceptor.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
40
backend/src/common/services/crypto.service.ts
Normal file
40
backend/src/common/services/crypto.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
35
backend/src/common/services/request-context.service.ts
Normal file
35
backend/src/common/services/request-context.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
65
backend/src/modules/circulation/circulation.controller.ts
Normal file
65
backend/src/modules/circulation/circulation.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
backend/src/modules/circulation/circulation.module.ts
Normal file
25
backend/src/modules/circulation/circulation.module.ts
Normal 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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; // ความคิดเห็นเพิ่มเติม
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; // สถานะการใช้งาน
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateJsonSchemaDto } from './create-json-schema.dto';
|
||||
|
||||
export class UpdateJsonSchemaDto extends PartialType(CreateJsonSchemaDto) {}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
15
backend/src/modules/project/dto/create-project.dto.ts
Normal file
15
backend/src/modules/project/dto/create-project.dto.ts
Normal 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)
|
||||
}
|
||||
27
backend/src/modules/project/dto/search-project.dto.ts
Normal file
27
backend/src/modules/project/dto/search-project.dto.ts
Normal 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;
|
||||
}
|
||||
4
backend/src/modules/project/dto/update-project.dto.ts
Normal file
4
backend/src/modules/project/dto/update-project.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateProjectDto } from './create-project.dto';
|
||||
|
||||
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
73
backend/src/modules/rfa/entities/rfa-workflow.entity.ts
Normal file
73
backend/src/modules/rfa/entities/rfa-workflow.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
27
backend/src/modules/search/dto/search-query.dto.ts
Normal file
27
backend/src/modules/search/dto/search-query.dto.ts
Normal 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;
|
||||
}
|
||||
22
backend/src/modules/search/search.controller.ts
Normal file
22
backend/src/modules/search/search.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
backend/src/modules/search/search.module.ts
Normal file
32
backend/src/modules/search/search.module.ts
Normal 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 {}
|
||||
152
backend/src/modules/search/search.service.ts
Normal file
152
backend/src/modules/search/search.service.ts
Normal 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 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
42
backend/src/modules/user/dto/update-preference.dto.ts
Normal file
42
backend/src/modules/user/dto/update-preference.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
27
backend/src/modules/user/entities/permission.entity.ts
Normal file
27
backend/src/modules/user/entities/permission.entity.ts
Normal 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;
|
||||
}
|
||||
29
backend/src/modules/user/entities/role.entity.ts
Normal file
29
backend/src/modules/user/entities/role.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
47
backend/src/modules/user/user-preference.service.ts
Normal file
47
backend/src/modules/user/user-preference.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user