diff --git a/.vscode/settings.json b/.vscode/settings.json
index c662075..3bf0f0a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -24,5 +24,6 @@
"database": "lcbp3_dev",
"username": "root"
}
- ]
+ ],
+ "editor.fontSize": 16
}
\ No newline at end of file
diff --git a/backend.bak/.prettierrc b/backend.bak/.prettierrc
deleted file mode 100644
index a20502b..0000000
--- a/backend.bak/.prettierrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "singleQuote": true,
- "trailingComma": "all"
-}
diff --git a/backend.bak/Dockerfile b/backend.bak/Dockerfile
deleted file mode 100644
index 3812e04..0000000
--- a/backend.bak/Dockerfile
+++ /dev/null
@@ -1,48 +0,0 @@
-# File: Dockerfile
-# บันทึกการแก้ไข: (สร้างไฟล์)
-
-# --- STAGE 1: Builder ---
-# ติดตั้ง Dependencies และ Build โค้ด
-FROM node:18-alpine AS builder
-
-WORKDIR /usr/src/app
-
-# Copy package.json และ lock file
-COPY package*.json ./
-
-# ติดตั้ง Dependencies (สำหรับ Build)
-RUN npm install
-
-# Copy source code ทั้งหมด
-COPY . .
-
-# Build application
-RUN npm run build
-
-# ติดตั้งเฉพาะ Production Dependencies (สำหรับ Stage สุดท้าย)
-RUN npm prune --production
-
-# --- STAGE 2: Runner ---
-# Image สุดท้ายที่มีขนาดเล็ก
-FROM node:18-alpine
-
-WORKDIR /usr/src/app
-
-# (Security) สร้าง User ที่ไม่มีสิทธิ์ Root
-RUN addgroup -S nestjs && adduser -S nestjs -G nestjs
-USER nestjs
-
-# Copy Production Dependencies (จาก Stage 1)
-COPY --from=builder /usr/src/app/node_modules ./node_modules
-
-# Copy Build Artifacts (จาก Stage 1)
-COPY --from=builder /usr/src/app/dist ./dist
-
-# Copy package.json (เผื่อจำเป็น)
-COPY package*.json ./
-
-# เปิด Port (อ่านจาก Environment Variable)
-EXPOSE ${PORT:-3000}
-
-# รัน Application
-CMD [ "node", "dist/main" ]
\ No newline at end of file
diff --git a/backend.bak/README.md b/backend.bak/README.md
deleted file mode 100644
index 8f0f65f..0000000
--- a/backend.bak/README.md
+++ /dev/null
@@ -1,98 +0,0 @@
-
-
-
-
-[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
-[circleci-url]: https://circleci.com/gh/nestjs/nest
-
- A progressive Node.js framework for building efficient and scalable server-side applications.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-## Description
-
-[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
-
-## Project setup
-
-```bash
-$ npm install
-```
-
-## Compile and run the project
-
-```bash
-# development
-$ npm run start
-
-# watch mode
-$ npm run start:dev
-
-# production mode
-$ npm run start:prod
-```
-
-## Run tests
-
-```bash
-# unit tests
-$ npm run test
-
-# e2e tests
-$ npm run test:e2e
-
-# test coverage
-$ npm run test:cov
-```
-
-## Deployment
-
-When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
-
-If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
-
-```bash
-$ npm install -g @nestjs/mau
-$ mau deploy
-```
-
-With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
-
-## Resources
-
-Check out a few resources that may come in handy when working with NestJS:
-
-- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
-- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
-- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
-- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
-- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
-- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
-- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
-- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
-
-## Support
-
-Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
-
-## Stay in touch
-
-- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
-- Website - [https://nestjs.com](https://nestjs.com/)
-- Twitter - [@nestframework](https://twitter.com/nestframework)
-
-## License
-
-Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
diff --git a/backend.bak/docker-compose.yml b/backend.bak/docker-compose.yml
deleted file mode 100644
index a05d67e..0000000
--- a/backend.bak/docker-compose.yml
+++ /dev/null
@@ -1,62 +0,0 @@
-# File: docker-compose.yml
-# บันทึกการแก้ไข: (สร้างไฟล์)
-# (สำคัญ: ไฟล์นี้จะถูก import หรือคัดลอกไปใส่ใน UI ของ QNAP Container Station)
-
-version: '3.8'
-
-services:
- # ---------------------------------
- # Service 1: Backend (NestJS)
- # (Req 2.3)
- # ---------------------------------
- backend:
- build:
- context: ./backend # (สมมติว่า Dockerfile อยู่ในโฟลเดอร์ backend)
- dockerfile: Dockerfile
- image: lcbp3-backend:1.3.0 # (ตั้งชื่อ Image)
- container_name: lcbp3-backend
- restart: unless-stopped
-
- # (สำคัญ) กำหนด Environment Variables ที่นี่ (ห้ามใช้ .env)
- # (Req 6.5, 2.1)
- environment:
- # --- App Config ---
- - PORT=3000
- - NODE_ENV=production
-
- # --- Database (Req 2.4) ---
- # (ชี้ไปที่ Service 'mariadb' ใน Network 'lcbp3')
- - DATABASE_HOST=mariadb
- - DATABASE_PORT=3306
- - DATABASE_USER=your_db_user # (ต้องเปลี่ยน)
- - DATABASE_PASSWORD=your_db_pass # (ต้องเปลี่ยน)
- - DATABASE_NAME=lcbp3_dms
-
- # --- Security (JWT) (Req 6.5) ---
- - JWT_SECRET=YOUR_VERY_STRONG_JWT_SECRET_KEY # (ต้องเปลี่ยน)
- - JWT_EXPIRATION_TIME=3600s # (เช่น 1 ชั่วโมง)
-
- # --- Phase 4 Services ---
- - ELASTICSEARCH_URL=http://elasticsearch:9200 # (ชี้ไปที่ Service ES ถ้ามี)
- - N8N_WEBHOOK_URL=http://n8n:5678/webhook/your-webhook-id # (ชี้ไปที่ N8N)
-
- # (สำคัญ) เชื่อมต่อ Network กลาง (Req 2.1)
- networks:
- - lcbp3
-
- # (Deploy) ตั้งค่า Health Check
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/health"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 20s # (รอให้ App เริ่มก่อน)
-
-# ---------------------------------
-# Network กลาง (Req 2.1)
-# (ต้องสร้าง Network นี้ไว้ก่อนใน QNAP หรือสร้างพร้อมกัน)
-# ---------------------------------
-networks:
- lcbp3:
- external: true # (ถ้าสร้างไว้แล้ว)
- # name: lcbp3 # (ถ้าต้องการให้ Compose สร้าง)
\ No newline at end of file
diff --git a/backend.bak/eslint.config.mjs b/backend.bak/eslint.config.mjs
deleted file mode 100644
index 4e9f827..0000000
--- a/backend.bak/eslint.config.mjs
+++ /dev/null
@@ -1,35 +0,0 @@
-// @ts-check
-import eslint from '@eslint/js';
-import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
-import globals from 'globals';
-import tseslint from 'typescript-eslint';
-
-export default tseslint.config(
- {
- ignores: ['eslint.config.mjs'],
- },
- eslint.configs.recommended,
- ...tseslint.configs.recommendedTypeChecked,
- eslintPluginPrettierRecommended,
- {
- languageOptions: {
- globals: {
- ...globals.node,
- ...globals.jest,
- },
- sourceType: 'commonjs',
- parserOptions: {
- projectService: true,
- tsconfigRootDir: import.meta.dirname,
- },
- },
- },
- {
- rules: {
- '@typescript-eslint/no-explicit-any': 'off',
- '@typescript-eslint/no-floating-promises': 'warn',
- '@typescript-eslint/no-unsafe-argument': 'warn',
- "prettier/prettier": ["error", { endOfLine: "auto" }],
- },
- },
-);
diff --git a/backend.bak/nest-cli.json b/backend.bak/nest-cli.json
deleted file mode 100644
index f9aa683..0000000
--- a/backend.bak/nest-cli.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "$schema": "https://json.schemastore.org/nest-cli",
- "collection": "@nestjs/schematics",
- "sourceRoot": "src",
- "compilerOptions": {
- "deleteOutDir": true
- }
-}
diff --git a/backend.bak/package.json b/backend.bak/package.json
deleted file mode 100644
index b018b06..0000000
--- a/backend.bak/package.json
+++ /dev/null
@@ -1,98 +0,0 @@
-{
- "name": "backend",
- "version": "0.0.1",
- "description": "",
- "author": "",
- "private": true,
- "license": "UNLICENSED",
- "scripts": {
- "build": "nest build",
- "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
- "start": "nest start",
- "start:dev": "nest start --watch",
- "start:debug": "nest start --debug --watch",
- "start:prod": "node dist/main",
- "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
- "test": "jest",
- "test:watch": "jest --watch",
- "test:cov": "jest --coverage",
- "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
- "test:e2e": "jest --config ./test/jest-e2e.json"
- },
- "dependencies": {
- "@elastic/elasticsearch": "^9.2.0",
- "@nestjs/cache-manager": "^3.0.1",
- "@nestjs/common": "^11.0.1",
- "@nestjs/config": "^4.0.2",
- "@nestjs/core": "^11.0.1",
- "@nestjs/elasticsearch": "^11.1.0",
- "@nestjs/jwt": "^11.0.1",
- "@nestjs/passport": "^11.0.5",
- "@nestjs/platform-express": "^11.1.9",
- "@nestjs/schedule": "^6.0.1",
- "@nestjs/swagger": "^11.2.1",
- "@nestjs/typeorm": "^11.0.0",
- "@types/nodemailer": "^7.0.3",
- "@types/uuid": "^10.0.0",
- "bcrypt": "^6.0.0",
- "cache-manager": "^7.2.4",
- "casl": "^0.2.0",
- "class-transformer": "^0.5.1",
- "class-validator": "^0.14.2",
- "helmet": "^8.1.0",
- "multer": "^2.0.2",
- "mysql2": "^3.15.3",
- "nodemailer": "^7.0.10",
- "passport": "^0.7.0",
- "passport-jwt": "^4.0.1",
- "rate-limiter-flexible": "^8.2.1",
- "reflect-metadata": "^0.2.2",
- "rxjs": "^7.8.1",
- "typeorm": "^0.3.27",
- "uuid": "^13.0.0"
- },
- "devDependencies": {
- "@eslint/eslintrc": "^3.2.0",
- "@eslint/js": "^9.18.0",
- "@nestjs/cli": "^11.0.0",
- "@nestjs/schematics": "^11.0.0",
- "@nestjs/testing": "^11.1.9",
- "@types/express": "^5.0.0",
- "@types/jest": "^30.0.0",
- "@types/multer": "^2.0.0",
- "@types/node": "^22.10.7",
- "@types/passport-jwt": "^4.0.1",
- "@types/supertest": "^6.0.2",
- "eslint": "^9.18.0",
- "eslint-config-prettier": "^10.0.1",
- "eslint-plugin-prettier": "^5.2.2",
- "globals": "^16.0.0",
- "jest": "^30.2.0",
- "prettier": "^3.4.2",
- "source-map-support": "^0.5.21",
- "supertest": "^7.1.4",
- "ts-jest": "^29.2.5",
- "ts-loader": "^9.5.2",
- "ts-node": "^10.9.2",
- "tsconfig-paths": "^4.2.0",
- "typescript": "^5.7.3",
- "typescript-eslint": "^8.20.0"
- },
- "jest": {
- "moduleFileExtensions": [
- "js",
- "json",
- "ts"
- ],
- "rootDir": "src",
- "testRegex": ".*\\.spec\\.ts$",
- "transform": {
- "^.+\\.(t|j)s$": "ts-jest"
- },
- "collectCoverageFrom": [
- "**/*.(t|j)s"
- ],
- "coverageDirectory": "../coverage",
- "testEnvironment": "node"
- }
-}
diff --git a/backend.bak/src/app.controller.spec.ts b/backend.bak/src/app.controller.spec.ts
deleted file mode 100644
index d22f389..0000000
--- a/backend.bak/src/app.controller.spec.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Test, TestingModule } from '@nestjs/testing';
-import { AppController } from './app.controller';
-import { AppService } from './app.service';
-
-describe('AppController', () => {
- let appController: AppController;
-
- beforeEach(async () => {
- const app: TestingModule = await Test.createTestingModule({
- controllers: [AppController],
- providers: [AppService],
- }).compile();
-
- appController = app.get(AppController);
- });
-
- describe('root', () => {
- it('should return "Hello World!"', () => {
- expect(appController.getHello()).toBe('Hello World!');
- });
- });
-});
diff --git a/backend.bak/src/app.controller.ts b/backend.bak/src/app.controller.ts
deleted file mode 100644
index cce879e..0000000
--- a/backend.bak/src/app.controller.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Controller, Get } from '@nestjs/common';
-import { AppService } from './app.service';
-
-@Controller()
-export class AppController {
- constructor(private readonly appService: AppService) {}
-
- @Get()
- getHello(): string {
- return this.appService.getHello();
- }
-}
diff --git a/backend.bak/src/app.module.ts b/backend.bak/src/app.module.ts
deleted file mode 100644
index 8662803..0000000
--- a/backend.bak/src/app.module.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Module } from '@nestjs/common';
-import { AppController } from './app.controller';
-import { AppService } from './app.service';
-
-@Module({
- imports: [],
- controllers: [AppController],
- providers: [AppService],
-})
-export class AppModule {}
diff --git a/backend.bak/src/app.service.ts b/backend.bak/src/app.service.ts
deleted file mode 100644
index 927d7cc..0000000
--- a/backend.bak/src/app.service.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Injectable } from '@nestjs/common';
-
-@Injectable()
-export class AppService {
- getHello(): string {
- return 'Hello World!';
- }
-}
diff --git a/backend.bak/src/main.ts b/backend.bak/src/main.ts
deleted file mode 100644
index f76bc8d..0000000
--- a/backend.bak/src/main.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { NestFactory } from '@nestjs/core';
-import { AppModule } from './app.module';
-
-async function bootstrap() {
- const app = await NestFactory.create(AppModule);
- await app.listen(process.env.PORT ?? 3000);
-}
-bootstrap();
diff --git a/backend.bak/test/app.e2e-spec.ts b/backend.bak/test/app.e2e-spec.ts
deleted file mode 100644
index 36852c5..0000000
--- a/backend.bak/test/app.e2e-spec.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Test, TestingModule } from '@nestjs/testing';
-import { INestApplication } from '@nestjs/common';
-import request from 'supertest';
-import { App } from 'supertest/types';
-import { AppModule } from './../src/app.module';
-
-describe('AppController (e2e)', () => {
- let app: INestApplication;
-
- beforeEach(async () => {
- const moduleFixture: TestingModule = await Test.createTestingModule({
- imports: [AppModule],
- }).compile();
-
- app = moduleFixture.createNestApplication();
- await app.init();
- });
-
- it('/ (GET)', () => {
- return request(app.getHttpServer())
- .get('/')
- .expect(200)
- .expect('Hello World!');
- });
-});
diff --git a/backend.bak/test/jest-e2e.json b/backend.bak/test/jest-e2e.json
deleted file mode 100644
index e9d912f..0000000
--- a/backend.bak/test/jest-e2e.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "moduleFileExtensions": ["js", "json", "ts"],
- "rootDir": ".",
- "testEnvironment": "node",
- "testRegex": ".e2e-spec.ts$",
- "transform": {
- "^.+\\.(t|j)s$": "ts-jest"
- }
-}
diff --git a/backend.bak/tsconfig.build.json b/backend.bak/tsconfig.build.json
deleted file mode 100644
index 64f86c6..0000000
--- a/backend.bak/tsconfig.build.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "extends": "./tsconfig.json",
- "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
-}
diff --git a/backend.bak/tsconfig.json b/backend.bak/tsconfig.json
deleted file mode 100644
index aba29b0..0000000
--- a/backend.bak/tsconfig.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "compilerOptions": {
- "module": "nodenext",
- "moduleResolution": "nodenext",
- "resolvePackageJsonExports": true,
- "esModuleInterop": true,
- "isolatedModules": true,
- "declaration": true,
- "removeComments": true,
- "emitDecoratorMetadata": true,
- "experimentalDecorators": true,
- "allowSyntheticDefaultImports": true,
- "target": "ES2023",
- "sourceMap": true,
- "outDir": "./dist",
- "baseUrl": "./",
- "incremental": true,
- "skipLibCheck": true,
- "strictNullChecks": true,
- "forceConsistentCasingInFileNames": true,
- "noImplicitAny": false,
- "strictBindCallApply": false,
- "noFallthroughCasesInSwitch": false
- }
-}
diff --git a/backend/package.json b/backend/package.json
index 0b16809..87078d2 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -21,19 +21,26 @@
},
"dependencies": {
"@casl/ability": "^6.7.3",
+ "@elastic/elasticsearch": "^9.2.0",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
+ "@nestjs/elasticsearch": "^11.1.0",
"@nestjs/jwt": "^11.0.1",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
+ "@nestjs/platform-socket.io": "^11.1.9",
+ "@nestjs/schedule": "^6.0.1",
"@nestjs/swagger": "^11.2.3",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0",
+ "@nestjs/websockets": "^11.1.9",
+ "@types/nodemailer": "^7.0.4",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
+ "axios": "^1.13.2",
"bcrypt": "^6.0.0",
"bullmq": "^5.63.2",
"class-transformer": "^0.5.1",
@@ -44,11 +51,13 @@
"joi": "^18.0.1",
"multer": "^2.0.2",
"mysql2": "^3.15.3",
+ "nodemailer": "^7.0.10",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"redlock": "5.0.0-beta.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
+ "socket.io": "^4.8.1",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.27",
"uuid": "^13.0.0"
diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml
index 7f9f9fb..8e7e500 100644
--- a/backend/pnpm-lock.yaml
+++ b/backend/pnpm-lock.yaml
@@ -35,6 +35,9 @@ importers:
'@nestjs/platform-express':
specifier: ^11.0.1
version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
+ '@nestjs/schedule':
+ specifier: ^6.0.1
+ version: 6.0.1(@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/swagger':
specifier: ^11.2.3
version: 11.2.3(@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)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
@@ -44,12 +47,18 @@ importers:
'@nestjs/typeorm':
specifier: ^11.0.0
version: 11.0.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(ioredis@5.8.2)(mysql2@3.15.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))
+ '@types/nodemailer':
+ specifier: ^7.0.4
+ version: 7.0.4
ajv:
specifier: ^8.17.1
version: 8.17.1
ajv-formats:
specifier: ^3.0.1
version: 3.0.1(ajv@8.17.1)
+ axios:
+ specifier: ^1.13.2
+ version: 1.13.2
bcrypt:
specifier: ^6.0.0
version: 6.0.0
@@ -80,6 +89,9 @@ importers:
mysql2:
specifier: ^3.15.3
version: 3.15.3
+ nodemailer:
+ specifier: ^7.0.10
+ version: 7.0.10
passport:
specifier: ^0.7.0
version: 0.7.0
@@ -226,6 +238,135 @@ packages:
resolution: {integrity: sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==}
engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+ '@aws-crypto/sha256-browser@5.2.0':
+ resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==}
+
+ '@aws-crypto/sha256-js@5.2.0':
+ resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==}
+ engines: {node: '>=16.0.0'}
+
+ '@aws-crypto/supports-web-crypto@5.2.0':
+ resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==}
+
+ '@aws-crypto/util@5.2.0':
+ resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
+
+ '@aws-sdk/client-sesv2@3.938.0':
+ resolution: {integrity: sha512-GW07FQuZkW5ASm0WP+CWLetcortqup9l3+p1OlvuUN3rLBIzlWRqYd5Nf2GTS72sPbaNowE3dYJXCtwu1IlLuQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/client-sso@3.936.0':
+ resolution: {integrity: sha512-0G73S2cDqYwJVvqL08eakj79MZG2QRaB56Ul8/Ps9oQxllr7DMI1IQ/N3j3xjxgpq/U36pkoFZ8aK1n7Sbr3IQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/core@3.936.0':
+ resolution: {integrity: sha512-eGJ2ySUMvgtOziHhDRDLCrj473RJoL4J1vPjVM3NrKC/fF3/LoHjkut8AAnKmrW6a2uTzNKubigw8dEnpmpERw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-env@3.936.0':
+ resolution: {integrity: sha512-dKajFuaugEA5i9gCKzOaVy9uTeZcApE+7Z5wdcZ6j40523fY1a56khDAUYkCfwqa7sHci4ccmxBkAo+fW1RChA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-http@3.936.0':
+ resolution: {integrity: sha512-5FguODLXG1tWx/x8fBxH+GVrk7Hey2LbXV5h9SFzYCx/2h50URBm0+9hndg0Rd23+xzYe14F6SI9HA9c1sPnjg==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-ini@3.936.0':
+ resolution: {integrity: sha512-TbUv56ERQQujoHcLMcfL0Q6bVZfYF83gu/TjHkVkdSlHPOIKaG/mhE2XZSQzXv1cud6LlgeBbfzVAxJ+HPpffg==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-login@3.936.0':
+ resolution: {integrity: sha512-8DVrdRqPyUU66gfV7VZNToh56ZuO5D6agWrkLQE/xbLJOm2RbeRgh6buz7CqV8ipRd6m+zCl9mM4F3osQLZn8Q==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-node@3.936.0':
+ resolution: {integrity: sha512-rk/2PCtxX9xDsQW8p5Yjoca3StqmQcSfkmD7nQ61AqAHL1YgpSQWqHE+HjfGGiHDYKG7PvE33Ku2GyA7lEIJAw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-process@3.936.0':
+ resolution: {integrity: sha512-GpA4AcHb96KQK2PSPUyvChvrsEKiLhQ5NWjeef2IZ3Jc8JoosiedYqp6yhZR+S8cTysuvx56WyJIJc8y8OTrLA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-sso@3.936.0':
+ resolution: {integrity: sha512-wHlEAJJvtnSyxTfNhN98JcU4taA1ED2JvuI2eePgawqBwS/Tzi0mhED1lvNIaWOkjfLd+nHALwszGrtJwEq4yQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/credential-provider-web-identity@3.936.0':
+ resolution: {integrity: sha512-v3qHAuoODkoRXsAF4RG+ZVO6q2P9yYBT4GMpMEfU9wXVNn7AIfwZgTwzSUfnjNiGva5BKleWVpRpJ9DeuLFbUg==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/middleware-host-header@3.936.0':
+ resolution: {integrity: sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/middleware-logger@3.936.0':
+ resolution: {integrity: sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/middleware-recursion-detection@3.936.0':
+ resolution: {integrity: sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/middleware-sdk-s3@3.936.0':
+ resolution: {integrity: sha512-UQs/pVq4cOygsnKON0pOdSKIWkfgY0dzq4h+fR+xHi/Ng3XzxPJhWeAE6tDsKrcyQc1X8UdSbS70XkfGYr5hng==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/middleware-user-agent@3.936.0':
+ resolution: {integrity: sha512-YB40IPa7K3iaYX0lSnV9easDOLPLh+fJyUDF3BH8doX4i1AOSsYn86L4lVldmOaSX+DwiaqKHpvk4wPBdcIPWw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/nested-clients@3.936.0':
+ resolution: {integrity: sha512-eyj2tz1XmDSLSZQ5xnB7cLTVKkSJnYAEoNDSUNhzWPxrBDYeJzIbatecOKceKCU8NBf8gWWZCK/CSY0mDxMO0A==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/region-config-resolver@3.936.0':
+ resolution: {integrity: sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/signature-v4-multi-region@3.936.0':
+ resolution: {integrity: sha512-8qS0GFUqkmwO7JZ0P8tdluBmt1UTfYUah8qJXGzNh9n1Pcb0AIeT117cCSiCUtwk+gDbJvd4hhRIhJCNr5wgjg==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/token-providers@3.936.0':
+ resolution: {integrity: sha512-vvw8+VXk0I+IsoxZw0mX9TMJawUJvEsg3EF7zcCSetwhNPAU8Xmlhv7E/sN/FgSmm7b7DsqKoW6rVtQiCs1PWQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/types@3.936.0':
+ resolution: {integrity: sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/util-arn-parser@3.893.0':
+ resolution: {integrity: sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/util-endpoints@3.936.0':
+ resolution: {integrity: sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/util-locate-window@3.893.0':
+ resolution: {integrity: sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws-sdk/util-user-agent-browser@3.936.0':
+ resolution: {integrity: sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==}
+
+ '@aws-sdk/util-user-agent-node@3.936.0':
+ resolution: {integrity: sha512-XOEc7PF9Op00pWV2AYCGDSu5iHgYjIO53Py2VUQTIvP7SRCaCsXmA33mjBvC2Ms6FhSyWNa4aK4naUGIz0hQcw==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ aws-crt: '>=1.0.0'
+ peerDependenciesMeta:
+ aws-crt:
+ optional: true
+
+ '@aws-sdk/xml-builder@3.930.0':
+ resolution: {integrity: sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==}
+ engines: {node: '>=18.0.0'}
+
+ '@aws/lambda-invoke-store@0.2.1':
+ resolution: {integrity: sha512-sIyFcoPZkTtNu9xFeEoynMef3bPJIAbOfUh+ueYcfhVl6xm2VRtMcMclSxmZCMnHHd4hlYKJeq/aggmBEWynww==}
+ engines: {node: '>=18.0.0'}
+
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -891,6 +1032,12 @@ packages:
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
+ '@nestjs/schedule@6.0.1':
+ resolution: {integrity: sha512-v3yO6cSPAoBSSyH67HWnXHzuhPhSNZhRmLY38JvCt2sqY8sPMOODpcU1D79iUMFf7k16DaMEbL4Mgx61ZhiC8Q==}
+ peerDependencies:
+ '@nestjs/common': ^10.0.0 || ^11.0.0
+ '@nestjs/core': ^10.0.0 || ^11.0.0
+
'@nestjs/schematics@11.0.9':
resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==}
peerDependencies:
@@ -986,6 +1133,178 @@ packages:
'@sinonjs/fake-timers@13.0.5':
resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==}
+ '@smithy/abort-controller@4.2.5':
+ resolution: {integrity: sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/config-resolver@4.4.3':
+ resolution: {integrity: sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/core@3.18.5':
+ resolution: {integrity: sha512-6gnIz3h+PEPQGDj8MnRSjDvKBah042jEoPgjFGJ4iJLBE78L4lY/n98x14XyPF4u3lN179Ub/ZKFY5za9GeLQw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/credential-provider-imds@4.2.5':
+ resolution: {integrity: sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/fetch-http-handler@5.3.6':
+ resolution: {integrity: sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/hash-node@4.2.5':
+ resolution: {integrity: sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/invalid-dependency@4.2.5':
+ resolution: {integrity: sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/is-array-buffer@2.2.0':
+ resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
+ engines: {node: '>=14.0.0'}
+
+ '@smithy/is-array-buffer@4.2.0':
+ resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/middleware-content-length@4.2.5':
+ resolution: {integrity: sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/middleware-endpoint@4.3.12':
+ resolution: {integrity: sha512-9pAX/H+VQPzNbouhDhkW723igBMLgrI8OtX+++M7iKJgg/zY/Ig3i1e6seCcx22FWhE6Q/S61BRdi2wXBORT+A==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/middleware-retry@4.4.12':
+ resolution: {integrity: sha512-S4kWNKFowYd0lID7/DBqWHOQxmxlsf0jBaos9chQZUWTVOjSW1Ogyh8/ib5tM+agFDJ/TCxuCTvrnlc+9cIBcQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/middleware-serde@4.2.6':
+ resolution: {integrity: sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/middleware-stack@4.2.5':
+ resolution: {integrity: sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/node-config-provider@4.3.5':
+ resolution: {integrity: sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/node-http-handler@4.4.5':
+ resolution: {integrity: sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/property-provider@4.2.5':
+ resolution: {integrity: sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/protocol-http@5.3.5':
+ resolution: {integrity: sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/querystring-builder@4.2.5':
+ resolution: {integrity: sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/querystring-parser@4.2.5':
+ resolution: {integrity: sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/service-error-classification@4.2.5':
+ resolution: {integrity: sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/shared-ini-file-loader@4.4.0':
+ resolution: {integrity: sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/signature-v4@5.3.5':
+ resolution: {integrity: sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/smithy-client@4.9.8':
+ resolution: {integrity: sha512-8xgq3LgKDEFoIrLWBho/oYKyWByw9/corz7vuh1upv7ZBm0ZMjGYBhbn6v643WoIqA9UTcx5A5htEp/YatUwMA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/types@4.9.0':
+ resolution: {integrity: sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/url-parser@4.2.5':
+ resolution: {integrity: sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-base64@4.3.0':
+ resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-body-length-browser@4.2.0':
+ resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-body-length-node@4.2.1':
+ resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-buffer-from@2.2.0':
+ resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
+ engines: {node: '>=14.0.0'}
+
+ '@smithy/util-buffer-from@4.2.0':
+ resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-config-provider@4.2.0':
+ resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-defaults-mode-browser@4.3.11':
+ resolution: {integrity: sha512-yHv+r6wSQXEXTPVCIQTNmXVWs7ekBTpMVErjqZoWkYN75HIFN5y9+/+sYOejfAuvxWGvgzgxbTHa/oz61YTbKw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-defaults-mode-node@4.2.14':
+ resolution: {integrity: sha512-ljZN3iRvaJUgulfvobIuG97q1iUuCMrvXAlkZ4msY+ZuVHQHDIqn7FKZCEj+bx8omz6kF5yQXms/xhzjIO5XiA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-endpoints@3.2.5':
+ resolution: {integrity: sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-hex-encoding@4.2.0':
+ resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-middleware@4.2.5':
+ resolution: {integrity: sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-retry@4.2.5':
+ resolution: {integrity: sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-stream@4.5.6':
+ resolution: {integrity: sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-uri-escape@4.2.0':
+ resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/util-utf8@2.3.0':
+ resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
+ engines: {node: '>=14.0.0'}
+
+ '@smithy/util-utf8@4.2.0':
+ resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==}
+ engines: {node: '>=18.0.0'}
+
+ '@smithy/uuid@1.1.0':
+ resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==}
+ engines: {node: '>=18.0.0'}
+
'@sqltools/formatter@1.2.5':
resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==}
@@ -1084,6 +1403,9 @@ packages:
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
+ '@types/luxon@3.7.1':
+ resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
+
'@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
@@ -1099,6 +1421,9 @@ packages:
'@types/node@22.19.1':
resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==}
+ '@types/nodemailer@7.0.4':
+ resolution: {integrity: sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==}
+
'@types/passport-jwt@4.0.1':
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
@@ -1494,6 +1819,9 @@ packages:
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
engines: {node: '>= 6.0.0'}
+ axios@1.13.2:
+ resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
+
babel-jest@30.2.0:
resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -1540,6 +1868,9 @@ packages:
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
engines: {node: '>=18'}
+ bowser@2.12.1:
+ resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==}
+
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@@ -1760,6 +2091,10 @@ packages:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
+ cron@4.3.3:
+ resolution: {integrity: sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==}
+ engines: {node: '>=18.x'}
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2029,6 +2364,10 @@ packages:
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
+ fast-xml-parser@5.2.5:
+ resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==}
+ hasBin: true
+
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
@@ -2069,6 +2408,15 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
+ follow-redirects@1.15.11:
+ resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@@ -2809,6 +3157,10 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+ nodemailer@7.0.10:
+ resolution: {integrity: sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==}
+ engines: {node: '>=6.0.0'}
+
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -2972,6 +3324,9 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -3235,6 +3590,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
+ strnum@2.1.1:
+ resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==}
+
strtok3@10.3.4:
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
engines: {node: '>=18'}
@@ -3700,6 +4058,404 @@ snapshots:
transitivePeerDependencies:
- chokidar
+ '@aws-crypto/sha256-browser@5.2.0':
+ dependencies:
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-crypto/supports-web-crypto': 5.2.0
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-locate-window': 3.893.0
+ '@smithy/util-utf8': 2.3.0
+ tslib: 2.8.1
+
+ '@aws-crypto/sha256-js@5.2.0':
+ dependencies:
+ '@aws-crypto/util': 5.2.0
+ '@aws-sdk/types': 3.936.0
+ tslib: 2.8.1
+
+ '@aws-crypto/supports-web-crypto@5.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@aws-crypto/util@5.2.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/util-utf8': 2.3.0
+ tslib: 2.8.1
+
+ '@aws-sdk/client-sesv2@3.938.0':
+ dependencies:
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/credential-provider-node': 3.936.0
+ '@aws-sdk/middleware-host-header': 3.936.0
+ '@aws-sdk/middleware-logger': 3.936.0
+ '@aws-sdk/middleware-recursion-detection': 3.936.0
+ '@aws-sdk/middleware-user-agent': 3.936.0
+ '@aws-sdk/region-config-resolver': 3.936.0
+ '@aws-sdk/signature-v4-multi-region': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-endpoints': 3.936.0
+ '@aws-sdk/util-user-agent-browser': 3.936.0
+ '@aws-sdk/util-user-agent-node': 3.936.0
+ '@smithy/config-resolver': 4.4.3
+ '@smithy/core': 3.18.5
+ '@smithy/fetch-http-handler': 5.3.6
+ '@smithy/hash-node': 4.2.5
+ '@smithy/invalid-dependency': 4.2.5
+ '@smithy/middleware-content-length': 4.2.5
+ '@smithy/middleware-endpoint': 4.3.12
+ '@smithy/middleware-retry': 4.4.12
+ '@smithy/middleware-serde': 4.2.6
+ '@smithy/middleware-stack': 4.2.5
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/node-http-handler': 4.4.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/smithy-client': 4.9.8
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-body-length-browser': 4.2.0
+ '@smithy/util-body-length-node': 4.2.1
+ '@smithy/util-defaults-mode-browser': 4.3.11
+ '@smithy/util-defaults-mode-node': 4.2.14
+ '@smithy/util-endpoints': 3.2.5
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-retry': 4.2.5
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/client-sso@3.936.0':
+ dependencies:
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/middleware-host-header': 3.936.0
+ '@aws-sdk/middleware-logger': 3.936.0
+ '@aws-sdk/middleware-recursion-detection': 3.936.0
+ '@aws-sdk/middleware-user-agent': 3.936.0
+ '@aws-sdk/region-config-resolver': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-endpoints': 3.936.0
+ '@aws-sdk/util-user-agent-browser': 3.936.0
+ '@aws-sdk/util-user-agent-node': 3.936.0
+ '@smithy/config-resolver': 4.4.3
+ '@smithy/core': 3.18.5
+ '@smithy/fetch-http-handler': 5.3.6
+ '@smithy/hash-node': 4.2.5
+ '@smithy/invalid-dependency': 4.2.5
+ '@smithy/middleware-content-length': 4.2.5
+ '@smithy/middleware-endpoint': 4.3.12
+ '@smithy/middleware-retry': 4.4.12
+ '@smithy/middleware-serde': 4.2.6
+ '@smithy/middleware-stack': 4.2.5
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/node-http-handler': 4.4.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/smithy-client': 4.9.8
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-body-length-browser': 4.2.0
+ '@smithy/util-body-length-node': 4.2.1
+ '@smithy/util-defaults-mode-browser': 4.3.11
+ '@smithy/util-defaults-mode-node': 4.2.14
+ '@smithy/util-endpoints': 3.2.5
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-retry': 4.2.5
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/core@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/xml-builder': 3.930.0
+ '@smithy/core': 3.18.5
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/signature-v4': 5.3.5
+ '@smithy/smithy-client': 4.9.8
+ '@smithy/types': 4.9.0
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-env@3.936.0':
+ dependencies:
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-http@3.936.0':
+ dependencies:
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/fetch-http-handler': 5.3.6
+ '@smithy/node-http-handler': 4.4.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/smithy-client': 4.9.8
+ '@smithy/types': 4.9.0
+ '@smithy/util-stream': 4.5.6
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-ini@3.936.0':
+ dependencies:
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/credential-provider-env': 3.936.0
+ '@aws-sdk/credential-provider-http': 3.936.0
+ '@aws-sdk/credential-provider-login': 3.936.0
+ '@aws-sdk/credential-provider-process': 3.936.0
+ '@aws-sdk/credential-provider-sso': 3.936.0
+ '@aws-sdk/credential-provider-web-identity': 3.936.0
+ '@aws-sdk/nested-clients': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/credential-provider-imds': 4.2.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/credential-provider-login@3.936.0':
+ dependencies:
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/nested-clients': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/credential-provider-node@3.936.0':
+ dependencies:
+ '@aws-sdk/credential-provider-env': 3.936.0
+ '@aws-sdk/credential-provider-http': 3.936.0
+ '@aws-sdk/credential-provider-ini': 3.936.0
+ '@aws-sdk/credential-provider-process': 3.936.0
+ '@aws-sdk/credential-provider-sso': 3.936.0
+ '@aws-sdk/credential-provider-web-identity': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/credential-provider-imds': 4.2.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/credential-provider-process@3.936.0':
+ dependencies:
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/credential-provider-sso@3.936.0':
+ dependencies:
+ '@aws-sdk/client-sso': 3.936.0
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/token-providers': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/credential-provider-web-identity@3.936.0':
+ dependencies:
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/nested-clients': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/middleware-host-header@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-logger@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-recursion-detection@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@aws/lambda-invoke-store': 0.2.1
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-sdk-s3@3.936.0':
+ dependencies:
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-arn-parser': 3.893.0
+ '@smithy/core': 3.18.5
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/signature-v4': 5.3.5
+ '@smithy/smithy-client': 4.9.8
+ '@smithy/types': 4.9.0
+ '@smithy/util-config-provider': 4.2.0
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-stream': 4.5.6
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@aws-sdk/middleware-user-agent@3.936.0':
+ dependencies:
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-endpoints': 3.936.0
+ '@smithy/core': 3.18.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/nested-clients@3.936.0':
+ dependencies:
+ '@aws-crypto/sha256-browser': 5.2.0
+ '@aws-crypto/sha256-js': 5.2.0
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/middleware-host-header': 3.936.0
+ '@aws-sdk/middleware-logger': 3.936.0
+ '@aws-sdk/middleware-recursion-detection': 3.936.0
+ '@aws-sdk/middleware-user-agent': 3.936.0
+ '@aws-sdk/region-config-resolver': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@aws-sdk/util-endpoints': 3.936.0
+ '@aws-sdk/util-user-agent-browser': 3.936.0
+ '@aws-sdk/util-user-agent-node': 3.936.0
+ '@smithy/config-resolver': 4.4.3
+ '@smithy/core': 3.18.5
+ '@smithy/fetch-http-handler': 5.3.6
+ '@smithy/hash-node': 4.2.5
+ '@smithy/invalid-dependency': 4.2.5
+ '@smithy/middleware-content-length': 4.2.5
+ '@smithy/middleware-endpoint': 4.3.12
+ '@smithy/middleware-retry': 4.4.12
+ '@smithy/middleware-serde': 4.2.6
+ '@smithy/middleware-stack': 4.2.5
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/node-http-handler': 4.4.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/smithy-client': 4.9.8
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-body-length-browser': 4.2.0
+ '@smithy/util-body-length-node': 4.2.1
+ '@smithy/util-defaults-mode-browser': 4.3.11
+ '@smithy/util-defaults-mode-node': 4.2.14
+ '@smithy/util-endpoints': 3.2.5
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-retry': 4.2.5
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/region-config-resolver@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/config-resolver': 4.4.3
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/signature-v4-multi-region@3.936.0':
+ dependencies:
+ '@aws-sdk/middleware-sdk-s3': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/signature-v4': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/token-providers@3.936.0':
+ dependencies:
+ '@aws-sdk/core': 3.936.0
+ '@aws-sdk/nested-clients': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - aws-crt
+
+ '@aws-sdk/types@3.936.0':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/util-arn-parser@3.893.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@aws-sdk/util-endpoints@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ '@smithy/util-endpoints': 3.2.5
+ tslib: 2.8.1
+
+ '@aws-sdk/util-locate-window@3.893.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@aws-sdk/util-user-agent-browser@3.936.0':
+ dependencies:
+ '@aws-sdk/types': 3.936.0
+ '@smithy/types': 4.9.0
+ bowser: 2.12.1
+ tslib: 2.8.1
+
+ '@aws-sdk/util-user-agent-node@3.936.0':
+ dependencies:
+ '@aws-sdk/middleware-user-agent': 3.936.0
+ '@aws-sdk/types': 3.936.0
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@aws-sdk/xml-builder@3.930.0':
+ dependencies:
+ '@smithy/types': 4.9.0
+ fast-xml-parser: 5.2.5
+ tslib: 2.8.1
+
+ '@aws/lambda-invoke-store@0.2.1': {}
+
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -4503,6 +5259,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@nestjs/schedule@6.0.1(@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)':
+ 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)
+ cron: 4.3.3
+
'@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)':
dependencies:
'@angular-devkit/core': 19.2.17(chokidar@4.0.3)
@@ -4590,6 +5352,280 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
+ '@smithy/abort-controller@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/config-resolver@4.4.3':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-config-provider': 4.2.0
+ '@smithy/util-endpoints': 3.2.5
+ '@smithy/util-middleware': 4.2.5
+ tslib: 2.8.1
+
+ '@smithy/core@3.18.5':
+ dependencies:
+ '@smithy/middleware-serde': 4.2.6
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-body-length-browser': 4.2.0
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-stream': 4.5.6
+ '@smithy/util-utf8': 4.2.0
+ '@smithy/uuid': 1.1.0
+ tslib: 2.8.1
+
+ '@smithy/credential-provider-imds@4.2.5':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ tslib: 2.8.1
+
+ '@smithy/fetch-http-handler@5.3.6':
+ dependencies:
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/querystring-builder': 4.2.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-base64': 4.3.0
+ tslib: 2.8.1
+
+ '@smithy/hash-node@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ '@smithy/util-buffer-from': 4.2.0
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/invalid-dependency@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/is-array-buffer@2.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/is-array-buffer@4.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/middleware-content-length@4.2.5':
+ dependencies:
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/middleware-endpoint@4.3.12':
+ dependencies:
+ '@smithy/core': 3.18.5
+ '@smithy/middleware-serde': 4.2.6
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ '@smithy/url-parser': 4.2.5
+ '@smithy/util-middleware': 4.2.5
+ tslib: 2.8.1
+
+ '@smithy/middleware-retry@4.4.12':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/service-error-classification': 4.2.5
+ '@smithy/smithy-client': 4.9.8
+ '@smithy/types': 4.9.0
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-retry': 4.2.5
+ '@smithy/uuid': 1.1.0
+ tslib: 2.8.1
+
+ '@smithy/middleware-serde@4.2.6':
+ dependencies:
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/middleware-stack@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/node-config-provider@4.3.5':
+ dependencies:
+ '@smithy/property-provider': 4.2.5
+ '@smithy/shared-ini-file-loader': 4.4.0
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/node-http-handler@4.4.5':
+ dependencies:
+ '@smithy/abort-controller': 4.2.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/querystring-builder': 4.2.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/property-provider@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/protocol-http@5.3.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/querystring-builder@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ '@smithy/util-uri-escape': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/querystring-parser@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/service-error-classification@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+
+ '@smithy/shared-ini-file-loader@4.4.0':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/signature-v4@5.3.5':
+ dependencies:
+ '@smithy/is-array-buffer': 4.2.0
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-hex-encoding': 4.2.0
+ '@smithy/util-middleware': 4.2.5
+ '@smithy/util-uri-escape': 4.2.0
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/smithy-client@4.9.8':
+ dependencies:
+ '@smithy/core': 3.18.5
+ '@smithy/middleware-endpoint': 4.3.12
+ '@smithy/middleware-stack': 4.2.5
+ '@smithy/protocol-http': 5.3.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-stream': 4.5.6
+ tslib: 2.8.1
+
+ '@smithy/types@4.9.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/url-parser@4.2.5':
+ dependencies:
+ '@smithy/querystring-parser': 4.2.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-base64@4.3.0':
+ dependencies:
+ '@smithy/util-buffer-from': 4.2.0
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-body-length-browser@4.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-body-length-node@4.2.1':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-buffer-from@2.2.0':
+ dependencies:
+ '@smithy/is-array-buffer': 2.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-buffer-from@4.2.0':
+ dependencies:
+ '@smithy/is-array-buffer': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-config-provider@4.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-defaults-mode-browser@4.3.11':
+ dependencies:
+ '@smithy/property-provider': 4.2.5
+ '@smithy/smithy-client': 4.9.8
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-defaults-mode-node@4.2.14':
+ dependencies:
+ '@smithy/config-resolver': 4.4.3
+ '@smithy/credential-provider-imds': 4.2.5
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/property-provider': 4.2.5
+ '@smithy/smithy-client': 4.9.8
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-endpoints@3.2.5':
+ dependencies:
+ '@smithy/node-config-provider': 4.3.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-hex-encoding@4.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-middleware@4.2.5':
+ dependencies:
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-retry@4.2.5':
+ dependencies:
+ '@smithy/service-error-classification': 4.2.5
+ '@smithy/types': 4.9.0
+ tslib: 2.8.1
+
+ '@smithy/util-stream@4.5.6':
+ dependencies:
+ '@smithy/fetch-http-handler': 5.3.6
+ '@smithy/node-http-handler': 4.4.5
+ '@smithy/types': 4.9.0
+ '@smithy/util-base64': 4.3.0
+ '@smithy/util-buffer-from': 4.2.0
+ '@smithy/util-hex-encoding': 4.2.0
+ '@smithy/util-utf8': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-uri-escape@4.2.0':
+ dependencies:
+ tslib: 2.8.1
+
+ '@smithy/util-utf8@2.3.0':
+ dependencies:
+ '@smithy/util-buffer-from': 2.2.0
+ tslib: 2.8.1
+
+ '@smithy/util-utf8@4.2.0':
+ dependencies:
+ '@smithy/util-buffer-from': 4.2.0
+ tslib: 2.8.1
+
+ '@smithy/uuid@1.1.0':
+ dependencies:
+ tslib: 2.8.1
+
'@sqltools/formatter@1.2.5': {}
'@standard-schema/spec@1.0.0': {}
@@ -4717,6 +5753,8 @@ snapshots:
'@types/ms': 2.1.0
'@types/node': 22.19.1
+ '@types/luxon@3.7.1': {}
+
'@types/methods@1.1.4': {}
'@types/mime@1.3.5': {}
@@ -4731,6 +5769,13 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/nodemailer@7.0.4':
+ dependencies:
+ '@aws-sdk/client-sesv2': 3.938.0
+ '@types/node': 22.19.1
+ transitivePeerDependencies:
+ - aws-crt
+
'@types/passport-jwt@4.0.1':
dependencies:
'@types/jsonwebtoken': 9.0.10
@@ -5141,6 +6186,14 @@ snapshots:
aws-ssl-profiles@1.1.2: {}
+ axios@1.13.2:
+ dependencies:
+ follow-redirects: 1.15.11
+ form-data: 4.0.5
+ proxy-from-env: 1.1.0
+ transitivePeerDependencies:
+ - debug
+
babel-jest@30.2.0(@babel/core@7.28.5):
dependencies:
'@babel/core': 7.28.5
@@ -5224,6 +6277,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ bowser@2.12.1: {}
+
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -5432,6 +6487,11 @@ snapshots:
dependencies:
luxon: 3.7.2
+ cron@4.3.3:
+ dependencies:
+ '@types/luxon': 3.7.1
+ luxon: 3.7.2
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -5712,6 +6772,10 @@ snapshots:
fast-uri@3.1.0: {}
+ fast-xml-parser@5.2.5:
+ dependencies:
+ strnum: 2.1.1
+
fastq@1.19.1:
dependencies:
reusify: 1.1.0
@@ -5767,6 +6831,8 @@ snapshots:
flatted@3.3.3: {}
+ follow-redirects@1.15.11: {}
+
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
@@ -6677,6 +7743,8 @@ snapshots:
node-releases@2.0.27: {}
+ nodemailer@7.0.10: {}
+
normalize-path@3.0.0: {}
npm-run-path@4.0.1:
@@ -6825,6 +7893,8 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ proxy-from-env@1.1.0: {}
+
punycode@2.3.1: {}
pure-rand@7.0.1: {}
@@ -7091,6 +8161,8 @@ snapshots:
strip-json-comments@3.1.1: {}
+ strnum@2.1.1: {}
+
strtok3@10.3.4:
dependencies:
'@tokenizer/token': 0.3.0
diff --git a/backend/src/main.ts b/backend/src/main.ts
index 5072b09..f746b05 100644
--- a/backend/src/main.ts
+++ b/backend/src/main.ts
@@ -1,26 +1,34 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
-import { ValidationPipe } from '@nestjs/common';
-import { TransformInterceptor } from './common/interceptors/transform.interceptor.js'; // อย่าลืม .js ถ้าใช้ ESM
+import { ValidationPipe, Logger } from '@nestjs/common';
+import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; // ✅ เพิ่ม Import Swagger
+import { json, urlencoded } from 'express'; // ✅ เพิ่ม Import Body Parser
+import helmet from 'helmet';
+
+// Import ของเดิมของคุณ
+import { TransformInterceptor } from './common/interceptors/transform.interceptor.js';
import { HttpExceptionFilter } from './common/exceptions/http-exception.filter.js';
-import helmet from 'helmet'; // <--- Import Helmet
async function bootstrap() {
const app = await NestFactory.create(AppModule);
- // 🛡️ 1. เปิดใช้งาน Helmet (Security Headers)
- app.use(helmet());
+ const logger = new Logger('Bootstrap');
- // 🛡️ 2. เปิดใช้งาน CORS (เพื่อให้ Frontend จากโดเมนอื่นเรียกใช้ได้)
- // ใน Production ควรระบุ origin ให้ชัดเจน แทนที่จะเป็น *
+ // 🛡️ 1. Security (Helmet & CORS)
+ app.use(helmet());
app.enableCors({
origin: true, // หรือระบุเช่น ['https://lcbp3.np-dms.work']
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true,
});
- // 1. Global Prefix (เช่น /api/v1)
+
+ // 📁 2. Body Parser Limits (รองรับ File Upload 50MB)
+ app.use(json({ limit: '50mb' }));
+ app.use(urlencoded({ extended: true, limit: '50mb' }));
+
+ // 🌐 3. Global Prefix (เช่น /api/v1)
app.setGlobalPrefix('api');
- // 2. Global Validation Pipe (ตรวจสอบ Input DTO)
+ // ⚙️ 4. Global Pipes & Interceptors (ของเดิม)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // ตัด field ส่วนเกินทิ้ง
@@ -28,14 +36,31 @@ async function bootstrap() {
forbidNonWhitelisted: true, // แจ้ง Error ถ้าส่ง field แปลกปลอมมา
}),
);
-
- // 3. Global Interceptor (จัด Format Response)
app.useGlobalInterceptors(new TransformInterceptor());
-
- // 4. Global Exception Filter (จัดการ Error)
app.useGlobalFilters(new HttpExceptionFilter());
- await app.listen(process.env.PORT || 3000);
- console.log(`Application is running on: ${await app.getUrl()}`);
+ // 📘 5. Swagger Configuration (ส่วนที่ขาดไป)
+ const config = new DocumentBuilder()
+ .setTitle('LCBP3 DMS API')
+ .setDescription('Document Management System API Documentation')
+ .setVersion('1.4.3')
+ .addBearerAuth() // เพิ่มปุ่มใส่ Token (รูปกุญแจ)
+ .build();
+
+ const document = SwaggerModule.createDocument(app, config);
+
+ // ตั้งค่าให้เข้าถึงได้ที่ /docs
+ SwaggerModule.setup('docs', app, document, {
+ swaggerOptions: {
+ persistAuthorization: true, // จำ Token ไว้ไม่ต้องใส่ใหม่เวลารีเฟรช
+ },
+ });
+
+ // 🚀 6. Start Server
+ const port = process.env.PORT || 3000;
+ await app.listen(port);
+
+ logger.log(`Application is running on: http://localhost:${port}/api`);
+ logger.log(`Swagger UI is available at: http://localhost:${port}/docs`);
}
bootstrap();
diff --git a/backend/src/modules/circulation/circulation.service.ts b/backend/src/modules/circulation/circulation.service.ts
new file mode 100644
index 0000000..09b8165
--- /dev/null
+++ b/backend/src/modules/circulation/circulation.service.ts
@@ -0,0 +1,83 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository, DataSource } from 'typeorm';
+
+import { Circulation } from './entities/circulation.entity';
+import { CirculationRouting } from './entities/circulation-routing.entity';
+import { User } from '../user/entities/user.entity';
+import { CreateCirculationDto } from './dto/create-circulation.dto'; // ต้องสร้าง DTO นี้
+
+@Injectable()
+export class CirculationService {
+ constructor(
+ @InjectRepository(Circulation)
+ private circulationRepo: Repository,
+ @InjectRepository(CirculationRouting)
+ private routingRepo: Repository,
+ private dataSource: DataSource,
+ ) {}
+
+ async create(createDto: CreateCirculationDto, user: User) {
+ const queryRunner = this.dataSource.createQueryRunner();
+ await queryRunner.connect();
+ await queryRunner.startTransaction();
+
+ try {
+ // 1. Create Master Circulation
+ // TODO: Generate Circulation No. logic here (Simple format)
+ const circulationNo = `CIR-${Date.now()}`;
+
+ const circulation = queryRunner.manager.create(Circulation, {
+ organizationId: user.primaryOrganizationId,
+ correspondenceId: createDto.correspondenceId,
+ circulationNo: circulationNo,
+ subject: createDto.subject,
+ statusCode: 'OPEN',
+ createdByUserId: user.user_id,
+ });
+ const savedCirculation = await queryRunner.manager.save(circulation);
+
+ // 2. Create Routings (Assignees)
+ if (createDto.assigneeIds && createDto.assigneeIds.length > 0) {
+ const routings = createDto.assigneeIds.map((userId, index) =>
+ queryRunner.manager.create(CirculationRouting, {
+ circulationId: savedCirculation.id,
+ stepNumber: index + 1,
+ organizationId: user.primaryOrganizationId, // Internal routing
+ assignedTo: userId,
+ status: 'PENDING',
+ }),
+ );
+ await queryRunner.manager.save(routings);
+ }
+
+ await queryRunner.commitTransaction();
+ return savedCirculation;
+ } catch (err) {
+ await queryRunner.rollbackTransaction();
+ throw err;
+ } finally {
+ await queryRunner.release();
+ }
+ }
+
+ async findOne(id: number) {
+ const circulation = await this.circulationRepo.findOne({
+ where: { id },
+ relations: ['routings', 'routings.assignee', 'correspondence'],
+ });
+ if (!circulation) throw new NotFoundException('Circulation not found');
+ return circulation;
+ }
+
+ // Method update status (Complete task)
+ async updateRoutingStatus(
+ routingId: number,
+ status: string,
+ comments: string,
+ user: User,
+ ) {
+ // Logic to update routing status
+ // and Check if all routings are completed -> Close Circulation
+ }
+}
diff --git a/backend/src/modules/circulation/dto/create-circulation.dto.ts b/backend/src/modules/circulation/dto/create-circulation.dto.ts
new file mode 100644
index 0000000..33ffe3c
--- /dev/null
+++ b/backend/src/modules/circulation/dto/create-circulation.dto.ts
@@ -0,0 +1,26 @@
+import {
+ IsInt,
+ IsString,
+ IsNotEmpty,
+ IsArray,
+ IsOptional,
+} from 'class-validator';
+
+export class CreateCirculationDto {
+ @IsInt()
+ @IsNotEmpty()
+ correspondenceId!: number; // เอกสารต้นเรื่องที่จะเวียน
+
+ @IsString()
+ @IsNotEmpty()
+ subject!: string; // หัวข้อเรื่อง (Subject)
+
+ @IsArray()
+ @IsInt({ each: true })
+ @IsNotEmpty()
+ assigneeIds!: number[]; // รายชื่อ User ID ที่ต้องการส่งให้ (ผู้รับผิดชอบ)
+
+ @IsString()
+ @IsOptional()
+ remarks?: string; // หมายเหตุเพิ่มเติม (ถ้ามี)
+}
diff --git a/backend/src/modules/circulation/dto/create-transmittal.dto.ts b/backend/src/modules/circulation/dto/create-transmittal.dto.ts
new file mode 100644
index 0000000..bf8ea91
--- /dev/null
+++ b/backend/src/modules/circulation/dto/create-transmittal.dto.ts
@@ -0,0 +1,34 @@
+import {
+ IsInt,
+ IsString,
+ IsOptional,
+ IsArray,
+ IsNotEmpty,
+ IsEnum,
+} from 'class-validator';
+
+export enum TransmittalPurpose {
+ FOR_APPROVAL = 'FOR_APPROVAL',
+ FOR_INFORMATION = 'FOR_INFORMATION',
+ FOR_REVIEW = 'FOR_REVIEW',
+ OTHER = 'OTHER',
+}
+
+export class CreateTransmittalDto {
+ @IsInt()
+ @IsNotEmpty()
+ projectId!: number; // จำเป็นสำหรับการออกเลขที่เอกสาร
+
+ @IsEnum(TransmittalPurpose)
+ @IsOptional()
+ purpose?: TransmittalPurpose; // วัตถุประสงค์
+
+ @IsString()
+ @IsOptional()
+ remarks?: string; // หมายเหตุ
+
+ @IsArray()
+ @IsInt({ each: true })
+ @IsNotEmpty()
+ itemIds!: number[]; // ID ของเอกสาร (Correspondence IDs) ที่จะแนบไปใน Transmittal
+}
diff --git a/backend/src/modules/circulation/entities/circulation-routing.entity.ts b/backend/src/modules/circulation/entities/circulation-routing.entity.ts
new file mode 100644
index 0000000..8eb6ed4
--- /dev/null
+++ b/backend/src/modules/circulation/entities/circulation-routing.entity.ts
@@ -0,0 +1,62 @@
+import {
+ Entity,
+ PrimaryGeneratedColumn,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ ManyToOne,
+ JoinColumn,
+} from 'typeorm';
+import { Circulation } from './circulation.entity';
+import { Organization } from '../../project/entities/organization.entity';
+import { User } from '../../user/entities/user.entity';
+
+@Entity('circulation_routings')
+export class CirculationRouting {
+ @PrimaryGeneratedColumn()
+ id!: number;
+
+ @Column({ name: 'circulation_id' })
+ circulationId!: number;
+
+ @Column({ name: 'step_number' })
+ stepNumber!: number;
+
+ @Column({ name: 'organization_id' })
+ organizationId!: number;
+
+ @Column({ name: 'assigned_to', nullable: true })
+ assignedTo?: number;
+
+ @Column({
+ type: 'enum',
+ enum: ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'REJECTED'],
+ default: 'PENDING',
+ })
+ status!: string;
+
+ @Column({ type: 'text', nullable: true })
+ comments?: string;
+
+ @Column({ name: 'completed_at', type: 'datetime', nullable: true })
+ completedAt?: Date;
+
+ @CreateDateColumn({ name: 'created_at' })
+ createdAt!: Date;
+
+ @UpdateDateColumn({ name: 'updated_at' })
+ updatedAt!: Date;
+
+ // Relations
+ @ManyToOne(() => Circulation, (c) => c.routings, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'circulation_id' })
+ circulation!: Circulation;
+
+ @ManyToOne(() => Organization)
+ @JoinColumn({ name: 'organization_id' })
+ organization!: Organization;
+
+ @ManyToOne(() => User)
+ @JoinColumn({ name: 'assigned_to' })
+ assignee?: User;
+}
diff --git a/backend/src/modules/circulation/entities/circulation-status-code.entity.ts b/backend/src/modules/circulation/entities/circulation-status-code.entity.ts
new file mode 100644
index 0000000..0c6e16b
--- /dev/null
+++ b/backend/src/modules/circulation/entities/circulation-status-code.entity.ts
@@ -0,0 +1,19 @@
+import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
+
+@Entity('circulation_status_codes')
+export class CirculationStatusCode {
+ @PrimaryGeneratedColumn()
+ id!: number;
+
+ @Column({ length: 20, unique: true })
+ code!: string;
+
+ @Column({ length: 50 })
+ description!: string;
+
+ @Column({ name: 'sort_order', default: 0 })
+ sortOrder!: number;
+
+ @Column({ name: 'is_active', default: true })
+ isActive!: boolean;
+}
diff --git a/backend/src/modules/circulation/entities/circulation.entity.ts b/backend/src/modules/circulation/entities/circulation.entity.ts
new file mode 100644
index 0000000..acb8522
--- /dev/null
+++ b/backend/src/modules/circulation/entities/circulation.entity.ts
@@ -0,0 +1,73 @@
+import {
+ Entity,
+ PrimaryGeneratedColumn,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ ManyToOne,
+ JoinColumn,
+ OneToMany,
+} from 'typeorm';
+import { Correspondence } from '../../correspondence/entities/correspondence.entity';
+import { Organization } from '../../project/entities/organization.entity';
+import { User } from '../../user/entities/user.entity';
+import { CirculationStatusCode } from './circulation-status-code.entity';
+import { CirculationRouting } from './circulation-routing.entity';
+
+@Entity('circulations')
+export class Circulation {
+ @PrimaryGeneratedColumn()
+ id!: number;
+
+ @Column({ name: 'correspondence_id', nullable: true })
+ correspondenceId?: number;
+
+ @Column({ name: 'organization_id' })
+ organizationId!: number;
+
+ @Column({ name: 'circulation_no', length: 100 })
+ circulationNo!: string;
+
+ @Column({ name: 'circulation_subject', length: 500 })
+ subject!: string;
+
+ @Column({ name: 'circulation_status_code' })
+ statusCode!: string;
+
+ @Column({ name: 'created_by_user_id' })
+ createdByUserId!: number;
+
+ @Column({ name: 'submitted_at', type: 'timestamp', nullable: true })
+ submittedAt?: Date;
+
+ @Column({ name: 'closed_at', type: 'timestamp', nullable: true })
+ closedAt?: Date;
+
+ @CreateDateColumn({ name: 'created_at' })
+ createdAt!: Date;
+
+ @UpdateDateColumn({ name: 'updated_at' })
+ updatedAt!: Date;
+
+ // Relations
+ @ManyToOne(() => Correspondence)
+ @JoinColumn({ name: 'correspondence_id' })
+ correspondence?: Correspondence;
+
+ @ManyToOne(() => Organization)
+ @JoinColumn({ name: 'organization_id' })
+ organization!: Organization;
+
+ @ManyToOne(() => CirculationStatusCode)
+ @JoinColumn({ name: 'circulation_status_code', referencedColumnName: 'code' })
+ status!: CirculationStatusCode;
+
+ @ManyToOne(() => User)
+ @JoinColumn({ name: 'created_by_user_id' })
+ creator!: User;
+
+ @OneToMany(() => CirculationRouting, (routing) => routing.circulation, {
+ cascade: true,
+ })
+ routings!: CirculationRouting[];
+}
diff --git a/backend/src/modules/notification/dto/create-notification.dto.ts b/backend/src/modules/notification/dto/create-notification.dto.ts
new file mode 100644
index 0000000..f941d2e
--- /dev/null
+++ b/backend/src/modules/notification/dto/create-notification.dto.ts
@@ -0,0 +1,39 @@
+import {
+ IsString,
+ IsInt,
+ IsOptional,
+ IsEnum,
+ IsNotEmpty,
+ IsUrl,
+} from 'class-validator';
+import { NotificationType } from '../entities/notification.entity';
+
+export class CreateNotificationDto {
+ @IsInt()
+ @IsNotEmpty()
+ userId!: number; // ผู้รับ
+
+ @IsString()
+ @IsNotEmpty()
+ title!: string; // หัวข้อ
+
+ @IsString()
+ @IsNotEmpty()
+ message!: string; // ข้อความ
+
+ @IsEnum(NotificationType)
+ @IsNotEmpty()
+ type!: NotificationType; // ประเภท: EMAIL, LINE, SYSTEM
+
+ @IsString()
+ @IsOptional()
+ entityType?: string; // e.g., 'rfa', 'correspondence'
+
+ @IsInt()
+ @IsOptional()
+ entityId?: number; // e.g., rfa_id
+
+ @IsString()
+ @IsOptional()
+ link?: string; // Link ไปยังหน้าเว็บ (Frontend)
+}
diff --git a/backend/src/modules/notification/dto/search-notification.dto.ts b/backend/src/modules/notification/dto/search-notification.dto.ts
new file mode 100644
index 0000000..12c1af1
--- /dev/null
+++ b/backend/src/modules/notification/dto/search-notification.dto.ts
@@ -0,0 +1,23 @@
+import { IsInt, IsOptional, IsBoolean } from 'class-validator';
+import { Type, Transform } from 'class-transformer';
+
+export class SearchNotificationDto {
+ @IsOptional()
+ @IsInt()
+ @Type(() => Number)
+ page: number = 1;
+
+ @IsOptional()
+ @IsInt()
+ @Type(() => Number)
+ limit: number = 20;
+
+ @IsOptional()
+ @IsBoolean()
+ @Transform(({ value }) => {
+ if (value === 'true') return true;
+ if (value === 'false') return false;
+ return value;
+ })
+ isRead?: boolean; // กรอง: อ่านแล้ว/ยังไม่อ่าน
+}
diff --git a/backend/src/modules/notification/entities/notification.entity.ts b/backend/src/modules/notification/entities/notification.entity.ts
new file mode 100644
index 0000000..70bde93
--- /dev/null
+++ b/backend/src/modules/notification/entities/notification.entity.ts
@@ -0,0 +1,55 @@
+import {
+ Entity,
+ PrimaryGeneratedColumn,
+ Column,
+ CreateDateColumn,
+ ManyToOne,
+ JoinColumn,
+} from 'typeorm';
+import { User } from '../../user/entities/user.entity';
+
+export enum NotificationType {
+ EMAIL = 'EMAIL',
+ LINE = 'LINE',
+ SYSTEM = 'SYSTEM',
+}
+
+@Entity('notifications')
+export class Notification {
+ @PrimaryGeneratedColumn()
+ id!: number;
+
+ @Column({ name: 'user_id' })
+ userId!: number;
+
+ @Column({ length: 255 })
+ title!: string;
+
+ @Column({ type: 'text' })
+ message!: string;
+
+ @Column({
+ name: 'notification_type',
+ type: 'enum',
+ enum: NotificationType,
+ })
+ notificationType!: NotificationType;
+
+ @Column({ name: 'is_read', default: false })
+ isRead!: boolean;
+
+ @Column({ name: 'entity_type', length: 50, nullable: true })
+ entityType?: string; // e.g., 'rfa', 'circulation', 'correspondence'
+
+ @Column({ name: 'entity_id', nullable: true })
+ entityId?: number;
+
+ @CreateDateColumn({ name: 'created_at' })
+ createdAt!: Date;
+
+ // --- Relations ---
+
+ @ManyToOne(() => User)
+ @JoinColumn({ name: 'user_id' })
+ user!: User;
+}
diff --git a/backend/src/modules/notification/notification-cleanup.service.ts b/backend/src/modules/notification/notification-cleanup.service.ts
new file mode 100644
index 0000000..0ca71ba
--- /dev/null
+++ b/backend/src/modules/notification/notification-cleanup.service.ts
@@ -0,0 +1,39 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { Cron, CronExpression } from '@nestjs/schedule';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository, LessThan } from 'typeorm';
+import { Notification } from './entities/notification.entity';
+
+@Injectable()
+export class NotificationCleanupService {
+ private readonly logger = new Logger(NotificationCleanupService.name);
+
+ constructor(
+ @InjectRepository(Notification)
+ private notificationRepo: Repository,
+ ) {}
+
+ /**
+ * ลบแจ้งเตือนที่ "อ่านแล้ว" และเก่ากว่า 30 วัน
+ * รันทุกวันเวลาเที่ยงคืน
+ */
+ @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
+ async handleCleanup() {
+ this.logger.log('Running notification cleanup...');
+
+ const daysAgo = 30;
+ const dateThreshold = new Date();
+ dateThreshold.setDate(dateThreshold.getDate() - daysAgo);
+
+ try {
+ const result = await this.notificationRepo.delete({
+ isRead: true,
+ createdAt: LessThan(dateThreshold),
+ });
+
+ this.logger.log(`Deleted ${result.affected} old read notifications.`);
+ } catch (error) {
+ this.logger.error('Failed to cleanup notifications', error);
+ }
+ }
+}
diff --git a/backend/src/modules/notification/notification.controller.ts b/backend/src/modules/notification/notification.controller.ts
new file mode 100644
index 0000000..821db6a
--- /dev/null
+++ b/backend/src/modules/notification/notification.controller.ts
@@ -0,0 +1,75 @@
+import {
+ Controller,
+ Get,
+ Put,
+ Param,
+ UseGuards,
+ ParseIntPipe,
+ Query,
+} from '@nestjs/common';
+import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { Notification } from './entities/notification.entity';
+import { NotificationService } from './notification.service';
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { CurrentUser } from '../../common/decorators/current-user.decorator';
+import { User } from '../user/entities/user.entity';
+import { SearchNotificationDto } from './dto/search-notification.dto'; // ✅ Import
+
+@ApiTags('Notifications')
+@ApiBearerAuth()
+@UseGuards(JwtAuthGuard)
+@Controller('notifications')
+export class NotificationController {
+ constructor(
+ private readonly notificationService: NotificationService,
+ @InjectRepository(Notification)
+ private notificationRepo: Repository,
+ ) {}
+
+ @Get()
+ @ApiOperation({ summary: 'Get my notifications' })
+ async getMyNotifications(
+ @CurrentUser() user: User,
+ @Query() searchDto: SearchNotificationDto, // ✅ ใช้ DTO แทน
+ ) {
+ const { page = 1, limit = 20, isRead } = searchDto;
+
+ const where: any = { userId: user.user_id };
+
+ // เพิ่ม Filter isRead ถ้ามีการส่งมา
+ if (isRead !== undefined) {
+ where.isRead = isRead;
+ }
+
+ const [items, total] = await this.notificationRepo.findAndCount({
+ where,
+ order: { createdAt: 'DESC' },
+ take: limit,
+ skip: (page - 1) * limit,
+ });
+
+ const unreadCount = await this.notificationRepo.count({
+ where: { userId: user.user_id, isRead: false },
+ });
+
+ return { data: items, meta: { total, page, limit, unreadCount } };
+ }
+
+ @Put(':id/read')
+ @ApiOperation({ summary: 'Mark notification as read' })
+ async markAsRead(
+ @Param('id', ParseIntPipe) id: number,
+ @CurrentUser() user: User,
+ ) {
+ return this.notificationService.markAsRead(id, user.user_id);
+ }
+
+ @Put('read-all')
+ @ApiOperation({ summary: 'Mark all as read' })
+ async markAllAsRead(@CurrentUser() user: User) {
+ return this.notificationService.markAllAsRead(user.user_id);
+ }
+}
diff --git a/backend/src/modules/notification/notification.gateway.ts b/backend/src/modules/notification/notification.gateway.ts
new file mode 100644
index 0000000..a54773d
--- /dev/null
+++ b/backend/src/modules/notification/notification.gateway.ts
@@ -0,0 +1,38 @@
+import {
+ WebSocketGateway,
+ WebSocketServer,
+ OnGatewayConnection,
+ OnGatewayDisconnect,
+} from '@nestjs/websockets';
+import { Server, Socket } from 'socket.io';
+import { Logger } from '@nestjs/common';
+
+@WebSocketGateway({
+ cors: {
+ origin: '*',
+ },
+ namespace: 'notifications',
+})
+export class NotificationGateway
+ implements OnGatewayConnection, OnGatewayDisconnect
+{
+ @WebSocketServer()
+ server!: Server; // ✅ FIX: เติม ! (Definite Assignment Assertion)
+
+ private readonly logger = new Logger(NotificationGateway.name);
+
+ handleConnection(client: Socket) {
+ this.logger.log(`Client connected: ${client.id}`);
+ }
+
+ handleDisconnect(client: Socket) {
+ this.logger.log(`Client disconnected: ${client.id}`);
+ }
+
+ /**
+ * ส่งแจ้งเตือนไปหา User แบบ Real-time
+ */
+ sendToUser(userId: number, payload: any) {
+ this.server.to(`user_${userId}`).emit('new_notification', payload);
+ }
+}
diff --git a/backend/src/modules/notification/notification.module.ts b/backend/src/modules/notification/notification.module.ts
new file mode 100644
index 0000000..dc249ad
--- /dev/null
+++ b/backend/src/modules/notification/notification.module.ts
@@ -0,0 +1,37 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { BullModule } from '@nestjs/bullmq';
+import { ConfigModule } from '@nestjs/config';
+import { ScheduleModule } from '@nestjs/schedule'; // ✅ New
+
+import { Notification } from './entities/notification.entity';
+import { User } from '../user/entities/user.entity';
+import { UserPreference } from '../user/entities/user-preference.entity';
+
+import { NotificationService } from './notification.service';
+import { NotificationController } from './notification.controller';
+import { NotificationProcessor } from './notification.processor';
+import { NotificationGateway } from './notification.gateway'; // ✅ New
+import { NotificationCleanupService } from './notification-cleanup.service'; // ✅ New
+import { UserModule } from '../user/user.module';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([Notification, User, UserPreference]),
+ BullModule.registerQueue({
+ name: 'notifications',
+ }),
+ ScheduleModule.forRoot(), // ✅ New (ถ้ายังไม่ได้ import ใน AppModule)
+ ConfigModule,
+ UserModule,
+ ],
+ controllers: [NotificationController],
+ providers: [
+ NotificationService,
+ NotificationProcessor,
+ NotificationGateway, // ✅ New
+ NotificationCleanupService, // ✅ New
+ ],
+ exports: [NotificationService],
+})
+export class NotificationModule {}
diff --git a/backend/src/modules/notification/notification.processor.ts b/backend/src/modules/notification/notification.processor.ts
new file mode 100644
index 0000000..5c44ace
--- /dev/null
+++ b/backend/src/modules/notification/notification.processor.ts
@@ -0,0 +1,88 @@
+import { Processor, WorkerHost } from '@nestjs/bullmq';
+import { Job } from 'bullmq';
+import { Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import * as nodemailer from 'nodemailer';
+import axios from 'axios';
+
+import { UserService } from '../user/user.service';
+
+@Processor('notifications')
+export class NotificationProcessor extends WorkerHost {
+ private readonly logger = new Logger(NotificationProcessor.name);
+ private mailerTransport: nodemailer.Transporter;
+
+ constructor(
+ private configService: ConfigService,
+ private userService: UserService,
+ ) {
+ super();
+ // Setup Nodemailer
+ this.mailerTransport = nodemailer.createTransport({
+ host: this.configService.get('SMTP_HOST'),
+ port: this.configService.get('SMTP_PORT'),
+ secure: this.configService.get('SMTP_SECURE') === 'true',
+ auth: {
+ user: this.configService.get('SMTP_USER'),
+ pass: this.configService.get('SMTP_PASS'),
+ },
+ });
+ }
+
+ async process(job: Job): Promise {
+ this.logger.debug(`Processing job ${job.name} for user ${job.data.userId}`);
+
+ switch (job.name) {
+ case 'send-email':
+ return this.handleSendEmail(job.data);
+ case 'send-line':
+ return this.handleSendLine(job.data);
+ default:
+ throw new Error(`Unknown job name: ${job.name}`);
+ }
+ }
+
+ private async handleSendEmail(data: any) {
+ const user = await this.userService.findOne(data.userId);
+ if (!user || !user.email) {
+ this.logger.warn(`User ${data.userId} has no email`);
+ return;
+ }
+
+ await this.mailerTransport.sendMail({
+ from: '"LCBP3 DMS" ',
+ to: user.email,
+ subject: `[DMS] ${data.title}`,
+ html: `
+ ${data.title}
+ ${data.message}
+
+ คลิกเพื่อดูรายละเอียด
+ `,
+ });
+ this.logger.log(`Email sent to ${user.email}`);
+ }
+
+ private async handleSendLine(data: any) {
+ const user = await this.userService.findOne(data.userId);
+ // ตรวจสอบว่า User มี Line ID หรือไม่ (หรือใช้ Group Token ถ้าเป็นระบบรวม)
+ // ในที่นี้สมมติว่าเรายิงเข้า n8n webhook เพื่อจัดการต่อ
+ const n8nWebhookUrl = this.configService.get('N8N_LINE_WEBHOOK_URL');
+
+ if (!n8nWebhookUrl) {
+ this.logger.warn('N8N_LINE_WEBHOOK_URL not configured');
+ return;
+ }
+
+ try {
+ await axios.post(n8nWebhookUrl, {
+ userId: user.user_id, // หรือ user.lineId ถ้ามี
+ message: `${data.title}\n${data.message}`,
+ link: data.link,
+ });
+ this.logger.log(`Line notification sent via n8n for user ${data.userId}`);
+ } catch (error: any) {
+ throw new Error(`Failed to send Line notification: ${error.message}`);
+ }
+ }
+}
diff --git a/backend/src/modules/notification/notification.service.ts b/backend/src/modules/notification/notification.service.ts
new file mode 100644
index 0000000..127effb
--- /dev/null
+++ b/backend/src/modules/notification/notification.service.ts
@@ -0,0 +1,136 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectQueue } from '@nestjs/bullmq';
+import { Queue } from 'bullmq';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+// Entities
+import { Notification, NotificationType } from './entities/notification.entity';
+import { User } from '../user/entities/user.entity';
+import { UserPreference } from '../user/entities/user-preference.entity';
+
+// Gateway
+import { NotificationGateway } from './notification.gateway';
+
+// Interfaces
+export interface NotificationJobData {
+ userId: number;
+ title: string;
+ message: string;
+ type: 'EMAIL' | 'LINE' | 'SYSTEM';
+ entityType?: string; // e.g., 'rfa'
+ entityId?: number; // e.g., rfa_id
+ link?: string; // Deep link to frontend
+}
+
+@Injectable()
+export class NotificationService {
+ private readonly logger = new Logger(NotificationService.name);
+
+ constructor(
+ @InjectQueue('notifications') private notificationQueue: Queue,
+ @InjectRepository(Notification)
+ private notificationRepo: Repository,
+ @InjectRepository(User)
+ private userRepo: Repository,
+ @InjectRepository(UserPreference)
+ private userPrefRepo: Repository,
+ private notificationGateway: NotificationGateway,
+ ) {}
+
+ /**
+ * ส่งการแจ้งเตือน (Trigger Notification)
+ * ฟังก์ชันนี้จะตรวจสอบ Preference ของผู้ใช้ และ Push ลง Queue
+ */
+ async send(data: NotificationJobData) {
+ try {
+ // 1. สร้าง Entity Instance (ยังไม่บันทึกลง DB)
+ // ใช้ Enum NotificationType.SYSTEM เพื่อให้ตรงกับ Type Definition
+ const notification = this.notificationRepo.create({
+ userId: data.userId,
+ title: data.title,
+ message: data.message,
+ notificationType: NotificationType.SYSTEM,
+ entityType: data.entityType,
+ entityId: data.entityId,
+ isRead: false,
+ });
+
+ // 2. บันทึกลง DB (ต้อง await เพื่อให้ได้ ID กลับมา)
+ const savedNotification = await this.notificationRepo.save(notification);
+
+ // 3. Real-time Push (ผ่าน WebSocket Gateway)
+ // ส่งข้อมูลที่ save แล้ว (มี ID) ไปให้ Frontend
+ this.notificationGateway.sendToUser(data.userId, savedNotification);
+
+ // 4. ตรวจสอบ User Preferences เพื่อส่งช่องทางอื่น (Email/Line)
+ const userPref = await this.userPrefRepo.findOne({
+ where: { userId: data.userId },
+ });
+
+ // Default: ถ้าไม่มี Pref ให้ส่ง Email/Line เป็นค่าเริ่มต้น (true)
+ const shouldSendEmail = userPref ? userPref.notifyEmail : true;
+ const shouldSendLine = userPref ? userPref.notifyLine : true;
+
+ const jobs = [];
+
+ // 5. Push to Queue (Email)
+ // เงื่อนไข: User เปิดรับ Email และ Type ของ Noti นี้ไม่ใช่ LINE-only
+ if (shouldSendEmail && data.type !== 'LINE') {
+ jobs.push({
+ name: 'send-email',
+ data: { ...data, notificationId: savedNotification.id },
+ opts: {
+ attempts: 3, // ลองใหม่ 3 ครั้งถ้าล่ม
+ backoff: {
+ type: 'exponential',
+ delay: 5000, // รอ 5 วิ, 10 วิ, 20 วิ...
+ },
+ },
+ });
+ }
+
+ // 6. Push to Queue (Line)
+ // เงื่อนไข: User เปิดรับ Line และ Type ของ Noti นี้ไม่ใช่ EMAIL-only
+ if (shouldSendLine && data.type !== 'EMAIL') {
+ jobs.push({
+ name: 'send-line',
+ data: { ...data, notificationId: savedNotification.id },
+ opts: {
+ attempts: 3,
+ backoff: { type: 'fixed', delay: 3000 },
+ },
+ });
+ }
+
+ if (jobs.length > 0) {
+ await this.notificationQueue.addBulk(jobs);
+ }
+
+ this.logger.log(`Notification queued for user ${data.userId}`);
+ } catch (error) {
+ // Cast Error เพื่อให้ TypeScript ไม่ฟ้องใน Strict Mode
+ this.logger.error(
+ `Failed to queue notification: ${(error as Error).message}`,
+ );
+ // Note: ไม่ Throw error เพื่อไม่ให้กระทบ Flow หลัก (Resilience Pattern)
+ }
+ }
+
+ /**
+ * อ่านแจ้งเตือน (Mark as Read)
+ */
+ async markAsRead(id: number, userId: number) {
+ await this.notificationRepo.update({ id, userId }, { isRead: true });
+ }
+
+ /**
+ * อ่านทั้งหมด (Mark All as Read)
+ */
+ async markAllAsRead(userId: number) {
+ await this.notificationRepo.update(
+ { userId, isRead: false },
+ { isRead: true },
+ );
+ }
+}
diff --git a/backend/src/modules/rfa/dto/create-rfa.dto.ts b/backend/src/modules/rfa/dto/create-rfa.dto.ts
new file mode 100644
index 0000000..a694c34
--- /dev/null
+++ b/backend/src/modules/rfa/dto/create-rfa.dto.ts
@@ -0,0 +1,43 @@
+import {
+ IsInt,
+ IsString,
+ IsOptional,
+ IsDateString,
+ IsArray,
+ IsNotEmpty,
+} from 'class-validator';
+
+export class CreateRfaDto {
+ @IsInt()
+ @IsNotEmpty()
+ projectId!: number;
+
+ @IsInt()
+ @IsNotEmpty()
+ rfaTypeId!: number;
+
+ @IsString()
+ @IsNotEmpty()
+ title!: string;
+
+ @IsInt()
+ @IsNotEmpty()
+ toOrganizationId!: number; // ส่งถึงใคร (สำหรับ Routing Step 1)
+
+ @IsString()
+ @IsOptional()
+ description?: string;
+
+ @IsDateString()
+ @IsOptional()
+ documentDate?: string;
+
+ @IsDateString()
+ @IsOptional()
+ dueDate?: string; // กำหนดวันตอบกลับ
+
+ @IsArray()
+ @IsInt({ each: true })
+ @IsOptional()
+ shopDrawingRevisionIds?: number[]; // Shop Drawings ที่แนบมา
+}
diff --git a/backend/src/modules/rfa/dto/search-rfa.dto.ts b/backend/src/modules/rfa/dto/search-rfa.dto.ts
new file mode 100644
index 0000000..6ef040c
--- /dev/null
+++ b/backend/src/modules/rfa/dto/search-rfa.dto.ts
@@ -0,0 +1,33 @@
+import { IsInt, IsOptional, IsString, IsNotEmpty } from 'class-validator';
+import { Type } from 'class-transformer';
+
+export class SearchRfaDto {
+ @IsInt()
+ @Type(() => Number)
+ @IsNotEmpty()
+ projectId!: number; // บังคับระบุ Project
+
+ @IsOptional()
+ @IsInt()
+ @Type(() => Number)
+ rfaTypeId?: number; // กรองตามประเภท RFA
+
+ @IsOptional()
+ @IsInt()
+ @Type(() => Number)
+ statusId?: number; // กรองตามสถานะ (เช่น Draft, For Approve)
+
+ @IsOptional()
+ @IsString()
+ search?: string; // ค้นหาจาก เลขที่เอกสาร หรือ หัวข้อเรื่อง
+
+ @IsOptional()
+ @IsInt()
+ @Type(() => Number)
+ page: number = 1;
+
+ @IsOptional()
+ @IsInt()
+ @Type(() => Number)
+ pageSize: number = 20;
+}
diff --git a/backend/src/modules/rfa/dto/update-rfa.dto.ts b/backend/src/modules/rfa/dto/update-rfa.dto.ts
new file mode 100644
index 0000000..0a4dcbe
--- /dev/null
+++ b/backend/src/modules/rfa/dto/update-rfa.dto.ts
@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/swagger';
+import { CreateRfaDto } from './create-rfa.dto';
+
+export class UpdateRfaDto extends PartialType(CreateRfaDto) {}
diff --git a/backend/src/modules/rfa/entities/rfa-approve-code.entity.ts b/backend/src/modules/rfa/entities/rfa-approve-code.entity.ts
new file mode 100644
index 0000000..42cd045
--- /dev/null
+++ b/backend/src/modules/rfa/entities/rfa-approve-code.entity.ts
@@ -0,0 +1,22 @@
+import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
+
+@Entity('rfa_approve_codes')
+export class RfaApproveCode {
+ @PrimaryGeneratedColumn()
+ id!: number;
+
+ @Column({ name: 'approve_code', length: 20, unique: true })
+ approveCode!: string;
+
+ @Column({ name: 'approve_name', length: 100 })
+ approveName!: string;
+
+ @Column({ type: 'text', nullable: true })
+ description?: string;
+
+ @Column({ name: 'sort_order', default: 0 })
+ sortOrder!: number;
+
+ @Column({ name: 'is_active', default: true })
+ isActive!: boolean;
+}
diff --git a/backend/src/modules/rfa/entities/rfa-item.entity.ts b/backend/src/modules/rfa/entities/rfa-item.entity.ts
new file mode 100644
index 0000000..cd8e8e1
--- /dev/null
+++ b/backend/src/modules/rfa/entities/rfa-item.entity.ts
@@ -0,0 +1,27 @@
+import { Entity, ManyToOne, JoinColumn, PrimaryColumn } from 'typeorm';
+import { RfaRevision } from './rfa-revision.entity';
+import { ShopDrawingRevision } from '../../drawing/entities/shop-drawing-revision.entity';
+
+@Entity('rfa_items')
+export class RfaItem {
+ @PrimaryColumn({ name: 'rfarev_correspondence_id' })
+ rfaRevisionId!: number;
+
+ @PrimaryColumn({ name: 'shop_drawing_revision_id' })
+ shopDrawingRevisionId!: number;
+
+ // Relations
+ @ManyToOne(() => RfaRevision, (rfaRev) => rfaRev.items, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn({ name: 'rfarev_correspondence_id' }) // Link to correspondence_id of the revision (as per SQL schema) OR id
+ // Note: ตาม SQL Schema "rfarev_correspondence_id" FK ไปที่ correspondence_revisions(correspondence_id)
+ // แต่เพื่อให้ TypeORM ใช้ง่าย ปกติเราจะ Link ไปที่ PK ของ RfaRevision
+ // **แต่** ตาม SQL: FOREIGN KEY (rfarev_correspondence_id) REFERENCES correspondences(id)
+ // ดังนั้นต้องระวังจุดนี้ ใน Service เราจะใช้ correspondenceId เป็น Key
+ rfaRevision!: RfaRevision;
+
+ @ManyToOne(() => ShopDrawingRevision)
+ @JoinColumn({ name: 'shop_drawing_revision_id' })
+ shopDrawingRevision!: ShopDrawingRevision;
+}
diff --git a/backend/src/modules/rfa/entities/rfa-revision.entity.ts b/backend/src/modules/rfa/entities/rfa-revision.entity.ts
new file mode 100644
index 0000000..cc1f27d
--- /dev/null
+++ b/backend/src/modules/rfa/entities/rfa-revision.entity.ts
@@ -0,0 +1,99 @@
+import {
+ Entity,
+ PrimaryGeneratedColumn,
+ Column,
+ CreateDateColumn,
+ UpdateDateColumn,
+ ManyToOne,
+ JoinColumn,
+ OneToMany,
+ Unique,
+} from 'typeorm';
+import { Rfa } from './rfa.entity';
+import { Correspondence } from '../../correspondence/entities/correspondence.entity';
+import { RfaStatusCode } from './rfa-status-code.entity';
+import { RfaApproveCode } from './rfa-approve-code.entity';
+import { User } from '../../user/entities/user.entity';
+import { RfaItem } from './rfa-item.entity';
+
+@Entity('rfa_revisions')
+@Unique(['rfaId', 'revisionNumber'])
+@Unique(['rfaId', 'isCurrent'])
+export class RfaRevision {
+ @PrimaryGeneratedColumn()
+ id!: number;
+
+ @Column({ name: 'correspondence_id' })
+ correspondenceId!: number;
+
+ @Column({ name: 'rfa_id' })
+ rfaId!: number;
+
+ @Column({ name: 'revision_number' })
+ revisionNumber!: number;
+
+ @Column({ name: 'revision_label', length: 10, nullable: true })
+ revisionLabel?: string;
+
+ @Column({ name: 'is_current', default: false })
+ isCurrent!: boolean;
+
+ @Column({ name: 'rfa_status_code_id' })
+ rfaStatusCodeId!: number;
+
+ @Column({ name: 'rfa_approve_code_id', nullable: true })
+ rfaApproveCodeId?: number;
+
+ @Column({ length: 255 })
+ title!: string;
+
+ @Column({ name: 'document_date', type: 'date', nullable: true })
+ documentDate?: Date;
+
+ @Column({ name: 'issued_date', type: 'date', nullable: true })
+ issuedDate?: Date;
+
+ @Column({ name: 'received_date', type: 'datetime', nullable: true })
+ receivedDate?: Date;
+
+ @Column({ name: 'approved_date', type: 'date', nullable: true })
+ approvedDate?: Date;
+
+ @Column({ type: 'text', nullable: true })
+ description?: string;
+
+ @CreateDateColumn({ name: 'created_at' })
+ createdAt!: Date;
+
+ @Column({ name: 'created_by', nullable: true })
+ createdBy?: number;
+
+ @Column({ name: 'updated_by', nullable: true })
+ updatedBy?: number;
+
+ // --- Relations ---
+
+ @ManyToOne(() => Correspondence)
+ @JoinColumn({ name: 'correspondence_id' })
+ correspondence!: Correspondence;
+
+ @ManyToOne(() => Rfa)
+ @JoinColumn({ name: 'rfa_id' })
+ rfa!: Rfa;
+
+ @ManyToOne(() => RfaStatusCode)
+ @JoinColumn({ name: 'rfa_status_code_id' })
+ statusCode!: RfaStatusCode;
+
+ @ManyToOne(() => RfaApproveCode)
+ @JoinColumn({ name: 'rfa_approve_code_id' })
+ approveCode?: RfaApproveCode;
+
+ @ManyToOne(() => User)
+ @JoinColumn({ name: 'created_by' })
+ creator?: User;
+
+ // Items (Shop Drawings inside this RFA)
+ @OneToMany(() => RfaItem, (item) => item.rfaRevision, { cascade: true })
+ items!: RfaItem[];
+}
diff --git a/backend/src/modules/rfa/entities/rfa-status-code.entity.ts b/backend/src/modules/rfa/entities/rfa-status-code.entity.ts
new file mode 100644
index 0000000..902f98a
--- /dev/null
+++ b/backend/src/modules/rfa/entities/rfa-status-code.entity.ts
@@ -0,0 +1,22 @@
+import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
+
+@Entity('rfa_status_codes')
+export class RfaStatusCode {
+ @PrimaryGeneratedColumn()
+ id!: number;
+
+ @Column({ name: 'status_code', length: 20, unique: true })
+ statusCode!: string;
+
+ @Column({ name: 'status_name', length: 100 })
+ statusName!: string;
+
+ @Column({ type: 'text', nullable: true })
+ description?: string;
+
+ @Column({ name: 'sort_order', default: 0 })
+ sortOrder!: number;
+
+ @Column({ name: 'is_active', default: true })
+ isActive!: boolean;
+}
diff --git a/backend/src/modules/rfa/entities/rfa-type.entity.ts b/backend/src/modules/rfa/entities/rfa-type.entity.ts
new file mode 100644
index 0000000..0f7a29b
--- /dev/null
+++ b/backend/src/modules/rfa/entities/rfa-type.entity.ts
@@ -0,0 +1,22 @@
+import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
+
+@Entity('rfa_types')
+export class RfaType {
+ @PrimaryGeneratedColumn()
+ id!: number;
+
+ @Column({ name: 'type_code', length: 20, unique: true })
+ typeCode!: string;
+
+ @Column({ name: 'type_name', length: 100 })
+ typeName!: string;
+
+ @Column({ type: 'text', nullable: true })
+ description?: string;
+
+ @Column({ name: 'sort_order', default: 0 })
+ sortOrder!: number;
+
+ @Column({ name: 'is_active', default: true })
+ isActive!: boolean;
+}
diff --git a/backend/src/modules/rfa/entities/rfa.entity.ts b/backend/src/modules/rfa/entities/rfa.entity.ts
new file mode 100644
index 0000000..fefd9cd
--- /dev/null
+++ b/backend/src/modules/rfa/entities/rfa.entity.ts
@@ -0,0 +1,43 @@
+import {
+ Entity,
+ PrimaryGeneratedColumn,
+ Column,
+ CreateDateColumn,
+ DeleteDateColumn,
+ ManyToOne,
+ JoinColumn,
+ OneToMany,
+} from 'typeorm';
+import { RfaType } from './rfa-type.entity';
+import { User } from '../../user/entities/user.entity';
+import { RfaRevision } from './rfa-revision.entity';
+
+@Entity('rfas')
+export class Rfa {
+ @PrimaryGeneratedColumn()
+ id!: number;
+
+ @Column({ name: 'rfa_type_id' })
+ rfaTypeId!: number;
+
+ @CreateDateColumn({ name: 'created_at' })
+ createdAt!: Date;
+
+ @Column({ name: 'created_by', nullable: true })
+ createdBy?: number;
+
+ @DeleteDateColumn({ name: 'deleted_at' })
+ deletedAt?: Date;
+
+ // Relations
+ @ManyToOne(() => RfaType)
+ @JoinColumn({ name: 'rfa_type_id' })
+ rfaType!: RfaType;
+
+ @ManyToOne(() => User)
+ @JoinColumn({ name: 'created_by' })
+ creator?: User;
+
+ @OneToMany(() => RfaRevision, (revision) => revision.rfa)
+ revisions!: RfaRevision[];
+}
diff --git a/backend/src/modules/rfa/rfa.controller.ts b/backend/src/modules/rfa/rfa.controller.ts
new file mode 100644
index 0000000..7398248
--- /dev/null
+++ b/backend/src/modules/rfa/rfa.controller.ts
@@ -0,0 +1,64 @@
+import {
+ Controller,
+ Get,
+ Post,
+ Body,
+ Param,
+ ParseIntPipe,
+ UseGuards,
+} from '@nestjs/common';
+import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
+
+import { RfaService } from './rfa.service';
+import { CreateRfaDto } from './dto/create-rfa.dto';
+import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; // Reuse DTO
+import { User } from '../user/entities/user.entity';
+
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { RbacGuard } from '../../common/guards/rbac.guard';
+import { RequirePermission } from '../../common/decorators/require-permission.decorator';
+import { CurrentUser } from '../../common/decorators/current-user.decorator';
+
+@ApiTags('RFA (Request for Approval)')
+@ApiBearerAuth()
+@UseGuards(JwtAuthGuard, RbacGuard)
+@Controller('rfas')
+export class RfaController {
+ constructor(private readonly rfaService: RfaService) {}
+
+ @Post()
+ @ApiOperation({ summary: 'Create new RFA (Draft)' })
+ @RequirePermission('rfa.create') // สิทธิ์ ID 37
+ create(@Body() createDto: CreateRfaDto, @CurrentUser() user: User) {
+ return this.rfaService.create(createDto, user);
+ }
+
+ @Post(':id/submit')
+ @ApiOperation({ summary: 'Submit RFA to Workflow' })
+ @RequirePermission('rfa.create') // ผู้สร้างมีสิทธิ์ส่ง
+ submit(
+ @Param('id', ParseIntPipe) id: number,
+ @Body('templateId', ParseIntPipe) templateId: number, // รับ Template ID
+ @CurrentUser() user: User,
+ ) {
+ return this.rfaService.submit(id, templateId, user);
+ }
+
+ @Post(':id/action')
+ @ApiOperation({ summary: 'Process Workflow Action (Approve/Reject)' })
+ @RequirePermission('workflow.action_review') // สิทธิ์ในการ Approve/Review
+ processAction(
+ @Param('id', ParseIntPipe) id: number,
+ @Body() actionDto: WorkflowActionDto,
+ @CurrentUser() user: User,
+ ) {
+ return this.rfaService.processAction(id, actionDto, user);
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: 'Get RFA details with revisions and items' })
+ @RequirePermission('document.view')
+ findOne(@Param('id', ParseIntPipe) id: number) {
+ return this.rfaService.findOne(id);
+ }
+}
diff --git a/backend/src/modules/rfa/rfa.module.ts b/backend/src/modules/rfa/rfa.module.ts
new file mode 100644
index 0000000..45f2aed
--- /dev/null
+++ b/backend/src/modules/rfa/rfa.module.ts
@@ -0,0 +1,43 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+// Entities
+import { Rfa } from './entities/rfa.entity';
+import { RfaRevision } from './entities/rfa-revision.entity';
+import { RfaItem } from './entities/rfa-item.entity';
+import { RfaType } from './entities/rfa-type.entity';
+import { RfaStatusCode } from './entities/rfa-status-code.entity';
+import { RfaApproveCode } from './entities/rfa-approve-code.entity';
+import { Correspondence } from '../correspondence/entities/correspondence.entity';
+import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
+
+// Services
+import { RfaService } from './rfa.service';
+
+// Controllers
+import { RfaController } from './rfa.controller';
+
+// External Modules
+import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
+import { UserModule } from '../user/user.module';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([
+ Rfa,
+ RfaRevision,
+ RfaItem,
+ RfaType,
+ RfaStatusCode,
+ RfaApproveCode,
+ Correspondence,
+ ShopDrawingRevision,
+ ]),
+ DocumentNumberingModule,
+ UserModule,
+ ],
+ providers: [RfaService],
+ controllers: [RfaController],
+ exports: [RfaService],
+})
+export class RfaModule {}
diff --git a/backend/src/modules/rfa/rfa.service.ts b/backend/src/modules/rfa/rfa.service.ts
new file mode 100644
index 0000000..3459cb5
--- /dev/null
+++ b/backend/src/modules/rfa/rfa.service.ts
@@ -0,0 +1,426 @@
+import {
+ Injectable,
+ NotFoundException,
+ InternalServerErrorException,
+ Logger,
+ BadRequestException,
+ ForbiddenException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository, DataSource, In } from 'typeorm';
+
+// Entities
+import { Rfa } from './entities/rfa.entity';
+import { RfaRevision } from './entities/rfa-revision.entity';
+import { RfaItem } from './entities/rfa-item.entity';
+import { RfaType } from './entities/rfa-type.entity';
+import { RfaStatusCode } from './entities/rfa-status-code.entity';
+import { RfaApproveCode } from './entities/rfa-approve-code.entity';
+import { Correspondence } from '../correspondence/entities/correspondence.entity';
+import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
+import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
+import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
+import { User } from '../user/entities/user.entity';
+
+// DTOs
+import { CreateRfaDto } from './dto/create-rfa.dto';
+import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto';
+
+// Interfaces & Enums
+import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface'; // ตรวจสอบ path นี้ให้ตรงกับไฟล์จริง
+
+// Services
+import { DocumentNumberingService } from '../document-numbering/document-numbering.service';
+import { UserService } from '../user/user.service';
+import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
+import { NotificationService } from '../notification/notification.service';
+
+@Injectable()
+export class RfaService {
+ private readonly logger = new Logger(RfaService.name);
+
+ constructor(
+ @InjectRepository(Rfa)
+ private rfaRepo: Repository,
+ @InjectRepository(RfaRevision)
+ private rfaRevisionRepo: Repository,
+ @InjectRepository(RfaItem)
+ private rfaItemRepo: Repository,
+ @InjectRepository(Correspondence)
+ private correspondenceRepo: Repository,
+ @InjectRepository(RfaType)
+ private rfaTypeRepo: Repository,
+ @InjectRepository(RfaStatusCode)
+ private rfaStatusRepo: Repository,
+ @InjectRepository(RfaApproveCode)
+ private rfaApproveRepo: Repository,
+ @InjectRepository(ShopDrawingRevision)
+ private shopDrawingRevRepo: Repository,
+ @InjectRepository(CorrespondenceRouting)
+ private routingRepo: Repository,
+ @InjectRepository(RoutingTemplate)
+ private templateRepo: Repository,
+
+ private numberingService: DocumentNumberingService,
+ private userService: UserService,
+ private workflowEngine: WorkflowEngineService,
+ private notificationService: NotificationService,
+ private dataSource: DataSource,
+ ) {}
+
+ /**
+ * สร้างเอกสาร RFA ใหม่ (Create RFA)
+ */
+ async create(createDto: CreateRfaDto, user: User) {
+ const rfaType = await this.rfaTypeRepo.findOne({
+ where: { id: createDto.rfaTypeId },
+ });
+ if (!rfaType) throw new NotFoundException('RFA Type not found');
+
+ const statusDraft = await this.rfaStatusRepo.findOne({
+ where: { statusCode: 'DFT' },
+ });
+ if (!statusDraft) {
+ throw new InternalServerErrorException(
+ 'Status DFT (Draft) not found in Master Data',
+ );
+ }
+
+ let userOrgId = user.primaryOrganizationId;
+ if (!userOrgId) {
+ const fullUser = await this.userService.findOne(user.user_id);
+ if (fullUser) userOrgId = fullUser.primaryOrganizationId;
+ }
+ if (!userOrgId) {
+ throw new BadRequestException('User must belong to an organization');
+ }
+
+ const queryRunner = this.dataSource.createQueryRunner();
+ await queryRunner.connect();
+ await queryRunner.startTransaction();
+
+ try {
+ const orgCode = 'ORG'; // TODO: Fetch real ORG Code
+ const docNumber = await this.numberingService.generateNextNumber(
+ createDto.projectId,
+ userOrgId,
+ createDto.rfaTypeId,
+ new Date().getFullYear(),
+ {
+ TYPE_CODE: rfaType.typeCode,
+ ORG_CODE: orgCode,
+ },
+ );
+
+ const correspondence = queryRunner.manager.create(Correspondence, {
+ correspondenceNumber: docNumber,
+ correspondenceTypeId: createDto.rfaTypeId,
+ projectId: createDto.projectId,
+ originatorId: userOrgId,
+ isInternal: false,
+ createdBy: user.user_id,
+ });
+ const savedCorr = await queryRunner.manager.save(correspondence);
+
+ const rfa = queryRunner.manager.create(Rfa, {
+ rfaTypeId: createDto.rfaTypeId,
+ createdBy: user.user_id,
+ });
+ const savedRfa = await queryRunner.manager.save(rfa);
+
+ const rfaRevision = queryRunner.manager.create(RfaRevision, {
+ correspondenceId: savedCorr.id,
+ rfaId: savedRfa.id,
+ revisionNumber: 0,
+ revisionLabel: '0',
+ isCurrent: true,
+ rfaStatusCodeId: statusDraft.id,
+ title: createDto.title,
+ description: createDto.description,
+ documentDate: createDto.documentDate
+ ? new Date(createDto.documentDate)
+ : new Date(),
+ createdBy: user.user_id,
+ });
+ const savedRevision = await queryRunner.manager.save(rfaRevision);
+
+ if (
+ createDto.shopDrawingRevisionIds &&
+ createDto.shopDrawingRevisionIds.length > 0
+ ) {
+ const shopDrawings = await this.shopDrawingRevRepo.findBy({
+ id: In(createDto.shopDrawingRevisionIds),
+ });
+
+ if (shopDrawings.length !== createDto.shopDrawingRevisionIds.length) {
+ throw new NotFoundException('Some Shop Drawing Revisions not found');
+ }
+
+ const rfaItems = shopDrawings.map((sd) =>
+ queryRunner.manager.create(RfaItem, {
+ rfaRevisionId: savedCorr.id,
+ shopDrawingRevisionId: sd.id,
+ }),
+ );
+ await queryRunner.manager.save(rfaItems);
+ }
+
+ await queryRunner.commitTransaction();
+
+ return {
+ ...savedRfa,
+ currentRevision: {
+ ...savedRevision,
+ correspondenceNumber: docNumber,
+ },
+ };
+ } catch (err) {
+ await queryRunner.rollbackTransaction();
+ this.logger.error(`Failed to create RFA: ${(err as Error).message}`);
+ throw err;
+ } finally {
+ await queryRunner.release();
+ }
+ }
+
+ /**
+ * ดึงข้อมูล RFA รายตัว (Get One)
+ */
+ async findOne(id: number) {
+ const rfa = await this.rfaRepo.findOne({
+ where: { id },
+ relations: [
+ 'rfaType',
+ 'revisions',
+ 'revisions.statusCode',
+ 'revisions.approveCode',
+ 'revisions.correspondence',
+ 'revisions.items',
+ 'revisions.items.shopDrawingRevision',
+ 'revisions.items.shopDrawingRevision.shopDrawing',
+ ],
+ order: {
+ revisions: { revisionNumber: 'DESC' },
+ },
+ });
+
+ if (!rfa) {
+ throw new NotFoundException(`RFA ID ${id} not found`);
+ }
+
+ return rfa;
+ }
+
+ /**
+ * เริ่มต้นกระบวนการอนุมัติ (Submit Workflow)
+ */
+ async submit(rfaId: number, templateId: number, user: User) {
+ const rfa = await this.findOne(rfaId);
+ const currentRevision = rfa.revisions.find((r) => r.isCurrent);
+
+ if (!currentRevision) {
+ throw new NotFoundException('Current revision not found');
+ }
+
+ if (!currentRevision.correspondence) {
+ throw new InternalServerErrorException('Correspondence relation missing');
+ }
+
+ if (currentRevision.statusCode.statusCode !== 'DFT') {
+ throw new BadRequestException('Only DRAFT documents can be submitted');
+ }
+
+ const template = await this.templateRepo.findOne({
+ where: { id: templateId },
+ relations: ['steps'],
+ order: { steps: { sequence: 'ASC' } },
+ });
+
+ if (!template || !template.steps || template.steps.length === 0) {
+ throw new BadRequestException('Invalid routing template');
+ }
+
+ const statusForApprove = await this.rfaStatusRepo.findOne({
+ where: { statusCode: 'FAP' },
+ });
+ if (!statusForApprove) {
+ throw new InternalServerErrorException('Status FAP not found');
+ }
+
+ const queryRunner = this.dataSource.createQueryRunner();
+ await queryRunner.connect();
+ await queryRunner.startTransaction();
+
+ try {
+ currentRevision.rfaStatusCodeId = statusForApprove.id;
+ currentRevision.issuedDate = new Date();
+ await queryRunner.manager.save(currentRevision);
+
+ const firstStep = template.steps[0];
+
+ const routing = queryRunner.manager.create(CorrespondenceRouting, {
+ correspondenceId: currentRevision.correspondenceId,
+ templateId: template.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,
+ processedAt: new Date(),
+ });
+ await queryRunner.manager.save(routing);
+
+ // Notification
+ const recipientUserId = await this.userService.findDocControlIdByOrg(
+ firstStep.toOrganizationId,
+ );
+
+ if (recipientUserId) {
+ const docNo = currentRevision.correspondence.correspondenceNumber;
+ await this.notificationService.send({
+ userId: recipientUserId,
+ title: `RFA Submitted: ${currentRevision.title}`,
+ message: `มีเอกสาร RFA ใหม่รอการตรวจสอบจากคุณ (เลขที่: ${docNo})`,
+ type: 'SYSTEM',
+ entityType: 'rfa',
+ entityId: rfa.id,
+ link: `/rfas/${rfa.id}`,
+ });
+ }
+
+ await queryRunner.commitTransaction();
+ return { message: 'RFA Submitted successfully', routing };
+ } catch (err) {
+ await queryRunner.rollbackTransaction();
+ this.logger.error(`Failed to submit RFA: ${(err as Error).message}`);
+ throw err;
+ } finally {
+ await queryRunner.release();
+ }
+ }
+
+ /**
+ * ดำเนินการอนุมัติ/ปฏิเสธ (Process Workflow Action)
+ */
+ async processAction(rfaId: number, dto: WorkflowActionDto, user: User) {
+ const rfa = await this.findOne(rfaId);
+ const currentRevision = rfa.revisions.find((r) => r.isCurrent);
+ if (!currentRevision) {
+ throw new NotFoundException('Current revision not found');
+ }
+
+ const currentRouting = await this.routingRepo.findOne({
+ where: {
+ correspondenceId: currentRevision.correspondenceId,
+ status: 'SENT',
+ },
+ order: { sequence: 'DESC' },
+ relations: ['toOrganization'],
+ });
+
+ if (!currentRouting) {
+ throw new BadRequestException('No active workflow step found');
+ }
+
+ if (currentRouting.toOrganizationId !== user.primaryOrganizationId) {
+ throw new ForbiddenException(
+ 'You are not authorized to process this step',
+ );
+ }
+
+ const template = await this.templateRepo.findOne({
+ where: { id: currentRouting.templateId },
+ relations: ['steps'],
+ });
+
+ if (!template || !template.steps) {
+ throw new InternalServerErrorException('Template or steps not found');
+ }
+
+ const result = this.workflowEngine.processAction(
+ currentRouting.sequence,
+ template.steps.length,
+ dto.action,
+ dto.returnToSequence,
+ );
+
+ const queryRunner = this.dataSource.createQueryRunner();
+ await queryRunner.connect();
+ await queryRunner.startTransaction();
+
+ try {
+ currentRouting.status =
+ dto.action === WorkflowAction.REJECT ? 'REJECTED' : 'ACTIONED';
+ currentRouting.processedByUserId = user.user_id;
+ currentRouting.processedAt = new Date();
+ currentRouting.comments = dto.comments;
+ await queryRunner.manager.save(currentRouting);
+
+ if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) {
+ const nextStepConfig = template.steps.find(
+ (s) => s.sequence === result.nextStepSequence,
+ );
+
+ if (nextStepConfig) {
+ const nextRouting = queryRunner.manager.create(
+ CorrespondenceRouting,
+ {
+ correspondenceId: currentRevision.correspondenceId,
+ templateId: template.id,
+ sequence: result.nextStepSequence,
+ fromOrganizationId: user.primaryOrganizationId,
+ toOrganizationId: nextStepConfig.toOrganizationId,
+ stepPurpose: nextStepConfig.stepPurpose,
+ status: 'SENT',
+ dueDate: new Date(
+ Date.now() +
+ (nextStepConfig.expectedDays || 7) * 24 * 60 * 60 * 1000,
+ ),
+ },
+ );
+ await queryRunner.manager.save(nextRouting);
+ }
+ } else if (
+ result.nextStepSequence === null &&
+ dto.action !== WorkflowAction.REJECT
+ ) {
+ // Completed (Approved)
+ const approveCodeStr =
+ dto.action === WorkflowAction.APPROVE ? '1A' : '4X';
+ const approveCode = await this.rfaApproveRepo.findOne({
+ where: { approveCode: approveCodeStr },
+ });
+
+ if (approveCode) {
+ currentRevision.rfaApproveCodeId = approveCode.id;
+ currentRevision.approvedDate = new Date();
+ }
+ await queryRunner.manager.save(currentRevision);
+ } else if (dto.action === WorkflowAction.REJECT) {
+ // Rejected
+ const rejectCode = await this.rfaApproveRepo.findOne({
+ where: { approveCode: '4X' },
+ });
+ if (rejectCode) {
+ currentRevision.rfaApproveCodeId = rejectCode.id;
+ }
+ await queryRunner.manager.save(currentRevision);
+ }
+
+ await queryRunner.commitTransaction();
+ return { message: 'Action processed successfully', result };
+ } catch (err) {
+ await queryRunner.rollbackTransaction();
+ this.logger.error(
+ `Failed to process RFA action: ${(err as Error).message}`,
+ );
+ throw err;
+ } finally {
+ await queryRunner.release();
+ }
+ }
+}
diff --git a/backend/src/modules/transmittal/dto/create-transmittal.dto.ts b/backend/src/modules/transmittal/dto/create-transmittal.dto.ts
new file mode 100644
index 0000000..fb1847a
--- /dev/null
+++ b/backend/src/modules/transmittal/dto/create-transmittal.dto.ts
@@ -0,0 +1,36 @@
+import {
+ IsInt,
+ IsString,
+ IsOptional,
+ IsArray,
+ IsNotEmpty,
+ IsEnum,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+
+// Enum นี้ควรตรงกับใน Entity หรือสร้างไฟล์ enum แยก (ในที่นี้ใส่ไว้ใน DTO เพื่อความสะดวก)
+export enum TransmittalPurpose {
+ FOR_APPROVAL = 'FOR_APPROVAL',
+ FOR_INFORMATION = 'FOR_INFORMATION',
+ FOR_REVIEW = 'FOR_REVIEW',
+ OTHER = 'OTHER',
+}
+
+export class CreateTransmittalDto {
+ @IsInt()
+ @IsNotEmpty()
+ projectId!: number; // จำเป็นสำหรับการออกเลขที่เอกสาร (Running Number)
+
+ @IsEnum(TransmittalPurpose)
+ @IsOptional()
+ purpose?: TransmittalPurpose; // วัตถุประสงค์การส่ง
+
+ @IsString()
+ @IsOptional()
+ remarks?: string; // หมายเหตุเพิ่มเติม
+
+ @IsArray()
+ @IsInt({ each: true })
+ @IsNotEmpty()
+ itemIds!: number[]; // ID ของเอกสาร (Correspondence IDs) ที่จะแนบไปใน Transmittal นี้
+}
diff --git a/backend/src/modules/transmittal/dto/search-transmittal.dto.ts b/backend/src/modules/transmittal/dto/search-transmittal.dto.ts
new file mode 100644
index 0000000..2cc5ca5
--- /dev/null
+++ b/backend/src/modules/transmittal/dto/search-transmittal.dto.ts
@@ -0,0 +1,34 @@
+import {
+ IsInt,
+ IsOptional,
+ IsString,
+ IsEnum,
+ IsNotEmpty,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+import { TransmittalPurpose } from './create-transmittal.dto';
+
+export class SearchTransmittalDto {
+ @IsInt()
+ @Type(() => Number)
+ @IsNotEmpty()
+ projectId!: number; // บังคับระบุ Project
+
+ @IsEnum(TransmittalPurpose)
+ @IsOptional()
+ purpose?: TransmittalPurpose;
+
+ @IsString()
+ @IsOptional()
+ search?: string; // ค้นหาจากเลขที่เอกสาร หรือ remarks
+
+ @IsOptional()
+ @IsInt()
+ @Type(() => Number)
+ page: number = 1;
+
+ @IsOptional()
+ @IsInt()
+ @Type(() => Number)
+ pageSize: number = 20;
+}
diff --git a/backend/src/modules/transmittal/dto/update-transmittal.dto.ts b/backend/src/modules/transmittal/dto/update-transmittal.dto.ts
new file mode 100644
index 0000000..4d4c6d7
--- /dev/null
+++ b/backend/src/modules/transmittal/dto/update-transmittal.dto.ts
@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/swagger';
+import { CreateTransmittalDto } from './create-transmittal.dto';
+
+export class UpdateTransmittalDto extends PartialType(CreateTransmittalDto) {}
diff --git a/backend/src/modules/transmittal/entities/transmittal-item.entity.ts b/backend/src/modules/transmittal/entities/transmittal-item.entity.ts
new file mode 100644
index 0000000..5fb653c
--- /dev/null
+++ b/backend/src/modules/transmittal/entities/transmittal-item.entity.ts
@@ -0,0 +1,36 @@
+import {
+ Entity,
+ PrimaryGeneratedColumn,
+ Column,
+ ManyToOne,
+ JoinColumn,
+} from 'typeorm';
+import { Transmittal } from './transmittal.entity';
+import { Correspondence } from '../../correspondence/entities/correspondence.entity';
+
+@Entity('transmittal_items')
+export class TransmittalItem {
+ @PrimaryGeneratedColumn()
+ id!: number;
+
+ @Column({ name: 'transmittal_id' })
+ transmittalId!: number;
+
+ @Column({ name: 'item_correspondence_id' })
+ itemCorrespondenceId!: number;
+
+ @Column({ default: 1 })
+ quantity!: number;
+
+ @Column({ length: 255, nullable: true })
+ remarks?: string;
+
+ // Relations
+ @ManyToOne(() => Transmittal, (t) => t.items, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'transmittal_id' })
+ transmittal!: Transmittal;
+
+ @ManyToOne(() => Correspondence)
+ @JoinColumn({ name: 'item_correspondence_id' })
+ itemDocument!: Correspondence;
+}
diff --git a/backend/src/modules/transmittal/entities/transmittal.entity.ts b/backend/src/modules/transmittal/entities/transmittal.entity.ts
new file mode 100644
index 0000000..70b1f76
--- /dev/null
+++ b/backend/src/modules/transmittal/entities/transmittal.entity.ts
@@ -0,0 +1,36 @@
+import {
+ Entity,
+ PrimaryColumn,
+ Column,
+ OneToOne,
+ JoinColumn,
+ OneToMany,
+} from 'typeorm';
+import { Correspondence } from '../../correspondence/entities/correspondence.entity';
+import { TransmittalItem } from './transmittal-item.entity';
+
+@Entity('transmittals')
+export class Transmittal {
+ @PrimaryColumn({ name: 'correspondence_id' })
+ correspondenceId!: number;
+
+ @Column({
+ type: 'enum',
+ enum: ['FOR_APPROVAL', 'FOR_INFORMATION', 'FOR_REVIEW', 'OTHER'],
+ nullable: true,
+ })
+ purpose?: string;
+
+ @Column({ type: 'text', nullable: true })
+ remarks?: string;
+
+ // Relations
+ @OneToOne(() => Correspondence)
+ @JoinColumn({ name: 'correspondence_id' })
+ correspondence!: Correspondence;
+
+ @OneToMany(() => TransmittalItem, (item) => item.transmittal, {
+ cascade: true,
+ })
+ items!: TransmittalItem[];
+}
diff --git a/backend/src/modules/transmittal/transmittal.controller.ts b/backend/src/modules/transmittal/transmittal.controller.ts
new file mode 100644
index 0000000..4b51b00
--- /dev/null
+++ b/backend/src/modules/transmittal/transmittal.controller.ts
@@ -0,0 +1,53 @@
+import {
+ Controller,
+ Get,
+ Post,
+ Body,
+ Param,
+ Query,
+ UseGuards,
+ ParseIntPipe,
+} from '@nestjs/common';
+import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
+
+import { TransmittalService } from './transmittal.service';
+import { CreateTransmittalDto } from './dto/create-transmittal.dto';
+import { SearchTransmittalDto } from './dto/search-transmittal.dto'; // เดี๋ยวสร้าง DTO นี้เพิ่มให้ครับถ้ายังไม่มี
+import { User } from '../user/entities/user.entity';
+
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { RbacGuard } from '../../common/guards/rbac.guard';
+import { RequirePermission } from '../../common/decorators/require-permission.decorator';
+import { CurrentUser } from '../../common/decorators/current-user.decorator';
+
+@ApiTags('Transmittals')
+@ApiBearerAuth()
+@UseGuards(JwtAuthGuard, RbacGuard)
+@Controller('transmittals')
+export class TransmittalController {
+ constructor(private readonly transmittalService: TransmittalService) {}
+
+ @Post()
+ @ApiOperation({ summary: 'Create new Transmittal' })
+ @RequirePermission('transmittal.create') // สิทธิ์ ID 40
+ create(@Body() createDto: CreateTransmittalDto, @CurrentUser() user: User) {
+ return this.transmittalService.create(createDto, user);
+ }
+
+ // เพิ่ม Endpoint พื้นฐานสำหรับการค้นหา (Optional)
+ /*
+ @Get()
+ @ApiOperation({ summary: 'Search Transmittals' })
+ @RequirePermission('document.view')
+ findAll(@Query() searchDto: SearchTransmittalDto) {
+ // return this.transmittalService.findAll(searchDto);
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: 'Get Transmittal details' })
+ @RequirePermission('document.view')
+ findOne(@Param('id', ParseIntPipe) id: number) {
+ // return this.transmittalService.findOne(id);
+ }
+ */
+}
diff --git a/backend/src/modules/transmittal/transmittal.module.ts b/backend/src/modules/transmittal/transmittal.module.ts
new file mode 100644
index 0000000..8574ec8
--- /dev/null
+++ b/backend/src/modules/transmittal/transmittal.module.ts
@@ -0,0 +1,19 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Transmittal } from './entities/transmittal.entity';
+import { TransmittalItem } from './entities/transmittal-item.entity';
+import { Correspondence } from '../correspondence/entities/correspondence.entity';
+import { TransmittalService } from './transmittal.service';
+import { TransmittalController } from './transmittal.controller';
+import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([Transmittal, TransmittalItem, Correspondence]),
+ DocumentNumberingModule,
+ ],
+ controllers: [TransmittalController],
+ providers: [TransmittalService],
+ exports: [TransmittalService],
+})
+export class TransmittalModule {}
diff --git a/backend/src/modules/transmittal/transmittal.service.ts b/backend/src/modules/transmittal/transmittal.service.ts
new file mode 100644
index 0000000..4280660
--- /dev/null
+++ b/backend/src/modules/transmittal/transmittal.service.ts
@@ -0,0 +1,95 @@
+import {
+ Injectable,
+ NotFoundException,
+ BadRequestException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository, DataSource, In } from 'typeorm';
+
+import { Transmittal } from './entities/transmittal.entity';
+import { TransmittalItem } from './entities/transmittal-item.entity';
+import { Correspondence } from '../correspondence/entities/correspondence.entity';
+import { CreateTransmittalDto } from './dto/create-transmittal.dto'; // ต้องสร้าง DTO
+import { User } from '../user/entities/user.entity';
+import { DocumentNumberingService } from '../document-numbering/document-numbering.service';
+
+@Injectable()
+export class TransmittalService {
+ constructor(
+ @InjectRepository(Transmittal)
+ private transmittalRepo: Repository,
+ @InjectRepository(TransmittalItem)
+ private transmittalItemRepo: Repository,
+ @InjectRepository(Correspondence)
+ private correspondenceRepo: Repository,
+ private numberingService: DocumentNumberingService,
+ private dataSource: DataSource,
+ ) {}
+
+ async create(createDto: CreateTransmittalDto, user: User) {
+ // ✅ FIX: ตรวจสอบว่า User มีสังกัดองค์กรหรือไม่
+ if (!user.primaryOrganizationId) {
+ throw new BadRequestException(
+ 'User must belong to an organization to create documents',
+ );
+ }
+ const userOrgId = user.primaryOrganizationId; // TypeScript จะรู้ว่าเป็น number แล้ว
+
+ const queryRunner = this.dataSource.createQueryRunner();
+ await queryRunner.connect();
+ await queryRunner.startTransaction();
+
+ try {
+ // 1. Generate Document Number
+ const transmittalTypeId = 3; // TODO: ควรดึง ID จริงจาก DB หรือ Config
+ const orgCode = 'ORG'; // TODO: Fetch real ORG Code
+
+ const docNumber = await this.numberingService.generateNextNumber(
+ createDto.projectId,
+ userOrgId, // ✅ ส่งค่าที่เช็คแล้ว
+ transmittalTypeId,
+ new Date().getFullYear(),
+ { TYPE_CODE: 'TR', ORG_CODE: orgCode },
+ );
+
+ // 2. Create Correspondence (Header)
+ const correspondence = queryRunner.manager.create(Correspondence, {
+ correspondenceNumber: docNumber,
+ correspondenceTypeId: transmittalTypeId,
+ projectId: createDto.projectId,
+ originatorId: userOrgId, // ✅ ส่งค่าที่เช็คแล้ว
+ isInternal: false,
+ createdBy: user.user_id,
+ });
+ const savedCorr = await queryRunner.manager.save(correspondence);
+
+ // 3. Create Transmittal (Detail)
+ const transmittal = queryRunner.manager.create(Transmittal, {
+ correspondenceId: savedCorr.id,
+ purpose: createDto.purpose,
+ remarks: createDto.remarks,
+ });
+ await queryRunner.manager.save(transmittal);
+
+ // 4. Link Items (Documents being sent)
+ if (createDto.itemIds && createDto.itemIds.length > 0) {
+ const items = createDto.itemIds.map((itemId) =>
+ queryRunner.manager.create(TransmittalItem, {
+ transmittalId: savedCorr.id,
+ itemCorrespondenceId: itemId,
+ quantity: 1,
+ }),
+ );
+ await queryRunner.manager.save(items);
+ }
+
+ await queryRunner.commitTransaction();
+ return { ...savedCorr, transmittal };
+ } catch (err) {
+ await queryRunner.rollbackTransaction();
+ throw err;
+ } finally {
+ await queryRunner.release();
+ }
+ }
+}
diff --git a/backend/src/modules/user/entities/user-preference.entity.ts b/backend/src/modules/user/entities/user-preference.entity.ts
new file mode 100644
index 0000000..11e0423
--- /dev/null
+++ b/backend/src/modules/user/entities/user-preference.entity.ts
@@ -0,0 +1,37 @@
+import {
+ Entity,
+ PrimaryColumn,
+ Column,
+ UpdateDateColumn,
+ OneToOne,
+ JoinColumn,
+} from 'typeorm';
+import { User } from './user.entity';
+
+@Entity('user_preferences')
+export class UserPreference {
+ // ใช้ user_id เป็น Primary Key และ Foreign Key ในตัวเดียวกัน (1:1 Relation)
+ @PrimaryColumn({ name: 'user_id' })
+ userId!: number;
+
+ @Column({ name: 'notify_email', default: true })
+ notifyEmail!: boolean;
+
+ @Column({ name: 'notify_line', default: true })
+ notifyLine!: boolean;
+
+ @Column({ name: 'digest_mode', default: true })
+ digestMode!: boolean; // รับแจ้งเตือนแบบรวม (Digest) แทน Real-time
+
+ @Column({ name: 'ui_theme', length: 20, default: 'light' })
+ uiTheme!: string;
+
+ @UpdateDateColumn({ name: 'updated_at' })
+ updatedAt!: Date;
+
+ // --- Relations ---
+
+ @OneToOne(() => User)
+ @JoinColumn({ name: 'user_id' })
+ user!: User;
+}
diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts
index 1f8fd34..a3be051 100644
--- a/backend/src/modules/user/user.service.ts
+++ b/backend/src/modules/user/user.service.ts
@@ -6,15 +6,15 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
-import { User } from './entities/user.entity.js';
-import { CreateUserDto } from './dto/create-user.dto.js';
-import { UpdateUserDto } from './dto/update-user.dto.js';
+import { User } from './entities/user.entity';
+import { CreateUserDto } from './dto/create-user.dto';
+import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
- private usersRepository: Repository,
+ private usersRepository: Repository, // ✅ ชื่อตัวแปรจริงคือ usersRepository
) {}
// 1. สร้างผู้ใช้ (Hash Password ก่อนบันทึก)
@@ -61,7 +61,7 @@ export class UserService {
// 3. ดึงข้อมูลรายคน
async findOne(id: number): Promise {
const user = await this.usersRepository.findOne({
- where: { user_id: id }, // ใช้ user_id ตามที่คุณตั้งชื่อไว้
+ where: { user_id: id }, // ใช้ user_id ตาม Entity
});
if (!user) {
@@ -102,12 +102,23 @@ export class UserService {
}
}
- // 👇👇 เพิ่มฟังก์ชันใหม่นี้ 👇👇
- async getUserPermissions(userId: number): Promise {
- // Query ข้อมูลจาก View: v_user_all_permissions (ที่เราสร้างไว้ใน SQL Script)
- // เนื่องจาก TypeORM ไม่รองรับ View โดยตรงในบางท่า เราใช้ query builder หรือ query raw ได้
- // แต่เพื่อความง่ายและประสิทธิภาพ เราจะใช้ query raw ครับ
+ /**
+ * หา User ID ของคนที่เป็น Document Control (หรือตัวแทน) ในองค์กร
+ * เพื่อส่ง Notification
+ */
+ async findDocControlIdByOrg(organizationId: number): Promise {
+ // ✅ FIX: ใช้ usersRepository ให้ตรงกับ Constructor
+ const user = await this.usersRepository.findOne({
+ where: { primaryOrganizationId: organizationId },
+ // order: { roleId: 'ASC' } // (Optional) Logic การเลือกคน
+ });
+ return user ? user.user_id : null;
+ }
+
+ // ฟังก์ชันดึงสิทธิ์ (Permission)
+ async getUserPermissions(userId: number): Promise {
+ // Query ข้อมูลจาก View: v_user_all_permissions
const permissions = await this.usersRepository.query(
`SELECT permission_name FROM v_user_all_permissions WHERE user_id = ?`,
[userId],
diff --git a/temp.md b/temp.md
index e69de29..033bdae 100644
--- a/temp.md
+++ b/temp.md
@@ -0,0 +1,52 @@
+import {
+ Controller,
+ Get,
+ Post,
+ Body,
+ Param,
+ ParseIntPipe,
+ UseGuards,
+} from '@nestjs/common';
+import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
+
+import { RfaService } from './rfa.service';
+import { CreateRfaDto } from './dto/create-rfa.dto';
+import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; // Reuse DTO
+import { User } from '../user/entities/user.entity';
+
+import { JwtAuthGuard } from '../../common/auth/guards/jwt-auth.guard';
+import { RbacGuard } from '../../common/auth/guards/rbac.guard';
+import { RequirePermission } from '../../common/decorators/require-permission.decorator';
+import { CurrentUser } from '../../common/decorators/current-user.decorator';
+
+@ApiTags('RFA (Request for Approval)')
+@ApiBearerAuth()
+@UseGuards(JwtAuthGuard, RbacGuard)
+@Controller('rfas')
+export class RfaController {
+ constructor(private readonly rfaService: RfaService) {}
+
+ // ... (Create, FindOne endpoints) ...
+
+ @Post(':id/submit')
+ @ApiOperation({ summary: 'Submit RFA to Workflow' })
+ @RequirePermission('rfa.create') // ผู้สร้างมีสิทธิ์ส่ง
+ submit(
+ @Param('id', ParseIntPipe) id: number,
+ @Body('templateId', ParseIntPipe) templateId: number, // รับ Template ID
+ @CurrentUser() user: User,
+ ) {
+ return this.rfaService.submit(id, templateId, user);
+ }
+
+ @Post(':id/action')
+ @ApiOperation({ summary: 'Process Workflow Action (Approve/Reject)' })
+ @RequirePermission('workflow.action_review') // สิทธิ์ในการ Approve/Review
+ processAction(
+ @Param('id', ParseIntPipe) id: number,
+ @Body() actionDto: WorkflowActionDto,
+ @CurrentUser() user: User,
+ ) {
+ return this.rfaService.processAction(id, actionDto, user);
+ }
+}
\ No newline at end of file