From 9c970f8ed8f144f5c76e5b503d3e6d4d6569a77f Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 23 Nov 2025 02:23:38 +0700 Subject: [PATCH] 251123:0200 T6.1 to DO --- .vscode/settings.json | 3 +- backend.bak/.prettierrc | 4 - backend.bak/Dockerfile | 48 - backend.bak/README.md | 98 -- backend.bak/docker-compose.yml | 62 - backend.bak/eslint.config.mjs | 35 - backend.bak/nest-cli.json | 8 - backend.bak/package.json | 98 -- backend.bak/src/app.controller.spec.ts | 22 - backend.bak/src/app.controller.ts | 12 - backend.bak/src/app.module.ts | 10 - backend.bak/src/app.service.ts | 8 - backend.bak/src/main.ts | 8 - backend.bak/test/app.e2e-spec.ts | 25 - backend.bak/test/jest-e2e.json | 9 - backend.bak/tsconfig.build.json | 4 - backend.bak/tsconfig.json | 25 - backend/package.json | 9 + backend/pnpm-lock.yaml | 1072 +++++++++++++++++ backend/src/main.ts | 55 +- .../circulation/circulation.service.ts | 83 ++ .../circulation/dto/create-circulation.dto.ts | 26 + .../circulation/dto/create-transmittal.dto.ts | 34 + .../entities/circulation-routing.entity.ts | 62 + .../circulation-status-code.entity.ts | 19 + .../entities/circulation.entity.ts | 73 ++ .../dto/create-notification.dto.ts | 39 + .../dto/search-notification.dto.ts | 23 + .../entities/notification.entity.ts | 55 + .../notification-cleanup.service.ts | 39 + .../notification/notification.controller.ts | 75 ++ .../notification/notification.gateway.ts | 38 + .../notification/notification.module.ts | 37 + .../notification/notification.processor.ts | 88 ++ .../notification/notification.service.ts | 136 +++ backend/src/modules/rfa/dto/create-rfa.dto.ts | 43 + backend/src/modules/rfa/dto/search-rfa.dto.ts | 33 + backend/src/modules/rfa/dto/update-rfa.dto.ts | 4 + .../rfa/entities/rfa-approve-code.entity.ts | 22 + .../modules/rfa/entities/rfa-item.entity.ts | 27 + .../rfa/entities/rfa-revision.entity.ts | 99 ++ .../rfa/entities/rfa-status-code.entity.ts | 22 + .../modules/rfa/entities/rfa-type.entity.ts | 22 + .../src/modules/rfa/entities/rfa.entity.ts | 43 + backend/src/modules/rfa/rfa.controller.ts | 64 + backend/src/modules/rfa/rfa.module.ts | 43 + backend/src/modules/rfa/rfa.service.ts | 426 +++++++ .../transmittal/dto/create-transmittal.dto.ts | 36 + .../transmittal/dto/search-transmittal.dto.ts | 34 + .../transmittal/dto/update-transmittal.dto.ts | 4 + .../entities/transmittal-item.entity.ts | 36 + .../entities/transmittal.entity.ts | 36 + .../transmittal/transmittal.controller.ts | 53 + .../modules/transmittal/transmittal.module.ts | 19 + .../transmittal/transmittal.service.ts | 95 ++ .../user/entities/user-preference.entity.ts | 37 + backend/src/modules/user/user.service.ts | 31 +- temp.md | 52 + 58 files changed, 3221 insertions(+), 502 deletions(-) delete mode 100644 backend.bak/.prettierrc delete mode 100644 backend.bak/Dockerfile delete mode 100644 backend.bak/README.md delete mode 100644 backend.bak/docker-compose.yml delete mode 100644 backend.bak/eslint.config.mjs delete mode 100644 backend.bak/nest-cli.json delete mode 100644 backend.bak/package.json delete mode 100644 backend.bak/src/app.controller.spec.ts delete mode 100644 backend.bak/src/app.controller.ts delete mode 100644 backend.bak/src/app.module.ts delete mode 100644 backend.bak/src/app.service.ts delete mode 100644 backend.bak/src/main.ts delete mode 100644 backend.bak/test/app.e2e-spec.ts delete mode 100644 backend.bak/test/jest-e2e.json delete mode 100644 backend.bak/tsconfig.build.json delete mode 100644 backend.bak/tsconfig.json create mode 100644 backend/src/modules/circulation/circulation.service.ts create mode 100644 backend/src/modules/circulation/dto/create-circulation.dto.ts create mode 100644 backend/src/modules/circulation/dto/create-transmittal.dto.ts create mode 100644 backend/src/modules/circulation/entities/circulation-routing.entity.ts create mode 100644 backend/src/modules/circulation/entities/circulation-status-code.entity.ts create mode 100644 backend/src/modules/circulation/entities/circulation.entity.ts create mode 100644 backend/src/modules/notification/dto/create-notification.dto.ts create mode 100644 backend/src/modules/notification/dto/search-notification.dto.ts create mode 100644 backend/src/modules/notification/entities/notification.entity.ts create mode 100644 backend/src/modules/notification/notification-cleanup.service.ts create mode 100644 backend/src/modules/notification/notification.controller.ts create mode 100644 backend/src/modules/notification/notification.gateway.ts create mode 100644 backend/src/modules/notification/notification.module.ts create mode 100644 backend/src/modules/notification/notification.processor.ts create mode 100644 backend/src/modules/notification/notification.service.ts create mode 100644 backend/src/modules/rfa/dto/create-rfa.dto.ts create mode 100644 backend/src/modules/rfa/dto/search-rfa.dto.ts create mode 100644 backend/src/modules/rfa/dto/update-rfa.dto.ts create mode 100644 backend/src/modules/rfa/entities/rfa-approve-code.entity.ts create mode 100644 backend/src/modules/rfa/entities/rfa-item.entity.ts create mode 100644 backend/src/modules/rfa/entities/rfa-revision.entity.ts create mode 100644 backend/src/modules/rfa/entities/rfa-status-code.entity.ts create mode 100644 backend/src/modules/rfa/entities/rfa-type.entity.ts create mode 100644 backend/src/modules/rfa/entities/rfa.entity.ts create mode 100644 backend/src/modules/rfa/rfa.controller.ts create mode 100644 backend/src/modules/rfa/rfa.module.ts create mode 100644 backend/src/modules/rfa/rfa.service.ts create mode 100644 backend/src/modules/transmittal/dto/create-transmittal.dto.ts create mode 100644 backend/src/modules/transmittal/dto/search-transmittal.dto.ts create mode 100644 backend/src/modules/transmittal/dto/update-transmittal.dto.ts create mode 100644 backend/src/modules/transmittal/entities/transmittal-item.entity.ts create mode 100644 backend/src/modules/transmittal/entities/transmittal.entity.ts create mode 100644 backend/src/modules/transmittal/transmittal.controller.ts create mode 100644 backend/src/modules/transmittal/transmittal.module.ts create mode 100644 backend/src/modules/transmittal/transmittal.service.ts create mode 100644 backend/src/modules/user/entities/user-preference.entity.ts 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 @@ -

- Nest Logo -

- -[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.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Discord -Backers on Open Collective -Sponsors on Open Collective - Donate us - Support us - Follow us on Twitter -

- - -## 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