251120:1700 Backend T3.4
This commit is contained in:
@@ -33,7 +33,7 @@ services:
|
|||||||
container_name: lcbp3-redis-local
|
container_name: lcbp3-redis-local
|
||||||
restart: always
|
restart: always
|
||||||
# ใช้ Command นี้เพื่อตั้ง Password
|
# ใช้ Command นี้เพื่อตั้ง Password
|
||||||
command: redis-server --requirepass "redis_password_secure"
|
command: redis-server --requirepass "Center2025"
|
||||||
ports:
|
ports:
|
||||||
- '6379:6379'
|
- '6379:6379'
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -29,18 +29,27 @@
|
|||||||
"@nestjs/mapped-types": "^2.1.0",
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/throttler": "^6.4.0",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.63.2",
|
"bullmq": "^5.63.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
|
"fs-extra": "^11.3.2",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"ioredis": "^5.8.2",
|
||||||
"joi": "^18.0.1",
|
"joi": "^18.0.1",
|
||||||
|
"multer": "^2.0.2",
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
"redlock": "5.0.0-beta.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.27"
|
"typeorm": "^0.3.27",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
@@ -50,10 +59,14 @@
|
|||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
"@types/ioredis": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@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",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-prettier": "^5.2.2",
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
|||||||
122
backend/pnpm-lock.yaml
generated
122
backend/pnpm-lock.yaml
generated
@@ -35,9 +35,18 @@ importers:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.0.1
|
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)
|
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':
|
'@nestjs/typeorm':
|
||||||
specifier: ^11.0.0
|
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)))
|
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:
|
bcrypt:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@@ -50,9 +59,21 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.14.2
|
specifier: ^0.14.2
|
||||||
version: 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:
|
joi:
|
||||||
specifier: ^18.0.1
|
specifier: ^18.0.1
|
||||||
version: 18.0.1
|
version: 18.0.1
|
||||||
|
multer:
|
||||||
|
specifier: ^2.0.2
|
||||||
|
version: 2.0.2
|
||||||
mysql2:
|
mysql2:
|
||||||
specifier: ^3.15.3
|
specifier: ^3.15.3
|
||||||
version: 3.15.3
|
version: 3.15.3
|
||||||
@@ -62,6 +83,9 @@ importers:
|
|||||||
passport-jwt:
|
passport-jwt:
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
|
redlock:
|
||||||
|
specifier: 5.0.0-beta.2
|
||||||
|
version: 5.0.0-beta.2
|
||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
@@ -71,6 +95,9 @@ importers:
|
|||||||
typeorm:
|
typeorm:
|
||||||
specifier: ^0.3.27
|
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))
|
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:
|
devDependencies:
|
||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3.2.0
|
specifier: ^3.2.0
|
||||||
@@ -93,9 +120,18 @@ importers:
|
|||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.5
|
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':
|
'@types/jest':
|
||||||
specifier: ^30.0.0
|
specifier: ^30.0.0
|
||||||
version: 30.0.0
|
version: 30.0.0
|
||||||
|
'@types/multer':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.7
|
specifier: ^22.10.7
|
||||||
version: 22.19.1
|
version: 22.19.1
|
||||||
@@ -105,6 +141,9 @@ importers:
|
|||||||
'@types/supertest':
|
'@types/supertest':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
|
'@types/uuid':
|
||||||
|
specifier: ^11.0.0
|
||||||
|
version: 11.0.0
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.18.0
|
specifier: ^9.18.0
|
||||||
version: 9.39.1
|
version: 9.39.1
|
||||||
@@ -861,6 +900,13 @@ packages:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
optional: true
|
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':
|
'@nestjs/typeorm@11.0.0':
|
||||||
resolution: {integrity: sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==}
|
resolution: {integrity: sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -978,9 +1024,16 @@ packages:
|
|||||||
'@types/express@5.0.5':
|
'@types/express@5.0.5':
|
||||||
resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==}
|
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':
|
'@types/http-errors@2.0.5':
|
||||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
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':
|
'@types/istanbul-lib-coverage@2.0.6':
|
||||||
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
|
resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
|
||||||
|
|
||||||
@@ -996,6 +1049,9 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/jsonfile@6.1.4':
|
||||||
|
resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==}
|
||||||
|
|
||||||
'@types/jsonwebtoken@9.0.10':
|
'@types/jsonwebtoken@9.0.10':
|
||||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||||
|
|
||||||
@@ -1008,6 +1064,9 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
|
'@types/multer@2.0.0':
|
||||||
|
resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==}
|
||||||
|
|
||||||
'@types/node@22.19.1':
|
'@types/node@22.19.1':
|
||||||
resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==}
|
resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==}
|
||||||
|
|
||||||
@@ -1044,6 +1103,10 @@ packages:
|
|||||||
'@types/supertest@6.0.3':
|
'@types/supertest@6.0.3':
|
||||||
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
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':
|
'@types/validator@13.15.10':
|
||||||
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
||||||
|
|
||||||
@@ -2012,6 +2075,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
fs-extra@11.3.2:
|
||||||
|
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
|
||||||
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
fs-monkey@1.1.0:
|
fs-monkey@1.1.0:
|
||||||
resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==}
|
resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==}
|
||||||
|
|
||||||
@@ -2119,6 +2186,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
helmet@8.1.0:
|
||||||
|
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
html-escaper@2.0.2:
|
html-escaper@2.0.2:
|
||||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||||
|
|
||||||
@@ -2916,6 +2987,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
redlock@5.0.0-beta.2:
|
||||||
|
resolution: {integrity: sha512-2RDWXg5jgRptDrB1w9O/JgSZC0j7y4SlaXnor93H/UJm/QyDiFgBKNtrh0TI6oCXqYSaSoXxFh6Sd3VtYfhRXw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
reflect-metadata@0.2.2:
|
reflect-metadata@0.2.2:
|
||||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||||
|
|
||||||
@@ -3418,6 +3493,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
uuid@13.0.0:
|
||||||
|
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
v8-compile-cache-lib@3.0.1:
|
v8-compile-cache-lib@3.0.1:
|
||||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||||
|
|
||||||
@@ -4403,6 +4482,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
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/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)))':
|
'@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:
|
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/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/express-serve-static-core': 5.1.0
|
||||||
'@types/serve-static': 1.15.10
|
'@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/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-coverage@2.0.6': {}
|
||||||
|
|
||||||
'@types/istanbul-lib-report@3.0.3':
|
'@types/istanbul-lib-report@3.0.3':
|
||||||
@@ -4555,6 +4651,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/jsonfile@6.1.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.1
|
||||||
|
|
||||||
'@types/jsonwebtoken@9.0.10':
|
'@types/jsonwebtoken@9.0.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
@@ -4566,6 +4666,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
|
'@types/multer@2.0.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/express': 5.0.5
|
||||||
|
|
||||||
'@types/node@22.19.1':
|
'@types/node@22.19.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@@ -4617,6 +4721,10 @@ snapshots:
|
|||||||
'@types/methods': 1.1.4
|
'@types/methods': 1.1.4
|
||||||
'@types/superagent': 8.1.9
|
'@types/superagent': 8.1.9
|
||||||
|
|
||||||
|
'@types/uuid@11.0.0':
|
||||||
|
dependencies:
|
||||||
|
uuid: 13.0.0
|
||||||
|
|
||||||
'@types/validator@13.15.10': {}
|
'@types/validator@13.15.10': {}
|
||||||
|
|
||||||
'@types/yargs-parser@21.0.3': {}
|
'@types/yargs-parser@21.0.3': {}
|
||||||
@@ -5652,6 +5760,12 @@ snapshots:
|
|||||||
jsonfile: 6.2.0
|
jsonfile: 6.2.0
|
||||||
universalify: 2.0.1
|
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-monkey@1.1.0: {}
|
||||||
|
|
||||||
fs.realpath@1.0.0: {}
|
fs.realpath@1.0.0: {}
|
||||||
@@ -5763,6 +5877,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
helmet@8.1.0: {}
|
||||||
|
|
||||||
html-escaper@2.0.2: {}
|
html-escaper@2.0.2: {}
|
||||||
|
|
||||||
http-errors@2.0.0:
|
http-errors@2.0.0:
|
||||||
@@ -6691,6 +6807,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
redis-errors: 1.2.0
|
redis-errors: 1.2.0
|
||||||
|
|
||||||
|
redlock@5.0.0-beta.2:
|
||||||
|
dependencies:
|
||||||
|
node-abort-controller: 3.1.1
|
||||||
|
|
||||||
reflect-metadata@0.2.2: {}
|
reflect-metadata@0.2.2: {}
|
||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
@@ -7186,6 +7306,8 @@ snapshots:
|
|||||||
|
|
||||||
uuid@11.1.0: {}
|
uuid@11.1.0: {}
|
||||||
|
|
||||||
|
uuid@13.0.0: {}
|
||||||
|
|
||||||
v8-compile-cache-lib@3.0.1: {}
|
v8-compile-cache-lib@3.0.1: {}
|
||||||
|
|
||||||
v8-to-istanbul@9.3.0:
|
v8-to-istanbul@9.3.0:
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
// File: src/app.module.ts
|
// File: src/app.module.ts
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core'; // <--- เพิ่ม Import นี้ T2.4
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { BullModule } from '@nestjs/bullmq'; // Import BullModule
|
import { BullModule } from '@nestjs/bullmq'; // Import BullModule
|
||||||
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; // <--- เพิ่ม Import นี้ T2.4
|
||||||
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'; // สังเกต .js สำหรับ ESM
|
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 { UserModule } from './modules/user/user.module';
|
||||||
import { ProjectModule } from './modules/project/project.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
// 1. Setup Config Module พร้อม Validation
|
// 1. Setup Config Module พร้อม Validation
|
||||||
@@ -22,6 +29,13 @@ import { ProjectModule } from './modules/project/project.module';
|
|||||||
abortEarly: true,
|
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)
|
// 2. Setup TypeORM (MariaDB)
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
@@ -39,7 +53,7 @@ import { ProjectModule } from './modules/project/project.module';
|
|||||||
// synchronize: configService.get<string>('NODE_ENV') === 'development',
|
// synchronize: configService.get<string>('NODE_ENV') === 'development',
|
||||||
// แก้บรรทัดนี้เป็น false ครับ
|
// แก้บรรทัดนี้เป็น false ครับ
|
||||||
// เพราะเราใช้ SQL Script สร้าง DB แล้ว ไม่ต้องการให้ TypeORM มาแก้ Structure อัตโนมัติ
|
// เพราะเราใช้ SQL Script สร้าง DB แล้ว ไม่ต้องการให้ TypeORM มาแก้ Structure อัตโนมัติ
|
||||||
synchronize: false,
|
synchronize: false, // เราใช้ false ตามที่ตกลงกัน
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -55,14 +69,24 @@ import { ProjectModule } from './modules/project/project.module';
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
AuthModule,
|
||||||
CommonModule,
|
// CommonModule,
|
||||||
|
|
||||||
UserModule,
|
UserModule,
|
||||||
|
|
||||||
ProjectModule,
|
ProjectModule,
|
||||||
|
FileStorageModule,
|
||||||
|
DocumentNumberingModule,
|
||||||
|
JsonSchemaModule,
|
||||||
|
WorkflowEngineModule,
|
||||||
|
CorrespondenceModule, // <--- เพิ่ม
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [
|
||||||
|
AppService,
|
||||||
|
// 🛡️ 2. Register Global Guard
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
|
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
|
||||||
|
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 DTO
|
import { LoginDto } from './dto/login.dto.js'; // <--- Import DTO
|
||||||
import { RegisterDto } from './dto/register.dto.js'; // <--- Import DTO
|
import { RegisterDto } from './dto/register.dto.js'; // <--- Import DTO
|
||||||
@@ -8,6 +9,8 @@ export class AuthController {
|
|||||||
constructor(private authService: AuthService) {}
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
|
// เพิ่มความเข้มงวดให้ Login (กัน Brute Force)
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60000 } }) // 🔒 ให้ลองได้แค่ 5 ครั้ง ใน 1 นาที
|
||||||
// เปลี่ยน @Body() req เป็น @Body() loginDto: LoginDto
|
// เปลี่ยน @Body() req เป็น @Body() loginDto: LoginDto
|
||||||
async login(@Body() loginDto: LoginDto) {
|
async login(@Body() loginDto: LoginDto) {
|
||||||
const user = await this.authService.validateUser(
|
const user = await this.authService.validateUser(
|
||||||
@@ -27,4 +30,11 @@ export class AuthController {
|
|||||||
async register(@Body() registerDto: RegisterDto) {
|
async register(@Body() registerDto: RegisterDto) {
|
||||||
return this.authService.register(registerDto);
|
return this.authService.register(registerDto);
|
||||||
}
|
}
|
||||||
|
/*ตัวอย่าง: ยกเว้นการนับ (เช่น Health Check)
|
||||||
|
import { SkipThrottle } from '@nestjs/throttler';
|
||||||
|
|
||||||
|
@SkipThrottle()
|
||||||
|
@Get('health')
|
||||||
|
check() { ... }
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,20 @@ import { AppModule } from './app.module';
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor.js'; // อย่าลืม .js ถ้าใช้ ESM
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor.js'; // อย่าลืม .js ถ้าใช้ ESM
|
||||||
import { HttpExceptionFilter } from './common/exceptions/http-exception.filter.js';
|
import { HttpExceptionFilter } from './common/exceptions/http-exception.filter.js';
|
||||||
|
import helmet from 'helmet'; // <--- Import Helmet
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
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)
|
// 1. Global Prefix (เช่น /api/v1)
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
|
|||||||
@@ -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>(CorrespondenceController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
backend/src/modules/correspondence/correspondence.module.ts
Normal file
39
backend/src/modules/correspondence/correspondence.module.ts
Normal file
@@ -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 {}
|
||||||
@@ -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>(CorrespondenceService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
250
backend/src/modules/correspondence/correspondence.service.ts
Normal file
250
backend/src/modules/correspondence/correspondence.service.ts
Normal file
@@ -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<Correspondence>,
|
||||||
|
@InjectRepository(CorrespondenceRevision)
|
||||||
|
private revisionRepo: Repository<CorrespondenceRevision>,
|
||||||
|
@InjectRepository(CorrespondenceType)
|
||||||
|
private typeRepo: Repository<CorrespondenceType>,
|
||||||
|
@InjectRepository(CorrespondenceStatus)
|
||||||
|
private statusRepo: Repository<CorrespondenceStatus>,
|
||||||
|
@InjectRepository(RoutingTemplate)
|
||||||
|
private templateRepo: Repository<RoutingTemplate>,
|
||||||
|
@InjectRepository(CorrespondenceRouting)
|
||||||
|
private routingRepo: Repository<CorrespondenceRouting>,
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, any>; // ข้อมูล JSON (เช่น RFI question)
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isInternal?: boolean;
|
||||||
|
|
||||||
|
// (Optional) ถ้าจะมีการแนบไฟล์มาด้วยเลย
|
||||||
|
// @IsArray()
|
||||||
|
// @IsString({ each: true })
|
||||||
|
// attachmentTempIds?: string[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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>(DocumentNumberingService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<DocumentNumberCounter>,
|
||||||
|
@InjectRepository(DocumentNumberFormat)
|
||||||
|
private formatRepo: Repository<DocumentNumberFormat>,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// 1. เริ่มต้นเชื่อมต่อ Redis และ Redlock เมื่อ Module ถูกโหลด
|
||||||
|
onModuleInit() {
|
||||||
|
this.redisClient = new Redis({
|
||||||
|
host: this.configService.get<string>('REDIS_HOST'),
|
||||||
|
port: this.configService.get<number>('REDIS_PORT'),
|
||||||
|
password: this.configService.get<string>('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<string, string> = {},
|
||||||
|
): Promise<string> {
|
||||||
|
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<string, string>,
|
||||||
|
): Promise<string> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>(FileStorageController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
49
backend/src/modules/file-storage/file-storage.controller.ts
Normal file
49
backend/src/modules/file-storage/file-storage.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/modules/file-storage/file-storage.module.ts
Normal file
13
backend/src/modules/file-storage/file-storage.module.ts
Normal file
@@ -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 {}
|
||||||
@@ -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>(FileStorageService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
128
backend/src/modules/file-storage/file-storage.service.ts
Normal file
128
backend/src/modules/file-storage/file-storage.service.ts
Normal file
@@ -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<Attachment>,
|
||||||
|
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<Attachment> {
|
||||||
|
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<Attachment[]> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>(JsonSchemaController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
backend/src/modules/json-schema/json-schema.controller.ts
Normal file
25
backend/src/modules/json-schema/json-schema.controller.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/src/modules/json-schema/json-schema.module.ts
Normal file
17
backend/src/modules/json-schema/json-schema.module.ts
Normal file
@@ -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 {}
|
||||||
18
backend/src/modules/json-schema/json-schema.service.spec.ts
Normal file
18
backend/src/modules/json-schema/json-schema.service.spec.ts
Normal file
@@ -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>(JsonSchemaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
101
backend/src/modules/json-schema/json-schema.service.ts
Normal file
101
backend/src/modules/json-schema/json-schema.service.ts
Normal file
@@ -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<string, any>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(JsonSchema)
|
||||||
|
private schemaRepo: Repository<JsonSchema>,
|
||||||
|
) {
|
||||||
|
// ตั้งค่า AJV
|
||||||
|
this.ajv = new Ajv({ allErrors: true, strict: false }); // strict: false เพื่อยืดหยุ่นกับ custom keywords
|
||||||
|
addFormats(this.ajv); // รองรับ format เช่น email, date-time
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
// (Optional) โหลด Schema ทั้งหมดมา Cache ตอนเริ่ม App ก็ได้
|
||||||
|
// แต่ตอนนี้ใช้วิธี Lazy Load (โหลดเมื่อใช้) ไปก่อน
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ตรวจสอบข้อมูล JSON ว่าถูกต้องตาม Schema หรือไม่
|
||||||
|
*/
|
||||||
|
async validate(schemaCode: string, data: any): Promise<boolean> {
|
||||||
|
let validate = this.validators.get(schemaCode);
|
||||||
|
|
||||||
|
// ถ้ายังไม่มีใน Cache หรือต้องการตัวล่าสุด ให้ดึงจาก DB
|
||||||
|
if (!validate) {
|
||||||
|
const schema = await this.schemaRepo.findOne({
|
||||||
|
where: { schemaCode, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; // สถานะเอกสารหลักที่ควรจะเป็น
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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>(WorkflowEngineService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/redlock.d.ts
vendored
Normal file
28
backend/src/redlock.d.ts
vendored
Normal file
@@ -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<void>;
|
||||||
|
extend(ttl: number): Promise<Lock>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Redlock {
|
||||||
|
constructor(clients: Redis[], options?: Options);
|
||||||
|
acquire(resources: string[], ttl: number): Promise<Lock>;
|
||||||
|
release(lock: Lock): Promise<void>;
|
||||||
|
quit(): Promise<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
backend/uploads/temp/5a6d4c26-84b2-4c8a-b177-9fa267651a93.pdf
Normal file
BIN
backend/uploads/temp/5a6d4c26-84b2-4c8a-b177-9fa267651a93.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user