251123:0200 T6.1 to DO

This commit is contained in:
2025-11-23 02:23:38 +07:00
parent 17d9f172d4
commit 23006898d9
58 changed files with 3221 additions and 502 deletions

View File

@@ -24,5 +24,6 @@
"database": "lcbp3_dev",
"username": "root"
}
]
],
"editor.fontSize": 16
}

View File

@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -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" ]

View File

@@ -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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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).

View File

@@ -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 สร้าง)

View File

@@ -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" }],
},
},
);

View File

@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@@ -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"
}
}

View File

@@ -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!');
});
});
});

View File

@@ -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();
}
}

View File

@@ -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 {}

View File

@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@@ -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();

View File

@@ -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!');
});
});

View File

@@ -1,9 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@@ -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
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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();

View 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
}
}

View File

@@ -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; // หมายเหตุเพิ่มเติม (ถ้ามี)
}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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)
}

View File

@@ -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; // กรอง: อ่านแล้ว/ยังไม่อ่าน
}

View File

@@ -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;
}

View File

@@ -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);
}
}
}

View 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);
}
}

View 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);
}
}

View 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 {}

View 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}`);
}
}
}

View 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 },
);
}
}

View 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 ที่แนบมา
}

View 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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRfaDto } from './create-rfa.dto';
export class UpdateRfaDto extends PartialType(CreateRfaDto) {}

View 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;
}

View 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;
}

View 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[];
}

View 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;
}

View 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;
}

View 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[];
}

View 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);
}
}

View 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 {}

View 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();
}
}
}

View File

@@ -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 นี้
}

View File

@@ -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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateTransmittalDto } from './create-transmittal.dto';
export class UpdateTransmittalDto extends PartialType(CreateTransmittalDto) {}

View File

@@ -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;
}

View File

@@ -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[];
}

View 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);
}
*/
}

View 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 {}

View 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();
}
}
}

View 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;
}

View File

@@ -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
View File

@@ -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);
}
}