251124:1700 Ready to Phase 7

This commit is contained in:
admin
2025-11-24 17:01:58 +07:00
parent 3d9b6e4d05
commit cb6faacba6
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] `.env` (สำหรับ Local Dev เท่านั้น ห้าม commit)
* [x] `.gitignore` * [x] `.gitignore`
* [x] `docker-compose.yml` (Configuration หลักสำหรับ Deploy) * [x] `.prettierrc`
* [x] `docker-compose.override.yml` (สำหรับ Inject Secrets ตอน Dev) * [x] `docker-compose.override.yml`
* [x] `docker-compose.yml`
* [x] `nest-cli.json`
* [x] `tsconfig.build.json`
* [x] `tsconfig.json`
* [x] `package.json` * [x] `package.json`
* [x] `pnpm-lock.yaml` * [x] `pnpm-lock.yaml`
* [x] `tsconfig.json`
* [x] `nest-cli.json`
* [x] `README.md` * [x] `README.md`
--- ---
@@ -24,6 +26,10 @@
* [x] `main.ts` (Application Bootstrap, Swagger, Global Pipes) * [x] `main.ts` (Application Bootstrap, Swagger, Global Pipes)
* [x] `app.module.ts` (Root Module ที่รวมทุก Modules เข้าด้วยกัน) * [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) ### **📁 src/common/** (Shared Resources)
@@ -32,6 +38,9 @@
* **dto/** * **dto/**
* [x] **login.dto.ts** * [x] **login.dto.ts**
* [x] **register.dto.ts** * [x] **register.dto.ts**
* **strategies/**
* [x] **local.strategy.ts**
* [x] **jwt.strategy.ts**
* [x] **auth.controller.spec.ts** * [x] **auth.controller.spec.ts**
* [x] **auth.controller.ts** * [x] **auth.controller.ts**
* [x] **auth.module.ts** * [x] **auth.module.ts**
@@ -39,10 +48,15 @@
* [x] **auth.service.ts** * [x] **auth.service.ts**
* **config/** (Configuration Service) * **config/** (Configuration Service)
* [x] **env.validation.ts** * [x] **env.validation.ts**
* [x] **redis.config.ts**
* **decorators/** * **decorators/**
* [x] **audit.decorator.ts** * [x] **audit.decorator.ts**
* [x] **bypass-maintenance.decorator.ts**
* [x] `current-user.decorator.ts` * [x] `current-user.decorator.ts`
* [x] **idempotency.decorator.ts**
* [x] `require-permission.decorator.ts` * [x] `require-permission.decorator.ts`
* [x] **retry.decorator.ts**
* [x] **circuit-breaker.decorator.ts**
* **entities/** * **entities/**
* [x] **audit-log.entity.ts** * [x] **audit-log.entity.ts**
* [x] **base.entity.ts** * [x] **base.entity.ts**
@@ -56,14 +70,21 @@
* [x] **file-storage.module.ts** * [x] **file-storage.module.ts**
* [x] **file-storage.service.spec.ts** * [x] **file-storage.service.spec.ts**
* [x] `file-storage.service.ts` (Upload, Scan Virus, Commit) * [x] `file-storage.service.ts` (Upload, Scan Virus, Commit)
* [x] **file-cleanup.service.ts** (Cleanup Temporary Files)
* [x] `guards/` * [x] `guards/`
* [x] `jwt-auth.guard.ts` * [x] `jwt-auth.guard.ts`
* [x] **jwt.strategy.ts** * [x] **jwt-refresh.guard.ts**
* [x] **maintenance-mode.guard.ts**
* [x] `rbac.guard.ts` (ตรวจสอบสิทธิ์ 4 ระดับ) * [x] `rbac.guard.ts` (ตรวจสอบสิทธิ์ 4 ระดับ)
* **interceptors/** * **interceptors/**
* [x] `audit-log.interceptor.ts` (เก็บ Log ลง DB) * [x] `audit-log.interceptor.ts` (เก็บ Log ลง DB)
* [x] **idempotency.interceptor.ts** (Idempotency Interceptor)
* [x] `transform.interceptor.ts` (Standard Response Format) * [x] `transform.interceptor.ts` (Standard Response Format)
* **resilience/** (Circuit Breaker & Retry) * **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) ### **📁 src/modules/** (Feature Modules)
@@ -71,13 +92,17 @@
* [x] `dto/` * [x] `dto/`
* [x] **assign-user-role.dto.ts** * [x] **assign-user-role.dto.ts**
* [x] `create-user.dto.ts` * [x] `create-user.dto.ts`
* [ ] **search-user.dto.ts**
* [x] `update-user.dto.ts` * [x] `update-user.dto.ts`
* [x] `update-user-preference.dto.ts`
* [x] `entities/` * [x] `entities/`
* [x] `user.entity.ts` * [x] `user.entity.ts`
* [x] `role.entity.ts` * [x] `role.entity.ts`
* [x] `permission.entity.ts` * [x] `permission.entity.ts`
* [x] **user-assignment.entity.ts**
* [x] `user-preference.entity.ts` * [x] `user-preference.entity.ts`
* [x] **user-assignment.service.ts** * [x] **user-assignment.service.ts**
* [x] **user-preference.service.ts**
* [x] `user.controller.ts` * [x] `user.controller.ts`
* [x] `user.module.ts` * [x] `user.module.ts`
* [x] `user.service.ts` * [x] `user.service.ts`
@@ -146,7 +171,7 @@
* [x] `shop-drawing.controller.ts` * [x] `shop-drawing.controller.ts`
* [x] `shop-drawing.service.ts` * [x] `shop-drawing.service.ts`
4. **rfa/** (Request for Approval & Advanced Workflow) 5. **rfa/** (Request for Approval & Advanced Workflow)
* [x] `dto/` * [x] `dto/`
* [x] `create-rfa.dto.ts` * [x] `create-rfa.dto.ts`
* [x] `search-rfa.dto.ts` * [x] `search-rfa.dto.ts`
@@ -165,7 +190,7 @@
* [x] `rfa.module.ts` * [x] `rfa.module.ts`
* [x] `rfa.service.ts` (Unified Workflow Integration) * [x] `rfa.service.ts` (Unified Workflow Integration)
5. **circulation/** (Internal Routing) 6. **circulation/** (Internal Routing)
* [x] `dto/` * [x] `dto/`
* [x] `create-circulation.dto.ts` * [x] `create-circulation.dto.ts`
* [x] `update-circulation-routing.dto.ts` * [x] `update-circulation-routing.dto.ts`
@@ -178,7 +203,7 @@
* [x] `circulation.module.ts` * [x] `circulation.module.ts`
* [x] `circulation.service.ts` * [x] `circulation.service.ts`
6. **transmittal/** (Document Forwarding) 7. **transmittal/** (Document Forwarding)
* [x] `dto/` * [x] `dto/`
* [x] `create-transmittal.dto.ts` * [x] `create-transmittal.dto.ts`
* [x] `search-transmittal.dto.ts` * [x] `search-transmittal.dto.ts`
@@ -190,7 +215,7 @@
* [x] `transmittal.module.ts` * [x] `transmittal.module.ts`
* [x] `transmittal.service.ts` * [x] `transmittal.service.ts`
7. **notification/** (System Alerts) 8. **notification/** (System Alerts)
* [x] `dto/` * [x] `dto/`
* [x] `create-notification.dto.ts` * [x] `create-notification.dto.ts`
* [x] `search-notification.dto.ts` * [x] `search-notification.dto.ts`
@@ -203,13 +228,13 @@
* [x] `notification.processor.ts` (Consumer/Worker for Email & Line) * [x] `notification.processor.ts` (Consumer/Worker for Email & Line)
* [x] `notification.service.ts` (Producer) * [x] `notification.service.ts` (Producer)
8. **search/** (Elasticsearch) 9. **search/** (Elasticsearch)
* [x] `dto/search-query.dto.ts` * [x] `dto/search-query.dto.ts`
* [x] `search.controller.ts` * [x] `search.controller.ts`
* [x] `search.module.ts` * [x] `search.module.ts`
* [x] `search.service.ts` (Indexing & Searching) * [x] `search.service.ts` (Indexing & Searching)
9. **document-numbering/** (Internal Service) 10. **document-numbering/** (Internal Service)
* [x] `entities/` * [x] `entities/`
* [x] `document-number-format.entity.ts` * [x] `document-number-format.entity.ts`
* [x] `document-number-counter.entity.ts` * [x] `document-number-counter.entity.ts`
@@ -217,13 +242,19 @@
* [x] **document-numbering.service.spec.ts** * [x] **document-numbering.service.spec.ts**
* [x] `document-numbering.service.ts` (Double-Lock Mechanism) * [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] `interfaces/workflow.interface.ts`
* [x] **workflow-dsl.service.ts**
* [x] `workflow-engine.module.ts` * [x] `workflow-engine.module.ts`
* [x] **workflow-engine.service.spec.ts** * [x] **workflow-engine.service.spec.ts**
* [x] `workflow-engine.service.ts` (State Machine Logic) * [x] `workflow-engine.service.ts` (State Machine Logic)
11. **json-schema/** (Validation) 12. **json-schema/** (Validation)
* [x] `dto/` * [x] `dto/`
* [x] `create-json-schema.dto.ts`+ * [x] `create-json-schema.dto.ts`+
* [x] `search-json-schema.dto.ts` * [x] `search-json-schema.dto.ts`
@@ -236,6 +267,16 @@
* [x] **json-schema.service.spec.ts** * [x] **json-schema.service.spec.ts**
* [x] `json-schema.service.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)** ที่ ## **Folder Structure ของ Backend (NestJS)** ที่
--- ---
@@ -244,40 +285,53 @@
```text ```text
backend/ backend/
├── .env # Environment variables for local development only (not committed) ├── .env # Environment variables for local development only (not committed)
├── .gitignore # Git ignore rules ├── .gitignore # Git ignore rules
├── docker-compose.yml # Main deployment container configuration ├── .prettierrc # Prettier configuration
├── docker-compose.yml # Main deployment container configuration
├── docker-compose.override.yml # Dev-time secret/environment injection ├── docker-compose.override.yml # Dev-time secret/environment injection
├── package.json # Node dependencies and NPM scripts ├── package.json # Node dependencies and NPM scripts
├── pnpm-lock.yaml # Dependency lock file for pnpm ├── pnpm-lock.yaml # Dependency lock file for pnpm
├── tsconfig.json # TypeScript compiler configuration ├── tsconfig.json # TypeScript compiler configuration
├── nest-cli.json # NestJS project configuration ├── tsconfig.build.json # TypeScript compiler configuration for production
├── README.md # Project documentation ├── nest-cli.json # NestJS project configuration
├── README.md # Project documentation
└── src/ └── src/
├── main.ts # Application bootstrap and initialization ├── main.ts # Application bootstrap and initialization
├── app.module.ts # Root application module ├── 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/ # 🛠️ Shared framework resources used across modules ├── common.module.ts # Registers shared providers
│ ├── common.module.ts # Registers shared providers
│ │ │ │
│ ├── auth/ # 🛡️ Authentication module │ ├── auth/ # 🛡️ Authentication module
│ │ ├── dto/ │ │ ├── dto/
│ │ │ ├── login.dto.ts # Login request payload │ │ │ ├── login.dto.ts # Login request payload
│ │ │ └── register.dto.ts # Registration 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.module.ts # Auth DI module
│ │ ├── auth.controller.ts # Auth REST endpoints │ │ ├── auth.controller.ts # Auth REST endpoints
│ │ ├── auth.controller.spec.ts # Unit tests for controller │ │ ├── auth.controller.spec.ts # Unit tests for controller
│ │ ├── auth.service.ts # Authentication logic │ │ ├── auth.service.ts # Authentication logic
│ │ └── auth.service.spec.ts # Unit test for service │ │ └── auth.service.spec.ts # Unit test for service
│ │ │ │
│ ├── config/ # 📄 Configuration │ ├── config/ # 📄 Configuration
│ │ ── env.validation.ts # Zod/Joi validation for environment variables │ │ ── 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 │ │ ├── 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 │ │ ├── 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 │ ├── entities/ # 📚 Database entities
│ │ ├── audit-log.entity.ts # Audit log database entity │ │ ├── audit-log.entity.ts # Audit log database entity
@@ -293,186 +347,222 @@ backend/
│ │ ├── file-storage.controller.spec.ts # Unit tests │ │ ├── file-storage.controller.spec.ts # Unit tests
│ │ ├── file-storage.module.ts # Module DI bindings │ │ ├── file-storage.module.ts # Module DI bindings
│ │ ├── file-storage.service.ts # File handling logic │ │ ├── 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 │ ├── guards/ # 🛡️ JWT authentication guard
│ │ ├── jwt-auth.guard.ts # 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 │ │ └── 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 │ │ ├── audit-log.interceptor.ts # Automatically logs certain operations
│ │ ├── idempotency.interceptor.ts # Idempotency interceptor
│ │ └── transform.interceptor.ts # Standardized response formatting │ │ └── 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 ├── modules/ # 📦 Module-specific resources
│ ├── user/ # 👤 User + RBAC module │ ├── user/ # 👤 User + RBAC module
│ │ ├── dto/ │ │ ├── dto/
│ │ │ ├── assign-user-role.dto.ts # Assign roles to users │ │ │ ├── assign-user-role.dto.ts # Assign roles to users
│ │ │ ├── create-user.dto.ts │ │ │ ├── create-user.dto.ts # Create new user
│ │ │ ── update-user.dto.ts │ │ │ ── search-user.dto.ts # Search users
│ │ │ ├── update-user.dto.ts # Update user details
│ │ │ └── update-user-preference.dto.ts # Update user preferences
│ │ ├── entities/ │ │ ├── entities/
│ │ │ ├── user.entity.ts # User table definition │ │ │ ├── user.entity.ts # User table definition
│ │ │ ├── role.entity.ts # Role definition │ │ │ ├── role.entity.ts # Role definition
│ │ │ ├── permission.entity.ts # Permission entity │ │ │ ├── permission.entity.ts # Permission entity
│ │ │ ├── user-assignment.entity.ts # User assignment entity
│ │ │ └── user-preference.entity.ts # User preference settings │ │ │ └── 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.controller.ts # REST endpoints
│ │ ├── user.module.ts # Module DI container
│ │ ├── user.service.ts # Business logic │ │ ├── user.service.ts # Business logic
│ │ ── user.service.spec.ts # Unit tests │ │ ── user.service.spec.ts # Unit tests
│ │ └── user.module.ts # Module DI container
│ │ │ │
│ ├── project/ # 🏢 Project/Organization/Contract structure │ ├── project/ # 🏢 Project/Organization/Contract structure
│ │ ├── dto/ │ │ ├── dto/
│ │ │ ├── create-project.dto.ts │ │ │ ├── create-project.dto.ts # Create new project
│ │ │ ├── search-project.dto.ts │ │ │ ├── search-project.dto.ts # Search projects
│ │ │ └── update-project.dto.ts │ │ │ └── update-project.dto.ts # Update project
│ │ ├── entities/ │ │ ├── entities/
│ │ │ ├── project.entity.ts │ │ │ ├── project.entity.ts # Project table definition
│ │ │ ├── contract.entity.ts │ │ │ ├── contract.entity.ts # Contract table definition
│ │ │ ├── organization.entity.ts │ │ │ ├── organization.entity.ts # Organization table definition
│ │ │ ├── project-organization.entity.ts │ │ │ ├── project-organization.entity.ts # Project organization entity
│ │ │ └── contract-organization.entity.ts │ │ │ └── contract-organization.entity.ts # Contract organization entity
│ │ ├── project.controller.ts │ │ ├── project.controller.ts # REST endpoints
│ │ ├── project.controller.spec.ts │ │ ├── project.controller.spec.ts # Unit tests
│ │ ── project.service.ts │ │ ── project.module.ts # Module DI container
│ ├── project.service.ts # Business logic
│ │ └── project.service.spec.ts # Unit tests
│ │
│ ├── correspondence/ # ✉️ Formal letters with routing workflow │ ├── correspondence/ # ✉️ Formal letters with routing workflow
│ │ ├── dto/ │ │ ├── dto/
│ │ │ ├── add-reference.dto.ts │ │ │ ├── add-reference.dto.ts # Add reference to correspondence
│ │ │ ├── create-correspondence.dto.ts │ │ │ ├── create-correspondence.dto.ts # Create new correspondence
│ │ │ ├── search-correspondence.dto.ts │ │ │ ├── search-correspondence.dto.ts # Search correspondences
│ │ │ ├── submit-correspondence.dto.ts │ │ │ ├── submit-correspondence.dto.ts # Submit correspondence
│ │ │ └── workflow-action.dto.ts │ │ │ └── workflow-action.dto.ts # Workflow action
│ │ ├── entities/ │ │ ├── entities/
│ │ │ ├── correspondence.entity.ts │ │ │ ├── correspondence.entity.ts # Correspondence table definition
│ │ │ ├── correspondence-revision.entity.ts │ │ │ ├── correspondence-revision.entity.ts # Correspondence revision entity
│ │ │ ├── correspondence-routing.entity.ts │ │ │ ├── correspondence-routing.entity.ts # Correspondence routing entity
│ │ │ ├── correspondence-status.entity.ts │ │ │ ├── correspondence-status.entity.ts # Correspondence status entity
│ │ │ ├── correspondence-type.entity.ts │ │ │ ├── correspondence-type.entity.ts # Correspondence type entity
│ │ │ ├── correspondence-reference.entity.ts │ │ │ ├── correspondence-reference.entity.ts # Correspondence reference entity
│ │ │ ├── routing-template.entity.ts │ │ │ ├── routing-template.entity.ts # Routing template entity
│ │ │ └── routing-template-step.entity.ts │ │ │ └── routing-template-step.entity.ts # Routing template step entity
│ │ ├── correspondence.controller.ts │ │ ├── correspondence.controller.ts # REST endpoints
│ │ ├── correspondence.controller.spec.ts │ │ ├── correspondence.controller.spec.ts # Unit tests
│ │ ── correspondence.service.ts │ │ ── correspondence.module.ts # Module DI container
│ ├── correspondence.service.ts # Business logic
│ │ └── correspondence.service.spec.ts # Unit tests
│ │
│ ├── drawing/ # 📐Contract & Shop drawing tracking │ ├── drawing/ # 📐Contract & Shop drawing tracking
│ │ ├── dto/ │ │ ├── dto/
│ │ │ ├── create-contract-drawing.dto.ts │ │ │ ├── create-contract-drawing.dto.ts # Create new contract drawing
│ │ │ ├── create-shop-drawing.dto.ts │ │ │ ├── create-shop-drawing.dto.ts # Create new shop drawing
│ │ │ ├── create-shop-drawing-revision.dto.ts │ │ │ ├── create-shop-drawing-revision.dto.ts # Create new shop drawing revision
│ │ │ ├── search-contract-drawing.dto.ts │ │ │ ├── search-contract-drawing.dto.ts # Search contract drawings
│ │ │ ├── search-shop-drawing.dto.ts │ │ │ ├── search-shop-drawing.dto.ts # Search shop drawings
│ │ │ └── update-contract-drawing.dto.ts │ │ │ └── update-contract-drawing.dto.ts # Update contract drawing
│ │ ├── entities/ │ │ ├── entities/
│ │ │ ├── contract-drawing.entity.ts │ │ │ ├── contract-drawing.entity.ts # Contract drawing entity
│ │ │ ├── contract-drawing-volume.entity.ts │ │ │ ├── contract-drawing-volume.entity.ts # Contract drawing volume entity
│ │ │ ├── contract-drawing-sub-category.entity.ts │ │ │ ├── contract-drawing-sub-category.entity.ts # Contract drawing sub category entity
│ │ │ ├── shop-drawing.entity.ts │ │ │ ├── shop-drawing.entity.ts # Shop drawing entity
│ │ │ ├── shop-drawing-revision.entity.ts │ │ │ ├── shop-drawing-revision.entity.ts # Shop drawing revision entity
│ │ │ ├── shop-drawing-main-category.entity.ts │ │ │ ├── shop-drawing-main-category.entity.ts # Shop drawing main category entity
│ │ │ └── shop-drawing-sub-category.entity.ts │ │ │ └── shop-drawing-sub-category.entity.ts # Shop drawing sub category entity
│ │ ├── drawing.module.ts │ │ ├── drawing.module.ts # Module DI container
│ │ ├── contract-drawing.controller.ts │ │ ├── contract-drawing.controller.ts # REST endpoints
│ │ ├── contract-drawing.service.ts │ │ ├── contract-drawing.service.ts # Business logic
│ │ ├── drawing-master-data.controller.ts │ │ ├── drawing-master-data.controller.ts # REST endpoints
│ │ ├── drawing-master-data.service.ts │ │ ├── drawing-master-data.service.ts # Business logic
│ │ ├── shop-drawing.controller.ts │ │ ├── shop-drawing.controller.ts # REST endpoints
│ │ └── shop-drawing.service.ts │ │ └── shop-drawing.service.ts # Business logic
│ ├── rfa/ # ✅ Request for Approval (multi-step workflow) │ ├── rfa/ # ✅ Request for Approval (multi-step workflow)
│ │ ├── dto/ │ │ ├── dto/
│ │ │ ├── create-rfa.dto.ts │ │ │ ├── create-rfa.dto.ts # Create new RFA
│ │ │ ├── search-rfa.dto.ts │ │ │ ├── search-rfa.dto.ts # Search RFAs
│ │ │ └── update-rfa.dto.ts │ │ │ └── update-rfa.dto.ts # Update RFA
│ │ ├── entities/ │ │ ├── entities/
│ │ │ ├── rfa.entity.ts │ │ │ ├── rfa.entity.ts # RFA entity
│ │ │ ├── rfa-revision.entity.ts │ │ │ ├── rfa-revision.entity.ts # RFA revision entity
│ │ │ ├── rfa-item.entity.ts │ │ │ ├── rfa-item.entity.ts # RFA item entity
│ │ │ ├── rfa-type.entity.ts │ │ │ ├── rfa-type.entity.ts # RFA type entity
│ │ │ ├── rfa-status-code.entity.ts │ │ │ ├── rfa-status-code.entity.ts # RFA status code entity
│ │ │ ├── rfa-approve-code.entity.ts │ │ │ ├── rfa-approve-code.entity.ts # RFA approve code entity
│ │ │ ├── rfa-workflow.entity.ts │ │ │ ├── rfa-workflow.entity.ts # RFA workflow entity
│ │ │ ├── rfa-workflow-template.entity.ts │ │ │ ├── rfa-workflow-template.entity.ts # RFA workflow template entity
│ │ │ └── rfa-workflow-template-step.entity.ts │ │ │ └── rfa-workflow-template-step.entity.ts # RFA workflow template step entity
│ │ ├── rfa.controller.ts │ │ ├── rfa.controller.ts # REST endpoints
│ │ ├── rfa.module.ts │ │ ├── rfa.module.ts # Module DI container
│ │ └── rfa.service.ts │ │ └── rfa.service.ts # Business logic
│ ├── circulation/ # 🔄 Internal routing workflow │ ├── circulation/ # 🔄 Internal routing workflow
│ │ ├── dto/ │ │ ├── dto/
│ │ │ ├── create-circulation.dto.ts │ │ │ ├── create-circulation.dto.ts # Create new circulation
│ │ │ ├── update-circulation-routing.dto.ts │ │ │ ├── update-circulation-routing.dto.ts # Update circulation routing
│ │ │ └── search-circulation.dto.ts │ │ │ └── search-circulation.dto.ts # Search circulation
│ │ ├── entities/ │ │ ├── entities/
│ │ │ ├── circulation.entity.ts │ │ │ ├── circulation.entity.ts # Circulation entity
│ │ │ ├── circulation-routing.entity.ts │ │ │ ├── circulation-routing.entity.ts # Circulation routing entity
│ │ │ └── circulation-status-code.entity.ts │ │ │ └── circulation-status-code.entity.ts # Circulation status code entity
│ │ ├── circulation.controller.ts │ │ ├── circulation.controller.ts # REST endpoints
│ │ ├── circulation.module.ts │ │ ├── circulation.module.ts # Module DI container
│ │ └── circulation.service.ts │ │ └── circulation.service.ts # Business logic
│ ├── transmittal/ # 📤 Document forwarding │ ├── transmittal/ # 📤 Document forwarding
│ │ ├── dto/ │ │ ├── dto/
│ │ │ ├── create-transmittal.dto.ts │ │ │ ├── create-transmittal.dto.ts # Create new transmittal
│ │ │ ├── search-transmittal.dto.ts │ │ │ ├── search-transmittal.dto.ts # Search transmittal
│ │ │ └── update-transmittal.dto.ts │ │ │ └── update-transmittal.dto.ts # Update transmittal
│ │ ├── entities/ │ │ ├── entities/
│ │ │ ├── transmittal.entity.ts │ │ │ ├── transmittal.entity.ts # Transmittal entity
│ │ │ └── transmittal-item.entity.ts │ │ │ └── transmittal-item.entity.ts # Transmittal item entity
│ │ ├── transmittal.controller.ts │ │ ├── transmittal.controller.ts # REST endpoints
│ │ ├── transmittal.module.ts │ │ ├── transmittal.module.ts # Module DI container
│ │ └── transmittal.service.ts │ │ └── transmittal.service.ts # Business logic
│ ├── notification/ # 🔔 Real-Time notification system │ ├── notification/ # 🔔 Real-Time notification system
│ │ ├── dto/ │ │ ├── dto/
│ │ │ ├── create-notification.dto.ts │ │ │ ├── create-notification.dto.ts # Create new notification
│ │ │ └── search-notification.dto.ts │ │ │ └── search-notification.dto.ts # Search notification
│ │ ├── entities/ │ │ ├── entities/
│ │ │ └── notification.entity.ts │ │ │ └── notification.entity.ts # Notification entity
│ │ ├── notification.module.ts # WebSocket + Processor registration │ │ ├── notification.module.ts # WebSocket + Processor registration
│ │ ├── notification.controller.ts │ │ ├── notification.controller.ts # REST endpoints
│ │ ├── notification.gateway.ts # WebSocket gateway │ │ ├── notification.gateway.ts # WebSocket gateway
│ │ ├── notification.processor.ts # Message consumer (e.g. mail worker) │ │ ├── notification.processor.ts # Message consumer (e.g. mail worker)
│ │ ├── notification.service.ts │ │ ├── notification.service.ts # Business logic
│ │ └── notification-cleanup.service.ts # Cron-based cleanup job │ │ └── notification-cleanup.service.ts # Cron-based cleanup job
│ ├── search/ # 🔍 Elasticsearch integration │ ├── search/ # 🔍 Elasticsearch integration
│ │ ├── dto/ │ │ ├── dto/
│ │ │ └── search-query.dto.ts │ │ │ └── search-query.dto.ts # Search query
│ │ ├── search.module.ts │ │ ├── search.module.ts # Module DI container
│ │ ├── search.controller.ts │ │ ├── search.controller.ts # REST endpoints
│ │ └── search.service.ts # Indexing/search logic │ │ └── search.service.ts # Indexing/search logic
│ ├── document-numbering/ # 🔢 Auto-increment controlled ID generation │ ├── document-numbering/ # 🔢 Auto-increment controlled ID generation
│ │ ├── entities/ │ │ ├── entities/
│ │ │ ├── document-number-format.entity.ts │ │ │ ├── document-number-format.entity.ts # Document number format entity
│ │ │ └── document-number-counter.entity.ts │ │ │ └── document-number-counter.entity.ts # Document number counter entity
│ │ ├── document-numbering.module.ts │ │ ├── document-numbering.module.ts # Module DI container
│ │ ├── document-numbering.service.ts │ │ ├── document-numbering.service.ts # Business logic
│ │ └── document-numbering.service.spec.ts │ │ └── document-numbering.service.spec.ts # Unit tests
│ ├── workflow-engine/ # ⚙️ Unified state-machine workflow engine │ ├── 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/ │ │ ├── interfaces/
│ │ │ └── workflow.interface.ts │ │ │ └── workflow.interface.ts # Workflow interface
│ │ ├── workflow-engine.module.ts │ │ ├── workflow-engine.controller.ts # REST endpoints
│ │ ├── workflow-engine.service.ts │ │ ├── workflow-engine.module.ts # Module DI container
│ │ ── workflow-engine.service.spec.ts │ │ ── 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": { "dependencies": {
"@casl/ability": "^6.7.3", "@casl/ability": "^6.7.3",
"@elastic/elasticsearch": "^8.11.1", "@elastic/elasticsearch": "^8.11.1",
"@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.0.1", "@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
@@ -35,12 +36,14 @@
"@nestjs/platform-socket.io": "^11.1.9", "@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.0.1", "@nestjs/schedule": "^6.0.1",
"@nestjs/swagger": "^11.2.3", "@nestjs/swagger": "^11.2.3",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0", "@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@nestjs/websockets": "^11.1.9", "@nestjs/websockets": "^11.1.9",
"@types/nodemailer": "^7.0.4", "@types/nodemailer": "^7.0.4",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"async-retry": "^1.3.3",
"axios": "^1.13.2", "axios": "^1.13.2",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.63.2", "bullmq": "^5.63.2",
@@ -54,16 +57,20 @@
"joi": "^18.0.1", "joi": "^18.0.1",
"multer": "^2.0.2", "multer": "^2.0.2",
"mysql2": "^3.15.3", "mysql2": "^3.15.3",
"nest-winston": "^1.10.2",
"nodemailer": "^7.0.10", "nodemailer": "^7.0.10",
"opossum": "^9.0.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"prom-client": "^15.1.3",
"redlock": "5.0.0-beta.2", "redlock": "5.0.0-beta.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.27", "typeorm": "^0.3.27",
"uuid": "^13.0.0" "uuid": "^13.0.0",
"winston": "^3.18.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@@ -71,6 +78,7 @@
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/async-retry": "^1.4.9",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/cache-manager": "^5.0.0", "@types/cache-manager": "^5.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
@@ -79,6 +87,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/opossum": "^8.1.9",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
// File: src/common/auth/auth.service.ts // File: src/common/auth/auth.service.ts
// บันทึกการแก้ไข: เพิ่ม Refresh Token, Logout (Redis Blacklist) และ Profile ตาม T1.2 // บันทึกการแก้ไข: แก้ไข Type Mismatch ใน signAsync (Fix TS2769)
import { import {
Injectable, Injectable,
@@ -10,11 +10,10 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager'; import type { Cache } from 'cache-manager';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { UserService } from '../../modules/user/user.service.js'; import { UserService } from '../../modules/user/user.service.js';
import { RegisterDto } from './dto/register.dto.js'; import { RegisterDto } from './dto/register.dto.js';
import { User } from '../../modules/user/entities/user.entity.js';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -22,7 +21,7 @@ export class AuthService {
private userService: UserService, private userService: UserService,
private jwtService: JwtService, private jwtService: JwtService,
private configService: ConfigService, private configService: ConfigService,
@Inject(CACHE_MANAGER) private cacheManager: Cache, // ใช้ Redis สำหรับ Blacklist @Inject(CACHE_MANAGER) private cacheManager: Cache,
) {} ) {}
// 1. ตรวจสอบ Username/Password // 1. ตรวจสอบ Username/Password
@@ -41,31 +40,33 @@ export class AuthService {
const payload = { const payload = {
username: user.username, username: user.username,
sub: user.user_id, sub: user.user_id,
scope: 'Global', // ตัวอย่าง: ใส่ Scope เริ่มต้น หรือดึงจาก Role scope: 'Global',
}; };
const [accessToken, refreshToken] = await Promise.all([ const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, { this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_SECRET'), 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, { this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'), secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: // ✅ Fix: Cast as any
this.configService.get<string>('JWT_REFRESH_EXPIRATION') || '7d', expiresIn: (this.configService.get<string>('JWT_REFRESH_EXPIRATION') ||
'7d') as any,
}), }),
]); ]);
return { return {
access_token: accessToken, access_token: accessToken,
refresh_token: refreshToken, refresh_token: refreshToken,
user: user, // ส่งข้อมูล user กลับไปให้ Frontend ใช้แสดงผลเบื้องต้น user: user,
}; };
} }
// 3. Register (สำหรับ Admin) // 3. Register (สำหรับ Admin)
async register(userDto: RegisterDto) { async register(userDto: RegisterDto) {
// ตรวจสอบว่ามี user อยู่แล้วหรือไม่
const existingUser = await this.userService.findOneByUsername( const existingUser = await this.userService.findOneByUsername(
userDto.username, userDto.username,
); );
@@ -84,33 +85,30 @@ export class AuthService {
// 4. Refresh Token: ออก Token ใหม่ // 4. Refresh Token: ออก Token ใหม่
async refreshToken(userId: number, refreshToken: string) { async refreshToken(userId: number, refreshToken: string) {
// ตรวจสอบความถูกต้องของ Refresh Token (ถ้าใช้ DB เก็บ Refresh Token ก็เช็คตรงนี้)
// ในที่นี้เราเชื่อใจ Signature ของ JWT Refresh Secret
const user = await this.userService.findOne(userId); const user = await this.userService.findOne(userId);
if (!user) throw new UnauthorizedException('User not found'); if (!user) throw new UnauthorizedException('User not found');
// สร้าง Access Token ใหม่
const payload = { username: user.username, sub: user.user_id }; const payload = { username: user.username, sub: user.user_id };
const accessToken = await this.jwtService.signAsync(payload, { const accessToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get<string>('JWT_SECRET'), 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 { return {
access_token: accessToken, access_token: accessToken,
// refresh_token: refreshToken, // จะส่งเดิมกลับ หรือ Rotate ใหม่ก็ได้ (แนะนำ Rotate เพื่อความปลอดภัยสูงสุด)
}; };
} }
// 5. Logout: นำ Token เข้า Blacklist ใน Redis // 5. Logout: นำ Token เข้า Blacklist ใน Redis
async logout(userId: number, accessToken: string) { async logout(userId: number, accessToken: string) {
// หาเวลาที่เหลือของ Token เพื่อตั้ง TTL ใน Redis
try { try {
const decoded = this.jwtService.decode(accessToken); const decoded = this.jwtService.decode(accessToken);
if (decoded && decoded.exp) { if (decoded && decoded.exp) {
const ttl = decoded.exp - Math.floor(Date.now() / 1000); const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) { if (ttl > 0) {
// Key pattern: blacklist:token:{token_string}
await this.cacheManager.set( await this.cacheManager.set(
`blacklist:token:${accessToken}`, `blacklist:token:${accessToken}`,
true, true,

View File

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

View File

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

View File

@@ -1,11 +1,15 @@
// File: src/common/config/redis.config.ts // File: src/common/config/redis.config.ts
// บันทึกการแก้ไข: สร้าง Config สำหรับ Redis (T0.2) // บันทึกการแก้ไข: สร้าง Config สำหรับ Redis (T0.2)
// บันทึกการแก้ไข: แก้ไข TS2345 โดยการจัดการค่า undefined ของ process.env ก่อน parseInt
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
export default registerAs('redis', () => ({ export default registerAs('redis', () => ({
host: process.env.REDIS_HOST || 'cache', // Default เป็นชื่อ Service ใน Docker // ใช้ค่า Default 'cache' ถ้าหาไม่เจอ
port: parseInt(process.env.REDIS_PORT, 10) || 6379, host: process.env.REDIS_HOST || 'cache',
ttl: parseInt(process.env.REDIS_TTL, 10) || 3600, // Default TTL 1 ชั่วโมง // ✅ Fix: ใช้ || '6379' เพื่อให้มั่นใจว่าเป็น string ก่อนเข้า parseInt
// password: process.env.REDIS_PASSWORD, // เปิดใช้ถ้ามี Password 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 { import {
Controller, Controller,
Post, Post,
Get,
Delete, // ✅ Import Delete
Param,
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
@@ -8,12 +12,16 @@ import {
ParseFilePipe, ParseFilePipe,
MaxFileSizeValidator, MaxFileSizeValidator,
FileTypeValidator, FileTypeValidator,
Res,
StreamableFile,
ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { FileStorageService } from './file-storage.service.js'; import { FileStorageService } from './file-storage.service.js';
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js'; import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
// ✅ 1. สร้าง Interface เพื่อระบุ Type ของ Request // Interface เพื่อระบุ Type ของ Request ที่ผ่าน JwtAuthGuard มาแล้ว
interface RequestWithUser { interface RequestWithUser {
user: { user: {
userId: number; userId: number;
@@ -33,17 +41,56 @@ export class FileStorageController {
new ParseFilePipe({ new ParseFilePipe({
validators: [ validators: [
new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }), // 50MB new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }), // 50MB
// ตรวจสอบประเภทไฟล์ (Regex) // ตรวจสอบประเภทไฟล์ (Regex) - รวม image, pdf, docs, zip
new FileTypeValidator({ new FileTypeValidator({
fileType: /(pdf|msword|openxmlformats|zip|octet-stream)/, fileType:
/(pdf|msword|openxmlformats|zip|octet-stream|image|jpeg|png)/,
}), }),
], ],
}), }),
) )
file: Express.Multer.File, file: Express.Multer.File,
@Request() req: RequestWithUser, // ✅ 2. ระบุ Type ตรงนี้แทน any @Request() req: RequestWithUser,
) { ) {
// ส่ง userId จาก Token ไปด้วย // ส่ง userId จาก Token ไปด้วย
return this.fileStorageService.upload(file, req.user.userId); 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 { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule'; // ✅ Import
import { FileStorageService } from './file-storage.service.js'; import { FileStorageService } from './file-storage.service.js';
import { FileStorageController } from './file-storage.controller.js'; import { FileStorageController } from './file-storage.controller.js';
import { FileCleanupService } from './file-cleanup.service.js'; // ✅ Import
import { Attachment } from './entities/attachment.entity.js'; import { Attachment } from './entities/attachment.entity.js';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Attachment])], imports: [
TypeOrmModule.forFeature([Attachment]),
ScheduleModule.forRoot(), // ✅ เปิดใช้งาน Cron Job],
],
controllers: [FileStorageController], controllers: [FileStorageController],
providers: [FileStorageService], providers: [
FileStorageService,
FileCleanupService, // ✅ Register Provider
],
exports: [FileStorageService], // Export ให้ Module อื่น (เช่น Correspondence) เรียกใช้ตอน Commit exports: [FileStorageService], // Export ให้ Module อื่น (เช่น Correspondence) เรียกใช้ตอน Commit
}) })
export class FileStorageModule {} export class FileStorageModule {}

View File

@@ -1,3 +1,4 @@
// File: src/common/file-storage/file-storage.service.ts
import { import {
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -12,6 +13,7 @@ import * as path from 'path';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Attachment } from './entities/attachment.entity.js'; import { Attachment } from './entities/attachment.entity.js';
import { ForbiddenException } from '@nestjs/common'; // ✅ Import เพิ่ม
@Injectable() @Injectable()
export class FileStorageService { export class FileStorageService {
@@ -29,7 +31,7 @@ export class FileStorageService {
? '/share/dms-data' ? '/share/dms-data'
: path.join(process.cwd(), 'uploads'); : path.join(process.cwd(), 'uploads');
// สร้างโฟลเดอร์รอไว้เลยถ้ายังไม่มี // สร้างโฟลเดอร์ temp รอไว้เลยถ้ายังไม่มี
fs.ensureDirSync(path.join(this.uploadRoot, 'temp')); fs.ensureDirSync(path.join(this.uploadRoot, 'temp'));
} }
@@ -75,11 +77,20 @@ export class FileStorageService {
* เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save * เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save
*/ */
async commit(tempIds: string[]): Promise<Attachment[]> { async commit(tempIds: string[]): Promise<Attachment[]> {
if (!tempIds || tempIds.length === 0) {
return [];
}
const attachments = await this.attachmentRepository.find({ const attachments = await this.attachmentRepository.find({
where: { tempId: In(tempIds), isTemporary: true }, where: { tempId: In(tempIds), isTemporary: true },
}); });
if (attachments.length !== tempIds.length) { 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'); throw new NotFoundException('Some files not found or already committed');
} }
@@ -98,21 +109,27 @@ export class FileStorageService {
try { try {
// ย้ายไฟล์ // ย้ายไฟล์
await fs.move(oldPath, newPath, { overwrite: true }); if (await fs.pathExists(oldPath)) {
await fs.move(oldPath, newPath, { overwrite: true });
// อัปเดตข้อมูลใน DB // อัปเดตข้อมูลใน DB
att.filePath = newPath; att.filePath = newPath;
att.isTemporary = false; att.isTemporary = false;
att.tempId = undefined; // เคลียร์ tempId att.tempId = null as any; // เคลียร์ tempId (TypeORM อาจต้องการ null แทน undefined สำหรับ nullable)
att.expiresAt = undefined; // เคลียร์วันหมดอายุ 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) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to move file from ${oldPath} to ${newPath}`, `Failed to move file from ${oldPath} to ${newPath}`,
error, error,
); );
// ถ้า error ตัวนึง ควรจะ rollback หรือ throw error (ในที่นี้ throw เพื่อให้ Transaction ของผู้เรียกจัดการ)
throw new BadRequestException( throw new BadRequestException(
`Failed to commit file: ${att.originalFilename}`, `Failed to commit file: ${att.originalFilename}`,
); );
@@ -122,7 +139,83 @@ export class FileStorageService {
return committedAttachments; 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 { private calculateChecksum(buffer: Buffer): string {
return crypto.createHash('sha256').update(buffer).digest('hex'); 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'; } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { CACHE_MANAGER } from '@nestjs/cache-manager'; 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'; import { BYPASS_MAINTENANCE_KEY } from '../decorators/bypass-maintenance.decorator';
@Injectable() @Injectable()

View File

@@ -1,5 +1,6 @@
// File: src/common/interceptors/idempotency.interceptor.ts // File: src/common/interceptors/idempotency.interceptor.ts
// บันทึกการแก้ไข: สร้าง IdempotencyInterceptor เพื่อป้องกันการทำรายการซ้ำ (T1.1) // บันทึกการแก้ไข: สร้าง IdempotencyInterceptor เพื่อป้องกันการทำรายการซ้ำ (T1.1)
// บันทึกการแก้ไข: แก้ไข TS18046 โดยการตรวจสอบ Type ของ err ใน catch block
import { import {
CallHandler, CallHandler,
@@ -11,7 +12,7 @@ import {
Logger, Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager'; import type { Cache } from 'cache-manager';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { Request } from 'express'; import { Request } from 'express';
@@ -29,43 +30,37 @@ export class IdempotencyInterceptor implements NestInterceptor {
const request = context.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<Request>();
const method = request.method; const method = request.method;
// 1. ตรวจสอบว่าควรใช้ Idempotency หรือไม่ (เฉพาะ POST, PUT, DELETE)
if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
return next.handle(); return next.handle();
} }
// 2. ดึง Idempotency-Key จาก Header
const idempotencyKey = request.headers['idempotency-key'] as string; const idempotencyKey = request.headers['idempotency-key'] as string;
// ถ้าไม่มี Key ส่งมา ให้ทำงานปกติ (หรือจะบังคับให้ Error ก็ได้ ตาม Policy)
if (!idempotencyKey) { if (!idempotencyKey) {
// หมายเหตุ: ในระบบที่ Strict อาจจะ throw BadRequestException ถ้าไม่มี Key สำหรับ Transaction สำคัญ
return next.handle(); return next.handle();
} }
const cacheKey = `idempotency:${idempotencyKey}`; const cacheKey = `idempotency:${idempotencyKey}`;
// 3. ตรวจสอบใน Redis ว่า Key นี้เคยถูกประมวลผลหรือยัง
const cachedResponse = await this.cacheManager.get(cacheKey); const cachedResponse = await this.cacheManager.get(cacheKey);
if (cachedResponse) { if (cachedResponse) {
this.logger.warn( this.logger.warn(
`Idempotency key detected: ${idempotencyKey}. Returning cached response.`, `Idempotency key detected: ${idempotencyKey}. Returning cached response.`,
); );
// ถ้ามี ให้คืนค่าเดิมกลับไปเลย (เสมือนว่าทำรายการสำเร็จแล้ว)
return of(cachedResponse); return of(cachedResponse);
} }
// 4. ถ้ายังไม่มี ให้ประมวลผลต่อ และบันทึกผลลัพธ์ลง Redis
return next.handle().pipe( return next.handle().pipe(
tap(async (response) => { tap(async (response) => {
try { try {
// บันทึก Response ลง Cache (TTL 24 ชั่วโมง หรือตามความเหมาะสม)
await this.cacheManager.set(cacheKey, response, 86400 * 1000); await this.cacheManager.set(cacheKey, response, 86400 * 1000);
} catch (err) { } catch (err) {
// ✅ Fix: ตรวจสอบว่า err เป็น Error Object หรือไม่ ก่อนเรียก .stack
const errorMessage = err instanceof Error ? err.stack : String(err);
this.logger.error( this.logger.error(
`Failed to cache idempotency key ${idempotencyKey}`, `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 { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@@ -12,15 +13,18 @@ import { UserPreference } from '../user/entities/user-preference.entity';
// Gateway // Gateway
import { NotificationGateway } from './notification.gateway'; import { NotificationGateway } from './notification.gateway';
// DTOs
import { SearchNotificationDto } from './dto/search-notification.dto';
// Interfaces // Interfaces
export interface NotificationJobData { export interface NotificationJobData {
userId: number; userId: number;
title: string; title: string;
message: string; message: string;
type: 'EMAIL' | 'LINE' | 'SYSTEM'; type: 'EMAIL' | 'LINE' | 'SYSTEM'; // ช่องทางหลักที่ต้องการส่ง (Trigger Type)
entityType?: string; // e.g., 'rfa' entityType?: string; // e.g., 'rfa', 'correspondence'
entityId?: number; // e.g., rfa_id entityId?: number; // e.g., rfa_id
link?: string; // Deep link to frontend link?: string; // Deep link to frontend page
} }
@Injectable() @Injectable()
@@ -39,98 +43,195 @@ export class NotificationService {
) {} ) {}
/** /**
* ส่งการแจ้งเตือน (Trigger Notification) * ส่งการแจ้งเตือน (Centralized Notification Sender)
* ฟังก์ชันนี้จะตรวจสอบ Preference ของผู้ใช้ และ Push ลง Queue * 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 { try {
// 1. สร้าง Entity Instance (ยังไม่บันทึกลง DB) // ---------------------------------------------------------
// ใช้ Enum NotificationType.SYSTEM เพื่อให้ตรงกับ Type Definition // 1. สร้าง Entity และบันทึกลง DB (เพื่อให้มี History ในระบบ)
// ---------------------------------------------------------
const notification = this.notificationRepo.create({ const notification = this.notificationRepo.create({
userId: data.userId, userId: data.userId,
title: data.title, title: data.title,
message: data.message, message: data.message,
notificationType: NotificationType.SYSTEM, notificationType: NotificationType.SYSTEM, // ใน DB เก็บเป็น SYSTEM เสมอเพื่อแสดงใน App
entityType: data.entityType, entityType: data.entityType,
entityId: data.entityId, entityId: data.entityId,
isRead: false, isRead: false,
// link: data.link // ถ้า Entity มี field link ให้ใส่ด้วย
}); });
// 2. บันทึกลง DB (ต้อง await เพื่อให้ได้ ID กลับมา)
const savedNotification = await this.notificationRepo.save(notification); 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); this.notificationGateway.sendToUser(data.userId, savedNotification);
// 4. ตรวจสอบ User Preferences เพื่อส่งช่องทางอื่น (Email/Line) // ---------------------------------------------------------
// 3. ตรวจสอบ User Preferences เพื่อส่งช่องทางอื่น (Email/Line)
// ---------------------------------------------------------
const userPref = await this.userPrefRepo.findOne({ const userPref = await this.userPrefRepo.findOne({
where: { userId: data.userId }, where: { userId: data.userId },
}); });
// Default: ถ้าไม่มี Pref ให้ส่ง Email/Line เป็นค่าเริ่มต้น (true) // ใช้ Nullish Coalescing Operator (??)
const shouldSendEmail = userPref ? userPref.notifyEmail : true; // ถ้าไม่มีค่า (undefined/null) ให้ Default เป็น true
const shouldSendLine = userPref ? userPref.notifyLine : true; const shouldSendEmail = userPref?.notifyEmail ?? true;
const shouldSendLine = userPref?.notifyLine ?? true;
const jobs = []; 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') { if (shouldSendEmail && data.type !== 'LINE') {
jobs.push({ jobs.push({
name: 'send-email', name: 'send-email',
data: { ...data, notificationId: savedNotification.id }, data: {
...data,
notificationId: savedNotification.id,
target: 'EMAIL',
},
opts: { opts: {
attempts: 3, // ลองใหม่ 3 ครั้งถ้าล่ม attempts: 3, // ลองใหม่ 3 ครั้งถ้าล่ม (Resilience)
backoff: { backoff: {
type: 'exponential', 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') { if (shouldSendLine && data.type !== 'EMAIL') {
jobs.push({ jobs.push({
name: 'send-line', name: 'send-line',
data: { ...data, notificationId: savedNotification.id }, data: {
...data,
notificationId: savedNotification.id,
target: 'LINE',
},
opts: { opts: {
attempts: 3, attempts: 3,
backoff: { type: 'fixed', delay: 3000 }, backoff: { type: 'fixed', delay: 3000 },
removeOnComplete: true,
}, },
}); });
} }
// ---------------------------------------------------------
// 6. Push Jobs ลง Redis BullMQ
// ---------------------------------------------------------
if (jobs.length > 0) { if (jobs.length > 0) {
await this.notificationQueue.addBulk(jobs); 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) { } catch (error) {
// Cast Error เพื่อให้ TypeScript ไม่ฟ้องใน Strict Mode // Error Handling: ไม่ Throw เพื่อไม่ให้ Flow หลัก (เช่น การสร้างเอกสาร) พัง
// แต่บันทึก Error ไว้ตรวจสอบ
this.logger.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) * อ่านแจ้งเตือน (Mark as Read)
*/ */
async markAsRead(id: number, userId: number) { async markAsRead(id: number, userId: number): Promise<void> {
await this.notificationRepo.update({ id, userId }, { isRead: true }); 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) * อ่านทั้งหมด (Mark All as Read)
*/ */
async markAllAsRead(userId: number) { async markAllAsRead(userId: number): Promise<void> {
await this.notificationRepo.update( await this.notificationRepo.update(
{ userId, isRead: false }, { userId, isRead: false },
{ isRead: true }, { 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 { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; 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 { RfaApproveCode } from './entities/rfa-approve-code.entity';
import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.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'; import { RfaService } from './rfa.service';
// Controllers
import { RfaController } from './rfa.controller'; import { RfaController } from './rfa.controller';
// External Modules // External Modules
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module'; import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { UserModule } from '../user/user.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({ @Module({
imports: [ imports: [
// 1. Register Entities (เฉพาะ Entity เท่านั้น ห้ามใส่ Module)
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([
Rfa, Rfa,
RfaRevision, RfaRevision,
@@ -38,14 +41,19 @@ import { SearchModule } from '../search/search.module'; // ✅ เพิ่ม
RfaApproveCode, RfaApproveCode,
Correspondence, Correspondence,
ShopDrawingRevision, ShopDrawingRevision,
// ... (ตัวเดิม)
RfaWorkflow, RfaWorkflow,
RfaWorkflowTemplate, RfaWorkflowTemplate,
RfaWorkflowTemplateStep, RfaWorkflowTemplateStep,
CorrespondenceRouting,
RoutingTemplate,
]), ]),
// 2. Import External Modules (Services ที่ Inject เข้ามา)
DocumentNumberingModule, DocumentNumberingModule,
UserModule, UserModule,
SearchModule, SearchModule,
WorkflowEngineModule, // ✅ ย้ายมาใส่ตรงนี้ (imports หลัก)
NotificationModule, // ✅ เพิ่มตรงนี้ เพื่อแก้ dependency index [13]
], ],
providers: [RfaService], providers: [RfaService],
controllers: [RfaController], controllers: [RfaController],

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ import { UpdateUserDto } from './dto/update-user.dto';
import { AssignRoleDto } from './dto/assign-role.dto'; import { AssignRoleDto } from './dto/assign-role.dto';
import { UpdatePreferenceDto } from './dto/update-preference.dto'; // ✅ เพิ่ม 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 { RbacGuard } from '../../common/guards/rbac.guard'; // สมมติว่ามีแล้ว ถ้ายังไม่มีให้คอมเมนต์ไว้ก่อน
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.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 { import {
Injectable, Injectable,
NotFoundException, NotFoundException,
ConflictException, ConflictException,
Inject,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from '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 * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity'; import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
@@ -14,26 +20,23 @@ import { UpdateUserDto } from './dto/update-user.dto';
export class UserService { export class UserService {
constructor( constructor(
@InjectRepository(User) @InjectRepository(User)
private usersRepository: Repository<User>, // ✅ ชื่อตัวแปรจริงคือ usersRepository private usersRepository: Repository<User>,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {} ) {}
// 1. สร้างผู้ใช้ (Hash Password ก่อนบันทึก) // 1. สร้างผู้ใช้ (Hash Password ก่อนบันทึก)
async create(createUserDto: CreateUserDto): Promise<User> { async create(createUserDto: CreateUserDto): Promise<User> {
// สร้าง Salt และ Hash Password
const salt = await bcrypt.genSalt(); const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(createUserDto.password, salt); const hashedPassword = await bcrypt.hash(createUserDto.password, salt);
// เตรียมข้อมูล (เปลี่ยน password ธรรมดา เป็น password_hash)
const newUser = this.usersRepository.create({ const newUser = this.usersRepository.create({
...createUserDto, ...createUserDto,
password: hashedPassword, password: hashedPassword,
}); });
try { try {
// บันทึกลง DB
return await this.usersRepository.save(newUser); return await this.usersRepository.save(newUser);
} catch (error: any) { } catch (error: any) {
// เช็ค Error กรณี Username/Email ซ้ำ (MySQL Error Code 1062)
if (error.code === 'ER_DUP_ENTRY') { if (error.code === 'ER_DUP_ENTRY') {
throw new ConflictException('Username or Email already exists'); throw new ConflictException('Username or Email already exists');
} }
@@ -44,7 +47,6 @@ export class UserService {
// 2. ดึงข้อมูลทั้งหมด // 2. ดึงข้อมูลทั้งหมด
async findAll(): Promise<User[]> { async findAll(): Promise<User[]> {
return this.usersRepository.find({ return this.usersRepository.find({
// ไม่ส่ง password กลับไปเพื่อความปลอดภัย
select: [ select: [
'user_id', 'user_id',
'username', 'username',
@@ -61,7 +63,7 @@ export class UserService {
// 3. ดึงข้อมูลรายคน // 3. ดึงข้อมูลรายคน
async findOne(id: number): Promise<User> { async findOne(id: number): Promise<User> {
const user = await this.usersRepository.findOne({ const user = await this.usersRepository.findOne({
where: { user_id: id }, // ใช้ user_id ตาม Entity where: { user_id: id },
}); });
if (!user) { if (!user) {
@@ -71,26 +73,26 @@ export class UserService {
return user; return user;
} }
// ฟังก์ชันแถม: สำหรับ AuthService ใช้ (ต้องเห็น Password เพื่อเอาไปเทียบ)
async findOneByUsername(username: string): Promise<User | null> { async findOneByUsername(username: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { username } }); return this.usersRepository.findOne({ where: { username } });
} }
// 4. แก้ไขข้อมูล // 4. แก้ไขข้อมูล
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> { async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
// เช็คก่อนว่ามี User นี้ไหม
const user = await this.findOne(id); const user = await this.findOne(id);
// ถ้ามีการแก้รหัสผ่าน ต้อง Hash ใหม่ด้วย
if (updateUserDto.password) { if (updateUserDto.password) {
const salt = await bcrypt.genSalt(); const salt = await bcrypt.genSalt();
updateUserDto.password = await bcrypt.hash(updateUserDto.password, salt); updateUserDto.password = await bcrypt.hash(updateUserDto.password, salt);
} }
// รวมร่างข้อมูลเดิม + ข้อมูลใหม่
const updatedUser = this.usersRepository.merge(user, updateUserDto); 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) // 5. ลบผู้ใช้ (Soft Delete)
@@ -100,31 +102,48 @@ export class UserService {
if (result.affected === 0) { if (result.affected === 0) {
throw new NotFoundException(`User with ID ${id} not found`); 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> { async findDocControlIdByOrg(organizationId: number): Promise<number | null> {
// ✅ FIX: ใช้ usersRepository ให้ตรงกับ Constructor
const user = await this.usersRepository.findOne({ const user = await this.usersRepository.findOne({
where: { primaryOrganizationId: organizationId }, where: { primaryOrganizationId: organizationId },
// order: { roleId: 'ASC' } // (Optional) Logic การเลือกคน
}); });
return user ? user.user_id : null; return user ? user.user_id : null;
} }
// ฟังก์ชันดึงสิทธิ์ (Permission) /**
* ✅ ดึงสิทธิ์ (Permission) โดยใช้ Caching Strategy
* TTL: 30 นาที (ตาม Requirement 6.5.2)
*/
async getUserPermissions(userId: number): Promise<string[]> { 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( const permissions = await this.usersRepository.query(
`SELECT permission_name FROM v_user_all_permissions WHERE user_id = ?`, `SELECT permission_name FROM v_user_all_permissions WHERE user_id = ?`,
[userId], [userId],
); );
// แปลงผลลัพธ์เป็น Array ของ string ['user.create', 'project.view', ...] const permissionList = permissions.map((row: any) => row.permission_name);
return 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 { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkflowEngineService } from './workflow-engine.service'; 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({ @Module({
providers: [WorkflowEngineService], imports: [
// ✅ เพิ่มบรรทัดนี้ เพื่ออนุญาตให้ Module อื่น (เช่น Correspondence) เรียกใช้ Service นี้ได้ // เชื่อมต่อกับตาราง workflow_definitions
exports: [WorkflowEngineService], TypeOrmModule.forFeature([WorkflowDefinition]),
],
controllers: [WorkflowEngineController], // เพิ่ม Controller สำหรับรับ API
providers: [
WorkflowEngineService, // Service หลัก
WorkflowDslService, // [New] Service สำหรับ Compile/Validate DSL
],
exports: [WorkflowEngineService], // Export ให้ module อื่นใช้เหมือนเดิม
}) })
export class WorkflowEngineModule {} 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 { import {
WorkflowStep, Injectable,
WorkflowAction, NotFoundException,
StepStatus, BadRequestException,
TransitionResult, Logger,
} from './interfaces/workflow.interface.js'; } 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() @Injectable()
export class WorkflowEngineService { 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) * สร้างหรืออัปเดต Workflow Definition ใหม่ (Auto Versioning)
* @param currentSequence ลำดับปัจจุบัน */
* @param totalSteps จำนวนขั้นตอนทั้งหมด async createDefinition(
* @param action การกระทำ (Approve/Reject/Return) dto: CreateWorkflowDefinitionDto,
* @param returnToSequence (Optional) ถ้า Return จะให้กลับไปขั้นไหน ): 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( processAction(
currentSequence: number, currentSequence: number,
totalSteps: number, totalSteps: number,
action: WorkflowAction, action: string, // รับเป็น string เพื่อความยืดหยุ่น
returnToSequence?: number, returnToSequence?: number,
): TransitionResult { ): TransitionResult {
// Map string action to enum logic
switch (action) { switch (action) {
case WorkflowAction.APPROVE: case WorkflowAction.APPROVE:
case WorkflowAction.ACKNOWLEDGE: case WorkflowAction.ACKNOWLEDGE:
// ถ้าเป็นขั้นตอนสุดท้าย -> จบ Workflow case 'APPROVE': // Case sensitive handling fallback
case 'ACKNOWLEDGE':
if (currentSequence >= totalSteps) { if (currentSequence >= totalSteps) {
return { return {
nextStepSequence: null, // ไม่มีขั้นต่อไปแล้ว nextStepSequence: null,
shouldUpdateStatus: true, shouldUpdateStatus: true,
documentStatus: 'COMPLETED', // หรือ APPROVED documentStatus: 'COMPLETED',
}; };
} }
// ถ้ายังไม่จบ -> ไปขั้นต่อไป
return { return {
nextStepSequence: currentSequence + 1, nextStepSequence: currentSequence + 1,
shouldUpdateStatus: false, shouldUpdateStatus: false,
}; };
case WorkflowAction.REJECT: case WorkflowAction.REJECT:
// จบ Workflow ทันทีแบบไม่สวย case 'REJECT':
return { return {
nextStepSequence: null, nextStepSequence: null,
shouldUpdateStatus: true, shouldUpdateStatus: true,
@@ -47,7 +181,7 @@ export class WorkflowEngineService {
}; };
case WorkflowAction.RETURN: case WorkflowAction.RETURN:
// ย้อนกลับไปขั้นตอนก่อนหน้า (หรือที่ระบุ) case 'RETURN':
const targetStep = returnToSequence || currentSequence - 1; const targetStep = returnToSequence || currentSequence - 1;
if (targetStep < 1) { if (targetStep < 1) {
throw new BadRequestException('Cannot return beyond the first step'); throw new BadRequestException('Cannot return beyond the first step');
@@ -55,38 +189,25 @@ export class WorkflowEngineService {
return { return {
nextStepSequence: targetStep, nextStepSequence: targetStep,
shouldUpdateStatus: true, shouldUpdateStatus: true,
documentStatus: 'REVISE_REQUIRED', // สถานะเอกสารเป็น "รอแก้ไข" documentStatus: 'REVISE_REQUIRED',
}; };
default: 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 { ## บทบาท: คุณคือ Programmer ที่เชี่ยวชาญ การจัดการฐานข้อมูล (Database Management), การวิเคราะห์ฐานข้อมูล (Database Analysis), การจัดการฐานข้อมูลเชิงสัมพันธ์ (Relational Databases), ภาษา SQL, RBAC, ABAC, การเขียนโค๊ด NodeJS NestJS NextJS, การ debug โค้ด และ แก้ไข error ภายในโค้ด
Controller,
Get,
Post,
Body,
Param,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { RfaService } from './rfa.service'; ## Basic data:
import { CreateRfaDto } from './dto/create-rfa.dto'; 1. Application Requirements file: 0_Requirements_V1_4_3.md
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; // Reuse DTO 2. Full Stack JS file: 1_FullStackJS_V1_4_3.md
import { User } from '../user/entities/user.entity'; 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'; ## rules:
import { RbacGuard } from '../../common/auth/guards/rbac.guard'; - ใช้ภาษาไทยใน comments
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; - เขียนโค้ดให้อ่านง่าย, ใส่ path/filename ในบรรทัดแรก โค้ด
import { CurrentUser } from '../../common/decorators/current-user.decorator'; - การอัพเดทโค้ด ให้แก้ไขจากต้นฉบับเป็น โค้ดที่สมบูรณ์
- เขียน documentation สำหรับ function สำคัญ
@ApiTags('RFA (Request for Approval)') ## เป้าหมายและจุดประสงค์:
@ApiBearerAuth() * ให้ความช่วยเหลือผู้ใช้ในงานที่เกี่ยวข้องกับการพัฒนาซอฟต์แวร์ โดยเฉพาะอย่างยิ่งในส่วนของ JavaScript (NodeJS, NestJS, NextJS) และฐานข้อมูล (SQL, Relational Databases)
@UseGuards(JwtAuthGuard, RbacGuard) * ให้คำปรึกษาเกี่ยวกับการจัดการข้อมูล, การออกแบบฐานข้อมูลเชิงสัมพันธ์, และการใช้โมเดลการควบคุมการเข้าถึง (RBAC, ABAC)
@Controller('rfas') * ช่วยเหลือในการวิเคราะห์และแก้ไขข้อผิดพลาด (debug และ error) ในโค้ดตามที่ผู้ใช้ระบุ
export class RfaController { * ใช้ข้อมูลพื้นฐานที่ให้มา (Basic data) เพื่อให้คำแนะนำและโค้ดที่สอดคล้องกับเอกสารโครงการ (เช่น Requirements, Plans, Data Dictionary)
constructor(private readonly rfaService: RfaService) {}
// ... (Create, FindOne endpoints) ... ## พฤติกรรมและกฎเพิ่มเติม:
1) การเริ่มต้นและการโต้ตอบ:
a) ทักทายผู้ใช้ด้วยภาษาไทยอย่างเป็นมิตร และสอบถามเกี่ยวกับปัญหาหรือความช่วยเหลือที่ต้องการในด้านการเขียนโปรแกรมหรือฐานข้อมูล
b) ตอบคำถามทางเทคนิคอย่างแม่นยำและเป็นมืออาชีพ โดยใช้ศัพท์เฉพาะทางที่ถูกต้อง
c) จำกัดจำนวนประโยคในการตอบกลับแต่ละครั้งให้กระชับและตรงประเด็นเพื่อความรวดเร็วในการสื่อสาร
@Post(':id/submit') 2) การจัดการโค้ดและข้อมูล:
@ApiOperation({ summary: 'Submit RFA to Workflow' }) a) เมื่อผู้ใช้ขอให้อัพเดทโค้ด ให้ทำการแสดงโค้ดฉบับเต็มที่สมบูรณ์และได้รับการแก้ไขแล้ว (ไม่ใช่แค่ส่วนที่แก้ไข)
@RequirePermission('rfa.create') // ผู้สร้างมีสิทธิ์ส่ง b) ต้องแน่ใจว่าโค้ดที่สร้างขึ้นมานั้นอ่านง่ายและมี comments เป็นภาษาไทยตามที่ระบุใน rules
submit( c) สำหรับฟังก์ชันที่มีความซับซ้อนหรือมีความสำคัญต่อระบบ ต้องเขียน documentation อธิบายวัตถุประสงค์, พารามิเตอร์, และผลลัพธ์ของฟังก์ชันนั้นๆ ด้วยภาษาไทย
@Param('id', ParseIntPipe) id: number, d) หากต้องอ้างอิงถึงโครงสร้างข้อมูลหรือข้อกำหนดใดๆ ให้ตรวจสอบจากไฟล์ Basic data ที่ผู้ใช้ให้มาก่อนเสมอ ถ้าไม่พบ ให้แจ้งผู้ใช้ทราบ
@Body('templateId', ParseIntPipe) templateId: number, // รับ Template ID e) ถ้ามีการอ้างอิงถึงโค้ดที่อยู่ใน Phase หรือ Task ก่อนหน้า ให้สอบถามผู้ใช้เพื่อให้ upload ไฟล์โค้ดที่อ้างอิง (ไม่เดาหรือสร้างใหม่ เพิ่อประหยัดเวลา)
@CurrentUser() user: User,
) {
return this.rfaService.submit(id, templateId, user);
}
@Post(':id/action') 1) โทนโดยรวม:
@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);
}
}