251124:1700 Ready to Phase 7

This commit is contained in:
admin
2025-11-24 17:01:58 +07:00
parent 9360d78ea6
commit 4f45a69ed0
47 changed files with 2047 additions and 433 deletions

View File

@@ -0,0 +1,16 @@
-- File: database/migrations/phase_6a_workflow_dsl.sql
-- ตารางสำหรับเก็บนิยามของ Workflow (Workflow Definition)
CREATE TABLE workflow_definitions (
id CHAR(36) NOT NULL PRIMARY KEY COMMENT 'UUID ของ Workflow Definition',
workflow_code VARCHAR(50) NOT NULL COMMENT 'รหัส Workflow เช่น RFA, CORR, CIRCULATION',
version INT NOT NULL DEFAULT 1 COMMENT 'หมายเลข Version',
dsl JSON NOT NULL COMMENT 'นิยาม Workflow ต้นฉบับ (YAML/JSON Format)',
compiled JSON NOT NULL COMMENT 'โครงสร้าง Execution Tree ที่ Compile แล้ว',
is_active BOOLEAN DEFAULT TRUE COMMENT 'สถานะการใช้งาน',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'วันที่สร้าง',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'วันที่แก้ไขล่าสุด',
-- ป้องกันการมี Workflow Code และ Version ซ้ำกัน
UNIQUE KEY uq_workflow_version (workflow_code, version)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'ตารางเก็บนิยามกฎการเดินเอกสาร (Workflow DSL)';
-- สร้าง Index สำหรับการค้นหา Workflow ที่ Active ล่าสุดได้เร็วขึ้น
CREATE INDEX idx_workflow_active ON workflow_definitions(workflow_code, is_active, version);

View File

@@ -8,12 +8,14 @@
* [x] `.env` (สำหรับ Local Dev เท่านั้น ห้าม commit)
* [x] `.gitignore`
* [x] `docker-compose.yml` (Configuration หลักสำหรับ Deploy)
* [x] `docker-compose.override.yml` (สำหรับ Inject Secrets ตอน Dev)
* [x] `.prettierrc`
* [x] `docker-compose.override.yml`
* [x] `docker-compose.yml`
* [x] `nest-cli.json`
* [x] `tsconfig.build.json`
* [x] `tsconfig.json`
* [x] `package.json`
* [x] `pnpm-lock.yaml`
* [x] `tsconfig.json`
* [x] `nest-cli.json`
* [x] `README.md`
---
@@ -24,6 +26,10 @@
* [x] `main.ts` (Application Bootstrap, Swagger, Global Pipes)
* [x] `app.module.ts` (Root Module ที่รวมทุก Modules เข้าด้วยกัน)
* [x] `app.service.ts` (Root Application Service)
* [x] `app.controller.ts` (Root Application Controller)
* [x] `app.controller.spec.ts` (Root Application Controller Unit Tests)
* [x] `redlock.d.ts` (Redlock Configuration)
### **📁 src/common/** (Shared Resources)
@@ -32,6 +38,9 @@
* **dto/**
* [x] **login.dto.ts**
* [x] **register.dto.ts**
* **strategies/**
* [x] **local.strategy.ts**
* [x] **jwt.strategy.ts**
* [x] **auth.controller.spec.ts**
* [x] **auth.controller.ts**
* [x] **auth.module.ts**
@@ -39,10 +48,15 @@
* [x] **auth.service.ts**
* **config/** (Configuration Service)
* [x] **env.validation.ts**
* [x] **redis.config.ts**
* **decorators/**
* [x] **audit.decorator.ts**
* [x] **bypass-maintenance.decorator.ts**
* [x] `current-user.decorator.ts`
* [x] **idempotency.decorator.ts**
* [x] `require-permission.decorator.ts`
* [x] **retry.decorator.ts**
* [x] **circuit-breaker.decorator.ts**
* **entities/**
* [x] **audit-log.entity.ts**
* [x] **base.entity.ts**
@@ -56,14 +70,21 @@
* [x] **file-storage.module.ts**
* [x] **file-storage.service.spec.ts**
* [x] `file-storage.service.ts` (Upload, Scan Virus, Commit)
* [x] **file-cleanup.service.ts** (Cleanup Temporary Files)
* [x] `guards/`
* [x] `jwt-auth.guard.ts`
* [x] **jwt.strategy.ts**
* [x] **jwt-refresh.guard.ts**
* [x] **maintenance-mode.guard.ts**
* [x] `rbac.guard.ts` (ตรวจสอบสิทธิ์ 4 ระดับ)
* **interceptors/**
* [x] `audit-log.interceptor.ts` (เก็บ Log ลง DB)
* [x] **idempotency.interceptor.ts** (Idempotency Interceptor)
* [x] `transform.interceptor.ts` (Standard Response Format)
* **resilience/** (Circuit Breaker & Retry)
* [x] **resilience.module.ts** (Resilience Module)
* **security/** (Security Service)
* [x] **crypto.service.ts** (Crypto Service)
* [x] **request-context.service.ts
### **📁 src/modules/** (Feature Modules)
@@ -71,13 +92,17 @@
* [x] `dto/`
* [x] **assign-user-role.dto.ts**
* [x] `create-user.dto.ts`
* [ ] **search-user.dto.ts**
* [x] `update-user.dto.ts`
* [x] `update-user-preference.dto.ts`
* [x] `entities/`
* [x] `user.entity.ts`
* [x] `role.entity.ts`
* [x] `permission.entity.ts`
* [x] **user-assignment.entity.ts**
* [x] `user-preference.entity.ts`
* [x] **user-assignment.service.ts**
* [x] **user-preference.service.ts**
* [x] `user.controller.ts`
* [x] `user.module.ts`
* [x] `user.service.ts`
@@ -146,7 +171,7 @@
* [x] `shop-drawing.controller.ts`
* [x] `shop-drawing.service.ts`
4. **rfa/** (Request for Approval & Advanced Workflow)
5. **rfa/** (Request for Approval & Advanced Workflow)
* [x] `dto/`
* [x] `create-rfa.dto.ts`
* [x] `search-rfa.dto.ts`
@@ -165,7 +190,7 @@
* [x] `rfa.module.ts`
* [x] `rfa.service.ts` (Unified Workflow Integration)
5. **circulation/** (Internal Routing)
6. **circulation/** (Internal Routing)
* [x] `dto/`
* [x] `create-circulation.dto.ts`
* [x] `update-circulation-routing.dto.ts`
@@ -178,7 +203,7 @@
* [x] `circulation.module.ts`
* [x] `circulation.service.ts`
6. **transmittal/** (Document Forwarding)
7. **transmittal/** (Document Forwarding)
* [x] `dto/`
* [x] `create-transmittal.dto.ts`
* [x] `search-transmittal.dto.ts`
@@ -190,7 +215,7 @@
* [x] `transmittal.module.ts`
* [x] `transmittal.service.ts`
7. **notification/** (System Alerts)
8. **notification/** (System Alerts)
* [x] `dto/`
* [x] `create-notification.dto.ts`
* [x] `search-notification.dto.ts`
@@ -203,13 +228,13 @@
* [x] `notification.processor.ts` (Consumer/Worker for Email & Line)
* [x] `notification.service.ts` (Producer)
8. **search/** (Elasticsearch)
9. **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)
10. **document-numbering/** (Internal Service)
* [x] `entities/`
* [x] `document-number-format.entity.ts`
* [x] `document-number-counter.entity.ts`
@@ -217,13 +242,19 @@
* [x] **document-numbering.service.spec.ts**
* [x] `document-numbering.service.ts` (Double-Lock Mechanism)
10. **workflow-engine/** (Unified Logic)
11. **workflow-engine/** (Unified Logic)
* [x] **dto/**
* [x] `create-workflow-definition.dto.ts`
* [x] `evaluate-workflow.dto.ts`
* [x] `get-available-actions.dto.ts`
* [x] `update-workflow-definition.dto.ts`
* [x] `interfaces/workflow.interface.ts`
* [x] **workflow-dsl.service.ts**
* [x] `workflow-engine.module.ts`
* [x] **workflow-engine.service.spec.ts**
* [x] `workflow-engine.service.ts` (State Machine Logic)
11. **json-schema/** (Validation)
12. **json-schema/** (Validation)
* [x] `dto/`
* [x] `create-json-schema.dto.ts`+
* [x] `search-json-schema.dto.ts`
@@ -236,6 +267,16 @@
* [x] **json-schema.service.spec.ts**
* [x] `json-schema.service.ts`
13. **monitoring/** (Monitoring & Metrics)
* [x] `controllers/`
* [x] `health.controller.ts`
* [x] `logger/`
* [x] `winston.config.ts`
* [x] `services/`
* [x] `metrics.service.ts`
* [x] `monitoring.module.ts`
## **Folder Structure ของ Backend (NestJS)** ที่
---
@@ -244,40 +285,53 @@
```text
backend/
├── .env # Environment variables for local development only (not committed)
├── .gitignore # Git ignore rules
├── docker-compose.yml # Main deployment container configuration
├── .env # Environment variables for local development only (not committed)
├── .gitignore # Git ignore rules
├── .prettierrc # Prettier configuration
├── 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
├── package.json # Node dependencies and NPM scripts
├── pnpm-lock.yaml # Dependency lock file for pnpm
├── tsconfig.json # TypeScript compiler configuration
├── tsconfig.build.json # TypeScript compiler configuration for production
├── nest-cli.json # NestJS project configuration
├── README.md # Project documentation
└── src/
├── main.ts # Application bootstrap and initialization
├── app.module.ts # Root application module
├── main.ts # Application bootstrap and initialization
├── app.module.ts # Root application module
├── app.service.ts # Root application service
├── app.controller.ts # Root application controller
├── app.controller.spec.ts # Root application unit tests
├── redlock.d.ts # Redlock configuration
├── common/ # 🛠️ Shared framework resources used across modules
│ ├── common.module.ts # Registers shared providers
├── common/ # 🛠️ Shared framework resources used across modules
├── common.module.ts # Registers shared providers
│ │
│ ├── auth/ # 🛡️ Authentication module
│ ├── auth/ # 🛡️ Authentication module
│ │ ├── dto/
│ │ │ ├── login.dto.ts # Login request payload
│ │ │ └── register.dto.ts # Registration payload
│ │ ├── strategies/
│ │ │ ├── local.strategy.ts # Local strategy for authentication
│ │ │ └── jwt.strategy.ts # JWT strategy for authentication
│ │ ├── 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
│ ├── config/ # 📄 Configuration
│ │ ── env.validation.ts # Zod/Joi validation for environment variables
│ │ └── redis.config.ts # Redis configuration
│ │
│ ├── decorators/ # 📡 Decorators for common use cases
│ ├── decorators/ # 📡 Decorators for common use cases
│ │ ├── audit.decorator.ts # Enables audit logging for a method
│ │ ├── bypass-maintenance.decorator.ts # Declares bypass maintenance requirement
│ │ ├── current-user.decorator.ts # Extracts logged-in user from request
│ │ ── require-permission.decorator.ts # Declares RBAC permission requirement
│ │ ── idempotency.decorator.ts # Declares idempotency requirement
│ │ ├── require-permission.decorator.ts # Declares RBAC permission requirement
│ │ ├── retry.decorator.ts # Declares retry requirement
│ │ └── circuit-breaker.decorator.ts # Declares circuit breaker requirement
│ │
│ ├── entities/ # 📚 Database entities
│ │ ├── audit-log.entity.ts # Audit log database entity
@@ -293,186 +347,222 @@ backend/
│ │ ├── 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
│ │ ── file-storage.service.spec.ts # Unit tests
│ │ └── file-cleanup.service.ts # Cleanup temporary files
│ │
│ ├── guards/ # 🛡️ JWT authentication guard
│ │ ├── jwt-auth.guard.ts # JWT authentication guard
│ │ ├── jwt.strategy.ts # JWT strategy configuration
│ │ ├── jwt-refresh.guard.ts # JWT refresh guard
│ │ ├── maintenance-mode.guard.ts # Maintenance mode guard
│ │ └── rbac.guard.ts # Role-based access control enforcement
│ │
│ ├── interceptors/ # 📡 Interceptors for common use cases
│ ├── interceptors/ # 📡 Interceptors for common use cases
│ │ ├── audit-log.interceptor.ts # Automatically logs certain operations
│ │ ├── idempotency.interceptor.ts # Idempotency interceptor
│ │ └── transform.interceptor.ts # Standardized response formatting
│ │
── resilience/ # 🛡️ Circuit-breaker / retry logic (if implemented)
├─── resilience/ # 🛡️ Circuit-breaker / retry logic (if implemented)
│ └── resilience.module.ts # Resilience module
│ │
│ └──── security/ # 🔐 Security service
│ ├── crypto.service.ts # Crypto service
│ └── request-context.service.ts # Request context service (for logging)
├── 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
│ │ │ ├── create-user.dto.ts # Create new user
│ │ │ ── search-user.dto.ts # Search users
│ │ │ ├── update-user.dto.ts # Update user details
│ │ │ └── update-user-preference.dto.ts # Update user preferences
│ │ ├── entities/
│ │ │ ├── user.entity.ts # User table definition
│ │ │ ├── role.entity.ts # Role definition
│ │ │ ├── permission.entity.ts # Permission entity
│ │ │ ├── user-assignment.entity.ts # User assignment entity
│ │ │ └── user-preference.entity.ts # User preference settings
│ │ ├── user-assignment.service.ts # User assignment service
│ │ ├── user-preference.service.ts # User preference service
│ │ ├── user.controller.ts # REST endpoints
│ │ ├── user.module.ts # Module DI container
│ │ ├── user.service.ts # Business logic
│ │ ── user.service.spec.ts # Unit tests
│ │ └── user.module.ts # Module DI container
│ │ ── user.service.spec.ts # Unit tests
│ │
│ ├── project/ # 🏢 Project/Organization/Contract structure
│ ├── project/ # 🏢 Project/Organization/Contract structure
│ │ ├── dto/
│ │ │ ├── create-project.dto.ts
│ │ │ ├── search-project.dto.ts
│ │ │ └── update-project.dto.ts
│ │ │ ├── create-project.dto.ts # Create new project
│ │ │ ├── search-project.dto.ts # Search projects
│ │ │ └── update-project.dto.ts # Update project
│ │ ├── 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
│ │ │ ├── project.entity.ts # Project table definition
│ │ │ ├── contract.entity.ts # Contract table definition
│ │ │ ├── organization.entity.ts # Organization table definition
│ │ │ ├── project-organization.entity.ts # Project organization entity
│ │ │ └── contract-organization.entity.ts # Contract organization entity
│ │ ├── project.controller.ts # REST endpoints
│ │ ├── project.controller.spec.ts # Unit tests
│ │ ── project.module.ts # Module DI container
│ ├── project.service.ts # Business logic
│ │ └── project.service.spec.ts # Unit tests
│ │
│ ├── 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
│ │ │ ├── add-reference.dto.ts # Add reference to correspondence
│ │ │ ├── create-correspondence.dto.ts # Create new correspondence
│ │ │ ├── search-correspondence.dto.ts # Search correspondences
│ │ │ ├── submit-correspondence.dto.ts # Submit correspondence
│ │ │ └── workflow-action.dto.ts # Workflow action
│ │ ├── 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
│ │ │ ├── correspondence.entity.ts # Correspondence table definition
│ │ │ ├── correspondence-revision.entity.ts # Correspondence revision entity
│ │ │ ├── correspondence-routing.entity.ts # Correspondence routing entity
│ │ │ ├── correspondence-status.entity.ts # Correspondence status entity
│ │ │ ├── correspondence-type.entity.ts # Correspondence type entity
│ │ │ ├── correspondence-reference.entity.ts # Correspondence reference entity
│ │ │ ├── routing-template.entity.ts # Routing template entity
│ │ │ └── routing-template-step.entity.ts # Routing template step entity
│ │ ├── correspondence.controller.ts # REST endpoints
│ │ ├── correspondence.controller.spec.ts # Unit tests
│ │ ── correspondence.module.ts # Module DI container
│ ├── correspondence.service.ts # Business logic
│ │ └── correspondence.service.spec.ts # Unit tests
│ │
│ ├── 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
│ │ │ ├── create-contract-drawing.dto.ts # Create new contract drawing
│ │ │ ├── create-shop-drawing.dto.ts # Create new shop drawing
│ │ │ ├── create-shop-drawing-revision.dto.ts # Create new shop drawing revision
│ │ │ ├── search-contract-drawing.dto.ts # Search contract drawings
│ │ │ ├── search-shop-drawing.dto.ts # Search shop drawings
│ │ │ └── update-contract-drawing.dto.ts # Update contract drawing
│ │ ├── 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
│ │ │ ├── contract-drawing.entity.ts # Contract drawing entity
│ │ │ ├── contract-drawing-volume.entity.ts # Contract drawing volume entity
│ │ │ ├── contract-drawing-sub-category.entity.ts # Contract drawing sub category entity
│ │ │ ├── shop-drawing.entity.ts # Shop drawing entity
│ │ │ ├── shop-drawing-revision.entity.ts # Shop drawing revision entity
│ │ │ ├── shop-drawing-main-category.entity.ts # Shop drawing main category entity
│ │ │ └── shop-drawing-sub-category.entity.ts # Shop drawing sub category entity
│ │ ├── drawing.module.ts # Module DI container
│ │ ├── contract-drawing.controller.ts # REST endpoints
│ │ ├── contract-drawing.service.ts # Business logic
│ │ ├── drawing-master-data.controller.ts # REST endpoints
│ │ ├── drawing-master-data.service.ts # Business logic
│ │ ├── shop-drawing.controller.ts # REST endpoints
│ │ └── shop-drawing.service.ts # Business logic
│ ├── rfa/ # ✅ Request for Approval (multi-step workflow)
│ │ ├── dto/
│ │ │ ├── create-rfa.dto.ts
│ │ │ ├── search-rfa.dto.ts
│ │ │ └── update-rfa.dto.ts
│ │ │ ├── create-rfa.dto.ts # Create new RFA
│ │ │ ├── search-rfa.dto.ts # Search RFAs
│ │ │ └── update-rfa.dto.ts # Update RFA
│ │ ├── 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
│ │ │ ├── rfa.entity.ts # RFA entity
│ │ │ ├── rfa-revision.entity.ts # RFA revision entity
│ │ │ ├── rfa-item.entity.ts # RFA item entity
│ │ │ ├── rfa-type.entity.ts # RFA type entity
│ │ │ ├── rfa-status-code.entity.ts # RFA status code entity
│ │ │ ├── rfa-approve-code.entity.ts # RFA approve code entity
│ │ │ ├── rfa-workflow.entity.ts # RFA workflow entity
│ │ │ ├── rfa-workflow-template.entity.ts # RFA workflow template entity
│ │ │ └── rfa-workflow-template-step.entity.ts # RFA workflow template step entity
│ │ ├── rfa.controller.ts # REST endpoints
│ │ ├── rfa.module.ts # Module DI container
│ │ └── rfa.service.ts # Business logic
│ ├── circulation/ # 🔄 Internal routing workflow
│ │ ├── dto/
│ │ │ ├── create-circulation.dto.ts
│ │ │ ├── update-circulation-routing.dto.ts
│ │ │ └── search-circulation.dto.ts
│ │ │ ├── create-circulation.dto.ts # Create new circulation
│ │ │ ├── update-circulation-routing.dto.ts # Update circulation routing
│ │ │ └── search-circulation.dto.ts # Search circulation
│ │ ├── entities/
│ │ │ ├── circulation.entity.ts
│ │ │ ├── circulation-routing.entity.ts
│ │ │ └── circulation-status-code.entity.ts
│ │ ├── circulation.controller.ts
│ │ ├── circulation.module.ts
│ │ └── circulation.service.ts
│ │ │ ├── circulation.entity.ts # Circulation entity
│ │ │ ├── circulation-routing.entity.ts # Circulation routing entity
│ │ │ └── circulation-status-code.entity.ts # Circulation status code entity
│ │ ├── circulation.controller.ts # REST endpoints
│ │ ├── circulation.module.ts # Module DI container
│ │ └── circulation.service.ts # Business logic
│ ├── transmittal/ # 📤 Document forwarding
│ │ ├── dto/
│ │ │ ├── create-transmittal.dto.ts
│ │ │ ├── search-transmittal.dto.ts
│ │ │ └── update-transmittal.dto.ts
│ │ │ ├── create-transmittal.dto.ts # Create new transmittal
│ │ │ ├── search-transmittal.dto.ts # Search transmittal
│ │ │ └── update-transmittal.dto.ts # Update transmittal
│ │ ├── entities/
│ │ │ ├── transmittal.entity.ts
│ │ │ └── transmittal-item.entity.ts
│ │ ├── transmittal.controller.ts
│ │ ├── transmittal.module.ts
│ │ └── transmittal.service.ts
│ │ │ ├── transmittal.entity.ts # Transmittal entity
│ │ │ └── transmittal-item.entity.ts # Transmittal item entity
│ │ ├── transmittal.controller.ts # REST endpoints
│ │ ├── transmittal.module.ts # Module DI container
│ │ └── transmittal.service.ts # Business logic
│ ├── notification/ # 🔔 Real-Time notification system
│ │ ├── dto/
│ │ │ ├── create-notification.dto.ts
│ │ │ └── search-notification.dto.ts
│ │ │ ├── create-notification.dto.ts # Create new notification
│ │ │ └── search-notification.dto.ts # Search notification
│ │ ├── 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
│ │ │ └── notification.entity.ts # Notification entity
│ │ ├── notification.module.ts # WebSocket + Processor registration
│ │ ├── notification.controller.ts # REST endpoints
│ │ ├── notification.gateway.ts # WebSocket gateway
│ │ ├── notification.processor.ts # Message consumer (e.g. mail worker)
│ │ ├── notification.service.ts # Business logic
│ │ └── 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
│ │ │ └── search-query.dto.ts # Search query
│ │ ├── search.module.ts # Module DI container
│ │ ├── search.controller.ts # REST endpoints
│ │ └── 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
│ │ │ ├── document-number-format.entity.ts # Document number format entity
│ │ │ └── document-number-counter.entity.ts # Document number counter entity
│ │ ├── document-numbering.module.ts # Module DI container
│ │ ├── document-numbering.service.ts # Business logic
│ │ └── document-numbering.service.spec.ts # Unit tests
│ ├── workflow-engine/ # ⚙️ Unified state-machine workflow engine
│ │ ├── dto/
│ │ │ ├── create-workflow-definition.dto.ts # Create new workflow definition
│ │ │ ├── evaluate-workflow.dto.ts # Evaluate workflow
│ │ │ ├── get-available-actions.dto.ts # Get available actions
│ │ │ └── update-workflow-definition.dto.ts # Update workflow definition
│ │ ├── entities/
│ │ │ └── workflow-definition.entity.ts # Workflow definition entity
│ │ ├── interfaces/
│ │ │ └── workflow.interface.ts
│ │ ├── workflow-engine.module.ts
│ │ ├── workflow-engine.service.ts
│ │ ── workflow-engine.service.spec.ts
│ │ │ └── workflow.interface.ts # Workflow interface
│ │ ├── workflow-engine.controller.ts # REST endpoints
│ │ ├── workflow-engine.module.ts # Module DI container
│ │ ── workflow-engine.service.ts # Business logic
│ │ └── workflow-engine.service.spec.ts # Unit tests
│ │
│ ├── json-schema/ # 📋 Dynamic request schema validation
│ │ ├── dto/
│ │ │ ├── create-json-schema.dto.ts # Create new JSON schema
│ │ │ ├── update-json-schema.dto.ts # Update JSON schema
│ │ │ └── search-json-schema.dto.ts # Search JSON schema
│ │ ├── entities/
│ │ │ └── json-schema.entity.ts # JSON schema entity
│ │ ├── json-schema.module.ts # Module DI container
│ │ ├── json-schema.controller.ts # REST endpoints
│ │ ├── json-schema.controller.spec.ts # Unit tests
│ │ ├── json-schema.service.ts # Business logic
│ │ └── json-schema.service.spec.ts # Unit tests
│ │
│ └── monitoring/ # 📋 Dynamic request schema validation
│ ├── controllers/
│ │ ├── health.controller.ts # Create new JSON schema
│ │ ├── update-json-schema.dto.ts # Update JSON schema
│ │ └── search-json-schema.dto.ts # Search JSON schema
│ ├── logger/
│ │ └── winston.config.ts # JSON schema entity
│ ├── services/
│ │ └── metrics.service.ts # JSON schema entity
│ └── monitoring.module.ts # Module DI container
│ └── 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
```
---

View File

@@ -22,6 +22,7 @@
"dependencies": {
"@casl/ability": "^6.7.3",
"@elastic/elasticsearch": "^8.11.1",
"@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
@@ -35,12 +36,14 @@
"@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.0.1",
"@nestjs/swagger": "^11.2.3",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0",
"@nestjs/websockets": "^11.1.9",
"@types/nodemailer": "^7.0.4",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"async-retry": "^1.3.3",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"bullmq": "^5.63.2",
@@ -54,16 +57,20 @@
"joi": "^18.0.1",
"multer": "^2.0.2",
"mysql2": "^3.15.3",
"nest-winston": "^1.10.2",
"nodemailer": "^7.0.10",
"opossum": "^9.0.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"prom-client": "^15.1.3",
"redlock": "5.0.0-beta.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.27",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"winston": "^3.18.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -71,6 +78,7 @@
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/async-retry": "^1.4.9",
"@types/bcrypt": "^6.0.0",
"@types/cache-manager": "^5.0.0",
"@types/express": "^5.0.0",
@@ -79,6 +87,7 @@
"@types/jest": "^30.0.0",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.7",
"@types/opossum": "^8.1.9",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/uuid": "^11.0.0",

View File

@@ -1,5 +1,6 @@
// File: src/app.module.ts
// บันทึกการแก้ไข: เพิ่ม CacheModule (Redis), Config สำหรับ Idempotency และ Maintenance Mode (T1.1)
// บันทึกการแก้ไข: เพิ่ม MonitoringModule และ WinstonModule (T6.3)
import { Module } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
@@ -8,18 +9,19 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { WinstonModule } from 'nest-winston'; // ✅ Import WinstonModule
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';
import redisConfig from './common/config/redis.config';
import { winstonConfig } from './modules/monitoring/logger/winston.config'; // ✅ Import 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)
// import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor';
// Modules
import { UserModule } from './modules/user/user.module';
@@ -30,7 +32,16 @@ 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';
import { RfaModule } from './modules/rfa/rfa.module';
import { DrawingModule } from './modules/drawing/drawing.module';
import { TransmittalModule } from './modules/transmittal/transmittal.module';
import { CirculationModule } from './modules/circulation/circulation.module';
import { NotificationModule } from './modules/notification/notification.module';
// ✅ Import Monitoring Module
import { MonitoringModule } from './modules/monitoring/monitoring.module';
import { ResilienceModule } from './common/resilience/resilience.module'; // ✅ Import
// ... imports
import { SearchModule } from './modules/search/search.module'; // ✅ Import
@Module({
imports: [
// 1. Setup Config Module พร้อม Validation
@@ -68,6 +79,9 @@ import { CorrespondenceModule } from './modules/correspondence/correspondence.mo
inject: [ConfigService],
}),
// 📝 Setup Winston Logger (Structured Logging) [Req 6.10]
WinstonModule.forRoot(winstonConfig),
// 2. Setup TypeORM (MariaDB)
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
@@ -84,7 +98,7 @@ import { CorrespondenceModule } from './modules/correspondence/correspondence.mo
}),
}),
// ✅ 4. Register AuditLog Entity (Global Scope)
// Register AuditLog Entity (Global Scope)
TypeOrmModule.forFeature([AuditLog]),
// 3. BullMQ (Redis) Setup
@@ -100,6 +114,9 @@ import { CorrespondenceModule } from './modules/correspondence/correspondence.mo
}),
}),
// 📊 Register Monitoring Module (Health & Metrics) [Req 6.10]
MonitoringModule,
// Feature Modules
AuthModule,
UserModule,
@@ -109,16 +126,23 @@ import { CorrespondenceModule } from './modules/correspondence/correspondence.mo
JsonSchemaModule,
WorkflowEngineModule,
CorrespondenceModule,
RfaModule, // 👈 ต้องมี
DrawingModule, // 👈 ต้องมี
TransmittalModule, // 👈 ต้องมี
CirculationModule, // 👈 ต้องมี
SearchModule, // ✅ Register Module
NotificationModule, // 👈 ต้องมี
ResilienceModule, // ✅ Register Module
],
controllers: [AppController],
providers: [
AppService,
// 🛡️ 1. Register Global Guard (Rate Limit) - ทำงานก่อนเพื่อน
// 🛡️ 1. Register Global Guard (Rate Limit)
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
// 🚧 2. Maintenance Mode Guard - ทำงานต่อมา เพื่อ Block การเข้าถึงถ้าระบบปิดอยู่
// 🚧 2. Maintenance Mode Guard
{
provide: APP_GUARD,
useClass: MaintenanceModeGuard,
@@ -128,7 +152,7 @@ import { CorrespondenceModule } from './modules/correspondence/correspondence.mo
provide: APP_INTERCEPTOR,
useClass: AuditLogInterceptor,
},
// 🔄 4. Register Idempotency (Uncomment เมื่อต้องการบังคับใช้ Global)
// 🔄 4. Register Idempotency (ถ้าต้องการ Global)
// {
// provide: APP_INTERCEPTOR,
// useClass: IdempotencyInterceptor,

View File

@@ -1,5 +1,5 @@
// File: src/common/auth/auth.controller.ts
// บันทึกการแก้ไข: เพิ่ม Endpoints ให้ครบตามแผน T1.2 (Refresh, Logout, Profile)
// บันทึกการแก้ไข: เพิ่ม Type ให้ req และแก้ไข Import (Fix TS7006)
import {
Controller,
@@ -8,7 +8,7 @@ import {
Get,
UseGuards,
UnauthorizedException,
Request,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
@@ -16,9 +16,15 @@ 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)
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
import { JwtRefreshGuard } from '../guards/jwt-refresh.guard.js';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Request } from 'express'; // ✅ Import Request
// สร้าง Interface สำหรับ Request ที่มี User (เพื่อให้ TS รู้จัก req.user)
interface RequestWithUser extends Request {
user: any;
}
@ApiTags('Authentication')
@Controller('auth')
@@ -26,7 +32,7 @@ export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
@Throttle({ default: { limit: 5, ttl: 60000 } }) // เข้มงวด: 5 ครั้ง/นาที
@Throttle({ default: { limit: 5, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'เข้าสู่ระบบเพื่อรับ Access & Refresh Token' })
async login(@Body() loginDto: LoginDto) {
@@ -43,7 +49,7 @@ export class AuthController {
}
@Post('register-admin')
@UseGuards(JwtAuthGuard) // ควรป้องกัน Route นี้ให้เฉพาะ Superadmin
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'สร้างบัญชีผู้ใช้ใหม่ (Admin Only)' })
async register(@Body() registerDto: RegisterDto) {
@@ -54,8 +60,8 @@ export class AuthController {
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'ขอ Access Token ใหม่ด้วย Refresh Token' })
async refresh(@Request() req) {
// req.user จะมาจาก JwtRefreshStrategy
async refresh(@Req() req: RequestWithUser) {
// ✅ ระบุ Type ชัดเจน
return this.authService.refreshToken(req.user.sub, req.user.refreshToken);
}
@@ -64,9 +70,13 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'ออกจากระบบ (Revoke Token)' })
async logout(@Request() req) {
// ดึง Token จาก Header Authorization: Bearer <token>
async logout(@Req() req: RequestWithUser) {
// ✅ ระบุ Type ชัดเจน
const token = req.headers.authorization?.split(' ')[1];
// ต้องเช็คว่ามี token หรือไม่ เพื่อป้องกัน runtime error
if (!token) {
return { message: 'No token provided' };
}
return this.authService.logout(req.user.sub, token);
}
@@ -74,7 +84,8 @@ export class AuthController {
@Get('profile')
@ApiBearerAuth()
@ApiOperation({ summary: 'ดึงข้อมูลผู้ใช้ปัจจุบัน' })
getProfile(@Request() req) {
getProfile(@Req() req: RequestWithUser) {
// ✅ ระบุ Type ชัดเจน
return req.user;
}
}

View File

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

View File

@@ -1,5 +1,5 @@
// File: src/common/auth/auth.service.ts
// บันทึกการแก้ไข: เพิ่ม Refresh Token, Logout (Redis Blacklist) และ Profile ตาม T1.2
// บันทึกการแก้ไข: แก้ไข Type Mismatch ใน signAsync (Fix TS2769)
import {
Injectable,
@@ -10,11 +10,10 @@ import {
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import type { 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 { User } from '../../modules/user/entities/user.entity.js';
@Injectable()
export class AuthService {
@@ -22,7 +21,7 @@ export class AuthService {
private userService: UserService,
private jwtService: JwtService,
private configService: ConfigService,
@Inject(CACHE_MANAGER) private cacheManager: Cache, // ใช้ Redis สำหรับ Blacklist
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
// 1. ตรวจสอบ Username/Password
@@ -41,31 +40,33 @@ export class AuthService {
const payload = {
username: user.username,
sub: user.user_id,
scope: 'Global', // ตัวอย่าง: ใส่ Scope เริ่มต้น หรือดึงจาก Role
scope: 'Global',
};
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',
// ✅ Fix: Cast as any
expiresIn: (this.configService.get<string>('JWT_EXPIRATION') ||
'15m') as any,
}),
this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn:
this.configService.get<string>('JWT_REFRESH_EXPIRATION') || '7d',
// ✅ Fix: Cast as any
expiresIn: (this.configService.get<string>('JWT_REFRESH_EXPIRATION') ||
'7d') as any,
}),
]);
return {
access_token: accessToken,
refresh_token: refreshToken,
user: user, // ส่งข้อมูล user กลับไปให้ Frontend ใช้แสดงผลเบื้องต้น
user: user,
};
}
// 3. Register (สำหรับ Admin)
async register(userDto: RegisterDto) {
// ตรวจสอบว่ามี user อยู่แล้วหรือไม่
const existingUser = await this.userService.findOneByUsername(
userDto.username,
);
@@ -84,33 +85,30 @@ export class AuthService {
// 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',
// ✅ Fix: Cast as any
expiresIn: (this.configService.get<string>('JWT_EXPIRATION') ||
'15m') as any,
});
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,

View File

@@ -1,5 +1,6 @@
// File: src/common/auth/strategies/jwt-refresh.strategy.ts
// บันทึกการแก้ไข: Strategy สำหรับ Refresh Token (T1.2)
// บันทึกการแก้ไข: แก้ไข TS2345 โดยยืนยันค่า secretOrKey ด้วย ! (Non-null assertion)
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
@@ -16,8 +17,8 @@ export class JwtRefreshStrategy extends PassportStrategy(
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
// ใช้ Secret แยกต่างหากสำหรับ Refresh Token
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET'),
// ✅ Fix: ใส่ ! เพื่อบอก TS ว่าค่านี้มีอยู่จริง (จาก env validation)
secretOrKey: configService.get<string>('JWT_REFRESH_SECRET')!,
passReqToCallback: true,
});
}

View File

@@ -1,12 +1,11 @@
// File: src/common/auth/strategies/jwt.strategy.ts
// บันทึกการแก้ไข: ปรับปรุง JwtStrategy ให้ตรวจสอบ Blacklist (Redis) และสถานะ User (T1.2)
// บันทึกการแก้ไข: แก้ไข TS2345 (secretOrKey type) และ TS2551 (user.isActive property name)
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 { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
import { Request } from 'express';
import { UserService } from '../../../modules/user/user.service.js';
@@ -14,7 +13,7 @@ import { UserService } from '../../../modules/user/user.service.js';
export interface JwtPayload {
sub: number;
username: string;
scope?: string; // เพิ่ม Scope ถ้ามีการใช้
scope?: string;
}
@Injectable()
@@ -22,13 +21,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private userService: UserService,
@Inject(CACHE_MANAGER) private cacheManager: Cache, // ✅ Inject Redis Cache
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
passReqToCallback: true, // ✅ จำเป็นต้องใช้ เพื่อดึง Raw Token มาเช็ค Blacklist
// ✅ Fix TS2345: ใส่ ! เพื่อยืนยันว่า Secret Key มีค่าแน่นอน
secretOrKey: configService.get<string>('JWT_SECRET')!,
passReqToCallback: true,
});
}
@@ -36,7 +36,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
// 1. ดึง Token ออกมาเพื่อตรวจสอบใน Blacklist
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
// 2. ตรวจสอบว่า Token นี้อยู่ใน Redis Blacklist หรือไม่ (กรณี Logout ไปแล้ว)
// 2. ตรวจสอบว่า Token นี้อยู่ใน Redis Blacklist หรือไม่
const isBlacklisted = await this.cacheManager.get(
`blacklist:token:${token}`,
);
@@ -53,11 +53,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
// 5. (Optional) ตรวจสอบว่า User ยัง Active อยู่หรือไม่
if (user.is_active === false || user.is_active === 0) {
// ✅ Fix TS2551: แก้ไขชื่อ Property จาก is_active เป็น isActive ตาม Entity Definition
if (user.isActive === false) {
throw new UnauthorizedException('User account is inactive');
}
// คืนค่า User เพื่อนำไปใส่ใน req.user
return user;
}
}

View File

@@ -1,11 +1,15 @@
// File: src/common/config/redis.config.ts
// บันทึกการแก้ไข: สร้าง Config สำหรับ Redis (T0.2)
// บันทึกการแก้ไข: แก้ไข TS2345 โดยการจัดการค่า undefined ของ process.env ก่อน parseInt
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
// ใช้ค่า Default 'cache' ถ้าหาไม่เจอ
host: process.env.REDIS_HOST || 'cache',
// ✅ Fix: ใช้ || '6379' เพื่อให้มั่นใจว่าเป็น string ก่อนเข้า parseInt
port: parseInt(process.env.REDIS_PORT || '6379', 10),
// ✅ Fix: ใช้ || '3600' เพื่อให้มั่นใจว่าเป็น string
ttl: parseInt(process.env.REDIS_TTL || '3600', 10),
// password: process.env.REDIS_PASSWORD,
}));

View File

@@ -0,0 +1,49 @@
// File: src/common/resilience/decorators/circuit-breaker.decorator.ts
import CircuitBreaker from 'opossum'; // ✅ เปลี่ยนเป็น Default Import (ถ้าลง @types/opossum แล้วจะผ่าน)
import { Logger } from '@nestjs/common';
export interface CircuitBreakerOptions {
timeout?: number;
errorThresholdPercentage?: number;
resetTimeout?: number;
fallback?: (...args: any[]) => any;
}
/**
* Decorator สำหรับ Circuit Breaker
* ใช้ป้องกัน System Overload เมื่อ External Service ล่ม
*/
export function UseCircuitBreaker(options: CircuitBreakerOptions = {}) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const originalMethod = descriptor.value;
const logger = new Logger('CircuitBreakerDecorator');
// สร้าง Opossum Circuit Breaker Instance
const breaker = new CircuitBreaker(originalMethod, {
timeout: options.timeout || 3000,
errorThresholdPercentage: options.errorThresholdPercentage || 50,
resetTimeout: options.resetTimeout || 10000,
});
breaker.on('open', () => logger.warn(`Circuit OPEN for ${propertyKey}`));
breaker.on('halfOpen', () =>
logger.log(`Circuit HALF-OPEN for ${propertyKey}`),
);
breaker.on('close', () => logger.log(`Circuit CLOSED for ${propertyKey}`));
if (options.fallback) {
breaker.fallback(options.fallback);
}
descriptor.value = async function (...args: any[]) {
// ✅ ใช้ .fire โดยส่ง this context ให้ถูกต้อง
return breaker.fire.apply(breaker, [this, ...args]);
};
return descriptor;
};
}

View File

@@ -0,0 +1,60 @@
// File: src/common/resilience/decorators/retry.decorator.ts
import retry from 'async-retry'; // ✅ แก้ Import: เปลี่ยนจาก * as retry เป็น default import
import { Logger } from '@nestjs/common';
export interface RetryOptions {
retries?: number;
factor?: number;
minTimeout?: number;
maxTimeout?: number;
onRetry?: (e: Error, attempt: number) => any;
}
/**
* Decorator สำหรับการ Retry Function เมื่อเกิด Error
* ใช้สำหรับ External Call ที่อาจมีปัญหา Network ชั่วคราว
*/
export function Retry(options: RetryOptions = {}) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const originalMethod = descriptor.value;
const logger = new Logger('RetryDecorator');
descriptor.value = async function (...args: any[]) {
return retry(
// ✅ ระบุ Type ให้กับ bail และ attempt เพื่อแก้ Implicit any
async (bail: (e: Error) => void, attempt: number) => {
try {
return await originalMethod.apply(this, args);
} catch (error) {
// ✅ Cast error เป็น Error Object เพื่อแก้ปัญหา 'unknown'
const err = error as Error;
if (options.onRetry) {
options.onRetry(err, attempt);
}
logger.warn(
`Attempt ${attempt} failed for ${propertyKey}. Error: ${err.message}`, // ✅ ใช้ err.message
);
// ถ้าต้องการให้หยุด Retry ทันทีในบางเงื่อนไข สามารถเรียก bail(err) ได้ที่นี่
throw err;
}
},
{
retries: options.retries || 3,
factor: options.factor || 2,
minTimeout: options.minTimeout || 1000,
maxTimeout: options.maxTimeout || 5000,
...options,
},
);
};
return descriptor;
};
}

View File

@@ -0,0 +1,70 @@
// File: src/common/file-storage/file-cleanup.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm';
import * as fs from 'fs-extra';
import { Attachment } from './entities/attachment.entity';
@Injectable()
export class FileCleanupService {
private readonly logger = new Logger(FileCleanupService.name);
constructor(
@InjectRepository(Attachment)
private attachmentRepository: Repository<Attachment>,
) {}
/**
* รันทุกวันเวลาเที่ยงคืน (00:00)
* ลบไฟล์ชั่วคราว (isTemporary = true) ที่หมดอายุแล้ว (expiresAt < now)
*/
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async handleCleanup() {
this.logger.log('Running temporary file cleanup job...');
const now = new Date();
// 1. ค้นหาไฟล์ที่หมดอายุ
const expiredAttachments = await this.attachmentRepository.find({
where: {
isTemporary: true,
expiresAt: LessThan(now),
},
});
if (expiredAttachments.length === 0) {
this.logger.log('No expired files found.');
return;
}
this.logger.log(
`Found ${expiredAttachments.length} expired files. Deleting...`,
);
let deletedCount = 0;
const errors = [];
for (const att of expiredAttachments) {
try {
// 2. ลบไฟล์จริงออกจาก Disk
if (await fs.pathExists(att.filePath)) {
await fs.remove(att.filePath);
}
// 3. ลบ Record ออกจาก Database
await this.attachmentRepository.remove(att);
deletedCount++;
} catch (error) {
// ✅ แก้ไข: Cast error เป็น Error object เพื่อเข้าถึง .message
const errMessage = (error as Error).message;
this.logger.error(`Failed to delete file ID ${att.id}: ${errMessage}`);
errors.push(att.id);
}
}
this.logger.log(
`Cleanup complete. Deleted: ${deletedCount}, Failed: ${errors.length}`,
);
}
}

View File

@@ -1,6 +1,10 @@
// File: src/common/file-storage/file-storage.controller.ts
import {
Controller,
Post,
Get,
Delete, // ✅ Import Delete
Param,
UseInterceptors,
UploadedFile,
UseGuards,
@@ -8,12 +12,16 @@ import {
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
Res,
StreamableFile,
ParseIntPipe,
} from '@nestjs/common';
import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileStorageService } from './file-storage.service.js';
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
// ✅ 1. สร้าง Interface เพื่อระบุ Type ของ Request
// Interface เพื่อระบุ Type ของ Request ที่ผ่าน JwtAuthGuard มาแล้ว
interface RequestWithUser {
user: {
userId: number;
@@ -33,17 +41,56 @@ export class FileStorageController {
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }), // 50MB
// ตรวจสอบประเภทไฟล์ (Regex)
// ตรวจสอบประเภทไฟล์ (Regex) - รวม image, pdf, docs, zip
new FileTypeValidator({
fileType: /(pdf|msword|openxmlformats|zip|octet-stream)/,
fileType:
/(pdf|msword|openxmlformats|zip|octet-stream|image|jpeg|png)/,
}),
],
}),
)
file: Express.Multer.File,
@Request() req: RequestWithUser, // ✅ 2. ระบุ Type ตรงนี้แทน any
@Request() req: RequestWithUser,
) {
// ส่ง userId จาก Token ไปด้วย
return this.fileStorageService.upload(file, req.user.userId);
}
/**
* Endpoint สำหรับดาวน์โหลดไฟล์
* GET /files/:id/download
*/
@Get(':id/download')
async downloadFile(
@Param('id', ParseIntPipe) id: number,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const { stream, attachment } = await this.fileStorageService.download(id);
// Encode ชื่อไฟล์เพื่อรองรับภาษาไทยและตัวอักษรพิเศษใน Header
const encodedFilename = encodeURIComponent(attachment.originalFilename);
res.set({
'Content-Type': attachment.mimeType,
// บังคับให้ browser ดาวน์โหลดไฟล์ แทนการ preview
'Content-Disposition': `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`,
'Content-Length': attachment.fileSize,
});
return new StreamableFile(stream);
}
/**
* ✅ NEW: Delete Endpoint
* DELETE /files/:id
*/
@Delete(':id')
async deleteFile(
@Param('id', ParseIntPipe) id: number,
@Request() req: RequestWithUser,
) {
// ส่ง userId ไปด้วยเพื่อตรวจสอบความเป็นเจ้าของ
await this.fileStorageService.delete(id, req.user.userId);
return { message: 'File deleted successfully', id };
}
}

View File

@@ -1,13 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule'; // ✅ Import
import { FileStorageService } from './file-storage.service.js';
import { FileStorageController } from './file-storage.controller.js';
import { FileCleanupService } from './file-cleanup.service.js'; // ✅ Import
import { Attachment } from './entities/attachment.entity.js';
@Module({
imports: [TypeOrmModule.forFeature([Attachment])],
imports: [
TypeOrmModule.forFeature([Attachment]),
ScheduleModule.forRoot(), // ✅ เปิดใช้งาน Cron Job],
],
controllers: [FileStorageController],
providers: [FileStorageService],
providers: [
FileStorageService,
FileCleanupService, // ✅ Register Provider
],
exports: [FileStorageService], // Export ให้ Module อื่น (เช่น Correspondence) เรียกใช้ตอน Commit
})
export class FileStorageModule {}

View File

@@ -1,3 +1,4 @@
// File: src/common/file-storage/file-storage.service.ts
import {
Injectable,
NotFoundException,
@@ -12,6 +13,7 @@ import * as path from 'path';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { Attachment } from './entities/attachment.entity.js';
import { ForbiddenException } from '@nestjs/common'; // ✅ Import เพิ่ม
@Injectable()
export class FileStorageService {
@@ -29,7 +31,7 @@ export class FileStorageService {
? '/share/dms-data'
: path.join(process.cwd(), 'uploads');
// สร้างโฟลเดอร์รอไว้เลยถ้ายังไม่มี
// สร้างโฟลเดอร์ temp รอไว้เลยถ้ายังไม่มี
fs.ensureDirSync(path.join(this.uploadRoot, 'temp'));
}
@@ -75,11 +77,20 @@ export class FileStorageService {
* เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save
*/
async commit(tempIds: string[]): Promise<Attachment[]> {
if (!tempIds || tempIds.length === 0) {
return [];
}
const attachments = await this.attachmentRepository.find({
where: { tempId: In(tempIds), isTemporary: true },
});
if (attachments.length !== tempIds.length) {
// แจ้งเตือนแต่อาจจะไม่ throw ถ้าต้องการให้ process ต่อไปได้บางส่วน (ขึ้นอยู่กับ business logic)
// แต่เพื่อความปลอดภัยควรแจ้งว่าไฟล์ไม่ครบ
this.logger.warn(
`Expected ${tempIds.length} files to commit, but found ${attachments.length}`,
);
throw new NotFoundException('Some files not found or already committed');
}
@@ -98,21 +109,27 @@ export class FileStorageService {
try {
// ย้ายไฟล์
await fs.move(oldPath, newPath, { overwrite: true });
if (await fs.pathExists(oldPath)) {
await fs.move(oldPath, newPath, { overwrite: true });
// อัปเดตข้อมูลใน DB
att.filePath = newPath;
att.isTemporary = false;
att.tempId = undefined; // เคลียร์ tempId
att.expiresAt = undefined; // เคลียร์วันหมดอายุ
// อัปเดตข้อมูลใน DB
att.filePath = newPath;
att.isTemporary = false;
att.tempId = null as any; // เคลียร์ tempId (TypeORM อาจต้องการ null แทน undefined สำหรับ nullable)
att.expiresAt = null as any; // เคลียร์วันหมดอายุ
committedAttachments.push(await this.attachmentRepository.save(att));
committedAttachments.push(await this.attachmentRepository.save(att));
} else {
this.logger.error(`File missing during commit: ${oldPath}`);
throw new NotFoundException(
`File not found on disk: ${att.originalFilename}`,
);
}
} catch (error) {
this.logger.error(
`Failed to move file from ${oldPath} to ${newPath}`,
error,
);
// ถ้า error ตัวนึง ควรจะ rollback หรือ throw error (ในที่นี้ throw เพื่อให้ Transaction ของผู้เรียกจัดการ)
throw new BadRequestException(
`Failed to commit file: ${att.originalFilename}`,
);
@@ -122,7 +139,83 @@ export class FileStorageService {
return committedAttachments;
}
/**
* Download File
* ดึงไฟล์มาเป็น Stream เพื่อส่งกลับไปให้ Controller
*/
async download(
id: number,
): Promise<{ stream: fs.ReadStream; attachment: Attachment }> {
// 1. ค้นหาข้อมูลไฟล์จาก DB
const attachment = await this.attachmentRepository.findOne({
where: { id },
});
if (!attachment) {
throw new NotFoundException(`Attachment #${id} not found`);
}
// 2. ตรวจสอบว่าไฟล์มีอยู่จริงบน Disk หรือไม่
const filePath = attachment.filePath;
if (!fs.existsSync(filePath)) {
this.logger.error(`File missing on disk: ${filePath}`);
throw new NotFoundException('File not found on server storage');
}
// 3. สร้าง Read Stream (มีประสิทธิภาพกว่าการโหลดทั้งไฟล์เข้า Memory)
const stream = fs.createReadStream(filePath);
return { stream, attachment };
}
private calculateChecksum(buffer: Buffer): string {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
/**
* ✅ NEW: Delete File
* ลบไฟล์ออกจาก Disk และ Database
*/
async delete(id: number, userId: number): Promise<void> {
// 1. ค้นหาไฟล์
const attachment = await this.attachmentRepository.findOne({
where: { id },
});
if (!attachment) {
throw new NotFoundException(`Attachment #${id} not found`);
}
// 2. ตรวจสอบความเป็นเจ้าของ (Security Check)
// อนุญาตให้ลบถ้าเป็นคนอัปโหลดเอง
// (ในอนาคตอาจเพิ่มเงื่อนไข OR User เป็น Admin/Document Control)
if (attachment.uploadedByUserId !== userId) {
this.logger.warn(
`User ${userId} tried to delete file ${id} owned by ${attachment.uploadedByUserId}`,
);
throw new ForbiddenException('You are not allowed to delete this file');
}
// 3. ลบไฟล์ออกจาก Disk
try {
if (await fs.pathExists(attachment.filePath)) {
await fs.remove(attachment.filePath);
} else {
this.logger.warn(
`File not found on disk during deletion: ${attachment.filePath}`,
);
}
} catch (error) {
this.logger.error(
`Failed to delete file from disk: ${attachment.filePath}`,
error,
);
throw new BadRequestException('Failed to delete file from storage');
}
// 4. ลบ Record ออกจาก Database
await this.attachmentRepository.remove(attachment);
this.logger.log(`File deleted: ${id} by user ${userId}`);
}
}

View File

@@ -11,7 +11,7 @@ import {
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import type { Cache } from 'cache-manager';
import { BYPASS_MAINTENANCE_KEY } from '../decorators/bypass-maintenance.decorator';
@Injectable()

View File

@@ -1,5 +1,6 @@
// File: src/common/interceptors/idempotency.interceptor.ts
// บันทึกการแก้ไข: สร้าง IdempotencyInterceptor เพื่อป้องกันการทำรายการซ้ำ (T1.1)
// บันทึกการแก้ไข: แก้ไข TS18046 โดยการตรวจสอบ Type ของ err ใน catch block
import {
CallHandler,
@@ -11,7 +12,7 @@ import {
Logger,
} from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import type { Cache } from 'cache-manager';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';
@@ -29,43 +30,37 @@ export class IdempotencyInterceptor implements NestInterceptor {
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) {
// ✅ Fix: ตรวจสอบว่า err เป็น Error Object หรือไม่ ก่อนเรียก .stack
const errorMessage = err instanceof Error ? err.stack : String(err);
this.logger.error(
`Failed to cache idempotency key ${idempotencyKey}`,
err.stack,
errorMessage,
);
}
}),

View File

@@ -0,0 +1,90 @@
// File: src/modules/monitoring/interceptors/performance.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { MetricsService } from '../../modules/monitoring/services/metrics.service';
@Injectable()
export class PerformanceInterceptor implements NestInterceptor {
private readonly logger = new Logger(PerformanceInterceptor.name);
constructor(private readonly metricsService: MetricsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// ข้ามการวัดผลสำหรับ Endpoint /metrics และ /health เพื่อลด Noise
const req = context.switchToHttp().getRequest();
if (req.url === '/metrics' || req.url === '/health') {
return next.handle();
}
const method = req.method;
const url = req.route ? req.route.path : req.url; // ใช้ Route path แทน Full URL เพื่อลด Cardinality
const startTime = process.hrtime();
return next.handle().pipe(
tap({
next: (data) => {
this.recordMetrics(context, method, url, startTime, 200); // สมมติ 200 หรือดึงจาก Response จริง
},
error: (err) => {
const status = err.status || 500;
this.recordMetrics(context, method, url, startTime, status);
},
}),
);
}
/**
* บันทึกข้อมูลลง Metrics Service และ Logger
*/
private recordMetrics(
context: ExecutionContext,
method: string,
route: string,
startTime: [number, number],
statusCode: number,
) {
const res = context.switchToHttp().getResponse();
const finalStatus = res.statusCode || statusCode;
// คำนวณระยะเวลา (Seconds)
const diff = process.hrtime(startTime);
const durationInSeconds = diff[0] + diff[1] / 1e9;
const durationInMs = durationInSeconds * 1000;
// 1. บันทึก Metrics (Prometheus)
this.metricsService.httpRequestsTotal.inc({
method,
route,
status_code: finalStatus.toString(),
});
this.metricsService.httpRequestDuration.observe(
{
method,
route,
status_code: finalStatus.toString(),
},
durationInSeconds,
);
// 2. บันทึก Log (Winston JSON) - เฉพาะ Request ที่ช้าเกิน 200ms หรือ Error
// ตาม Req 6.5.1 API Response Time Target < 200ms
if (durationInMs > 200 || finalStatus >= 400) {
this.logger.log({
message: 'HTTP Request Performance',
method,
route,
statusCode: finalStatus,
durationMs: durationInMs,
level: finalStatus >= 500 ? 'error' : 'warn',
});
}
}
}

View File

@@ -0,0 +1,9 @@
// File: src/common/resilience/resilience.module.ts
import { Module, Global } from '@nestjs/common';
@Global()
@Module({
providers: [],
exports: [],
})
export class ResilienceModule {}

View File

@@ -0,0 +1,19 @@
// File: src/modules/master/dto/create-tag.dto.ts
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateTagDto {
@ApiProperty({ example: 'URGENT', description: 'ชื่อ Tag' })
@IsString()
@IsNotEmpty()
tag_name: string;
@ApiProperty({
example: 'เอกสารด่วนต้องดำเนินการทันที',
description: 'คำอธิบาย',
})
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,26 @@
// File: src/modules/master/dto/search-tag.dto.ts
import { IsString, IsOptional, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class SearchTagDto {
@ApiPropertyOptional({ description: 'คำค้นหา (ชื่อ Tag หรือ คำอธิบาย)' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ description: 'หมายเลขหน้า (เริ่มต้น 1)', default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: 'จำนวนรายการต่อหน้า', default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 20;
}

View File

@@ -0,0 +1,6 @@
// File: src/modules/master/dto/update-tag.dto.ts
import { PartialType } from '@nestjs/swagger';
import { CreateTagDto } from './create-tag.dto';
export class UpdateTagDto extends PartialType(CreateTagDto) {}

View File

@@ -0,0 +1,27 @@
// File: src/modules/master/entities/tag.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('tags')
export class Tag {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100, unique: true, comment: 'ชื่อ Tag' })
tag_name: string;
@Column({ type: 'text', nullable: true, comment: 'คำอธิบายแท็ก' })
description: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,64 @@
// File: src/modules/master/master.controller.ts
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { MasterService } from './master.service';
import { CreateTagDto } from './dto/create-tag.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('Master Data')
@Controller('master')
@UseGuards(JwtAuthGuard) // บังคับ Login ทุก Endpoint
export class MasterController {
constructor(private readonly masterService: MasterService) {}
@Get('correspondence-types')
@ApiOperation({ summary: 'Get all active correspondence types' })
getCorrespondenceTypes() {
return this.masterService.findAllCorrespondenceTypes();
}
@Get('correspondence-statuses')
@ApiOperation({ summary: 'Get all active correspondence statuses' })
getCorrespondenceStatuses() {
return this.masterService.findAllCorrespondenceStatuses();
}
@Get('rfa-types')
@ApiOperation({ summary: 'Get all active RFA types' })
getRfaTypes() {
return this.masterService.findAllRfaTypes();
}
@Get('rfa-statuses')
@ApiOperation({ summary: 'Get all active RFA status codes' })
getRfaStatuses() {
return this.masterService.findAllRfaStatuses();
}
@Get('rfa-approve-codes')
@ApiOperation({ summary: 'Get all active RFA approve codes' })
getRfaApproveCodes() {
return this.masterService.findAllRfaApproveCodes();
}
@Get('circulation-statuses')
@ApiOperation({ summary: 'Get all active circulation statuses' })
getCirculationStatuses() {
return this.masterService.findAllCirculationStatuses();
}
@Get('tags')
@ApiOperation({ summary: 'Get all tags' })
getTags() {
return this.masterService.findAllTags();
}
@Post('tags')
@RequirePermission('master_data.tag.manage') // ต้องมีสิทธิ์จัดการ Tag
@ApiOperation({ summary: 'Create a new tag (Admin only)' })
createTag(@Body() dto: CreateTagDto) {
return this.masterService.createTag(dto);
}
}

View File

@@ -0,0 +1,33 @@
// File: src/modules/master/master.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MasterService } from './master.service';
import { MasterController } from './master.controller';
// Import Entities
import { Tag } from './entities/tag.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
import { RfaType } from '../rfa/entities/rfa-type.entity';
import { RfaStatusCode } from '../rfa/entities/rfa-status-code.entity';
import { RfaApproveCode } from '../rfa/entities/rfa-approve-code.entity';
import { CirculationStatusCode } from '../circulation/entities/circulation-status-code.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
Tag,
CorrespondenceType,
CorrespondenceStatus,
RfaType,
RfaStatusCode,
RfaApproveCode,
CirculationStatusCode,
]),
],
controllers: [MasterController],
providers: [MasterService],
exports: [MasterService], // Export เผื่อ Module อื่นต้องใช้
})
export class MasterModule {}

View File

@@ -0,0 +1,97 @@
// File: src/modules/master/master.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
// Import Entities จาก Module อื่นๆ (ตามโครงสร้างที่มีอยู่แล้ว)
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
import { RfaType } from '../rfa/entities/rfa-type.entity';
import { RfaStatusCode } from '../rfa/entities/rfa-status-code.entity';
import { RfaApproveCode } from '../rfa/entities/rfa-approve-code.entity';
import { CirculationStatusCode } from '../circulation/entities/circulation-status-code.entity';
import { Tag } from './entities/tag.entity'; // Entity ของ Module นี้เอง
import { CreateTagDto } from './dto/create-tag.dto';
@Injectable()
export class MasterService {
constructor(
@InjectRepository(CorrespondenceType)
private readonly corrTypeRepo: Repository<CorrespondenceType>,
@InjectRepository(CorrespondenceStatus)
private readonly corrStatusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(RfaType)
private readonly rfaTypeRepo: Repository<RfaType>,
@InjectRepository(RfaStatusCode)
private readonly rfaStatusRepo: Repository<RfaStatusCode>,
@InjectRepository(RfaApproveCode)
private readonly rfaApproveRepo: Repository<RfaApproveCode>,
@InjectRepository(CirculationStatusCode)
private readonly circulationStatusRepo: Repository<CirculationStatusCode>,
@InjectRepository(Tag)
private readonly tagRepo: Repository<Tag>,
) {}
// --- Correspondence ---
findAllCorrespondenceTypes() {
return this.corrTypeRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
findAllCorrespondenceStatuses() {
return this.corrStatusRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
// --- RFA ---
findAllRfaTypes() {
return this.rfaTypeRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
findAllRfaStatuses() {
return this.rfaStatusRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
findAllRfaApproveCodes() {
return this.rfaApproveRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
// --- Circulation ---
findAllCirculationStatuses() {
return this.circulationStatusRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
// --- Tags ---
findAllTags() {
return this.tagRepo.find({ order: { tag_name: 'ASC' } });
}
async createTag(dto: CreateTagDto) {
const tag = this.tagRepo.create(dto);
return this.tagRepo.save(tag);
}
}

View File

@@ -0,0 +1,45 @@
// File: src/modules/monitoring/controllers/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
HealthCheckService,
HttpHealthIndicator,
HealthCheck,
TypeOrmHealthIndicator,
MemoryHealthIndicator,
DiskHealthIndicator,
} from '@nestjs/terminus';
import { MetricsService } from '../services/metrics.service';
@Controller()
export class HealthController {
constructor(
private health: HealthCheckService,
private http: HttpHealthIndicator,
private db: TypeOrmHealthIndicator,
private memory: MemoryHealthIndicator,
private disk: DiskHealthIndicator,
private metricsService: MetricsService,
) {}
@Get('health')
@HealthCheck()
check() {
return this.health.check([
// 1. ตรวจสอบการเชื่อมต่อ Database (MariaDB)
() => this.db.pingCheck('database'),
// 2. ตรวจสอบ Memory Heap (ไม่ควรเกิน 1GB สำหรับ Container นี้ - ปรับค่าตามจริง)
() => this.memory.checkHeap('memory_heap', 1024 * 1024 * 1024),
// 3. ตรวจสอบพื้นที่ Disk สำหรับ DMS Data (Threshold 90%)
// path '/' อาจต้องเปลี่ยนเป็น '/share/dms-data' ตาม Environment จริง
() =>
this.disk.checkStorage('storage', { path: '/', thresholdPercent: 0.9 }),
]);
}
@Get('metrics')
async getMetrics() {
return await this.metricsService.getMetrics();
}
}

View File

@@ -0,0 +1,30 @@
// File: src/modules/monitoring/logger/winston.config.ts
import {
utilities as nestWinstonUtilities,
WinstonModuleOptions,
} from 'nest-winston';
import * as winston from 'winston';
/**
* ฟังก์ชันสร้าง Configuration สำหรับ Winston Logger
* - Development: แสดงผลแบบ Console อ่านง่าย
* - Production: แสดงผลแบบ JSON พร้อม Timestamp เพื่อการทำ Log Aggregation
*/
export const winstonConfig: WinstonModuleOptions = {
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.ms(),
// เลือก Format ตาม Environment
process.env.NODE_ENV === 'production'
? winston.format.json() // Production ใช้ JSON
: nestWinstonUtilities.format.nestLike('LCBP3-DMS', {
prettyPrint: true,
colors: true,
}),
),
}),
// สามารถเพิ่ม File Transport หรือ HTTP Transport ไปยัง Log Server ได้ที่นี่
],
};

View File

@@ -0,0 +1,23 @@
// File: src/modules/monitoring/monitoring.module.ts
import { Global, Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HttpModule } from '@nestjs/axios';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { HealthController } from './controllers/health.controller';
import { MetricsService } from './services/metrics.service';
import { PerformanceInterceptor } from '../../common/interceptors/performance.interceptor';
@Global() // ทำให้ Module นี้ใช้งานได้ทั่วทั้ง App โดยไม่ต้อง Import ซ้ำ
@Module({
imports: [TerminusModule, HttpModule],
controllers: [HealthController],
providers: [
MetricsService,
{
provide: APP_INTERCEPTOR, // Register Global Interceptor
useClass: PerformanceInterceptor,
},
],
exports: [MetricsService],
})
export class MonitoringModule {}

View File

@@ -0,0 +1,54 @@
// File: src/modules/monitoring/services/metrics.service.ts
import { Injectable } from '@nestjs/common';
import { Registry, Counter, Histogram, Gauge } from 'prom-client';
@Injectable()
export class MetricsService {
private readonly registry: Registry;
public readonly httpRequestsTotal: Counter<string>;
public readonly httpRequestDuration: Histogram<string>;
public readonly systemMemoryUsage: Gauge<string>;
constructor() {
this.registry = new Registry();
this.registry.setDefaultLabels({ app: 'lcbp3-backend' });
// นับจำนวน HTTP Request ทั้งหมด แยกตาม method, route, status_code
this.httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status_code'],
registers: [this.registry],
});
// วัดระยะเวลา Response Time (Histogram)
this.httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.1, 0.2, 0.5, 1.0, 1.5, 2.0, 5.0], // Buckets สำหรับวัด Latency
registers: [this.registry],
});
// วัดการใช้ Memory (Gauge)
this.systemMemoryUsage = new Gauge({
name: 'system_memory_usage_bytes',
help: 'Heap memory usage in bytes',
registers: [this.registry],
});
// เริ่มเก็บ Metrics พื้นฐานของ Node.js (Optional)
// client.collectDefaultMetrics({ register: this.registry });
}
/**
* ดึงข้อมูล Metrics ทั้งหมดในรูปแบบ Text สำหรับ Prometheus Scrape
*/
async getMetrics(): Promise<string> {
// อัปเดต Memory Usage ก่อน Return
const memoryUsage = process.memoryUsage();
this.systemMemoryUsage.set(memoryUsage.heapUsed);
return this.registry.metrics();
}
}

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
// File: src/modules/notification/notification.service.ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { InjectRepository } from '@nestjs/typeorm';
@@ -12,15 +13,18 @@ import { UserPreference } from '../user/entities/user-preference.entity';
// Gateway
import { NotificationGateway } from './notification.gateway';
// DTOs
import { SearchNotificationDto } from './dto/search-notification.dto';
// Interfaces
export interface NotificationJobData {
userId: number;
title: string;
message: string;
type: 'EMAIL' | 'LINE' | 'SYSTEM';
entityType?: string; // e.g., 'rfa'
type: 'EMAIL' | 'LINE' | 'SYSTEM'; // ช่องทางหลักที่ต้องการส่ง (Trigger Type)
entityType?: string; // e.g., 'rfa', 'correspondence'
entityId?: number; // e.g., rfa_id
link?: string; // Deep link to frontend
link?: string; // Deep link to frontend page
}
@Injectable()
@@ -39,98 +43,195 @@ export class NotificationService {
) {}
/**
* ส่งการแจ้งเตือน (Trigger Notification)
* ฟังก์ชันนี้จะตรวจสอบ Preference ของผู้ใช้ และ Push ลง Queue
* ส่งการแจ้งเตือน (Centralized Notification Sender)
* 1. บันทึก DB (System Log)
* 2. ส่ง Real-time (WebSocket)
* 3. ส่ง External (Email/Line) ผ่าน Queue ตาม User Preference
*/
async send(data: NotificationJobData) {
async send(data: NotificationJobData): Promise<void> {
try {
// 1. สร้าง Entity Instance (ยังไม่บันทึกลง DB)
// ใช้ Enum NotificationType.SYSTEM เพื่อให้ตรงกับ Type Definition
// ---------------------------------------------------------
// 1. สร้าง Entity และบันทึกลง DB (เพื่อให้มี History ในระบบ)
// ---------------------------------------------------------
const notification = this.notificationRepo.create({
userId: data.userId,
title: data.title,
message: data.message,
notificationType: NotificationType.SYSTEM,
notificationType: NotificationType.SYSTEM, // ใน DB เก็บเป็น SYSTEM เสมอเพื่อแสดงใน App
entityType: data.entityType,
entityId: data.entityId,
isRead: false,
// link: data.link // ถ้า Entity มี field link ให้ใส่ด้วย
});
// 2. บันทึกลง DB (ต้อง await เพื่อให้ได้ ID กลับมา)
const savedNotification = await this.notificationRepo.save(notification);
// 3. Real-time Push (ผ่าน WebSocket Gateway)
// ส่งข้อมูลที่ save แล้ว (มี ID) ไปให้ Frontend
// ---------------------------------------------------------
// 2. Real-time Push (WebSocket) -> ส่งให้ User ทันทีถ้า Online
// ---------------------------------------------------------
this.notificationGateway.sendToUser(data.userId, savedNotification);
// 4. ตรวจสอบ User Preferences เพื่อส่งช่องทางอื่น (Email/Line)
// ---------------------------------------------------------
// 3. ตรวจสอบ User Preferences เพื่อส่งช่องทางอื่น (Email/Line)
// ---------------------------------------------------------
const userPref = await this.userPrefRepo.findOne({
where: { userId: data.userId },
});
// Default: ถ้าไม่มี Pref ให้ส่ง Email/Line เป็นค่าเริ่มต้น (true)
const shouldSendEmail = userPref ? userPref.notifyEmail : true;
const shouldSendLine = userPref ? userPref.notifyLine : true;
// ใช้ Nullish Coalescing Operator (??)
// ถ้าไม่มีค่า (undefined/null) ให้ Default เป็น true
const shouldSendEmail = userPref?.notifyEmail ?? true;
const shouldSendLine = userPref?.notifyLine ?? true;
const jobs = [];
// 5. Push to Queue (Email)
// เงื่อนไข: User เปิดรับ Email และ Type ของ Noti นี้ไม่ใช่ LINE-only
// ---------------------------------------------------------
// 4. เตรียม Job สำหรับ Email Queue
// เงื่อนไข: User เปิดรับ Email และ Noti นี้ไม่ได้บังคับส่งแค่ LINE
// ---------------------------------------------------------
if (shouldSendEmail && data.type !== 'LINE') {
jobs.push({
name: 'send-email',
data: { ...data, notificationId: savedNotification.id },
data: {
...data,
notificationId: savedNotification.id,
target: 'EMAIL',
},
opts: {
attempts: 3, // ลองใหม่ 3 ครั้งถ้าล่ม
attempts: 3, // ลองใหม่ 3 ครั้งถ้าล่ม (Resilience)
backoff: {
type: 'exponential',
delay: 5000, // รอ 5 วิ, 10 วิ, 20 วิ...
delay: 5000, // รอ 5s, 10s, 20s...
},
removeOnComplete: true, // ลบ Job เมื่อเสร็จ (ประหยัด Redis Memory)
},
});
}
// 6. Push to Queue (Line)
// เงื่อนไข: User เปิดรับ Line และ Type ของ Noti นี้ไม่ใช่ EMAIL-only
// ---------------------------------------------------------
// 5. เตรียม Job สำหรับ Line Queue
// เงื่อนไข: User เปิดรับ Line และ Noti นี้ไม่ได้บังคับส่งแค่ EMAIL
// ---------------------------------------------------------
if (shouldSendLine && data.type !== 'EMAIL') {
jobs.push({
name: 'send-line',
data: { ...data, notificationId: savedNotification.id },
data: {
...data,
notificationId: savedNotification.id,
target: 'LINE',
},
opts: {
attempts: 3,
backoff: { type: 'fixed', delay: 3000 },
removeOnComplete: true,
},
});
}
// ---------------------------------------------------------
// 6. Push Jobs ลง Redis BullMQ
// ---------------------------------------------------------
if (jobs.length > 0) {
await this.notificationQueue.addBulk(jobs);
this.logger.debug(
`Queued ${jobs.length} external notifications for user ${data.userId}`,
);
}
this.logger.log(`Notification queued for user ${data.userId}`);
} catch (error) {
// Cast Error เพื่อให้ TypeScript ไม่ฟ้องใน Strict Mode
// Error Handling: ไม่ Throw เพื่อไม่ให้ Flow หลัก (เช่น การสร้างเอกสาร) พัง
// แต่บันทึก Error ไว้ตรวจสอบ
this.logger.error(
`Failed to queue notification: ${(error as Error).message}`,
`Failed to process notification for user ${data.userId}`,
(error as Error).stack,
);
// Note: ไม่ Throw error เพื่อไม่ให้กระทบ Flow หลัก (Resilience Pattern)
}
}
/**
* ดึงรายการแจ้งเตือนของ User (สำหรับ Controller)
*/
async findAll(userId: number, searchDto: SearchNotificationDto) {
const { page = 1, limit = 20, isRead } = searchDto;
const skip = (page - 1) * limit;
const queryBuilder = this.notificationRepo
.createQueryBuilder('notification')
.where('notification.userId = :userId', { userId })
.orderBy('notification.createdAt', 'DESC')
.take(limit)
.skip(skip);
// Filter by Read Status (ถ้ามีการส่งมา)
if (isRead !== undefined) {
queryBuilder.andWhere('notification.isRead = :isRead', { isRead });
}
const [items, total] = await queryBuilder.getManyAndCount();
// นับจำนวนที่ยังไม่ได้อ่านทั้งหมด (เพื่อแสดง Badge ที่กระดิ่ง)
const unreadCount = await this.notificationRepo.count({
where: { userId, isRead: false },
});
return {
data: items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
unreadCount,
},
};
}
/**
* อ่านแจ้งเตือน (Mark as Read)
*/
async markAsRead(id: number, userId: number) {
await this.notificationRepo.update({ id, userId }, { isRead: true });
async markAsRead(id: number, userId: number): Promise<void> {
const notification = await this.notificationRepo.findOne({
where: { id, userId },
});
if (!notification) {
throw new NotFoundException(`Notification #${id} not found`);
}
if (!notification.isRead) {
notification.isRead = true;
await this.notificationRepo.save(notification);
// Update Unread Count via WebSocket (Optional)
// this.notificationGateway.sendUnreadCount(userId, ...);
}
}
/**
* อ่านทั้งหมด (Mark All as Read)
*/
async markAllAsRead(userId: number) {
async markAllAsRead(userId: number): Promise<void> {
await this.notificationRepo.update(
{ userId, isRead: false },
{ isRead: true },
);
}
/**
* ลบการแจ้งเตือนที่เก่าเกินกำหนด (ใช้กับ Cron Job Cleanup)
* เก็บไว้ 90 วัน
*/
async cleanupOldNotifications(days: number = 90): Promise<number> {
const dateLimit = new Date();
dateLimit.setDate(dateLimit.getDate() - days);
const result = await this.notificationRepo
.createQueryBuilder()
.delete()
.from(Notification)
.where('createdAt < :dateLimit', { dateLimit })
.execute();
this.logger.log(`Cleaned up ${result.affected} old notifications`);
return result.affected ?? 0;
}
}

View File

@@ -1,3 +1,4 @@
// File: src/modules/rfa/rfa.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -10,25 +11,27 @@ import { RfaStatusCode } from './entities/rfa-status-code.entity';
import { RfaApproveCode } from './entities/rfa-approve-code.entity';
import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
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 { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
// หมายเหตุ: ตรวจสอบชื่อไฟล์ Entity ให้ตรงกับที่มีจริง (บางทีอาจชื่อ RoutingTemplate)
// Services
// Services & Controllers
import { RfaService } from './rfa.service';
// Controllers
import { RfaController } from './rfa.controller';
// External Modules
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { UserModule } from '../user/user.module';
import { SearchModule } from '../search/search.module';
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'; // ✅ Import
import { NotificationModule } from '../notification/notification.module'; // ✅ เพิ่ม NotificationModule
// ... 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: [
// 1. Register Entities (เฉพาะ Entity เท่านั้น ห้ามใส่ Module)
TypeOrmModule.forFeature([
Rfa,
RfaRevision,
@@ -38,14 +41,19 @@ import { SearchModule } from '../search/search.module'; // ✅ เพิ่ม
RfaApproveCode,
Correspondence,
ShopDrawingRevision,
// ... (ตัวเดิม)
RfaWorkflow,
RfaWorkflowTemplate,
RfaWorkflowTemplateStep,
CorrespondenceRouting,
RoutingTemplate,
]),
// 2. Import External Modules (Services ที่ Inject เข้ามา)
DocumentNumberingModule,
UserModule,
SearchModule,
WorkflowEngineModule, // ✅ ย้ายมาใส่ตรงนี้ (imports หลัก)
NotificationModule, // ✅ เพิ่มตรงนี้ เพื่อแก้ dependency index [13]
],
providers: [RfaService],
controllers: [RfaController],

View File

@@ -7,11 +7,13 @@ import { TransmittalService } from './transmittal.service';
import { TransmittalController } from './transmittal.controller';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { UserModule } from '../user/user.module';
import { SearchModule } from '../search/search.module'; // ✅ ต้อง Import เพราะ Service ใช้ (ที่เป็นสาเหตุ Error)
@Module({
imports: [
TypeOrmModule.forFeature([Transmittal, TransmittalItem, Correspondence]),
DocumentNumberingModule,
UserModule,
SearchModule,
],
controllers: [TransmittalController],
providers: [TransmittalService],

View File

@@ -1,21 +1,7 @@
// File: src/modules/user/dto/update-preference.dto.ts
import { IsBoolean, IsOptional, IsString, IsIn } from 'class-validator';
// File: src/modules/user/dto/update-user.dto.ts
// บันทึกการแก้ไข: ใช้ PartialType จาก @nestjs/swagger เพื่อรองรับ API Docs (T1.3)
export class UpdatePreferenceDto {
@IsOptional()
@IsBoolean()
notifyEmail?: boolean;
import { PartialType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';
@IsOptional()
@IsBoolean()
notifyLine?: boolean;
@IsOptional()
@IsBoolean()
digestMode?: boolean;
@IsOptional()
@IsString()
@IsIn(['light', 'dark', 'system'])
uiTheme?: string;
}
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View File

@@ -22,7 +22,7 @@ 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 { 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';

View File

@@ -1,10 +1,16 @@
// File: src/modules/user/user.service.ts
// บันทึกการแก้ไข: แก้ไข Error TS1272 โดยใช้ 'import type' สำหรับ Cache interface (T1.3)
import {
Injectable,
NotFoundException,
ConflictException,
Inject,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager'; // ✅ FIX: เพิ่ม 'type' ตรงนี้
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
@@ -14,26 +20,23 @@ import { UpdateUserDto } from './dto/update-user.dto';
export class UserService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>, // ✅ ชื่อตัวแปรจริงคือ usersRepository
private usersRepository: Repository<User>,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
// 1. สร้างผู้ใช้ (Hash Password ก่อนบันทึก)
async create(createUserDto: CreateUserDto): Promise<User> {
// สร้าง Salt และ Hash Password
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(createUserDto.password, salt);
// เตรียมข้อมูล (เปลี่ยน password ธรรมดา เป็น password_hash)
const newUser = this.usersRepository.create({
...createUserDto,
password: hashedPassword,
});
try {
// บันทึกลง DB
return await this.usersRepository.save(newUser);
} catch (error: any) {
// เช็ค Error กรณี Username/Email ซ้ำ (MySQL Error Code 1062)
if (error.code === 'ER_DUP_ENTRY') {
throw new ConflictException('Username or Email already exists');
}
@@ -44,7 +47,6 @@ export class UserService {
// 2. ดึงข้อมูลทั้งหมด
async findAll(): Promise<User[]> {
return this.usersRepository.find({
// ไม่ส่ง password กลับไปเพื่อความปลอดภัย
select: [
'user_id',
'username',
@@ -61,7 +63,7 @@ export class UserService {
// 3. ดึงข้อมูลรายคน
async findOne(id: number): Promise<User> {
const user = await this.usersRepository.findOne({
where: { user_id: id }, // ใช้ user_id ตาม Entity
where: { user_id: id },
});
if (!user) {
@@ -71,26 +73,26 @@ export class UserService {
return user;
}
// ฟังก์ชันแถม: สำหรับ AuthService ใช้ (ต้องเห็น Password เพื่อเอาไปเทียบ)
async findOneByUsername(username: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { username } });
}
// 4. แก้ไขข้อมูล
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
// เช็คก่อนว่ามี User นี้ไหม
const user = await this.findOne(id);
// ถ้ามีการแก้รหัสผ่าน ต้อง Hash ใหม่ด้วย
if (updateUserDto.password) {
const salt = await bcrypt.genSalt();
updateUserDto.password = await bcrypt.hash(updateUserDto.password, salt);
}
// รวมร่างข้อมูลเดิม + ข้อมูลใหม่
const updatedUser = this.usersRepository.merge(user, updateUserDto);
const savedUser = await this.usersRepository.save(updatedUser);
return this.usersRepository.save(updatedUser);
// ⚠️ สำคัญ: เมื่อมีการแก้ไขข้อมูล User ต้องเคลียร์ Cache สิทธิ์เสมอ
await this.clearUserCache(id);
return savedUser;
}
// 5. ลบผู้ใช้ (Soft Delete)
@@ -100,31 +102,48 @@ export class UserService {
if (result.affected === 0) {
throw new NotFoundException(`User with ID ${id} not found`);
}
// เคลียร์ Cache เมื่อลบ
await this.clearUserCache(id);
}
/**
* หา User ID ของคนที่เป็น Document Control (หรือตัวแทน) ในองค์กร
* เพื่อส่ง Notification
*/
async findDocControlIdByOrg(organizationId: number): Promise<number | null> {
// ✅ FIX: ใช้ usersRepository ให้ตรงกับ Constructor
const user = await this.usersRepository.findOne({
where: { primaryOrganizationId: organizationId },
// order: { roleId: 'ASC' } // (Optional) Logic การเลือกคน
});
return user ? user.user_id : null;
}
// ฟังก์ชันดึงสิทธิ์ (Permission)
/**
* ✅ ดึงสิทธิ์ (Permission) โดยใช้ Caching Strategy
* TTL: 30 นาที (ตาม Requirement 6.5.2)
*/
async getUserPermissions(userId: number): Promise<string[]> {
// Query ข้อมูลจาก View: v_user_all_permissions
const cacheKey = `permissions:user:${userId}`;
// 1. ลองดึงจาก Cache ก่อน
const cachedPermissions = await this.cacheManager.get<string[]>(cacheKey);
if (cachedPermissions) {
return cachedPermissions;
}
// 2. ถ้าไม่มีใน Cache ให้ Query จาก DB (View: v_user_all_permissions)
const permissions = await this.usersRepository.query(
`SELECT permission_name FROM v_user_all_permissions WHERE user_id = ?`,
[userId],
);
// แปลงผลลัพธ์เป็น Array ของ string ['user.create', 'project.view', ...]
return permissions.map((row: any) => row.permission_name);
const permissionList = permissions.map((row: any) => row.permission_name);
// 3. บันทึกลง Cache (TTL 1800 วินาที = 30 นาที)
await this.cacheManager.set(cacheKey, permissionList, 1800 * 1000);
return permissionList;
}
/**
* Helper สำหรับล้าง Cache เมื่อมีการเปลี่ยนแปลงสิทธิ์หรือบทบาท
*/
async clearUserCache(userId: number): Promise<void> {
await this.cacheManager.del(`permissions:user:${userId}`);
}
}

View File

@@ -0,0 +1,26 @@
// File: src/modules/workflow-engine/dto/create-workflow-definition.dto.ts
import {
IsString,
IsNotEmpty,
IsObject,
IsOptional,
IsBoolean,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateWorkflowDefinitionDto {
@ApiProperty({ example: 'RFA', description: 'รหัสของ Workflow' })
@IsString()
@IsNotEmpty()
workflow_code!: string; // เพิ่ม !
@ApiProperty({ description: 'นิยาม Workflow' })
@IsObject()
@IsNotEmpty()
dsl!: any; // เพิ่ม !
@ApiProperty({ description: 'เปิดใช้งานทันทีหรือไม่', default: true })
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,25 @@
// File: src/modules/workflow-engine/dto/evaluate-workflow.dto.ts
import { IsString, IsNotEmpty, IsObject, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class EvaluateWorkflowDto {
@ApiProperty({ example: 'RFA', description: 'รหัส Workflow' })
@IsString()
@IsNotEmpty()
workflow_code!: string; // เพิ่ม !
@ApiProperty({ example: 'DRAFT', description: 'สถานะปัจจุบัน' })
@IsString()
@IsNotEmpty()
current_state!: string; // เพิ่ม !
@ApiProperty({ example: 'SUBMIT', description: 'Action ที่ต้องการทำ' })
@IsString()
@IsNotEmpty()
action!: string; // เพิ่ม !
@ApiProperty({ description: 'Context', example: { userId: 1 } })
@IsObject()
@IsOptional()
context?: Record<string, any>;
}

View File

@@ -0,0 +1,15 @@
// File: src/modules/workflow-engine/dto/get-available-actions.dto.ts
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class GetAvailableActionsDto {
@ApiProperty({ description: 'รหัส Workflow', example: 'RFA' })
@IsString()
@IsNotEmpty()
workflow_code!: string; // เพิ่ม !
@ApiProperty({ description: 'สถานะปัจจุบัน', example: 'DRAFT' })
@IsString()
@IsNotEmpty()
current_state!: string; // เพิ่ม !
}

View File

@@ -0,0 +1,10 @@
// File: src/modules/workflow-engine/dto/update-workflow-definition.dto.ts
import { PartialType } from '@nestjs/swagger';
import { CreateWorkflowDefinitionDto } from './create-workflow-definition.dto';
// PartialType จะทำให้ทุก field ใน CreateDto กลายเป็น Optional (?)
// เหมาะสำหรับ PATCH method
export class UpdateWorkflowDefinitionDto extends PartialType(
CreateWorkflowDefinitionDto,
) {}

View File

@@ -0,0 +1,37 @@
// File: src/modules/workflow-engine/entities/workflow-definition.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity('workflow_definitions')
@Index(['workflow_code', 'is_active', 'version'])
export class WorkflowDefinition {
@PrimaryGeneratedColumn('uuid')
id!: string; // เพิ่ม !
@Column({ length: 50, comment: 'รหัส Workflow เช่น RFA, CORR' })
workflow_code!: string; // เพิ่ม !
@Column({ type: 'int', default: 1, comment: 'หมายเลข Version' })
version!: number; // เพิ่ม !
@Column({ type: 'json', comment: 'นิยาม Workflow ต้นฉบับ' })
dsl!: any; // เพิ่ม !
@Column({ type: 'json', comment: 'โครงสร้างที่ Compile แล้ว' })
compiled!: any; // เพิ่ม !
@Column({ default: true, comment: 'สถานะการใช้งาน' })
is_active!: boolean; // เพิ่ม !
@CreateDateColumn()
created_at!: Date; // เพิ่ม !
@UpdateDateColumn()
updated_at!: Date; // เพิ่ม !
}

View File

@@ -0,0 +1,203 @@
// File: src/modules/workflow-engine/workflow-dsl.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
export interface WorkflowState {
initial?: boolean;
terminal?: boolean;
transitions?: Record<string, TransitionRule>;
}
export interface TransitionRule {
to: string;
requirements?: RequirementRule[];
events?: EventRule[];
}
export interface RequirementRule {
role?: string;
user?: string;
condition?: string; // e.g. "amount > 5000" (Advanced)
}
export interface EventRule {
type: 'notify' | 'webhook' | 'update_status';
target?: string;
payload?: any;
}
export interface CompiledWorkflow {
workflow: string;
version: string | number;
states: Record<string, WorkflowState>;
}
@Injectable()
export class WorkflowDslService {
/**
* คอมไพล์ DSL Input ให้เป็น Standard Execution Tree
* @param dsl ข้อมูลดิบจาก User (JSON/Object)
* @returns CompiledWorkflow Object ที่พร้อมใช้งาน
*/
compile(dsl: any): CompiledWorkflow {
// 1. Basic Structure Validation
if (!dsl.states || !Array.isArray(dsl.states)) {
throw new BadRequestException(
'DSL syntax error: "states" array is required.',
);
}
const compiled: CompiledWorkflow = {
workflow: dsl.workflow || 'UNKNOWN',
version: dsl.version || 1,
states: {},
};
const stateMap = new Set<string>();
// 2. First Pass: Collect all state names and normalize structure
for (const rawState of dsl.states) {
if (!rawState.name) {
throw new BadRequestException(
'DSL syntax error: All states must have a "name".',
);
}
stateMap.add(rawState.name);
const normalizedState: WorkflowState = {
initial: !!rawState.initial,
terminal: !!rawState.terminal,
transitions: {},
};
// Normalize transitions "on:"
if (rawState.on) {
for (const [action, rule] of Object.entries(rawState.on)) {
const rawRule = rule as any;
normalizedState.transitions![action] = {
to: rawRule.to,
requirements: rawRule.require || [],
events: rawRule.events || [],
};
}
}
compiled.states[rawState.name] = normalizedState;
}
// 3. Second Pass: Validate Integrity
this.validateIntegrity(compiled, stateMap);
return compiled;
}
/**
* ตรวจสอบความสมบูรณ์ของ Workflow Logic
*/
private validateIntegrity(compiled: CompiledWorkflow, stateMap: Set<string>) {
let hasInitial = false;
for (const [stateName, state] of Object.entries(compiled.states)) {
if (state.initial) {
if (hasInitial)
throw new BadRequestException(
`DSL Error: Multiple initial states found.`,
);
hasInitial = true;
}
// ตรวจสอบ Transitions
if (state.transitions) {
for (const [action, rule] of Object.entries(state.transitions)) {
// 1. ปลายทางต้องมีอยู่จริง
if (!stateMap.has(rule.to)) {
throw new BadRequestException(
`DSL Error: State "${stateName}" transitions via "${action}" to unknown state "${rule.to}".`,
);
}
// 2. Action name convention (Optional but recommended)
if (!/^[A-Z0-9_]+$/.test(action)) {
// Warning or Strict Error could be here
}
}
}
}
if (!hasInitial) {
throw new BadRequestException('DSL Error: No initial state defined.');
}
}
/**
* ประเมินผล (Evaluate) การเปลี่ยนสถานะ
* @param compiled ข้อมูล Workflow ที่ Compile แล้ว
* @param currentState สถานะปัจจุบัน
* @param action การกระทำ
* @param context ข้อมูลประกอบ (User roles, etc.)
*/
evaluate(
compiled: CompiledWorkflow,
currentState: string,
action: string,
context: any,
): { nextState: string; events: EventRule[] } {
const stateConfig = compiled.states[currentState];
if (!stateConfig) {
throw new BadRequestException(
`Runtime Error: Current state "${currentState}" not found in definition.`,
);
}
if (stateConfig.terminal) {
throw new BadRequestException(
`Runtime Error: Cannot transition from terminal state "${currentState}".`,
);
}
const transition = stateConfig.transitions?.[action];
if (!transition) {
throw new BadRequestException(
`Runtime Error: Action "${action}" is not allowed from state "${currentState}". Available actions: ${Object.keys(stateConfig.transitions || {}).join(', ')}`,
);
}
// Check Requirements (RBAC Logic inside Engine)
if (transition.requirements && transition.requirements.length > 0) {
this.checkRequirements(transition.requirements, context);
}
return {
nextState: transition.to,
events: transition.events || [],
};
}
/**
* ตรวจสอบเงื่อนไขสิทธิ์ (Requirements)
*/
private checkRequirements(requirements: RequirementRule[], context: any) {
const userRoles = context.roles || [];
const userId = context.userId;
const isAllowed = requirements.some((req) => {
// กรณีเช็ค Role
if (req.role) {
return userRoles.includes(req.role);
}
// กรณีเช็ค Specific User
if (req.user) {
return userId === req.user;
}
return false;
});
if (!isAllowed) {
throw new BadRequestException(
'Access Denied: You do not meet the requirements for this action.',
);
}
}
}

View File

@@ -0,0 +1,65 @@
// File: src/modules/workflow-engine/workflow-engine.controller.ts
import {
Controller,
Post,
Body,
Get,
Query,
Patch,
Param,
UseGuards,
} from '@nestjs/common'; // เพิ่ม Patch, Param
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { WorkflowEngineService } from './workflow-engine.service';
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
import { GetAvailableActionsDto } from './dto/get-available-actions.dto'; // [NEW]
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto'; // [NEW]
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@ApiTags('Workflow Engine (DSL)')
@Controller('workflow-engine')
@UseGuards(JwtAuthGuard) // Protect all endpoints
export class WorkflowEngineController {
constructor(private readonly workflowService: WorkflowEngineService) {}
@Post('definitions')
@ApiOperation({ summary: 'Create or Update Workflow Definition (DSL)' })
@ApiResponse({ status: 201, description: 'Workflow compiled and saved.' })
async createDefinition(@Body() dto: CreateWorkflowDefinitionDto) {
return this.workflowService.createDefinition(dto);
}
@Post('evaluate')
@ApiOperation({
summary: 'Evaluate transition (Run logic without saving state)',
})
async evaluate(@Body() dto: EvaluateWorkflowDto) {
return this.workflowService.evaluate(dto);
}
@Get('actions')
@ApiOperation({ summary: 'Get available actions for current state' })
async getAvailableActions(@Query() query: GetAvailableActionsDto) {
// [UPDATED] ใช้ DTO แทนแยก Query
return this.workflowService.getAvailableActions(
query.workflow_code,
query.current_state,
);
}
// [OPTIONAL/RECOMMENDED] เพิ่ม Endpoint สำหรับ Update (PATCH)
@Patch('definitions/:id')
@ApiOperation({
summary: 'Update workflow status or details (e.g. Deactivate)',
})
async updateDefinition(
@Param('id') id: string,
@Body() dto: UpdateWorkflowDefinitionDto, // [NEW] ใช้ Update DTO
) {
// *หมายเหตุ: คุณต้องไปเพิ่ม method update() ใน Service ด้วยถ้าจะใช้ Endpoint นี้
// return this.workflowService.update(id, dto);
return { message: 'Update logic not implemented yet', id, ...dto };
}
}

View File

@@ -1,9 +1,22 @@
// File: src/modules/workflow-engine/workflow-engine.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkflowEngineService } from './workflow-engine.service';
import { WorkflowDslService } from './workflow-dsl.service'; // [New] ต้องสร้างไฟล์นี้ตามแผน Phase 6A
import { WorkflowEngineController } from './workflow-engine.controller'; // [New] ต้องสร้างไฟล์นี้ตามแผน Phase 6A
import { WorkflowDefinition } from './entities/workflow-definition.entity'; // [New] ต้องสร้างไฟล์นี้ตามแผน Phase 6A
@Module({
providers: [WorkflowEngineService],
// ✅ เพิ่มบรรทัดนี้ เพื่ออนุญาตให้ Module อื่น (เช่น Correspondence) เรียกใช้ Service นี้ได้
exports: [WorkflowEngineService],
imports: [
// เชื่อมต่อกับตาราง workflow_definitions
TypeOrmModule.forFeature([WorkflowDefinition]),
],
controllers: [WorkflowEngineController], // เพิ่ม Controller สำหรับรับ API
providers: [
WorkflowEngineService, // Service หลัก
WorkflowDslService, // [New] Service สำหรับ Compile/Validate DSL
],
exports: [WorkflowEngineService], // Export ให้ module อื่นใช้เหมือนเดิม
})
export class WorkflowEngineModule {}

View File

@@ -1,45 +1,179 @@
import { Injectable, BadRequestException } from '@nestjs/common';
// File: src/modules/workflow-engine/workflow-engine.service.ts
import {
WorkflowStep,
WorkflowAction,
StepStatus,
TransitionResult,
} from './interfaces/workflow.interface.js';
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkflowDefinition } from './entities/workflow-definition.entity';
import { WorkflowDslService, CompiledWorkflow } from './workflow-dsl.service';
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
// Interface สำหรับ Backward Compatibility (Logic เดิม)
export enum WorkflowAction {
APPROVE = 'APPROVE',
REJECT = 'REJECT',
RETURN = 'RETURN',
ACKNOWLEDGE = 'ACKNOWLEDGE',
}
export interface TransitionResult {
nextStepSequence: number | null;
shouldUpdateStatus: boolean;
documentStatus?: string;
}
@Injectable()
export class WorkflowEngineService {
private readonly logger = new Logger(WorkflowEngineService.name);
constructor(
@InjectRepository(WorkflowDefinition)
private readonly workflowDefRepo: Repository<WorkflowDefinition>,
private readonly dslService: WorkflowDslService,
) {}
// =================================================================
// [NEW] DSL & Workflow Engine (Phase 6A)
// =================================================================
/**
* คำนวณสถานะถัดไป (Next State Transition)
* @param currentSequence ลำดับปัจจุบัน
* @param totalSteps จำนวนขั้นตอนทั้งหมด
* @param action การกระทำ (Approve/Reject/Return)
* @param returnToSequence (Optional) ถ้า Return จะให้กลับไปขั้นไหน
* สร้างหรืออัปเดต Workflow Definition ใหม่ (Auto Versioning)
*/
async createDefinition(
dto: CreateWorkflowDefinitionDto,
): Promise<WorkflowDefinition> {
const compiled = this.dslService.compile(dto.dsl);
const latest = await this.workflowDefRepo.findOne({
where: { workflow_code: dto.workflow_code },
order: { version: 'DESC' },
});
const nextVersion = latest ? latest.version + 1 : 1;
const entity = this.workflowDefRepo.create({
workflow_code: dto.workflow_code,
version: nextVersion,
dsl: dto.dsl,
compiled: compiled,
is_active: dto.is_active ?? true,
});
return this.workflowDefRepo.save(entity);
}
async update(
id: string,
dto: UpdateWorkflowDefinitionDto,
): Promise<WorkflowDefinition> {
const definition = await this.workflowDefRepo.findOne({ where: { id } });
if (!definition) {
throw new NotFoundException(
`Workflow Definition with ID "${id}" not found`,
);
}
if (dto.dsl) {
try {
const compiled = this.dslService.compile(dto.dsl);
definition.dsl = dto.dsl;
definition.compiled = compiled;
} catch (error: any) {
throw new BadRequestException(`Invalid DSL: ${error.message}`);
}
}
if (dto.is_active !== undefined) definition.is_active = dto.is_active;
if (dto.workflow_code) definition.workflow_code = dto.workflow_code;
return this.workflowDefRepo.save(definition);
}
async evaluate(dto: EvaluateWorkflowDto): Promise<any> {
const definition = await this.workflowDefRepo.findOne({
where: { workflow_code: dto.workflow_code, is_active: true },
order: { version: 'DESC' },
});
if (!definition) {
throw new NotFoundException(
`No active workflow definition found for "${dto.workflow_code}"`,
);
}
const compiled: CompiledWorkflow = definition.compiled;
const result = this.dslService.evaluate(
compiled,
dto.current_state,
dto.action,
dto.context || {},
);
this.logger.log(
`Workflow Evaluated: ${dto.workflow_code} [${dto.current_state}] --${dto.action}--> [${result.nextState}]`,
);
return result;
}
async getAvailableActions(
workflowCode: string,
currentState: string,
): Promise<string[]> {
const definition = await this.workflowDefRepo.findOne({
where: { workflow_code: workflowCode, is_active: true },
order: { version: 'DESC' },
});
if (!definition) return [];
const stateConfig = definition.compiled.states[currentState];
if (!stateConfig || !stateConfig.transitions) return [];
return Object.keys(stateConfig.transitions);
}
// =================================================================
// [LEGACY] Backward Compatibility for Correspondence/RFA Modules
// คืนค่า Logic เดิมเพื่อไม่ให้ Module อื่น Error (TS2339)
// =================================================================
/**
* คำนวณสถานะถัดไปแบบ Linear Sequence (Logic เดิม)
* ใช้สำหรับ CorrespondenceService และ RfaService ที่ยังไม่ได้ Refactor
*/
processAction(
currentSequence: number,
totalSteps: number,
action: WorkflowAction,
action: string, // รับเป็น string เพื่อความยืดหยุ่น
returnToSequence?: number,
): TransitionResult {
// Map string action to enum logic
switch (action) {
case WorkflowAction.APPROVE:
case WorkflowAction.ACKNOWLEDGE:
// ถ้าเป็นขั้นตอนสุดท้าย -> จบ Workflow
case 'APPROVE': // Case sensitive handling fallback
case 'ACKNOWLEDGE':
if (currentSequence >= totalSteps) {
return {
nextStepSequence: null, // ไม่มีขั้นต่อไปแล้ว
nextStepSequence: null,
shouldUpdateStatus: true,
documentStatus: 'COMPLETED', // หรือ APPROVED
documentStatus: 'COMPLETED',
};
}
// ถ้ายังไม่จบ -> ไปขั้นต่อไป
return {
nextStepSequence: currentSequence + 1,
shouldUpdateStatus: false,
};
case WorkflowAction.REJECT:
// จบ Workflow ทันทีแบบไม่สวย
case 'REJECT':
return {
nextStepSequence: null,
shouldUpdateStatus: true,
@@ -47,7 +181,7 @@ export class WorkflowEngineService {
};
case WorkflowAction.RETURN:
// ย้อนกลับไปขั้นตอนก่อนหน้า (หรือที่ระบุ)
case 'RETURN':
const targetStep = returnToSequence || currentSequence - 1;
if (targetStep < 1) {
throw new BadRequestException('Cannot return beyond the first step');
@@ -55,38 +189,25 @@ export class WorkflowEngineService {
return {
nextStepSequence: targetStep,
shouldUpdateStatus: true,
documentStatus: 'REVISE_REQUIRED', // สถานะเอกสารเป็น "รอแก้ไข"
documentStatus: 'REVISE_REQUIRED',
};
default:
throw new BadRequestException(`Invalid action: ${action}`);
// กรณีส่ง Action อื่นมา ให้ถือว่าเป็น Approve (หรือจะ Throw Error ก็ได้)
this.logger.warn(
`Unknown legacy action: ${action}, treating as next step.`,
);
if (currentSequence >= totalSteps) {
return {
nextStepSequence: null,
shouldUpdateStatus: true,
documentStatus: 'COMPLETED',
};
}
return {
nextStepSequence: currentSequence + 1,
shouldUpdateStatus: false,
};
}
}
/**
* ตรวจสอบว่า User คนนี้ มีสิทธิ์กด Action ในขั้นตอนนี้ไหม
* (Logic เบื้องต้น - เดี๋ยวเราจะเชื่อมกับ RBAC จริงๆ ใน Service หลัก)
*/
validateAccess(
step: WorkflowStep,
userOrgId: number,
userId: number,
): boolean {
// ถ้าขั้นตอนนี้ยังไม่ Active (เช่น PENDING หรือ SKIPPED) -> ห้ามยุ่ง
if (step.status !== StepStatus.IN_PROGRESS) {
return false;
}
// เช็คว่าตรงกับ Organization ที่กำหนดไหม
if (step.organizationId && step.organizationId !== userOrgId) {
return false;
}
// เช็คว่าตรงกับ User ที่กำหนดไหม (ถ้าระบุ)
if (step.assigneeId && step.assigneeId !== userId) {
return false;
}
return true;
}
}

80
temp.md
View File

@@ -1,52 +1,40 @@
import {
Controller,
Get,
Post,
Body,
Param,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
## บทบาท: คุณคือ Programmer ที่เชี่ยวชาญ การจัดการฐานข้อมูล (Database Management), การวิเคราะห์ฐานข้อมูล (Database Analysis), การจัดการฐานข้อมูลเชิงสัมพันธ์ (Relational Databases), ภาษา SQL, RBAC, ABAC, การเขียนโค๊ด NodeJS NestJS NextJS, การ debug โค้ด และ แก้ไข error ภายในโค้ด
import { RfaService } from './rfa.service';
import { CreateRfaDto } from './dto/create-rfa.dto';
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; // Reuse DTO
import { User } from '../user/entities/user.entity';
## Basic data:
1. Application Requirements file: 0_Requirements_V1_4_3.md
2. Full Stack JS file: 1_FullStackJS_V1_4_3.md
3. Backend Development Plan: 2_Backend_Plan_V1_4_3.md
4. Frontend Development Plan: 3_Frontend_Plan_V1_4_3.md
5. Data Dictionary file: 4_Data_Dictionary_V1_4_3.md, 01_lcbp3_v1_4_3.sql
6. Backend Development Plan Phase 6A: 2_Backend_Plan_Phase6A_V1_4_3.md
7. Backend File & Folder: 5_Backend_Folder_V1_4_3.md
import { JwtAuthGuard } from '../../common/auth/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/auth/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
## rules:
- ใช้ภาษาไทยใน comments
- เขียนโค้ดให้อ่านง่าย, ใส่ path/filename ในบรรทัดแรก โค้ด
- การอัพเดทโค้ด ให้แก้ไขจากต้นฉบับเป็น โค้ดที่สมบูรณ์
- เขียน documentation สำหรับ function สำคัญ
@ApiTags('RFA (Request for Approval)')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('rfas')
export class RfaController {
constructor(private readonly rfaService: RfaService) {}
## เป้าหมายและจุดประสงค์:
* ให้ความช่วยเหลือผู้ใช้ในงานที่เกี่ยวข้องกับการพัฒนาซอฟต์แวร์ โดยเฉพาะอย่างยิ่งในส่วนของ JavaScript (NodeJS, NestJS, NextJS) และฐานข้อมูล (SQL, Relational Databases)
* ให้คำปรึกษาเกี่ยวกับการจัดการข้อมูล, การออกแบบฐานข้อมูลเชิงสัมพันธ์, และการใช้โมเดลการควบคุมการเข้าถึง (RBAC, ABAC)
* ช่วยเหลือในการวิเคราะห์และแก้ไขข้อผิดพลาด (debug และ error) ในโค้ดตามที่ผู้ใช้ระบุ
* ใช้ข้อมูลพื้นฐานที่ให้มา (Basic data) เพื่อให้คำแนะนำและโค้ดที่สอดคล้องกับเอกสารโครงการ (เช่น Requirements, Plans, Data Dictionary)
// ... (Create, FindOne endpoints) ...
## พฤติกรรมและกฎเพิ่มเติม:
1) การเริ่มต้นและการโต้ตอบ:
a) ทักทายผู้ใช้ด้วยภาษาไทยอย่างเป็นมิตร และสอบถามเกี่ยวกับปัญหาหรือความช่วยเหลือที่ต้องการในด้านการเขียนโปรแกรมหรือฐานข้อมูล
b) ตอบคำถามทางเทคนิคอย่างแม่นยำและเป็นมืออาชีพ โดยใช้ศัพท์เฉพาะทางที่ถูกต้อง
c) จำกัดจำนวนประโยคในการตอบกลับแต่ละครั้งให้กระชับและตรงประเด็นเพื่อความรวดเร็วในการสื่อสาร
@Post(':id/submit')
@ApiOperation({ summary: 'Submit RFA to Workflow' })
@RequirePermission('rfa.create') // ผู้สร้างมีสิทธิ์ส่ง
submit(
@Param('id', ParseIntPipe) id: number,
@Body('templateId', ParseIntPipe) templateId: number, // รับ Template ID
@CurrentUser() user: User,
) {
return this.rfaService.submit(id, templateId, user);
}
2) การจัดการโค้ดและข้อมูล:
a) เมื่อผู้ใช้ขอให้อัพเดทโค้ด ให้ทำการแสดงโค้ดฉบับเต็มที่สมบูรณ์และได้รับการแก้ไขแล้ว (ไม่ใช่แค่ส่วนที่แก้ไข)
b) ต้องแน่ใจว่าโค้ดที่สร้างขึ้นมานั้นอ่านง่ายและมี comments เป็นภาษาไทยตามที่ระบุใน rules
c) สำหรับฟังก์ชันที่มีความซับซ้อนหรือมีความสำคัญต่อระบบ ต้องเขียน documentation อธิบายวัตถุประสงค์, พารามิเตอร์, และผลลัพธ์ของฟังก์ชันนั้นๆ ด้วยภาษาไทย
d) หากต้องอ้างอิงถึงโครงสร้างข้อมูลหรือข้อกำหนดใดๆ ให้ตรวจสอบจากไฟล์ Basic data ที่ผู้ใช้ให้มาก่อนเสมอ ถ้าไม่พบ ให้แจ้งผู้ใช้ทราบ
e) ถ้ามีการอ้างอิงถึงโค้ดที่อยู่ใน Phase หรือ Task ก่อนหน้า ให้สอบถามผู้ใช้เพื่อให้ upload ไฟล์โค้ดที่อ้างอิง (ไม่เดาหรือสร้างใหม่ เพิ่อประหยัดเวลา)
@Post(':id/action')
@ApiOperation({ summary: 'Process Workflow Action (Approve/Reject)' })
@RequirePermission('workflow.action_review') // สิทธิ์ในการ Approve/Review
processAction(
@Param('id', ParseIntPipe) id: number,
@Body() actionDto: WorkflowActionDto,
@CurrentUser() user: User,
) {
return this.rfaService.processAction(id, actionDto, user);
}
}
1) โทนโดยรวม:
* ใช้ภาษาไทยในการสื่อสารเป็นหลัก ยกเว้นศัพท์เทคนิค
* มีความมั่นใจและแสดงออกถึงความเชี่ยวชาญในฐานะโปรแกรมเมอร์ผู้เชี่ยวชาญ
* มีความเป็นระเบียบและให้คำแนะนำที่เป็นขั้นตอน