diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 3f40a6e..ea24128 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -33,7 +33,7 @@ services: container_name: lcbp3-redis-local restart: always # ใช้ Command นี้เพื่อตั้ง Password - command: redis-server --requirepass "redis_password_secure" + command: redis-server --requirepass "Center2025" ports: - '6379:6379' volumes: diff --git a/backend/package.json b/backend/package.json index 5427774..7bae0a4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,18 +29,27 @@ "@nestjs/mapped-types": "^2.1.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "bcrypt": "^6.0.0", "bullmq": "^5.63.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "fs-extra": "^11.3.2", + "helmet": "^8.1.0", + "ioredis": "^5.8.2", "joi": "^18.0.1", + "multer": "^2.0.2", "mysql2": "^3.15.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "redlock": "5.0.0-beta.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^0.3.27" + "typeorm": "^0.3.27", + "uuid": "^13.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -50,10 +59,14 @@ "@nestjs/testing": "^11.0.1", "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.0", + "@types/fs-extra": "^11.0.4", + "@types/ioredis": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.0.0", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", + "@types/uuid": "^11.0.0", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 719d3a3..9320341 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -35,9 +35,18 @@ importers: '@nestjs/platform-express': specifier: ^11.0.1 version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) + '@nestjs/throttler': + specifier: ^6.4.0 + version: 6.4.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^11.0.0 version: 11.0.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(ioredis@5.8.2)(mysql2@3.15.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))) + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) bcrypt: specifier: ^6.0.0 version: 6.0.0 @@ -50,9 +59,21 @@ importers: class-validator: specifier: ^0.14.2 version: 0.14.2 + fs-extra: + specifier: ^11.3.2 + version: 11.3.2 + helmet: + specifier: ^8.1.0 + version: 8.1.0 + ioredis: + specifier: ^5.8.2 + version: 5.8.2 joi: specifier: ^18.0.1 version: 18.0.1 + multer: + specifier: ^2.0.2 + version: 2.0.2 mysql2: specifier: ^3.15.3 version: 3.15.3 @@ -62,6 +83,9 @@ importers: passport-jwt: specifier: ^4.0.1 version: 4.0.1 + redlock: + specifier: 5.0.0-beta.2 + version: 5.0.0-beta.2 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -71,6 +95,9 @@ importers: typeorm: specifier: ^0.3.27 version: 0.3.27(ioredis@5.8.2)(mysql2@3.15.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + uuid: + specifier: ^13.0.0 + version: 13.0.0 devDependencies: '@eslint/eslintrc': specifier: ^3.2.0 @@ -93,9 +120,18 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.5 + '@types/fs-extra': + specifier: ^11.0.4 + version: 11.0.4 + '@types/ioredis': + specifier: ^5.0.0 + version: 5.0.0 '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/multer': + specifier: ^2.0.0 + version: 2.0.0 '@types/node': specifier: ^22.10.7 version: 22.19.1 @@ -105,6 +141,9 @@ importers: '@types/supertest': specifier: ^6.0.2 version: 6.0.3 + '@types/uuid': + specifier: ^11.0.0 + version: 11.0.0 eslint: specifier: ^9.18.0 version: 9.39.1 @@ -861,6 +900,13 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.4.0': + resolution: {integrity: sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + '@nestjs/typeorm@11.0.0': resolution: {integrity: sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==} peerDependencies: @@ -978,9 +1024,16 @@ packages: '@types/express@5.0.5': resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==} + '@types/fs-extra@11.0.4': + resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/ioredis@5.0.0': + resolution: {integrity: sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==} + deprecated: This is a stub types definition. ioredis provides its own type definitions, so you do not need this installed. + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -996,6 +1049,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonfile@6.1.4': + resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} @@ -1008,6 +1064,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@2.0.0': + resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} @@ -1044,6 +1103,10 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/uuid@11.0.0': + resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} + deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed. + '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} @@ -2012,6 +2075,10 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} @@ -2119,6 +2186,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2916,6 +2987,10 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redlock@5.0.0-beta.2: + resolution: {integrity: sha512-2RDWXg5jgRptDrB1w9O/JgSZC0j7y4SlaXnor93H/UJm/QyDiFgBKNtrh0TI6oCXqYSaSoXxFh6Sd3VtYfhRXw==} + engines: {node: '>=12'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -3418,6 +3493,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -4403,6 +4482,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) + '@nestjs/throttler@6.4.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + '@nestjs/typeorm@11.0.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(ioredis@5.8.2)(mysql2@3.15.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))': dependencies: '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -4536,8 +4621,19 @@ snapshots: '@types/express-serve-static-core': 5.1.0 '@types/serve-static': 1.15.10 + '@types/fs-extra@11.0.4': + dependencies: + '@types/jsonfile': 6.1.4 + '@types/node': 22.19.1 + '@types/http-errors@2.0.5': {} + '@types/ioredis@5.0.0': + dependencies: + ioredis: 5.8.2 + transitivePeerDependencies: + - supports-color + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -4555,6 +4651,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonfile@6.1.4': + dependencies: + '@types/node': 22.19.1 + '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 @@ -4566,6 +4666,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@2.0.0': + dependencies: + '@types/express': 5.0.5 + '@types/node@22.19.1': dependencies: undici-types: 6.21.0 @@ -4617,6 +4721,10 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/uuid@11.0.0': + dependencies: + uuid: 13.0.0 + '@types/validator@13.15.10': {} '@types/yargs-parser@21.0.3': {} @@ -5652,6 +5760,12 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fs-monkey@1.1.0: {} fs.realpath@1.0.0: {} @@ -5763,6 +5877,8 @@ snapshots: dependencies: function-bind: 1.1.2 + helmet@8.1.0: {} + html-escaper@2.0.2: {} http-errors@2.0.0: @@ -6691,6 +6807,10 @@ snapshots: dependencies: redis-errors: 1.2.0 + redlock@5.0.0-beta.2: + dependencies: + node-abort-controller: 3.1.1 + reflect-metadata@0.2.2: {} require-directory@2.1.1: {} @@ -7186,6 +7306,8 @@ snapshots: uuid@11.1.0: {} + uuid@13.0.0: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 93ba30a..f897351 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,15 +1,22 @@ // File: src/app.module.ts import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; // <--- เพิ่ม Import นี้ T2.4 import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BullModule } from '@nestjs/bullmq'; // Import BullModule +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; // <--- เพิ่ม Import นี้ T2.4 import { AppController } from './app.controller'; import { AppService } from './app.service'; import { envValidationSchema } from './common/config/env.validation.js'; // สังเกต .js สำหรับ ESM -import { CommonModule } from './common/common.module'; +// import { CommonModule } from './common/common.module'; import { UserModule } from './modules/user/user.module'; import { ProjectModule } from './modules/project/project.module'; - +import { FileStorageModule } from './modules/file-storage/file-storage.module'; +import { DocumentNumberingModule } from './modules/document-numbering/document-numbering.module'; +import { AuthModule } from './common/auth/auth.module.js'; // <--- เพิ่ม Import นี้ T2.4 +import { JsonSchemaModule } from './modules/json-schema/json-schema.module.js'; +import { WorkflowEngineModule } from './modules/workflow-engine/workflow-engine.module'; +import { CorrespondenceModule } from './modules/correspondence/correspondence.module'; @Module({ imports: [ // 1. Setup Config Module พร้อม Validation @@ -22,6 +29,13 @@ import { ProjectModule } from './modules/project/project.module'; abortEarly: true, }, }), + // 🛡️ T2.4 1. Setup Throttler Module (Rate Limiting) + ThrottlerModule.forRoot([ + { + ttl: 60000, // 60 วินาที (Time to Live) + limit: 100, // ยิงได้สูงสุด 100 ครั้ง (Global Default) + }, + ]), // 2. Setup TypeORM (MariaDB) TypeOrmModule.forRootAsync({ @@ -39,7 +53,7 @@ import { ProjectModule } from './modules/project/project.module'; // synchronize: configService.get('NODE_ENV') === 'development', // แก้บรรทัดนี้เป็น false ครับ // เพราะเราใช้ SQL Script สร้าง DB แล้ว ไม่ต้องการให้ TypeORM มาแก้ Structure อัตโนมัติ - synchronize: false, + synchronize: false, // เราใช้ false ตามที่ตกลงกัน }), }), @@ -55,14 +69,24 @@ import { ProjectModule } from './modules/project/project.module'; }, }), }), - - CommonModule, - + AuthModule, + // CommonModule, UserModule, - ProjectModule, + FileStorageModule, + DocumentNumberingModule, + JsonSchemaModule, + WorkflowEngineModule, + CorrespondenceModule, // <--- เพิ่ม ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + // 🛡️ 2. Register Global Guard + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], }) export class AppModule {} diff --git a/backend/src/common/auth/auth.controller.ts b/backend/src/common/auth/auth.controller.ts index ab3caf8..bcbe8ba 100644 --- a/backend/src/common/auth/auth.controller.ts +++ b/backend/src/common/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; // <--- ✅ เพิ่มบรรทัดนี้ครับ import { AuthService } from './auth.service.js'; import { LoginDto } from './dto/login.dto.js'; // <--- Import DTO import { RegisterDto } from './dto/register.dto.js'; // <--- Import DTO @@ -8,6 +9,8 @@ export class AuthController { constructor(private authService: AuthService) {} @Post('login') + // เพิ่มความเข้มงวดให้ Login (กัน Brute Force) + @Throttle({ default: { limit: 10, ttl: 60000 } }) // 🔒 ให้ลองได้แค่ 5 ครั้ง ใน 1 นาที // เปลี่ยน @Body() req เป็น @Body() loginDto: LoginDto async login(@Body() loginDto: LoginDto) { const user = await this.authService.validateUser( @@ -27,4 +30,11 @@ export class AuthController { async register(@Body() registerDto: RegisterDto) { return this.authService.register(registerDto); } + /*ตัวอย่าง: ยกเว้นการนับ (เช่น Health Check) +import { SkipThrottle } from '@nestjs/throttler'; + +@SkipThrottle() +@Get('health') +check() { ... } +*/ } diff --git a/backend/src/main.ts b/backend/src/main.ts index 0b8507b..5072b09 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -3,10 +3,20 @@ import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import { TransformInterceptor } from './common/interceptors/transform.interceptor.js'; // อย่าลืม .js ถ้าใช้ ESM import { HttpExceptionFilter } from './common/exceptions/http-exception.filter.js'; +import helmet from 'helmet'; // <--- Import Helmet async function bootstrap() { const app = await NestFactory.create(AppModule); + // 🛡️ 1. เปิดใช้งาน Helmet (Security Headers) + app.use(helmet()); + // 🛡️ 2. เปิดใช้งาน CORS (เพื่อให้ Frontend จากโดเมนอื่นเรียกใช้ได้) + // ใน Production ควรระบุ origin ให้ชัดเจน แทนที่จะเป็น * + app.enableCors({ + origin: true, // หรือระบุเช่น ['https://lcbp3.np-dms.work'] + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + credentials: true, + }); // 1. Global Prefix (เช่น /api/v1) app.setGlobalPrefix('api'); diff --git a/backend/src/modules/correspondence/correspondence.controller.spec.ts b/backend/src/modules/correspondence/correspondence.controller.spec.ts new file mode 100644 index 0000000..99337d8 --- /dev/null +++ b/backend/src/modules/correspondence/correspondence.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CorrespondenceController } from './correspondence.controller'; + +describe('CorrespondenceController', () => { + let controller: CorrespondenceController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CorrespondenceController], + }).compile(); + + controller = module.get(CorrespondenceController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/src/modules/correspondence/correspondence.controller.ts b/backend/src/modules/correspondence/correspondence.controller.ts new file mode 100644 index 0000000..fa8f6ab --- /dev/null +++ b/backend/src/modules/correspondence/correspondence.controller.ts @@ -0,0 +1,31 @@ +import { + Controller, + Get, + Post, + Body, + UseGuards, + Request, +} from '@nestjs/common'; +import { CorrespondenceService } from './correspondence.service.js'; +import { CreateCorrespondenceDto } from './dto/create-correspondence.dto.js'; +import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js'; +import { RbacGuard } from '../../common/auth/rbac.guard.js'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator.js'; + +@Controller('correspondences') +@UseGuards(JwtAuthGuard, RbacGuard) +export class CorrespondenceController { + constructor(private readonly correspondenceService: CorrespondenceService) {} + + @Post() + @RequirePermission('correspondence.create') // 🔒 ต้องมีสิทธิ์สร้าง + create(@Body() createDto: CreateCorrespondenceDto, @Request() req: any) { + return this.correspondenceService.create(createDto, req.user); + } + + @Get() + @RequirePermission('document.view') // 🔒 ต้องมีสิทธิ์ดู + findAll() { + return this.correspondenceService.findAll(); + } +} diff --git a/backend/src/modules/correspondence/correspondence.module.ts b/backend/src/modules/correspondence/correspondence.module.ts new file mode 100644 index 0000000..9f35cce --- /dev/null +++ b/backend/src/modules/correspondence/correspondence.module.ts @@ -0,0 +1,39 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CorrespondenceService } from './correspondence.service.js'; +import { CorrespondenceController } from './correspondence.controller.js'; +import { Correspondence } from './entities/correspondence.entity.js'; +import { CorrespondenceRevision } from './entities/correspondence-revision.entity.js'; +import { CorrespondenceType } from './entities/correspondence-type.entity.js'; +// Import Entities ใหม่ +import { RoutingTemplate } from './entities/routing-template.entity.js'; +import { RoutingTemplateStep } from './entities/routing-template-step.entity.js'; +import { CorrespondenceRouting } from './entities/correspondence-routing.entity.js'; + +import { CorrespondenceStatus } from './entities/correspondence-status.entity.js'; +import { DocumentNumberingModule } from '../document-numbering/document-numbering.module.js'; // ต้องใช้ตอน Create +import { JsonSchemaModule } from '../json-schema/json-schema.module.js'; // ต้องใช้ Validate Details +import { UserModule } from '../user/user.module.js'; // <--- 1. Import UserModule +import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module.js'; // <--- ✅ เพิ่มบรรทัดนี้ครับ + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Correspondence, + CorrespondenceRevision, + CorrespondenceType, + CorrespondenceStatus, + RoutingTemplate, // <--- ลงทะเบียน + RoutingTemplateStep, // <--- ลงทะเบียน + CorrespondenceRouting, // <--- ลงทะเบียน + ]), + DocumentNumberingModule, // Import เพื่อขอเลขที่เอกสาร + JsonSchemaModule, // Import เพื่อ Validate JSON + UserModule, // <--- 2. ใส่ UserModule ใน imports เพื่อให้ RbacGuard ทำงานได้ + WorkflowEngineModule, // <--- Import WorkflowEngine + ], + controllers: [CorrespondenceController], + providers: [CorrespondenceService], + exports: [CorrespondenceService], +}) +export class CorrespondenceModule {} diff --git a/backend/src/modules/correspondence/correspondence.service.spec.ts b/backend/src/modules/correspondence/correspondence.service.spec.ts new file mode 100644 index 0000000..60cc841 --- /dev/null +++ b/backend/src/modules/correspondence/correspondence.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CorrespondenceService } from './correspondence.service'; + +describe('CorrespondenceService', () => { + let service: CorrespondenceService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CorrespondenceService], + }).compile(); + + service = module.get(CorrespondenceService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/modules/correspondence/correspondence.service.ts b/backend/src/modules/correspondence/correspondence.service.ts new file mode 100644 index 0000000..5af36da --- /dev/null +++ b/backend/src/modules/correspondence/correspondence.service.ts @@ -0,0 +1,250 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + InternalServerErrorException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; + +// Entities +import { Correspondence } from './entities/correspondence.entity.js'; +import { CorrespondenceRevision } from './entities/correspondence-revision.entity.js'; +import { CorrespondenceType } from './entities/correspondence-type.entity.js'; +import { CorrespondenceStatus } from './entities/correspondence-status.entity.js'; +import { RoutingTemplate } from './entities/routing-template.entity.js'; +import { CorrespondenceRouting } from './entities/correspondence-routing.entity.js'; +import { User } from '../user/entities/user.entity.js'; + +// DTOs +import { CreateCorrespondenceDto } from './dto/create-correspondence.dto.js'; + +// Services +import { DocumentNumberingService } from '../document-numbering/document-numbering.service.js'; +import { JsonSchemaService } from '../json-schema/json-schema.service.js'; +import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service.js'; + +@Injectable() +export class CorrespondenceService { + constructor( + @InjectRepository(Correspondence) + private correspondenceRepo: Repository, + @InjectRepository(CorrespondenceRevision) + private revisionRepo: Repository, + @InjectRepository(CorrespondenceType) + private typeRepo: Repository, + @InjectRepository(CorrespondenceStatus) + private statusRepo: Repository, + @InjectRepository(RoutingTemplate) + private templateRepo: Repository, + @InjectRepository(CorrespondenceRouting) + private routingRepo: Repository, + + private numberingService: DocumentNumberingService, + private jsonSchemaService: JsonSchemaService, + private workflowEngine: WorkflowEngineService, + private dataSource: DataSource, + ) {} + + /** + * สร้างเอกสารใหม่ (Create Correspondence) + * - ตรวจสอบสิทธิ์และข้อมูลพื้นฐาน + * - Validate JSON Details ตาม Type + * - ขอเลขที่เอกสาร (Redis Lock) + * - บันทึกข้อมูลลง DB (Transaction) + */ + async create(createDto: CreateCorrespondenceDto, user: User) { + // 1. ตรวจสอบข้อมูลพื้นฐาน (Type, Status, Org) + const type = await this.typeRepo.findOne({ + where: { id: createDto.typeId }, + }); + if (!type) throw new NotFoundException('Document Type not found'); + + const statusDraft = await this.statusRepo.findOne({ + where: { statusCode: 'DRAFT' }, + }); + if (!statusDraft) { + throw new InternalServerErrorException( + 'Status DRAFT not found in Master Data', + ); + } + + const userOrgId = user.primaryOrganizationId; + if (!userOrgId) { + throw new BadRequestException( + 'User must belong to an organization to create documents', + ); + } + + // 2. Validate JSON Details (ถ้ามี) + if (createDto.details) { + try { + // ใช้ Type Code เป็น Key ในการค้นหา Schema (เช่น 'RFA', 'LETTER') + await this.jsonSchemaService.validate(type.typeCode, createDto.details); + } catch (error: any) { + // บันทึก Warning หรือ Throw Error ตามนโยบาย (ในที่นี้ให้ผ่านไปก่อนถ้ายังไม่สร้าง Schema) + console.warn( + `Schema validation warning for ${type.typeCode}: ${error.message}`, + ); + } + } + + // 3. เริ่ม Transaction + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // 3.1 ขอเลขที่เอกสาร (Double-Lock Mechanism) + // Mock ค่า replacements ไว้ก่อน (จริงๆ ต้อง Join เอา Org Code มา) + const docNumber = await this.numberingService.generateNextNumber( + createDto.projectId, + userOrgId, + createDto.typeId, + new Date().getFullYear(), + { + TYPE_CODE: type.typeCode, + ORG_CODE: 'ORG', // TODO: Fetch real organization code + }, + ); + + // 3.2 สร้าง Correspondence (หัวจดหมาย) + const correspondence = queryRunner.manager.create(Correspondence, { + correspondenceNumber: docNumber, + correspondenceTypeId: createDto.typeId, + projectId: createDto.projectId, + originatorId: userOrgId, + isInternal: createDto.isInternal || false, + createdBy: user.user_id, + }); + const savedCorr = await queryRunner.manager.save(correspondence); + + // 3.3 สร้าง Revision แรก (Rev 0) + const revision = queryRunner.manager.create(CorrespondenceRevision, { + correspondenceId: savedCorr.id, + revisionNumber: 0, + revisionLabel: 'A', + isCurrent: true, + statusId: statusDraft.id, + title: createDto.title, + details: createDto.details, + createdBy: user.user_id, + }); + await queryRunner.manager.save(revision); + + // 4. Commit Transaction + await queryRunner.commitTransaction(); + + return { + ...savedCorr, + currentRevision: revision, + }; + } catch (err) { + // Rollback หากเกิดข้อผิดพลาด + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + /** + * ดึงข้อมูลเอกสารทั้งหมด (สำหรับ List Page) + */ + async findAll() { + return this.correspondenceRepo.find({ + relations: ['revisions', 'type', 'project', 'originator'], + order: { createdAt: 'DESC' }, + }); + } + + /** + * ดึงข้อมูลเอกสารรายตัว (Detail Page) + */ + async findOne(id: number) { + const correspondence = await this.correspondenceRepo.findOne({ + where: { id }, + relations: ['revisions', 'type', 'project', 'originator'], + }); + + if (!correspondence) { + throw new NotFoundException(`Correspondence with ID ${id} not found`); + } + + return correspondence; + } + + /** + * ส่งเอกสาร (Submit) เพื่อเริ่ม Workflow การอนุมัติ/ส่งต่อ + */ + async submit(correspondenceId: number, templateId: number, user: User) { + // 1. ดึงข้อมูลเอกสารและหา Revision ปัจจุบัน + const correspondence = await this.correspondenceRepo.findOne({ + where: { id: correspondenceId }, + relations: ['revisions'], + }); + + if (!correspondence) { + throw new NotFoundException('Correspondence not found'); + } + + // หา Revision ที่เป็น current + const currentRevision = correspondence.revisions?.find((r) => r.isCurrent); + if (!currentRevision) { + throw new NotFoundException('Current revision not found'); + } + + // 2. ดึงข้อมูล Template และ Steps + const template = await this.templateRepo.findOne({ + where: { id: templateId }, + relations: ['steps'], + order: { steps: { sequence: 'ASC' } }, + }); + + if (!template || !template.steps?.length) { + throw new BadRequestException( + 'Invalid routing template or no steps defined', + ); + } + + // 3. เริ่ม Transaction + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const firstStep = template.steps[0]; + + // 3.1 สร้าง Routing Record แรก (Log การส่งต่อ) + const routing = queryRunner.manager.create(CorrespondenceRouting, { + correspondenceId: currentRevision.id, // เชื่อมกับ Revision ID + sequence: 1, + fromOrganizationId: user.primaryOrganizationId, + toOrganizationId: firstStep.toOrganizationId, + stepPurpose: firstStep.stepPurpose, + status: 'SENT', // สถานะเริ่มต้นของการส่ง + dueDate: new Date( + Date.now() + (firstStep.expectedDays || 7) * 24 * 60 * 60 * 1000, + ), + processedByUserId: user.user_id, // ผู้ส่ง (User ปัจจุบัน) + processedAt: new Date(), + }); + await queryRunner.manager.save(routing); + + // 3.2 (Optional) อัปเดตสถานะของ Revision เป็น 'SUBMITTED' + // const statusSubmitted = await this.statusRepo.findOne({ where: { statusCode: 'SUBMITTED' } }); + // if (statusSubmitted) { + // currentRevision.statusId = statusSubmitted.id; + // await queryRunner.manager.save(currentRevision); + // } + + await queryRunner.commitTransaction(); + return routing; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } +} diff --git a/backend/src/modules/correspondence/dto/create-correspondence.dto.ts b/backend/src/modules/correspondence/dto/create-correspondence.dto.ts new file mode 100644 index 0000000..2ff9e26 --- /dev/null +++ b/backend/src/modules/correspondence/dto/create-correspondence.dto.ts @@ -0,0 +1,35 @@ +import { + IsInt, + IsString, + IsNotEmpty, + IsOptional, + IsBoolean, + IsObject, +} from 'class-validator'; + +export class CreateCorrespondenceDto { + @IsInt() + @IsNotEmpty() + projectId!: number; + + @IsInt() + @IsNotEmpty() + typeId!: number; // ID ของประเภทเอกสาร (เช่น RFA, LETTER) + + @IsString() + @IsNotEmpty() + title!: string; + + @IsObject() + @IsOptional() + details?: Record; // ข้อมูล JSON (เช่น RFI question) + + @IsBoolean() + @IsOptional() + isInternal?: boolean; + + // (Optional) ถ้าจะมีการแนบไฟล์มาด้วยเลย + // @IsArray() + // @IsString({ each: true }) + // attachmentTempIds?: string[]; +} diff --git a/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts b/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts new file mode 100644 index 0000000..5bc20e0 --- /dev/null +++ b/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { Correspondence } from './correspondence.entity.js'; +import { CorrespondenceStatus } from './correspondence-status.entity.js'; +import { User } from '../../user/entities/user.entity.js'; + +@Entity('correspondence_revisions') +export class CorrespondenceRevision { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'correspondence_id' }) + correspondenceId!: number; + + @Column({ name: 'revision_number' }) + revisionNumber!: number; // 0, 1, 2... + + @Column({ name: 'revision_label', nullable: true, length: 10 }) + revisionLabel?: string; // A, B, 001... + + @Column({ name: 'is_current', default: false }) + isCurrent!: boolean; + + @Column({ name: 'correspondence_status_id' }) + statusId!: number; + + @Column({ length: 255 }) + title!: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'json', nullable: true }) + details?: any; // เก็บข้อมูลแบบ Dynamic ตาม Type + + // Dates + @Column({ name: 'document_date', type: 'date', nullable: true }) + documentDate?: Date; + + @Column({ name: 'issued_date', type: 'datetime', nullable: true }) + issuedDate?: Date; + + @Column({ name: 'received_date', type: 'datetime', nullable: true }) + receivedDate?: Date; + + @Column({ name: 'due_date', type: 'datetime', nullable: true }) + dueDate?: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @Column({ name: 'created_by', nullable: true }) + createdBy?: number; + + // Relations + @ManyToOne(() => Correspondence, (corr) => corr.revisions, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'correspondence_id' }) + correspondence?: Correspondence; + + @ManyToOne(() => CorrespondenceStatus) + @JoinColumn({ name: 'correspondence_status_id' }) + status?: CorrespondenceStatus; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + creator?: User; +} diff --git a/backend/src/modules/correspondence/entities/correspondence-routing.entity.ts b/backend/src/modules/correspondence/entities/correspondence-routing.entity.ts new file mode 100644 index 0000000..a769d28 --- /dev/null +++ b/backend/src/modules/correspondence/entities/correspondence-routing.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { CorrespondenceRevision } from './correspondence-revision.entity.js'; +import { Organization } from '../../project/entities/organization.entity.js'; +import { User } from '../../user/entities/user.entity.js'; + +@Entity('correspondence_routings') +export class CorrespondenceRouting { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'correspondence_id' }) + correspondenceId!: number; // FK -> CorrespondenceRevision + + @Column() + sequence!: number; + + @Column({ name: 'from_organization_id' }) + fromOrganizationId!: number; + + @Column({ name: 'to_organization_id' }) + toOrganizationId!: number; + + @Column({ name: 'step_purpose', default: 'FOR_REVIEW' }) + stepPurpose!: string; + + @Column({ default: 'SENT' }) + status!: string; // SENT, RECEIVED, ACTIONED, FORWARDED, REPLIED + + @Column({ type: 'text', nullable: true }) + comments?: string; + + @Column({ name: 'due_date', type: 'datetime', nullable: true }) + dueDate?: Date; + + @Column({ name: 'processed_by_user_id', nullable: true }) + processedByUserId?: number; + + @Column({ name: 'processed_at', type: 'datetime', nullable: true }) + processedAt?: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + // Relations + @ManyToOne(() => CorrespondenceRevision, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'correspondence_id' }) + correspondenceRevision?: CorrespondenceRevision; + + @ManyToOne(() => Organization) + @JoinColumn({ name: 'from_organization_id' }) + fromOrganization?: Organization; + + @ManyToOne(() => Organization) + @JoinColumn({ name: 'to_organization_id' }) + toOrganization?: Organization; + + @ManyToOne(() => User) + @JoinColumn({ name: 'processed_by_user_id' }) + processedBy?: User; +} diff --git a/backend/src/modules/correspondence/entities/correspondence-status.entity.ts b/backend/src/modules/correspondence/entities/correspondence-status.entity.ts new file mode 100644 index 0000000..ec48500 --- /dev/null +++ b/backend/src/modules/correspondence/entities/correspondence-status.entity.ts @@ -0,0 +1,19 @@ +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('correspondence_status') +export class CorrespondenceStatus { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'status_code', unique: true, length: 50 }) + statusCode!: string; // เช่น DRAFT, SUBOWN + + @Column({ name: 'status_name', length: 255 }) + statusName!: string; + + @Column({ name: 'sort_order', default: 0 }) + sortOrder!: number; + + @Column({ name: 'is_active', default: true, type: 'tinyint' }) + isActive!: boolean; +} diff --git a/backend/src/modules/correspondence/entities/correspondence-type.entity.ts b/backend/src/modules/correspondence/entities/correspondence-type.entity.ts new file mode 100644 index 0000000..628b35f --- /dev/null +++ b/backend/src/modules/correspondence/entities/correspondence-type.entity.ts @@ -0,0 +1,19 @@ +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('correspondence_types') +export class CorrespondenceType { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'type_code', unique: true, length: 50 }) + typeCode!: string; // เช่น RFA, RFI, LETTER + + @Column({ name: 'type_name', length: 255 }) + typeName!: string; + + @Column({ name: 'sort_order', default: 0 }) + sortOrder!: number; + + @Column({ name: 'is_active', default: true, type: 'tinyint' }) + isActive!: boolean; +} diff --git a/backend/src/modules/correspondence/entities/correspondence.entity.ts b/backend/src/modules/correspondence/entities/correspondence.entity.ts new file mode 100644 index 0000000..65d9612 --- /dev/null +++ b/backend/src/modules/correspondence/entities/correspondence.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + OneToMany, + DeleteDateColumn, + CreateDateColumn, +} from 'typeorm'; +import { Project } from '../../project/entities/project.entity.js'; +import { Organization } from '../../project/entities/organization.entity.js'; +import { CorrespondenceType } from './correspondence-type.entity.js'; +import { User } from '../../user/entities/user.entity.js'; +import { CorrespondenceRevision } from './correspondence-revision.entity.js'; // เดี๋ยวสร้าง + +@Entity('correspondences') +export class Correspondence { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'correspondence_number', length: 100 }) + correspondenceNumber!: string; + + @Column({ name: 'correspondence_type_id' }) + correspondenceTypeId!: number; + + @Column({ name: 'project_id' }) + projectId!: number; + + @Column({ name: 'originator_id', nullable: true }) + originatorId?: number; + + @Column({ + name: 'is_internal_communication', + default: false, + type: 'tinyint', + }) + isInternal!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @Column({ name: 'created_by', nullable: true }) + createdBy?: number; + + @DeleteDateColumn({ name: 'deleted_at', select: false }) + deletedAt?: Date; + + // Relations + @ManyToOne(() => CorrespondenceType) + @JoinColumn({ name: 'correspondence_type_id' }) + type?: CorrespondenceType; + + @ManyToOne(() => Project) + @JoinColumn({ name: 'project_id' }) + project?: Project; + + @ManyToOne(() => Organization) + @JoinColumn({ name: 'originator_id' }) + originator?: Organization; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + creator?: User; + + // One Correspondence has Many Revisions + @OneToMany( + () => CorrespondenceRevision, + (revision) => revision.correspondence, + ) + revisions?: CorrespondenceRevision[]; +} diff --git a/backend/src/modules/correspondence/entities/routing-template-step.entity.ts b/backend/src/modules/correspondence/entities/routing-template-step.entity.ts new file mode 100644 index 0000000..8d1f677 --- /dev/null +++ b/backend/src/modules/correspondence/entities/routing-template-step.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { RoutingTemplate } from './routing-template.entity.js'; +import { Organization } from '../../project/entities/organization.entity.js'; + +@Entity('correspondence_routing_template_steps') +export class RoutingTemplateStep { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'template_id' }) + templateId!: number; + + @Column() + sequence!: number; + + @Column({ name: 'to_organization_id' }) + toOrganizationId!: number; + + @Column({ name: 'step_purpose', default: 'FOR_REVIEW' }) + stepPurpose!: string; // FOR_APPROVAL, FOR_REVIEW + + @Column({ name: 'expected_days', nullable: true }) + expectedDays?: number; + + @ManyToOne(() => RoutingTemplate, (t) => t.steps, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'template_id' }) + template?: RoutingTemplate; + + @ManyToOne(() => Organization) + @JoinColumn({ name: 'to_organization_id' }) + toOrganization?: Organization; +} diff --git a/backend/src/modules/correspondence/entities/routing-template.entity.ts b/backend/src/modules/correspondence/entities/routing-template.entity.ts new file mode 100644 index 0000000..bc30a24 --- /dev/null +++ b/backend/src/modules/correspondence/entities/routing-template.entity.ts @@ -0,0 +1,27 @@ +import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; +import { BaseEntity } from '../../../common/entities/base.entity.js'; // ถ้าไม่ได้ใช้ BaseEntity ก็ลบออกแล้วใส่ createdAt เอง +import { RoutingTemplateStep } from './routing-template-step.entity.js'; // เดี๋ยวสร้าง + +@Entity('correspondence_routing_templates') +export class RoutingTemplate { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'template_name', length: 255 }) + templateName!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'project_id', nullable: true }) + projectId?: number; // NULL = แม่แบบทั่วไป + + @Column({ name: 'is_active', default: true }) + isActive!: boolean; + + @Column({ type: 'json', nullable: true, name: 'workflow_config' }) + workflowConfig?: any; + + @OneToMany(() => RoutingTemplateStep, (step) => step.template) + steps?: RoutingTemplateStep[]; +} diff --git a/backend/src/modules/document-numbering/document-numbering.module.ts b/backend/src/modules/document-numbering/document-numbering.module.ts new file mode 100644 index 0000000..6ae8934 --- /dev/null +++ b/backend/src/modules/document-numbering/document-numbering.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DocumentNumberingService } from './document-numbering.service.js'; +import { DocumentNumberFormat } from './entities/document-number-format.entity.js'; +import { DocumentNumberCounter } from './entities/document-number-counter.entity.js'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([DocumentNumberFormat, DocumentNumberCounter]), + ], + providers: [DocumentNumberingService], + exports: [DocumentNumberingService], // Export ให้คนอื่นเรียกใช้ +}) +export class DocumentNumberingModule {} diff --git a/backend/src/modules/document-numbering/document-numbering.service.spec.ts b/backend/src/modules/document-numbering/document-numbering.service.spec.ts new file mode 100644 index 0000000..4560cf2 --- /dev/null +++ b/backend/src/modules/document-numbering/document-numbering.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DocumentNumberingService } from './document-numbering.service'; + +describe('DocumentNumberingService', () => { + let service: DocumentNumberingService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [DocumentNumberingService], + }).compile(); + + service = module.get(DocumentNumberingService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/modules/document-numbering/document-numbering.service.ts b/backend/src/modules/document-numbering/document-numbering.service.ts new file mode 100644 index 0000000..81747c7 --- /dev/null +++ b/backend/src/modules/document-numbering/document-numbering.service.ts @@ -0,0 +1,164 @@ +import { + Injectable, + OnModuleInit, + OnModuleDestroy, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, OptimisticLockVersionMismatchError } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import Redlock from 'redlock'; +import { DocumentNumberCounter } from './entities/document-number-counter.entity.js'; +import { DocumentNumberFormat } from './entities/document-number-format.entity.js'; + +@Injectable() +export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(DocumentNumberingService.name); + private redisClient!: Redis; + private redlock!: Redlock; + + constructor( + @InjectRepository(DocumentNumberCounter) + private counterRepo: Repository, + @InjectRepository(DocumentNumberFormat) + private formatRepo: Repository, + private configService: ConfigService, + ) {} + + // 1. เริ่มต้นเชื่อมต่อ Redis และ Redlock เมื่อ Module ถูกโหลด + onModuleInit() { + this.redisClient = new Redis({ + host: this.configService.get('REDIS_HOST'), + port: this.configService.get('REDIS_PORT'), + password: this.configService.get('REDIS_PASSWORD'), + }); + + this.redlock = new Redlock([this.redisClient], { + driftFactor: 0.01, + retryCount: 10, // ลองใหม่ 10 ครั้งถ้า Lock ไม่สำเร็จ + retryDelay: 200, // รอ 200ms ก่อนลองใหม่ + retryJitter: 200, + }); + + this.logger.log('Redis & Redlock initialized for Document Numbering'); + } + + onModuleDestroy() { + this.redisClient.disconnect(); + } + + /** + * ฟังก์ชันหลักสำหรับขอเลขที่เอกสารถัดไป + * @param projectId ID โครงการ + * @param orgId ID องค์กรผู้ส่ง + * @param typeId ID ประเภทเอกสาร + * @param year ปีปัจจุบัน (ค.ศ.) + * @param replacements ค่าที่จะเอาไปแทนที่ใน Template (เช่น { ORG_CODE: 'TEAM' }) + */ + async generateNextNumber( + projectId: number, + orgId: number, + typeId: number, + year: number, + replacements: Record = {}, + ): Promise { + const resourceKey = `doc_num:${projectId}:${typeId}:${year}`; + const ttl = 5000; // Lock จะหมดอายุใน 5 วินาที (ป้องกัน Deadlock) + + let lock; + try { + // 🔒 Step 1: Redis Lock (Distributed Lock) + // ป้องกันไม่ให้ Process อื่นเข้ามายุ่งกับ Counter ตัวนี้พร้อมกัน + lock = await this.redlock.acquire([resourceKey], ttl); + + // 🔄 Step 2: Optimistic Locking Loop (Safety Net) + // เผื่อ Redis Lock หลุด หรือมีคนแทรกได้จริงๆ DB จะช่วยกันไว้อีกชั้น + const maxRetries = 3; + for (let i = 0; i < maxRetries; i++) { + try { + // 2.1 ดึง Counter ปัจจุบัน + let counter = await this.counterRepo.findOne({ + where: { projectId, originatorId: orgId, typeId, year }, + }); + + // ถ้ายังไม่มี ให้สร้างใหม่ (เริ่มที่ 0) + if (!counter) { + counter = this.counterRepo.create({ + projectId, + originatorId: orgId, + typeId, + year, + lastNumber: 0, + }); + } + + // 2.2 บวกเลข + counter.lastNumber += 1; + + // 2.3 บันทึก (จุดนี้ TypeORM จะเช็ค Version ให้เอง) + await this.counterRepo.save(counter); + + // 2.4 ถ้าบันทึกผ่าน -> สร้าง String ตาม Format + return await this.formatNumber( + projectId, + typeId, + counter.lastNumber, + replacements, + ); + } catch (err) { + // ถ้า Version ชนกัน (Optimistic Lock Error) ให้วนลูปทำใหม่ + if (err instanceof OptimisticLockVersionMismatchError) { + this.logger.warn( + `Optimistic Lock Hit! Retrying... (${i + 1}/${maxRetries})`, + ); + continue; + } + throw err; // ถ้าเป็น Error อื่น ให้โยนออกไปเลย + } + } + + throw new InternalServerErrorException( + 'Failed to generate document number after retries', + ); + } catch (err) { + this.logger.error('Error generating document number', err); + throw err; + } finally { + // 🔓 Step 3: Release Redis Lock เสมอ (ไม่ว่าจะสำเร็จหรือล้มเหลว) + if (lock) { + await lock.release().catch(() => {}); // ignore error if lock expired + } + } + } + + // Helper: แปลงเลขเป็น String ตาม Template (เช่น {ORG}-{SEQ:004}) + private async formatNumber( + projectId: number, + typeId: number, + seq: number, + replacements: Record, + ): Promise { + // 1. หา Template + const format = await this.formatRepo.findOne({ + where: { projectId, correspondenceTypeId: typeId }, + }); + + // ถ้าไม่มี Template ให้ใช้ Default: {SEQ} + let template = format ? format.formatTemplate : '{SEQ:4}'; + + // 2. แทนที่ค่าต่างๆ (ORG_CODE, TYPE_CODE, YEAR) + for (const [key, value] of Object.entries(replacements)) { + template = template.replace(new RegExp(`{${key}}`, 'g'), value); + } + + // 3. แทนที่ SEQ (รองรับรูปแบบ {SEQ:4} คือเติม 0 ข้างหน้าให้ครบ 4 หลัก) + template = template.replace(/{SEQ(?::(\d+))?}/g, (_, digits) => { + const pad = digits ? parseInt(digits, 10) : 0; + return seq.toString().padStart(pad, '0'); + }); + + return template; + } +} diff --git a/backend/src/modules/document-numbering/entities/document-number-counter.entity.ts b/backend/src/modules/document-numbering/entities/document-number-counter.entity.ts new file mode 100644 index 0000000..304f0e6 --- /dev/null +++ b/backend/src/modules/document-numbering/entities/document-number-counter.entity.ts @@ -0,0 +1,24 @@ +import { Entity, Column, PrimaryColumn, VersionColumn } from 'typeorm'; + +@Entity('document_number_counters') +export class DocumentNumberCounter { + // Composite Primary Key (Project + Org + Type + Year) + @PrimaryColumn({ name: 'project_id' }) + projectId!: number; + + @PrimaryColumn({ name: 'originator_organization_id' }) + originatorId!: number; + + @PrimaryColumn({ name: 'correspondence_type_id' }) + typeId!: number; + + @PrimaryColumn({ name: 'current_year' }) + year!: number; + + @Column({ name: 'last_number', default: 0 }) + lastNumber!: number; + + // ✨ หัวใจสำคัญของ Optimistic Lock + @VersionColumn() + version!: number; +} diff --git a/backend/src/modules/document-numbering/entities/document-number-format.entity.ts b/backend/src/modules/document-numbering/entities/document-number-format.entity.ts new file mode 100644 index 0000000..e554e49 --- /dev/null +++ b/backend/src/modules/document-numbering/entities/document-number-format.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Project } from '../../project/entities/project.entity.js'; +// เรายังไม่มี CorrespondenceType Entity เดี๋ยวสร้าง Dummy ไว้ก่อน หรือข้าม Relation ไปก่อนได้ +// แต่ตามหลักควรมี CorrespondenceType (Master Data) + +@Entity('document_number_formats') +@Unique(['projectId', 'correspondenceTypeId']) // 1 Project + 1 Type มีได้แค่ 1 Format +export class DocumentNumberFormat { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'project_id' }) + projectId!: number; + + @Column({ name: 'correspondence_type_id' }) + correspondenceTypeId!: number; + + @Column({ name: 'format_template', length: 255 }) + formatTemplate!: string; // เช่น "{ORG_CODE}-{TYPE_CODE}-{YEAR}-{SEQ:4}" + + // Relation + @ManyToOne(() => Project) + @JoinColumn({ name: 'project_id' }) + project?: Project; +} diff --git a/backend/src/modules/file-storage/entities/attachment.entity.ts b/backend/src/modules/file-storage/entities/attachment.entity.ts new file mode 100644 index 0000000..2d24c4d --- /dev/null +++ b/backend/src/modules/file-storage/entities/attachment.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity.js'; + +@Entity('attachments') +export class Attachment { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'original_filename', length: 255 }) + originalFilename!: string; + + @Column({ name: 'stored_filename', length: 255 }) + storedFilename!: string; + + @Column({ name: 'file_path', length: 500 }) + filePath!: string; + + @Column({ name: 'mime_type', length: 100 }) + mimeType!: string; + + @Column({ name: 'file_size' }) + fileSize!: number; + + @Column({ name: 'is_temporary', default: true }) + isTemporary!: boolean; + + @Column({ name: 'temp_id', length: 100, nullable: true }) + tempId?: string; + + @Column({ name: 'expires_at', type: 'datetime', nullable: true }) + expiresAt?: Date; + + @Column({ length: 64, nullable: true }) + checksum?: string; + + @Column({ name: 'uploaded_by_user_id' }) + uploadedByUserId!: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + // Relation กับ User (คนอัปโหลด) + @ManyToOne(() => User) + @JoinColumn({ name: 'uploaded_by_user_id' }) + uploadedBy?: User; +} diff --git a/backend/src/modules/file-storage/file-storage.controller.spec.ts b/backend/src/modules/file-storage/file-storage.controller.spec.ts new file mode 100644 index 0000000..17d3af1 --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FileStorageController } from './file-storage.controller'; + +describe('FileStorageController', () => { + let controller: FileStorageController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FileStorageController], + }).compile(); + + controller = module.get(FileStorageController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/src/modules/file-storage/file-storage.controller.ts b/backend/src/modules/file-storage/file-storage.controller.ts new file mode 100644 index 0000000..ceb342a --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.controller.ts @@ -0,0 +1,49 @@ +import { + Controller, + Post, + UseInterceptors, + UploadedFile, + UseGuards, + Request, + ParseFilePipe, + MaxFileSizeValidator, + FileTypeValidator, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { FileStorageService } from './file-storage.service.js'; +import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js'; + +// ✅ 1. สร้าง Interface เพื่อระบุ Type ของ Request +interface RequestWithUser { + user: { + userId: number; + username: string; + }; +} + +@Controller('files') +@UseGuards(JwtAuthGuard) +export class FileStorageController { + constructor(private readonly fileStorageService: FileStorageService) {} + + @Post('upload') + @UseInterceptors(FileInterceptor('file')) // รับ field ชื่อ 'file' + async uploadFile( + @UploadedFile( + new ParseFilePipe({ + validators: [ + new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }), // 50MB + // ตรวจสอบประเภทไฟล์ (Regex) + new FileTypeValidator({ + fileType: /(pdf|msword|openxmlformats|zip|octet-stream)/, + }), + ], + }), + ) + file: Express.Multer.File, + @Request() req: RequestWithUser, // ✅ 2. ระบุ Type ตรงนี้แทน any + ) { + // ส่ง userId จาก Token ไปด้วย + return this.fileStorageService.upload(file, req.user.userId); + } +} diff --git a/backend/src/modules/file-storage/file-storage.module.ts b/backend/src/modules/file-storage/file-storage.module.ts new file mode 100644 index 0000000..ede931f --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FileStorageService } from './file-storage.service.js'; +import { FileStorageController } from './file-storage.controller.js'; +import { Attachment } from './entities/attachment.entity.js'; + +@Module({ + imports: [TypeOrmModule.forFeature([Attachment])], + controllers: [FileStorageController], + providers: [FileStorageService], + exports: [FileStorageService], // Export ให้ Module อื่น (เช่น Correspondence) เรียกใช้ตอน Commit +}) +export class FileStorageModule {} diff --git a/backend/src/modules/file-storage/file-storage.service.spec.ts b/backend/src/modules/file-storage/file-storage.service.spec.ts new file mode 100644 index 0000000..8077d20 --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FileStorageService } from './file-storage.service'; + +describe('FileStorageService', () => { + let service: FileStorageService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [FileStorageService], + }).compile(); + + service = module.get(FileStorageService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/modules/file-storage/file-storage.service.ts b/backend/src/modules/file-storage/file-storage.service.ts new file mode 100644 index 0000000..c789a2c --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.service.ts @@ -0,0 +1,128 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { v4 as uuidv4 } from 'uuid'; +import { Attachment } from './entities/attachment.entity.js'; + +@Injectable() +export class FileStorageService { + private readonly logger = new Logger(FileStorageService.name); + private readonly uploadRoot: string; + + constructor( + @InjectRepository(Attachment) + private attachmentRepository: Repository, + private configService: ConfigService, + ) { + // ใช้ Path จริงถ้าอยู่บน Server (Production) หรือใช้ ./uploads ถ้าอยู่ Local + this.uploadRoot = + this.configService.get('NODE_ENV') === 'production' + ? '/share/dms-data' + : path.join(process.cwd(), 'uploads'); + + // สร้างโฟลเดอร์รอไว้เลยถ้ายังไม่มี + fs.ensureDirSync(path.join(this.uploadRoot, 'temp')); + } + + /** + * Phase 1: Upload (บันทึกไฟล์ลง Temp) + */ + async upload(file: Express.Multer.File, userId: number): Promise { + const tempId = uuidv4(); + const fileExt = path.extname(file.originalname); + const storedFilename = `${uuidv4()}${fileExt}`; + const tempPath = path.join(this.uploadRoot, 'temp', storedFilename); + + // 1. คำนวณ Checksum (SHA-256) เพื่อความปลอดภัยและความถูกต้องของไฟล์ + const checksum = this.calculateChecksum(file.buffer); + + // 2. บันทึกไฟล์ลง Disk (Temp Folder) + try { + await fs.writeFile(tempPath, file.buffer); + } catch (error) { + this.logger.error(`Failed to write file: ${tempPath}`, error); + throw new BadRequestException('File upload failed'); + } + + // 3. สร้าง Record ใน Database + const attachment = this.attachmentRepository.create({ + originalFilename: file.originalname, + storedFilename: storedFilename, + filePath: tempPath, // เก็บ path ปัจจุบันไปก่อน + mimeType: file.mimetype, + fileSize: file.size, + isTemporary: true, + tempId: tempId, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // หมดอายุใน 24 ชม. + checksum: checksum, + uploadedByUserId: userId, + }); + + return this.attachmentRepository.save(attachment); + } + + /** + * Phase 2: Commit (ย้ายไฟล์จาก Temp -> Permanent) + * เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save + */ + async commit(tempIds: string[]): Promise { + const attachments = await this.attachmentRepository.find({ + where: { tempId: In(tempIds), isTemporary: true }, + }); + + if (attachments.length !== tempIds.length) { + throw new NotFoundException('Some files not found or already committed'); + } + + const committedAttachments: Attachment[] = []; + const today = new Date(); + const year = today.getFullYear().toString(); + const month = (today.getMonth() + 1).toString().padStart(2, '0'); + + // โฟลเดอร์ถาวรแยกตาม ปี/เดือน + const permanentDir = path.join(this.uploadRoot, 'permanent', year, month); + await fs.ensureDir(permanentDir); + + for (const att of attachments) { + const oldPath = att.filePath; + const newPath = path.join(permanentDir, att.storedFilename); + + try { + // ย้ายไฟล์ + await fs.move(oldPath, newPath, { overwrite: true }); + + // อัปเดตข้อมูลใน DB + att.filePath = newPath; + att.isTemporary = false; + att.tempId = undefined; // เคลียร์ tempId + att.expiresAt = undefined; // เคลียร์วันหมดอายุ + + committedAttachments.push(await this.attachmentRepository.save(att)); + } catch (error) { + this.logger.error( + `Failed to move file from ${oldPath} to ${newPath}`, + error, + ); + // ถ้า error ตัวนึง ควรจะ rollback หรือ throw error (ในที่นี้ throw เพื่อให้ Transaction ของผู้เรียกจัดการ) + throw new BadRequestException( + `Failed to commit file: ${att.originalFilename}`, + ); + } + } + + return committedAttachments; + } + + private calculateChecksum(buffer: Buffer): string { + return crypto.createHash('sha256').update(buffer).digest('hex'); + } +} diff --git a/backend/src/modules/json-schema/entities/json-schema.entity.ts b/backend/src/modules/json-schema/entities/json-schema.entity.ts new file mode 100644 index 0000000..4faeea8 --- /dev/null +++ b/backend/src/modules/json-schema/entities/json-schema.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('json_schemas') +export class JsonSchema { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'schema_code', unique: true, length: 100 }) + schemaCode!: string; // เช่น 'RFA_DWG_V1' + + @Column({ default: 1 }) + version!: number; + + @Column({ name: 'schema_definition', type: 'json' }) + schemaDefinition!: any; // เก็บ JSON Schema มาตรฐาน (Draft 7/2019-09) + + @Column({ name: 'is_active', default: true }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/json-schema/json-schema.controller.spec.ts b/backend/src/modules/json-schema/json-schema.controller.spec.ts new file mode 100644 index 0000000..8d222b4 --- /dev/null +++ b/backend/src/modules/json-schema/json-schema.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JsonSchemaController } from './json-schema.controller'; + +describe('JsonSchemaController', () => { + let controller: JsonSchemaController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [JsonSchemaController], + }).compile(); + + controller = module.get(JsonSchemaController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/src/modules/json-schema/json-schema.controller.ts b/backend/src/modules/json-schema/json-schema.controller.ts new file mode 100644 index 0000000..d534447 --- /dev/null +++ b/backend/src/modules/json-schema/json-schema.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Post, Body, Param, UseGuards } from '@nestjs/common'; +import { JsonSchemaService } from './json-schema.service.js'; +import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js'; +import { RbacGuard } from '../../common/auth/rbac.guard.js'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator.js'; + +@Controller('json-schemas') +@UseGuards(JwtAuthGuard, RbacGuard) +export class JsonSchemaController { + constructor(private readonly schemaService: JsonSchemaService) {} + + @Post(':code') + @RequirePermission('system.manage_all') // เฉพาะ Superadmin หรือผู้มีสิทธิ์จัดการ System + create(@Param('code') code: string, @Body() definition: any) { + return this.schemaService.createOrUpdate(code, definition); + } + + // Endpoint สำหรับ Test Validate (Optional) + @Post(':code/validate') + @RequirePermission('document.view') + async validate(@Param('code') code: string, @Body() data: any) { + const isValid = await this.schemaService.validate(code, data); + return { valid: isValid }; + } +} diff --git a/backend/src/modules/json-schema/json-schema.module.ts b/backend/src/modules/json-schema/json-schema.module.ts new file mode 100644 index 0000000..087528b --- /dev/null +++ b/backend/src/modules/json-schema/json-schema.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JsonSchemaService } from './json-schema.service.js'; +import { JsonSchemaController } from './json-schema.controller.js'; +import { JsonSchema } from './entities/json-schema.entity.js'; +import { UserModule } from '../user/user.module.js'; // <--- 1. Import UserModule + +@Module({ + imports: [ + TypeOrmModule.forFeature([JsonSchema]), + UserModule, // <--- 2. ใส่ UserModule ใน imports + ], + controllers: [JsonSchemaController], + providers: [JsonSchemaService], + exports: [JsonSchemaService], // Export ให้ Module อื่นเรียกใช้ .validate() +}) +export class JsonSchemaModule {} diff --git a/backend/src/modules/json-schema/json-schema.service.spec.ts b/backend/src/modules/json-schema/json-schema.service.spec.ts new file mode 100644 index 0000000..af68f31 --- /dev/null +++ b/backend/src/modules/json-schema/json-schema.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { JsonSchemaService } from './json-schema.service'; + +describe('JsonSchemaService', () => { + let service: JsonSchemaService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [JsonSchemaService], + }).compile(); + + service = module.get(JsonSchemaService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/modules/json-schema/json-schema.service.ts b/backend/src/modules/json-schema/json-schema.service.ts new file mode 100644 index 0000000..6dc5e21 --- /dev/null +++ b/backend/src/modules/json-schema/json-schema.service.ts @@ -0,0 +1,101 @@ +import { + Injectable, + OnModuleInit, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import { JsonSchema } from './entities/json-schema.entity.js'; + +@Injectable() +export class JsonSchemaService implements OnModuleInit { + private ajv: Ajv; + // Cache ตัว Validator ที่ Compile แล้ว เพื่อประสิทธิภาพ + private validators = new Map(); + + constructor( + @InjectRepository(JsonSchema) + private schemaRepo: Repository, + ) { + // ตั้งค่า AJV + this.ajv = new Ajv({ allErrors: true, strict: false }); // strict: false เพื่อยืดหยุ่นกับ custom keywords + addFormats(this.ajv); // รองรับ format เช่น email, date-time + } + + onModuleInit() { + // (Optional) โหลด Schema ทั้งหมดมา Cache ตอนเริ่ม App ก็ได้ + // แต่ตอนนี้ใช้วิธี Lazy Load (โหลดเมื่อใช้) ไปก่อน + } + + /** + * ตรวจสอบข้อมูล JSON ว่าถูกต้องตาม Schema หรือไม่ + */ + async validate(schemaCode: string, data: any): Promise { + let validate = this.validators.get(schemaCode); + + // ถ้ายังไม่มีใน Cache หรือต้องการตัวล่าสุด ให้ดึงจาก DB + if (!validate) { + const schema = await this.schemaRepo.findOne({ + where: { schemaCode, isActive: true }, + }); + + if (!schema) { + throw new NotFoundException(`JSON Schema '${schemaCode}' not found`); + } + + try { + validate = this.ajv.compile(schema.schemaDefinition); + this.validators.set(schemaCode, validate); + } catch (error: any) { + throw new BadRequestException( + `Invalid Schema Definition for '${schemaCode}': ${error.message}`, + ); + } + } + + const valid = validate(data); + + if (!valid) { + // รวบรวม Error ทั้งหมดส่งกลับไป + const errors = validate.errors + ?.map((e: any) => `${e.instancePath} ${e.message}`) + .join(', '); + throw new BadRequestException(`JSON Validation Failed: ${errors}`); + } + + return true; + } + + // ฟังก์ชันสำหรับสร้าง/อัปเดต Schema (สำหรับ Admin) + async createOrUpdate(schemaCode: string, definition: any) { + // ตรวจสอบก่อนว่า Definition เป็น JSON Schema ที่ถูกต้องไหม + try { + this.ajv.compile(definition); + } catch (error: any) { + throw new BadRequestException( + `Invalid JSON Schema format: ${error.message}`, + ); + } + + let schema = await this.schemaRepo.findOne({ where: { schemaCode } }); + + if (schema) { + schema.schemaDefinition = definition; + schema.version += 1; + } else { + schema = this.schemaRepo.create({ + schemaCode, + schemaDefinition: definition, + version: 1, + }); + } + + // Clear Cache เก่า + this.validators.delete(schemaCode); + + return this.schemaRepo.save(schema); + } +} diff --git a/backend/src/modules/workflow-engine/interfaces/workflow.interface.ts b/backend/src/modules/workflow-engine/interfaces/workflow.interface.ts new file mode 100644 index 0000000..936c8c5 --- /dev/null +++ b/backend/src/modules/workflow-engine/interfaces/workflow.interface.ts @@ -0,0 +1,32 @@ +// สถานะของการดำเนินการในแต่ละขั้นตอน +export enum StepStatus { + PENDING = 'PENDING', // รอถึงคิว + IN_PROGRESS = 'IN_PROGRESS', // ถึงคิวแล้ว รอ action + COMPLETED = 'COMPLETED', // อนุมัติ/ดำเนินการเรียบร้อย + REJECTED = 'REJECTED', // ถูกปัดตก + SKIPPED = 'SKIPPED', // ถูกข้าม +} + +// การกระทำที่ผู้ใช้ทำได้ +export enum WorkflowAction { + APPROVE = 'APPROVE', // อนุมัติ / ยืนยัน / ส่งต่อ + REJECT = 'REJECT', // ปฏิเสธ (จบ workflow ทันที) + RETURN = 'RETURN', // ส่งกลับ (ไปแก้มาใหม่) + ACKNOWLEDGE = 'ACKNOWLEDGE', // รับทราบ (สำหรับ For Info) +} + +// ข้อมูลพื้นฐานของขั้นตอน (Step) ที่ Engine ต้องรู้ +export interface WorkflowStep { + sequence: number; // ลำดับที่ (1, 2, 3...) + assigneeId?: number; // User ID ที่รับผิดชอบ (ถ้าเจาะจงคน) + organizationId?: number; // Org ID ที่รับผิดชอบ (ถ้าเจาะจงหน่วยงาน) + roleId?: number; // Role ID ที่รับผิดชอบ (ถ้าเจาะจงตำแหน่ง) + status: StepStatus; // สถานะปัจจุบัน +} + +// ผลลัพธ์ที่ Engine จะบอกเราหลังจากประมวลผลเสร็จ +export interface TransitionResult { + nextStepSequence: number | null; // ขั้นตอนต่อไปคือเลขที่เท่าไหร่ (null = จบ workflow) + shouldUpdateStatus: boolean; // ต้องอัปเดตสถานะเอกสารหลักไหม? (เช่น เปลี่ยนจาก IN_REVIEW เป็น APPROVED) + documentStatus?: string; // สถานะเอกสารหลักที่ควรจะเป็น +} diff --git a/backend/src/modules/workflow-engine/workflow-engine.module.ts b/backend/src/modules/workflow-engine/workflow-engine.module.ts new file mode 100644 index 0000000..57f6727 --- /dev/null +++ b/backend/src/modules/workflow-engine/workflow-engine.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { WorkflowEngineService } from './workflow-engine.service'; + +@Module({ + providers: [WorkflowEngineService], + // ✅ เพิ่มบรรทัดนี้ เพื่ออนุญาตให้ Module อื่น (เช่น Correspondence) เรียกใช้ Service นี้ได้ + exports: [WorkflowEngineService], +}) +export class WorkflowEngineModule {} diff --git a/backend/src/modules/workflow-engine/workflow-engine.service.spec.ts b/backend/src/modules/workflow-engine/workflow-engine.service.spec.ts new file mode 100644 index 0000000..5615116 --- /dev/null +++ b/backend/src/modules/workflow-engine/workflow-engine.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WorkflowEngineService } from './workflow-engine.service'; + +describe('WorkflowEngineService', () => { + let service: WorkflowEngineService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [WorkflowEngineService], + }).compile(); + + service = module.get(WorkflowEngineService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/modules/workflow-engine/workflow-engine.service.ts b/backend/src/modules/workflow-engine/workflow-engine.service.ts new file mode 100644 index 0000000..554d05f --- /dev/null +++ b/backend/src/modules/workflow-engine/workflow-engine.service.ts @@ -0,0 +1,92 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { + WorkflowStep, + WorkflowAction, + StepStatus, + TransitionResult, +} from './interfaces/workflow.interface.js'; + +@Injectable() +export class WorkflowEngineService { + /** + * คำนวณสถานะถัดไป (Next State Transition) + * @param currentSequence ลำดับปัจจุบัน + * @param totalSteps จำนวนขั้นตอนทั้งหมด + * @param action การกระทำ (Approve/Reject/Return) + * @param returnToSequence (Optional) ถ้า Return จะให้กลับไปขั้นไหน + */ + processAction( + currentSequence: number, + totalSteps: number, + action: WorkflowAction, + returnToSequence?: number, + ): TransitionResult { + switch (action) { + case WorkflowAction.APPROVE: + case WorkflowAction.ACKNOWLEDGE: + // ถ้าเป็นขั้นตอนสุดท้าย -> จบ Workflow + if (currentSequence >= totalSteps) { + return { + nextStepSequence: null, // ไม่มีขั้นต่อไปแล้ว + shouldUpdateStatus: true, + documentStatus: 'COMPLETED', // หรือ APPROVED + }; + } + // ถ้ายังไม่จบ -> ไปขั้นต่อไป + return { + nextStepSequence: currentSequence + 1, + shouldUpdateStatus: false, + }; + + case WorkflowAction.REJECT: + // จบ Workflow ทันทีแบบไม่สวย + return { + nextStepSequence: null, + shouldUpdateStatus: true, + documentStatus: 'REJECTED', + }; + + case WorkflowAction.RETURN: + // ย้อนกลับไปขั้นตอนก่อนหน้า (หรือที่ระบุ) + const targetStep = returnToSequence || currentSequence - 1; + if (targetStep < 1) { + throw new BadRequestException('Cannot return beyond the first step'); + } + return { + nextStepSequence: targetStep, + shouldUpdateStatus: true, + documentStatus: 'REVISE_REQUIRED', // สถานะเอกสารเป็น "รอแก้ไข" + }; + + default: + throw new BadRequestException(`Invalid action: ${action}`); + } + } + + /** + * ตรวจสอบว่า 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; + } +} diff --git a/backend/src/redlock.d.ts b/backend/src/redlock.d.ts new file mode 100644 index 0000000..ebece3a --- /dev/null +++ b/backend/src/redlock.d.ts @@ -0,0 +1,28 @@ +declare module 'redlock' { + import { Redis } from 'ioredis'; + + export interface Options { + driftFactor?: number; + retryCount?: number; + retryDelay?: number; + retryJitter?: number; + automaticExtensionThreshold?: number; + } + + export interface Lock { + redlock: Redlock; + resource: string; + value: string | null; + expiration: number; + attempts: number; + release(): Promise; + extend(ttl: number): Promise; + } + + export default class Redlock { + constructor(clients: Redis[], options?: Options); + acquire(resources: string[], ttl: number): Promise; + release(lock: Lock): Promise; + quit(): Promise; + } +} diff --git a/backend/uploads/temp/5a6d4c26-84b2-4c8a-b177-9fa267651a93.pdf b/backend/uploads/temp/5a6d4c26-84b2-4c8a-b177-9fa267651a93.pdf new file mode 100644 index 0000000..e95f2b8 Binary files /dev/null and b/backend/uploads/temp/5a6d4c26-84b2-4c8a-b177-9fa267651a93.pdf differ