251123:0200 T6.1 to DO
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -24,5 +24,6 @@
|
||||
"database": "lcbp3_dev",
|
||||
"username": "root"
|
||||
}
|
||||
]
|
||||
],
|
||||
"editor.fontSize": 16
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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" ]
|
||||
@@ -1,98 +0,0 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## 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).
|
||||
@@ -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 สร้าง)
|
||||
@@ -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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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<App>;
|
||||
|
||||
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!');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
1072
backend/pnpm-lock.yaml
generated
1072
backend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
83
backend/src/modules/circulation/circulation.service.ts
Normal file
83
backend/src/modules/circulation/circulation.service.ts
Normal file
@@ -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<Circulation>,
|
||||
@InjectRepository(CirculationRouting)
|
||||
private routingRepo: Repository<CirculationRouting>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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; // หมายเหตุเพิ่มเติม (ถ้ามี)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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; // กรอง: อ่านแล้ว/ยังไม่อ่าน
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Notification>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ลบแจ้งเตือนที่ "อ่านแล้ว" และเก่ากว่า 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
75
backend/src/modules/notification/notification.controller.ts
Normal file
75
backend/src/modules/notification/notification.controller.ts
Normal file
@@ -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<Notification>,
|
||||
) {}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
38
backend/src/modules/notification/notification.gateway.ts
Normal file
38
backend/src/modules/notification/notification.gateway.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
37
backend/src/modules/notification/notification.module.ts
Normal file
37
backend/src/modules/notification/notification.module.ts
Normal file
@@ -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 {}
|
||||
88
backend/src/modules/notification/notification.processor.ts
Normal file
88
backend/src/modules/notification/notification.processor.ts
Normal file
@@ -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<any, any, string>): Promise<any> {
|
||||
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" <no-reply@np-dms.work>',
|
||||
to: user.email,
|
||||
subject: `[DMS] ${data.title}`,
|
||||
html: `
|
||||
<h3>${data.title}</h3>
|
||||
<p>${data.message}</p>
|
||||
<br/>
|
||||
<a href="${data.link}">คลิกเพื่อดูรายละเอียด</a>
|
||||
`,
|
||||
});
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
136
backend/src/modules/notification/notification.service.ts
Normal file
136
backend/src/modules/notification/notification.service.ts
Normal file
@@ -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<Notification>,
|
||||
@InjectRepository(User)
|
||||
private userRepo: Repository<User>,
|
||||
@InjectRepository(UserPreference)
|
||||
private userPrefRepo: Repository<UserPreference>,
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
43
backend/src/modules/rfa/dto/create-rfa.dto.ts
Normal file
43
backend/src/modules/rfa/dto/create-rfa.dto.ts
Normal file
@@ -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 ที่แนบมา
|
||||
}
|
||||
33
backend/src/modules/rfa/dto/search-rfa.dto.ts
Normal file
33
backend/src/modules/rfa/dto/search-rfa.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
4
backend/src/modules/rfa/dto/update-rfa.dto.ts
Normal file
4
backend/src/modules/rfa/dto/update-rfa.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateRfaDto } from './create-rfa.dto';
|
||||
|
||||
export class UpdateRfaDto extends PartialType(CreateRfaDto) {}
|
||||
22
backend/src/modules/rfa/entities/rfa-approve-code.entity.ts
Normal file
22
backend/src/modules/rfa/entities/rfa-approve-code.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
27
backend/src/modules/rfa/entities/rfa-item.entity.ts
Normal file
27
backend/src/modules/rfa/entities/rfa-item.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
99
backend/src/modules/rfa/entities/rfa-revision.entity.ts
Normal file
99
backend/src/modules/rfa/entities/rfa-revision.entity.ts
Normal file
@@ -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[];
|
||||
}
|
||||
22
backend/src/modules/rfa/entities/rfa-status-code.entity.ts
Normal file
22
backend/src/modules/rfa/entities/rfa-status-code.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
22
backend/src/modules/rfa/entities/rfa-type.entity.ts
Normal file
22
backend/src/modules/rfa/entities/rfa-type.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
43
backend/src/modules/rfa/entities/rfa.entity.ts
Normal file
43
backend/src/modules/rfa/entities/rfa.entity.ts
Normal file
@@ -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[];
|
||||
}
|
||||
64
backend/src/modules/rfa/rfa.controller.ts
Normal file
64
backend/src/modules/rfa/rfa.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
43
backend/src/modules/rfa/rfa.module.ts
Normal file
43
backend/src/modules/rfa/rfa.module.ts
Normal file
@@ -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 {}
|
||||
426
backend/src/modules/rfa/rfa.service.ts
Normal file
426
backend/src/modules/rfa/rfa.service.ts
Normal file
@@ -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<Rfa>,
|
||||
@InjectRepository(RfaRevision)
|
||||
private rfaRevisionRepo: Repository<RfaRevision>,
|
||||
@InjectRepository(RfaItem)
|
||||
private rfaItemRepo: Repository<RfaItem>,
|
||||
@InjectRepository(Correspondence)
|
||||
private correspondenceRepo: Repository<Correspondence>,
|
||||
@InjectRepository(RfaType)
|
||||
private rfaTypeRepo: Repository<RfaType>,
|
||||
@InjectRepository(RfaStatusCode)
|
||||
private rfaStatusRepo: Repository<RfaStatusCode>,
|
||||
@InjectRepository(RfaApproveCode)
|
||||
private rfaApproveRepo: Repository<RfaApproveCode>,
|
||||
@InjectRepository(ShopDrawingRevision)
|
||||
private shopDrawingRevRepo: Repository<ShopDrawingRevision>,
|
||||
@InjectRepository(CorrespondenceRouting)
|
||||
private routingRepo: Repository<CorrespondenceRouting>,
|
||||
@InjectRepository(RoutingTemplate)
|
||||
private templateRepo: Repository<RoutingTemplate>,
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 นี้
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateTransmittalDto } from './create-transmittal.dto';
|
||||
|
||||
export class UpdateTransmittalDto extends PartialType(CreateTransmittalDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
53
backend/src/modules/transmittal/transmittal.controller.ts
Normal file
53
backend/src/modules/transmittal/transmittal.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
*/
|
||||
}
|
||||
19
backend/src/modules/transmittal/transmittal.module.ts
Normal file
19
backend/src/modules/transmittal/transmittal.module.ts
Normal file
@@ -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 {}
|
||||
95
backend/src/modules/transmittal/transmittal.service.ts
Normal file
95
backend/src/modules/transmittal/transmittal.service.ts
Normal file
@@ -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<Transmittal>,
|
||||
@InjectRepository(TransmittalItem)
|
||||
private transmittalItemRepo: Repository<TransmittalItem>,
|
||||
@InjectRepository(Correspondence)
|
||||
private correspondenceRepo: Repository<Correspondence>,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
37
backend/src/modules/user/entities/user-preference.entity.ts
Normal file
37
backend/src/modules/user/entities/user-preference.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<User>,
|
||||
private usersRepository: Repository<User>, // ✅ ชื่อตัวแปรจริงคือ usersRepository
|
||||
) {}
|
||||
|
||||
// 1. สร้างผู้ใช้ (Hash Password ก่อนบันทึก)
|
||||
@@ -61,7 +61,7 @@ export class UserService {
|
||||
// 3. ดึงข้อมูลรายคน
|
||||
async findOne(id: number): Promise<User> {
|
||||
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<string[]> {
|
||||
// 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<number | null> {
|
||||
// ✅ 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<string[]> {
|
||||
// Query ข้อมูลจาก View: v_user_all_permissions
|
||||
const permissions = await this.usersRepository.query(
|
||||
`SELECT permission_name FROM v_user_all_permissions WHERE user_id = ?`,
|
||||
[userId],
|
||||
|
||||
52
temp.md
52
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user