260322:1648 Correct Coresspondence / Doing RFA / Correct CI
This commit is contained in:
@@ -196,7 +196,7 @@ pnpm add @nestjs/typeorm typeorm mysql2 @nestjs/config
|
||||
สร้างไฟล์ `docker-compose.yml` ที่ root ของโปรเจกต์ (ถ้ายังไม่มี):
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
@@ -209,7 +209,7 @@ services:
|
||||
MYSQL_USER: admin
|
||||
MYSQL_PASSWORD: password123
|
||||
ports:
|
||||
- "3306:3306"
|
||||
- '3306:3306'
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
networks:
|
||||
@@ -222,7 +222,7 @@ services:
|
||||
environment:
|
||||
PMA_HOST: mariadb
|
||||
ports:
|
||||
- "8080:80"
|
||||
- '8080:80'
|
||||
depends_on:
|
||||
- mariadb
|
||||
networks:
|
||||
@@ -248,36 +248,36 @@ docker-compose up -d
|
||||
|
||||
```typescript
|
||||
// src/app.module.ts
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 1. Load Config Module
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // ให้เรียกใช้ได้ทุกที่โดยไม่ต้อง import ใหม่
|
||||
envFilePath: ".env", // อ่านค่าจากไฟล์ .env
|
||||
envFilePath: '.env', // อ่านค่าจากไฟล์ .env
|
||||
}),
|
||||
|
||||
// 2. Setup TypeORM Connection (Async เพื่อรออ่าน Config ก่อน)
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
type: "mariadb", // หรือ 'mysql' ก็ได้เพราะใช้ driver เดียวกัน
|
||||
host: configService.get<string>("DB_HOST"),
|
||||
port: configService.get<number>("DB_PORT"),
|
||||
username: configService.get<string>("DB_USERNAME"),
|
||||
password: configService.get<string>("DB_PASSWORD"),
|
||||
database: configService.get<string>("DB_DATABASE"),
|
||||
type: 'mariadb', // หรือ 'mysql' ก็ได้เพราะใช้ driver เดียวกัน
|
||||
host: configService.get<string>('DB_HOST'),
|
||||
port: configService.get<number>('DB_PORT'),
|
||||
username: configService.get<string>('DB_USERNAME'),
|
||||
password: configService.get<string>('DB_PASSWORD'),
|
||||
database: configService.get<string>('DB_DATABASE'),
|
||||
// Auto Load Entities: โหลด Entity ทั้งหมดที่อยู่ในโปรเจกต์อัตโนมัติ
|
||||
autoLoadEntities: true,
|
||||
// Synchronize: true เฉพาะ Dev environment (ห้ามใช้ใน Prod)
|
||||
synchronize: configService.get<string>("NODE_ENV") === "development",
|
||||
synchronize: configService.get<string>('NODE_ENV') === 'development',
|
||||
// Logging: เปิดดู Query SQL ตอน Dev
|
||||
logging: configService.get<string>("NODE_ENV") === "development",
|
||||
logging: configService.get<string>('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
@@ -397,14 +397,12 @@ pnpm add @nestjs/config joi
|
||||
|
||||
```typescript
|
||||
// File: src/common/config/env.validation.ts
|
||||
import Joi from "joi";
|
||||
import Joi from 'joi';
|
||||
|
||||
// สร้าง Schema สำหรับตรวจสอบค่า Environment Variables
|
||||
export const envValidationSchema = Joi.object({
|
||||
// 1. Application Environment
|
||||
NODE_ENV: Joi.string()
|
||||
.valid("development", "production", "test", "provision")
|
||||
.default("development"),
|
||||
NODE_ENV: Joi.string().valid('development', 'production', 'test', 'provision').default('development'),
|
||||
PORT: Joi.number().default(3000),
|
||||
|
||||
// 2. Database Configuration (MariaDB)
|
||||
@@ -417,11 +415,8 @@ export const envValidationSchema = Joi.object({
|
||||
|
||||
// 3. Security (JWT)
|
||||
// ต้องมีค่า และควรยาวพอ (ตรวจสอบความยาวได้ถ้าระบุ min)
|
||||
JWT_SECRET: Joi.string()
|
||||
.required()
|
||||
.min(32)
|
||||
.message("JWT_SECRET must be at least 32 characters long for security."),
|
||||
JWT_EXPIRATION: Joi.string().default("8h"),
|
||||
JWT_SECRET: Joi.string().required().min(32).message('JWT_SECRET must be at least 32 characters long for security.'),
|
||||
JWT_EXPIRATION: Joi.string().default('8h'),
|
||||
});
|
||||
```
|
||||
|
||||
@@ -431,19 +426,19 @@ export const envValidationSchema = Joi.object({
|
||||
|
||||
```typescript
|
||||
// File: src/app.module.ts
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
import { envValidationSchema } from "./common/config/env.validation.js"; // สังเกต .js สำหรับ ESM
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { envValidationSchema } from './common/config/env.validation.js'; // สังเกต .js สำหรับ ESM
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 1. Setup Config Module พร้อม Validation
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // เรียกใช้ได้ทั่วทั้ง App ไม่ต้อง import ซ้ำ
|
||||
envFilePath: ".env", // อ่านไฟล์ .env (สำหรับ Dev)
|
||||
envFilePath: '.env', // อ่านไฟล์ .env (สำหรับ Dev)
|
||||
validationSchema: envValidationSchema, // ใช้ Schema ที่เราสร้างเพื่อตรวจสอบ
|
||||
validationOptions: {
|
||||
// ถ้ามีค่าไหนไม่ผ่าน Validation ให้ Error และหยุดทำงานทันที
|
||||
@@ -456,15 +451,15 @@ import { envValidationSchema } from "./common/config/env.validation.js"; // ส
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
type: "mariadb",
|
||||
host: configService.get<string>("DB_HOST"),
|
||||
port: configService.get<number>("DB_PORT"),
|
||||
username: configService.get<string>("DB_USERNAME"),
|
||||
password: configService.get<string>("DB_PASSWORD"),
|
||||
database: configService.get<string>("DB_DATABASE"),
|
||||
type: 'mariadb',
|
||||
host: configService.get<string>('DB_HOST'),
|
||||
port: configService.get<number>('DB_PORT'),
|
||||
username: configService.get<string>('DB_USERNAME'),
|
||||
password: configService.get<string>('DB_PASSWORD'),
|
||||
database: configService.get<string>('DB_DATABASE'),
|
||||
autoLoadEntities: true,
|
||||
// synchronize: true เฉพาะตอน Dev เท่านั้น ห้ามใช้บน Prod
|
||||
synchronize: configService.get<string>("NODE_ENV") === "development",
|
||||
synchronize: configService.get<string>('NODE_ENV') === 'development',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
@@ -481,7 +476,7 @@ export class AppModule {}
|
||||
สร้างไฟล์: `docker-compose.override.yml.example` ที่ root project:
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Override ค่า Config ของ Service Backend (เมื่อเราสร้าง Container Backend ในอนาคต)
|
||||
@@ -533,7 +528,7 @@ App **ต้อง Crash** และแสดง Error Message ชัดเจ
|
||||
**ไฟล์: `docker-compose.yml`**
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ... (mariadb & pma เดิม) ...
|
||||
@@ -546,7 +541,7 @@ services:
|
||||
# ใช้ Command นี้เพื่อตั้ง Password
|
||||
command: redis-server --requirepass "redis_password_secure"
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
@@ -614,20 +609,20 @@ pnpm add @nestjs/bullmq bullmq
|
||||
**ไฟล์: `src/app.module.ts`**
|
||||
|
||||
```typescript
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { BullModule } from "@nestjs/bullmq"; // Import BullModule
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
import { envValidationSchema } from "./common/config/env.validation.js";
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq'; // Import BullModule
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { envValidationSchema } from './common/config/env.validation.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 1. Config (เดิม)
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ".env",
|
||||
envFilePath: '.env',
|
||||
validationSchema: envValidationSchema,
|
||||
validationOptions: { abortEarly: true },
|
||||
}),
|
||||
@@ -637,14 +632,14 @@ import { envValidationSchema } from "./common/config/env.validation.js";
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
type: "mariadb",
|
||||
host: configService.get<string>("DB_HOST"),
|
||||
port: configService.get<number>("DB_PORT"),
|
||||
username: configService.get<string>("DB_USERNAME"),
|
||||
password: configService.get<string>("DB_PASSWORD"),
|
||||
database: configService.get<string>("DB_DATABASE"),
|
||||
type: 'mariadb',
|
||||
host: configService.get<string>('DB_HOST'),
|
||||
port: configService.get<number>('DB_PORT'),
|
||||
username: configService.get<string>('DB_USERNAME'),
|
||||
password: configService.get<string>('DB_PASSWORD'),
|
||||
database: configService.get<string>('DB_DATABASE'),
|
||||
autoLoadEntities: true,
|
||||
synchronize: configService.get<string>("NODE_ENV") === "development",
|
||||
synchronize: configService.get<string>('NODE_ENV') === 'development',
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -654,9 +649,9 @@ import { envValidationSchema } from "./common/config/env.validation.js";
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
connection: {
|
||||
host: configService.get<string>("REDIS_HOST"),
|
||||
port: configService.get<number>("REDIS_PORT"),
|
||||
password: configService.get<string>("REDIS_PASSWORD"),
|
||||
host: configService.get<string>('REDIS_HOST'),
|
||||
port: configService.get<number>('REDIS_PORT'),
|
||||
password: configService.get<string>('REDIS_PASSWORD'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -686,25 +681,25 @@ pnpm start:dev
|
||||
ให้เพิ่มบรรทัดนี้ที่ส่วนบนสุดของไฟล์ `src/app.module.ts` ครับ:
|
||||
|
||||
```typescript
|
||||
import { BullModule } from "@nestjs/bullmq";
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
```
|
||||
|
||||
หรือลองตรวจสอบไฟล์ `src/app.module.ts` ให้ครบถ้วนตามนี้ครับ:
|
||||
|
||||
```typescript
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { BullModule } from "@nestjs/bullmq"; // <--- ต้องมีบรรทัดนี้ครับ
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
import { envValidationSchema } from "./common/config/env.validation.js";
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq'; // <--- ต้องมีบรรทัดนี้ครับ
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { envValidationSchema } from './common/config/env.validation.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ".env",
|
||||
envFilePath: '.env',
|
||||
validationSchema: envValidationSchema,
|
||||
validationOptions: { abortEarly: true },
|
||||
}),
|
||||
@@ -712,14 +707,14 @@ import { envValidationSchema } from "./common/config/env.validation.js";
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
type: "mariadb",
|
||||
host: configService.get<string>("DB_HOST"),
|
||||
port: configService.get<number>("DB_PORT"),
|
||||
username: configService.get<string>("DB_USERNAME"),
|
||||
password: configService.get<string>("DB_PASSWORD"),
|
||||
database: configService.get<string>("DB_DATABASE"),
|
||||
type: 'mariadb',
|
||||
host: configService.get<string>('DB_HOST'),
|
||||
port: configService.get<number>('DB_PORT'),
|
||||
username: configService.get<string>('DB_USERNAME'),
|
||||
password: configService.get<string>('DB_PASSWORD'),
|
||||
database: configService.get<string>('DB_DATABASE'),
|
||||
autoLoadEntities: true,
|
||||
synchronize: configService.get<string>("NODE_ENV") === "development",
|
||||
synchronize: configService.get<string>('NODE_ENV') === 'development',
|
||||
}),
|
||||
}),
|
||||
// Queue Configuration
|
||||
@@ -728,9 +723,9 @@ import { envValidationSchema } from "./common/config/env.validation.js";
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
connection: {
|
||||
host: configService.get<string>("REDIS_HOST"),
|
||||
port: configService.get<number>("REDIS_PORT"),
|
||||
password: configService.get<string>("REDIS_PASSWORD"),
|
||||
host: configService.get<string>('REDIS_HOST'),
|
||||
port: configService.get<number>('REDIS_PORT'),
|
||||
password: configService.get<string>('REDIS_PASSWORD'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
nest g module common
|
||||
```
|
||||
|
||||
*(ถ้ามันฟ้องว่ามีไฟล์อยู่แล้ว ให้ข้ามขั้นตอนนี้ได้เลยครับ แต่ต้องแน่ใจว่า `app.module.ts` มี `CommonModule` ใน imports แล้ว)*
|
||||
_(ถ้ามันฟ้องว่ามีไฟล์อยู่แล้ว ให้ข้ามขั้นตอนนี้ได้เลยครับ แต่ต้องแน่ใจว่า `app.module.ts` มี `CommonModule` ใน imports แล้ว)_
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 2. สร้าง Base Entity (รากฐานของทุกตาราง)
|
||||
|
||||
@@ -27,12 +27,7 @@ nest g module common
|
||||
สร้างไฟล์: `src/common/entities/base.entity.ts`
|
||||
|
||||
```typescript
|
||||
import {
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
DeleteDateColumn,
|
||||
} from 'typeorm';
|
||||
import { PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm';
|
||||
|
||||
export abstract class BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@@ -49,7 +44,7 @@ export abstract class BaseEntity {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 3. สร้าง Standard Response Format (Interceptor)
|
||||
|
||||
@@ -58,12 +53,7 @@ export abstract class BaseEntity {
|
||||
สร้างไฟล์: `src/common/interceptors/transform.interceptor.ts`
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@@ -74,25 +64,20 @@ export interface Response<T> {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T>
|
||||
implements NestInterceptor<T, Response<T>>
|
||||
{
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<Response<T>> {
|
||||
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
statusCode: context.switchToHttp().getResponse().statusCode,
|
||||
message: data?.message || 'Success', // ถ้า data มี message ให้ใช้ ถ้าไม่มีใช้ 'Success'
|
||||
data: data?.result || data, // รองรับกรณีส่ง object ที่มี key result มา
|
||||
})),
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 4. สร้าง Global Exception Filter
|
||||
|
||||
@@ -101,14 +86,7 @@ export class TransformInterceptor<T>
|
||||
สร้างไฟล์: `src/common/exceptions/http-exception.filter.ts`
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
@@ -120,26 +98,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const exceptionResponse =
|
||||
exception instanceof HttpException
|
||||
? exception.getResponse()
|
||||
: 'Internal server error';
|
||||
const exceptionResponse = exception instanceof HttpException ? exception.getResponse() : 'Internal server error';
|
||||
|
||||
// จัดรูปแบบ Error Message
|
||||
let message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as any).message || exceptionResponse;
|
||||
|
||||
|
||||
// Log Error (สำคัญมากสำหรับการ Debug แต่ไม่ส่งให้ Client เห็นทั้งหมด)
|
||||
this.logger.error(
|
||||
`Http Status: ${status} Error Message: ${JSON.stringify(message)}`,
|
||||
);
|
||||
this.logger.error(`Http Status: ${status} Error Message: ${JSON.stringify(message)}`);
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
@@ -151,7 +121,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 5. ลงทะเบียนใช้งาน Global ใน `main.ts`
|
||||
|
||||
@@ -178,7 +148,7 @@ async function bootstrap() {
|
||||
whitelist: true, // ตัด field ส่วนเกินทิ้ง
|
||||
transform: true, // แปลง Type อัตโนมัติ (เช่น string -> number)
|
||||
forbidNonWhitelisted: true, // แจ้ง Error ถ้าส่ง field แปลกปลอมมา
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// 3. Global Interceptor (จัด Format Response)
|
||||
@@ -193,9 +163,9 @@ async function bootstrap() {
|
||||
bootstrap();
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
* **เสร็จสิ้น T1.1 ส่วน Infrastructure หลักแล้วครับ**
|
||||
- **เสร็จสิ้น T1.1 ส่วน Infrastructure หลักแล้วครับ**
|
||||
|
||||
ตอนนี้ระบบเรามี:
|
||||
|
||||
@@ -207,7 +177,7 @@ bootstrap();
|
||||
|
||||
Task นี้หัวใจสำคัญคือ "ความปลอดภัย" เราจะสร้างระบบ Login ที่ใช้ **JWT (JSON Web Token)** และเก็บรหัสผ่านแบบ **Bcrypt** ตามมาตรฐานครับ
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 1. ติดตั้ง Libraries
|
||||
|
||||
@@ -225,7 +195,7 @@ pnpm add -D @types/passport-jwt @types/bcrypt
|
||||
เนื่องจาก Auth ต้องทำงานกับ "ผู้ใช้" เราจำเป็นต้องสร้าง `UserModule` และ `User` Entity ขึ้นมาก่อนครับ (เป็นการทำ T1.3 บางส่วนล่วงหน้าเพื่อให้ T1.2 ทำงานได้)
|
||||
|
||||
สร้างไฟล์: `src/modules/user/entities/user.entity.ts`
|
||||
*(อ้างอิงโครงสร้างจาก Data Dictionary ตาราง `users`)*
|
||||
_(อ้างอิงโครงสร้างจาก Data Dictionary ตาราง `users`)_
|
||||
|
||||
```typescript
|
||||
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
|
||||
@@ -254,7 +224,7 @@ export class User extends BaseEntity {
|
||||
|
||||
@Column({ name: 'is_active', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
|
||||
// เดี๋ยวเราจะมาเพิ่ม Relation กับ Role/Org ทีหลังใน T1.3
|
||||
}
|
||||
```
|
||||
@@ -296,13 +266,13 @@ import { User } from './entities/user.entity.js';
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private usersRepository: Repository<User>,
|
||||
private usersRepository: Repository<User>
|
||||
) {}
|
||||
|
||||
async findOneByUsername(username: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
|
||||
// ใช้สำหรับ Seed หรือ Register เบื้องต้น
|
||||
async create(userData: Partial<User>): Promise<User> {
|
||||
const user = this.usersRepository.create(userData);
|
||||
@@ -342,8 +312,8 @@ import { JwtStrategy } from './jwt.strategy.js'; // เดี๋ยวสร้
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRATION') || '8h'
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRATION') || '8h',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -369,7 +339,7 @@ import { UserService } from '../../modules/user/user.service.js';
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UserService,
|
||||
private jwtService: JwtService,
|
||||
private jwtService: JwtService
|
||||
) {}
|
||||
|
||||
// ตรวจสอบ Username/Password
|
||||
@@ -451,16 +421,16 @@ export class AuthController {
|
||||
}
|
||||
return this.authService.login(user);
|
||||
}
|
||||
|
||||
|
||||
// Endpoint ชั่วคราวสำหรับสร้าง User คนแรก (Admin)
|
||||
@Post('register-admin')
|
||||
async register(@Body() req) {
|
||||
return this.authService.register(req);
|
||||
return this.authService.register(req);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### ✅ สรุปสิ่งที่ทำไป
|
||||
|
||||
@@ -473,11 +443,11 @@ export class AuthController {
|
||||
|
||||
1. Start App: `pnpm start:dev`
|
||||
2. ใช้ **Postman** หรือ **Thunder Client**:
|
||||
* **Register Admin:** `POST http://localhost:3000/api/auth/register-admin`
|
||||
* Body: `{"username": "admin", "password": "password123", "email": "admin@test.com"}`
|
||||
* **Login:** `POST http://localhost:3000/api/auth/login`
|
||||
* Body: `{"username": "admin", "password": "password123"}`
|
||||
* **Result:** ควรได้ `access_token` กลับมาครับ
|
||||
- **Register Admin:** `POST http://localhost:3000/api/auth/register-admin`
|
||||
- Body: `{"username": "admin", "password": "password123", "email": "admin@test.com"}`
|
||||
- **Login:** `POST http://localhost:3000/api/auth/login`
|
||||
- Body: `{"username": "admin", "password": "password123"}`
|
||||
- **Result:** ควรได้ `access_token` กลับมาครับ
|
||||
|
||||
ลองทำตามขั้นตอนนี้ดูครับ ติดขัดตรงไหนแจ้งได้เลยครับ (โดยเฉพาะเรื่อง `.js` extension ใน import ถ้าใช้ ESM อาจจะต้องระวังนิดนึงครับ)
|
||||
|
||||
@@ -555,7 +525,7 @@ export class RegisterDto {
|
||||
```typescript
|
||||
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { LoginDto } from './dto/login.dto.js'; // <--- Import DTO
|
||||
import { LoginDto } from './dto/login.dto.js'; // <--- Import DTO
|
||||
import { RegisterDto } from './dto/register.dto.js'; // <--- Import DTO
|
||||
|
||||
@Controller('auth')
|
||||
@@ -564,23 +534,20 @@ export class AuthController {
|
||||
|
||||
@Post('login')
|
||||
// เปลี่ยน @Body() req เป็น @Body() loginDto: LoginDto
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
const user = await this.authService.validateUser(
|
||||
loginDto.username,
|
||||
loginDto.password
|
||||
);
|
||||
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
const user = await this.authService.validateUser(loginDto.username, loginDto.password);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
|
||||
return this.authService.login(user);
|
||||
}
|
||||
|
||||
|
||||
@Post('register-admin')
|
||||
// เปลี่ยน @Body() req เป็น @Body() registerDto: RegisterDto
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.register(registerDto);
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -590,7 +557,7 @@ export class AuthController {
|
||||
ใน `auth.service.ts` ฟังก์ชัน `register` ควรระบุ Type ของ parameter ด้วยเช่นกันครับ
|
||||
|
||||
**ไฟล์: `src/common/auth/auth.service.ts`**
|
||||
*(เฉพาะส่วนที่ต้องแก้ method register)*
|
||||
_(เฉพาะส่วนที่ต้องแก้ method register)_
|
||||
|
||||
```typescript
|
||||
// ... imports อื่นๆ
|
||||
@@ -604,7 +571,7 @@ export class AuthService {
|
||||
async register(userDto: RegisterDto) {
|
||||
const salt = await bcrypt.genSalt();
|
||||
const hashedPassword = await bcrypt.hash(userDto.password, salt);
|
||||
|
||||
|
||||
// ส่งข้อมูลไป create (TypeScript จะตรวจสอบว่า field ตรงกับ User Entity หรือไม่)
|
||||
return this.userService.create({
|
||||
...userDto,
|
||||
@@ -667,7 +634,7 @@ export class AuthModule {}
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
// Cast เฉพาะค่า Value
|
||||
expiresIn: (configService.get<string>('JWT_EXPIRATION') || '8h') as any,
|
||||
expiresIn: (configService.get<string>('JWT_EXPIRATION') || '8h') as any,
|
||||
},
|
||||
}),
|
||||
```
|
||||
@@ -691,7 +658,7 @@ import { RegisterDto } from './dto/register.dto.js'; // <--- เพิ่มบ
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UserService,
|
||||
private jwtService: JwtService,
|
||||
private jwtService: JwtService
|
||||
) {}
|
||||
|
||||
async validateUser(username: string, pass: string): Promise<any> {
|
||||
@@ -781,7 +748,7 @@ export class RegisterDto {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
**หมายเหตุ:** ในอนาคตเมื่อคุณสร้าง **Entity** (เช่น `User` entity) คุณก็จะเจอ Error แบบเดียวกันนี้ ก็ให้ใช้ `!` แก้ไขในลักษณะเดียวกันครับ
|
||||
|
||||
@@ -810,7 +777,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
// ใส่ ! ต่อท้าย เพื่อบอกว่าค่านี้ไม่เป็น undefined แน่นอน
|
||||
secretOrKey: configService.get<string>('JWT_SECRET')!,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET')!,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -838,11 +805,11 @@ secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
|
||||
|
||||
เพื่อเป็นการแก้ไขและให้คุณไปต่อได้ราบรื่นที่สุด ผมขออนุญาต **รวมโค้ดฉบับสมบูรณ์ (Strict Mode Compliant)** ของไฟล์ที่เกี่ยวข้องทั้งหมดใน Module นี้มาให้ครับ คุณสามารถ **Copy ทับไฟล์เดิม** ได้เลย จะได้ไม่ต้องแก้ทีละจุดครับ
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
##### 1. ไฟล์ `src/common/auth/auth.service.ts` (ฉบับแก้ครบ)
|
||||
|
||||
*เพิ่ม Import DTO และระบุ Type ครบถ้วน*
|
||||
_เพิ่ม Import DTO และระบุ Type ครบถ้วน_
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@@ -855,7 +822,7 @@ import { RegisterDto } from './dto/register.dto.js'; // Import DTO
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private userService: UserService,
|
||||
private jwtService: JwtService,
|
||||
private jwtService: JwtService
|
||||
) {}
|
||||
|
||||
async validateUser(username: string, pass: string): Promise<any> {
|
||||
@@ -878,7 +845,7 @@ export class AuthService {
|
||||
async register(userDto: RegisterDto) {
|
||||
const salt = await bcrypt.genSalt();
|
||||
const hashedPassword = await bcrypt.hash(userDto.password, salt);
|
||||
|
||||
|
||||
// ใช้ค่าจาก DTO ที่ Validate มาแล้ว
|
||||
return this.userService.create({
|
||||
...userDto,
|
||||
@@ -890,7 +857,7 @@ export class AuthService {
|
||||
|
||||
##### 2. ไฟล์ `src/common/auth/jwt.strategy.ts` (ฉบับแก้ครบ)
|
||||
|
||||
*ใส่ `!` ป้องกัน undefined และระบุ Type ให้ payload*
|
||||
_ใส่ `!` ป้องกัน undefined และระบุ Type ให้ payload_
|
||||
|
||||
```typescript
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
@@ -911,7 +878,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
// ใส่ ! เพื่อยืนยันว่ามีค่าแน่นอน (ConfigValidation เช็คให้แล้ว)
|
||||
secretOrKey: configService.get<string>('JWT_SECRET')!,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET')!,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -923,7 +890,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
|
||||
##### 3. ไฟล์ `src/common/auth/auth.module.ts` (ฉบับแก้ครบ)
|
||||
|
||||
*Cast Type ตรง expiresIn*
|
||||
_Cast Type ตรง expiresIn_
|
||||
|
||||
```typescript
|
||||
import { Module } from '@nestjs/common';
|
||||
@@ -958,7 +925,7 @@ import { JwtStrategy } from './jwt.strategy.js';
|
||||
export class AuthModule {}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
หลังจากนี้ ผมจะตรวจสอบโค้ดทุกบรรทัดให้รองรับ **Strict Mode** และ **ESM** ก่อนส่งให้คุณเสมอครับ หาก Copy ไปวางแล้วยังเจอ Error อีก ด่าผมได้เต็มที่เลยครับ
|
||||
|
||||
@@ -989,10 +956,10 @@ export class AuthModule {}
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
type: 'mariadb',
|
||||
// ... ค่าอื่นๆ เหมือนเดิม
|
||||
|
||||
|
||||
// แก้บรรทัดนี้เป็น false ครับ
|
||||
// เพราะเราใช้ SQL Script สร้าง DB แล้ว ไม่ต้องการให้ TypeORM มาแก้ Structure อัตโนมัติ
|
||||
synchronize: false,
|
||||
synchronize: false,
|
||||
}),
|
||||
}),
|
||||
// ...
|
||||
@@ -1000,7 +967,7 @@ export class AuthModule {}
|
||||
|
||||
หลังจากแก้เป็น `false` แล้ว ให้ลอง **Restart Server** (`pnpm start:dev`) อีกครั้ง Error นี้จะหายไปและเชื่อมต่อได้ครับ
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
##### วิธีที่ 2: สร้าง Entity ให้ครบ (ทางเลือก)
|
||||
|
||||
@@ -1069,12 +1036,10 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
// ... (โค้ดส่วนต้นเหมือนเดิม) ...
|
||||
|
||||
// 👇👇 เพิ่มบรรทัดนี้ครับ (สำคัญมาก!) 👇👇
|
||||
console.error('💥 REAL ERROR:', exception);
|
||||
|
||||
console.error('💥 REAL ERROR:', exception);
|
||||
|
||||
// ... (โค้ดส่วน Log เดิม) ...
|
||||
this.logger.error(
|
||||
`Http Status: ${status} Error Message: ${JSON.stringify(message)}`,
|
||||
);
|
||||
this.logger.error(`Http Status: ${status} Error Message: ${JSON.stringify(message)}`);
|
||||
|
||||
// ... (ส่วน response.status... เหมือนเดิม) ...
|
||||
}
|
||||
@@ -1087,23 +1052,21 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
2. กด **Send** ใน Postman อีกครั้ง
|
||||
3. ดูที่ **Terminal** ครับ คุณจะเห็นข้อความหลัง `💥 REAL ERROR:` ที่ยาวๆ
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
##### 🔮 ผมขอเดาสาเหตุที่เป็นไปได้ (ลองเช็คดูนะครับ)
|
||||
|
||||
จากประสบการณ์ Error 500 หลังจากแก้เรื่อง Column แล้ว มักจะเกิดจาก 2 สาเหตุนี้ครับ:
|
||||
|
||||
1. **ข้อมูลซ้ำ (Duplicate Entry):**
|
||||
|
||||
* คุณอาจจะกด Send สำเร็จไปแล้วรอบหนึ่ง (ตอนที่แก้ DB เสร็จ) ข้อมูล User `admin` เลยเข้าไปอยู่ใน DB แล้ว
|
||||
* พอกด Send อีกรอบ มันเลยฟ้องว่า **"Username หรือ Email นี้มีอยู่แล้ว"** (Duplicate entry)
|
||||
* *วิธีเช็ค:* ลองเปลี่ยน `username` ใน Postman เป็น `admin2` แล้วกด Send ดูครับ ถ้าผ่าน แสดงว่าใช่เลย
|
||||
- คุณอาจจะกด Send สำเร็จไปแล้วรอบหนึ่ง (ตอนที่แก้ DB เสร็จ) ข้อมูล User `admin` เลยเข้าไปอยู่ใน DB แล้ว
|
||||
- พอกด Send อีกรอบ มันเลยฟ้องว่า **"Username หรือ Email นี้มีอยู่แล้ว"** (Duplicate entry)
|
||||
- _วิธีเช็ค:_ ลองเปลี่ยน `username` ใน Postman เป็น `admin2` แล้วกด Send ดูครับ ถ้าผ่าน แสดงว่าใช่เลย
|
||||
|
||||
2. **Column `user_id` หรือ `id` ไม่ตรงกัน:**
|
||||
|
||||
* ใน Entity เราใช้ `@PrimaryGeneratedColumn({ name: 'user_id' }) id: number;`
|
||||
* แต่ถ้าใน Database จริง คอลัมน์ชื่อ `id` เฉยๆ (หรือ Entity แม่ `BaseEntity` ไปตีกัน) ก็จะเกิด Error ได้
|
||||
* *แต่เคสนี้ Data Dictionary ระบุว่าเป็น `user_id` น่าจะตรงกันแล้ว*
|
||||
- ใน Entity เราใช้ `@PrimaryGeneratedColumn({ name: 'user_id' }) id: number;`
|
||||
- แต่ถ้าใน Database จริง คอลัมน์ชื่อ `id` เฉยๆ (หรือ Entity แม่ `BaseEntity` ไปตีกัน) ก็จะเกิด Error ได้
|
||||
- _แต่เคสนี้ Data Dictionary ระบุว่าเป็น `user_id` น่าจะตรงกันแล้ว_
|
||||
|
||||
**สรุป:**
|
||||
ลองเปลี่ยน Username ใน Postman ดูก่อนครับ ถ้ายัง Error อีก รบกวนก๊อปปี้ข้อความหลัง `💥 REAL ERROR:` ใน Terminal มาให้ผมดูหน่อยครับ แล้วผมจะบอกวิธีแก้ที่เป๊ะๆ ให้ทันทีครับ
|
||||
@@ -1118,9 +1081,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
*(รอจนขึ้นว่า Nest application successfully started)*
|
||||
_(รอจนขึ้นว่า Nest application successfully started)_
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 1. สร้าง Collection ใหม่ (แนะนำ)
|
||||
|
||||
@@ -1134,24 +1097,24 @@ pnpm start:dev
|
||||
2. ตั้งชื่อ Request: `Register Admin`
|
||||
3. **Method:** เลือก `POST`
|
||||
4. **URL:** ใส่ `http://localhost:3000/api/auth/register-admin`
|
||||
*(อย่าลืม `/api` เพราะเราตั้ง Global Prefix ไว้ใน main.ts)*
|
||||
_(อย่าลืม `/api` เพราะเราตั้ง Global Prefix ไว้ใน main.ts)_
|
||||
5. ไปที่แท็บ **Body**:
|
||||
* เลือก `raw`
|
||||
* เลือกประเภทเป็น `JSON`
|
||||
- เลือก `raw`
|
||||
- เลือกประเภทเป็น `JSON`
|
||||
6. ใส่ข้อมูลในช่องว่าง:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
"email": "admin@example.com",
|
||||
"firstName": "System",
|
||||
"lastName": "Admin"
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
"email": "admin@example.com",
|
||||
"firstName": "System",
|
||||
"lastName": "Admin"
|
||||
}
|
||||
```
|
||||
|
||||
7. กดปุ่ม **Send**
|
||||
* **ผลลัพธ์:** ควรได้ JSON ข้อมูล User กลับมา (มี id, created\_at ฯลฯ แต่ไม่มี password) และ Status `201 Created`
|
||||
- **ผลลัพธ์:** ควรได้ JSON ข้อมูล User กลับมา (มี id, created_at ฯลฯ แต่ไม่มี password) และ Status `201 Created`
|
||||
|
||||
### 3. ทดสอบเข้าสู่ระบบ (Login)
|
||||
|
||||
@@ -1162,25 +1125,25 @@ pnpm start:dev
|
||||
3. **Method:** เลือก `POST`
|
||||
4. **URL:** ใส่ `http://localhost:3000/api/auth/login`
|
||||
5. ไปที่แท็บ **Body**:
|
||||
* เลือก `raw`
|
||||
* เลือกประเภทเป็น `JSON`
|
||||
- เลือก `raw`
|
||||
- เลือกประเภทเป็น `JSON`
|
||||
6. ใส่ข้อมูล:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
7. กดปุ่ม **Send**
|
||||
* **ผลลัพธ์:** ควรได้ `access_token` กลับมาครับ
|
||||
<!-- end list -->
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
- **ผลลัพธ์:** ควรได้ `access_token` กลับมาครับ
|
||||
<!-- end list -->
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
### 4. (แถม) วิธีนำ Token ไปใช้ต่อ
|
||||
|
||||
@@ -1193,7 +1156,7 @@ pnpm start:dev
|
||||
5. **Token:** วาง Token ที่ Copy มาลงไป
|
||||
6. กด Send (ถ้า API นั้นมีอยู่จริงและ Token ถูกต้อง ก็จะผ่านครับ)
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
**ถ้าทดสอบผ่านแล้ว:** แสดงว่าระบบ Auth เบื้องต้น (T1.2) ของเราสมบูรณ์ครับ\!
|
||||
เราจะไปต่อที่ **Task T1.3 UserModule - User Management** เพื่อสร้าง API จัดการ User แบบเต็มรูปแบบ (CRUD) กันไหมครับ?
|
||||
@@ -1214,7 +1177,7 @@ pnpm start:dev
|
||||
pnpm add @nestjs/mapped-types
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 2. สร้างไฟล์ `create-user.dto.ts`
|
||||
|
||||
@@ -1261,7 +1224,7 @@ export class CreateUserDto {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 3. สร้างไฟล์ `update-user.dto.ts`
|
||||
|
||||
@@ -1276,7 +1239,7 @@ import { CreateUserDto } from './create-user.dto.js';
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### ✅ เสร็จสิ้นขั้นตอนที่ 1
|
||||
|
||||
@@ -1306,7 +1269,7 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 2. อัปเดต `UserController` (สร้าง Endpoints)
|
||||
|
||||
@@ -1315,17 +1278,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
แก้ไขไฟล์: `src/modules/user/user.controller.ts`
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, ParseIntPipe } from '@nestjs/common';
|
||||
import { UserService } from './user.service.js';
|
||||
import { CreateUserDto } from './dto/create-user.dto.js';
|
||||
import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||
@@ -1356,10 +1309,7 @@ export class UserController {
|
||||
|
||||
// 4. แก้ไขข้อมูลผู้ใช้
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
) {
|
||||
update(@Param('id', ParseIntPipe) id: number, @Body() updateUserDto: UpdateUserDto) {
|
||||
return this.userService.update(id, updateUserDto);
|
||||
}
|
||||
|
||||
@@ -1371,7 +1321,7 @@ export class UserController {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### ⚠️ ข้อควรระวัง (ยังรันไม่ได้ทันที)
|
||||
|
||||
@@ -1404,7 +1354,7 @@ import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private usersRepository: Repository<User>,
|
||||
private usersRepository: Repository<User>
|
||||
) {}
|
||||
|
||||
// 1. สร้างผู้ใช้ (Hash Password ก่อนบันทึก)
|
||||
@@ -1470,7 +1420,7 @@ export class UserService {
|
||||
|
||||
// รวมร่างข้อมูลเดิม + ข้อมูลใหม่
|
||||
const updatedUser = this.usersRepository.merge(user, updateUserDto);
|
||||
|
||||
|
||||
return this.usersRepository.save(updatedUser);
|
||||
}
|
||||
|
||||
@@ -1485,7 +1435,7 @@ export class UserService {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### ✅ เสร็จสิ้น Task T1.3 User Management
|
||||
|
||||
@@ -1497,15 +1447,16 @@ export class UserService {
|
||||
|
||||
1. **Login** (ด้วย Admin) เพื่อเอา `access_token` มาก่อน
|
||||
2. **สร้าง Request ใหม่** (เช่น `Get All Users`)
|
||||
* **URL:** `GET http://localhost:3000/api/users`
|
||||
* **Auth:** เลือก Type `Bearer Token` แล้ววาง Token ลงไป
|
||||
* **Send:** ควรเห็นรายชื่อ User ออกมาเป็น JSON ครับ
|
||||
- **URL:** `GET http://localhost:3000/api/users`
|
||||
- **Auth:** เลือก Type `Bearer Token` แล้ววาง Token ลงไป
|
||||
- **Send:** ควรเห็นรายชื่อ User ออกมาเป็น JSON ครับ
|
||||
|
||||
ถ้าทดสอบ `GET` ผ่านแล้ว ลองทดสอบ `POST` (สร้าง User ใหม่), `PATCH` (แก้ไข), และ `DELETE` (ลบ) ดูได้เลยครับ
|
||||
|
||||
ถ้าทุกอย่างเรียบร้อย เราจะปิดจ็อบ Phase 1 แล้วไปขึ้น **Phase 2: High-Integrity Data (File Upload)** กันต่อไหมครับ?
|
||||
|
||||
### **"มอบหมายบทบาท (Assign Role)"**
|
||||
|
||||
**ถูกต้องที่สุดครับ!** คุณเข้าใจคอนเซปต์ของระบบ RBAC ในระบบนี้ได้อย่างทะลุปรุโปร่งครับ 👍
|
||||
|
||||
ถ้าเราสร้าง User ลงในตาราง `users` เพียงอย่างเดียว User คนนั้นจะ:
|
||||
@@ -1515,11 +1466,11 @@ export class UserService {
|
||||
|
||||
ดังนั้น การ **"มอบหมายบทบาท (Assign Role)"** จึงเป็นขั้นตอนที่ขาดไม่ได้ครับ
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### 🛠️ สิ่งที่เราต้องทำเพิ่ม (เพื่อให้ Admin มอบสิทธิ์ได้)
|
||||
|
||||
ใน Task T1.3 เราทำแค่ `Create User` (ลงตาราง users) ไปแล้ว ตอนนี้เราต้องทำ API สำหรับ **Assign Role** (ลงตาราง user\_assignments) ครับ
|
||||
ใน Task T1.3 เราทำแค่ `Create User` (ลงตาราง users) ไปแล้ว ตอนนี้เราต้องทำ API สำหรับ **Assign Role** (ลงตาราง user_assignments) ครับ
|
||||
|
||||
มาสร้างฟังก์ชันนี้กันครับ:
|
||||
|
||||
@@ -1572,12 +1523,12 @@ import { User } from './entities/user.entity.js';
|
||||
export class UserAssignmentService {
|
||||
constructor(
|
||||
@InjectRepository(UserAssignment)
|
||||
private assignmentRepo: Repository<UserAssignment>,
|
||||
private assignmentRepo: Repository<UserAssignment>
|
||||
) {}
|
||||
|
||||
async assignRole(dto: AssignRoleDto, assigner: User) {
|
||||
// Validation: ตรวจสอบกฎเหล็ก (เลือกได้แค่ Scope เดียว)
|
||||
const scopes = [dto.organizationId, dto.projectId, dto.contractId].filter(v => v != null);
|
||||
const scopes = [dto.organizationId, dto.projectId, dto.contractId].filter((v) => v != null);
|
||||
if (scopes.length > 1) {
|
||||
throw new BadRequestException('Cannot assign multiple scopes at once. Choose one of Org, Project, or Contract.');
|
||||
}
|
||||
@@ -1652,7 +1603,7 @@ import { UserAssignmentService } from './user-assignment.service.js'; // อย
|
||||
export class UserController {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly assignmentService: UserAssignmentService, // <--- Inject เพิ่ม
|
||||
private readonly assignmentService: UserAssignmentService // <--- Inject เพิ่ม
|
||||
) {}
|
||||
|
||||
// ... Endpoints เดิม ...
|
||||
@@ -1665,13 +1616,13 @@ export class UserController {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### 📝 สรุปภาพรวม
|
||||
|
||||
1. **สร้าง User:** `POST /users` -\> ได้ User เปล่าๆ (Login ได้ แต่ทำไรไม่ได้)
|
||||
2. **กำหนดสิทธิ์:** `POST /users/assign-role` -\> ยิง API นี้เพื่อบอกว่า "User นี้ เป็น Project Manager ของ Project A นะ"
|
||||
* Body: `{ "userId": 5, "roleId": 6, "projectId": 1 }`
|
||||
- Body: `{ "userId": 5, "roleId": 6, "projectId": 1 }`
|
||||
3. **ใช้งาน:** เมื่อ User ID 5 ล็อกอิน ระบบจะไปเช็คใน `user_assignments` เห็นว่ามีสิทธิ์ Project Manager ของ Project 1 จึงอนุญาตให้ทำงานได้ครับ
|
||||
|
||||
ระบบ RBAC ของเรายืดหยุ่นและรองรับ Real-world scenario มากๆ ครับ (เช่น คนหนึ่งคนเป็น Admin ใน Project A แต่เป็นแค่ Viewer ใน Project B ก็ทำได้ เพราะเรามีตาราง `user_assignments` ตัวนี้แหละครับ)
|
||||
@@ -1700,14 +1651,14 @@ import { User } from './entities/user.entity.js';
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
// 2. เพิ่มบรรทัดนี้ เพื่อบอก NestJS ว่ามี Controller นี้อยู่
|
||||
controllers: [UserController],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### 2. ตรวจสอบ `src/app.module.ts` (เช็คความชัวร์)
|
||||
|
||||
@@ -1755,16 +1706,16 @@ import { UserAssignment } from './entities/user-assignment.entity.js'; // <--- 2
|
||||
@Module({
|
||||
imports: [
|
||||
// 3. ลงทะเบียน Entity ทั้ง User และ UserAssignment
|
||||
TypeOrmModule.forFeature([User, UserAssignment]),
|
||||
TypeOrmModule.forFeature([User, UserAssignment]),
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [
|
||||
UserService,
|
||||
UserAssignmentService // <--- 4. ลงทะเบียน Service เป็น Provider
|
||||
UserService,
|
||||
UserAssignmentService, // <--- 4. ลงทะเบียน Service เป็น Provider
|
||||
],
|
||||
exports: [
|
||||
UserService,
|
||||
UserAssignmentService // <--- 5. Export เผื่อที่อื่นใช้
|
||||
UserService,
|
||||
UserAssignmentService, // <--- 5. Export เผื่อที่อื่นใช้
|
||||
],
|
||||
})
|
||||
export class UserModule {}
|
||||
@@ -1778,7 +1729,7 @@ export class UserModule {}
|
||||
|
||||
ตอนนี้คุณมี **User Management API** (CRUD) ที่สมบูรณ์และปลอดภัยแล้ว ถือว่าปิดจ็อบ **Task T1.3** อย่างสวยงามครับ
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 🚀 เป้าหมายต่อไป: Task T1.4 RBAC Guard (4-Level Authorization)
|
||||
|
||||
@@ -1786,12 +1737,12 @@ export class UserModule {}
|
||||
|
||||
**โจทย์ของเราคือ:** ผู้ใช้คนหนึ่งอาจมีสิทธิ์ต่างกันตามบริบท เช่น:
|
||||
|
||||
* เป็น **Viewer** ในระดับองค์กร
|
||||
* แต่เป็น **Manager** ในโปรเจกต์ A
|
||||
- เป็น **Viewer** ในระดับองค์กร
|
||||
- แต่เป็น **Manager** ในโปรเจกต์ A
|
||||
|
||||
เราจะสร้าง **RBAC Guard** เพื่อตรวจสอบสิทธิ์ 4 ระดับนี้ครับ (Global \> Organization \> Project \> Contract)
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### ขั้นตอนที่ 1: ติดตั้ง Library (CASL)
|
||||
|
||||
@@ -1816,8 +1767,7 @@ export const PERMISSION_KEY = 'permission';
|
||||
|
||||
// ใช้สำหรับแปะหน้า Controller/Method
|
||||
// ตัวอย่าง: @RequirePermission('user.create')
|
||||
export const RequirePermission = (permission: string) =>
|
||||
SetMetadata(PERMISSION_KEY, permission);
|
||||
export const RequirePermission = (permission: string) => SetMetadata(PERMISSION_KEY, permission);
|
||||
```
|
||||
|
||||
### ขั้นตอนที่ 3: สร้าง `RbacGuard` (หัวใจสำคัญ)
|
||||
@@ -1836,7 +1786,7 @@ import { UserService } from '../../modules/user/user.service.js';
|
||||
export class RbacGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private userService: UserService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@@ -1893,10 +1843,10 @@ export class UserService {
|
||||
// Query ข้อมูลจาก View: v_user_all_permissions (ที่เราสร้างไว้ใน SQL Script)
|
||||
// เนื่องจาก TypeORM ไม่รองรับ View โดยตรงในบางท่า เราใช้ query builder หรือ query raw ได้
|
||||
// แต่เพื่อความง่ายและประสิทธิภาพ เราจะใช้ query raw ครับ
|
||||
|
||||
|
||||
const permissions = await this.usersRepository.query(
|
||||
`SELECT permission_name FROM v_user_all_permissions WHERE user_id = ?`,
|
||||
[userId],
|
||||
[userId]
|
||||
);
|
||||
|
||||
// แปลงผลลัพธ์เป็น Array ของ string ['user.create', 'project.view', ...]
|
||||
@@ -1918,7 +1868,6 @@ import { RbacGuard } from '../../common/auth/rbac.guard.js';
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard) // 🔒 เพิ่ม RbacGuard ต่อท้าย
|
||||
export class UserController {
|
||||
|
||||
@Post()
|
||||
@RequirePermission('user.create') // 🔒 ต้องมีสิทธิ์ user.create ถึงจะเข้าได้
|
||||
create(@Body() createUserDto: CreateUserDto) {
|
||||
@@ -1929,7 +1878,7 @@ export class UserController {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
**พร้อมลองของจริงไหมครับ?**
|
||||
ถ้าทำเสร็จแล้ว ลองใช้ Postman ยิง `POST /api/users` โดยใช้ Token ของ `admin` ดูครับ (ซึ่ง Admin มีสิทธิ์ครบอยู่แล้ว น่าจะผ่าน)
|
||||
@@ -1947,7 +1896,7 @@ export class UserController {
|
||||
2. ✅ **Authorization:** ตรวจสอบสิทธิ์ด้วย RBAC Guard + CASL
|
||||
3. ✅ **User Management:** จัดการผู้ใช้ได้ครบวงจร
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 🚀 เป้าหมายต่อไป: Task T1.5 ProjectModule - Base Structures
|
||||
|
||||
@@ -1974,7 +1923,7 @@ nest g controller modules/project
|
||||
|
||||
### 2. สร้าง Organization Entity
|
||||
|
||||
*แก้หนี้เก่าที่เราติดไว้ตอนทำ User Entity ครับ*
|
||||
_แก้หนี้เก่าที่เราติดไว้ตอนทำ User Entity ครับ_
|
||||
|
||||
สร้างไฟล์: `src/modules/project/entities/organization.entity.ts`
|
||||
|
||||
@@ -2064,7 +2013,7 @@ export class Contract extends BaseEntity {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 5. (สำคัญ) กลับไปแก้ `User` Entity ให้สมบูรณ์
|
||||
|
||||
@@ -2089,7 +2038,7 @@ export class User extends BaseEntity {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
เมื่อสร้างไฟล์เหล่านี้เสร็จแล้ว อย่าลืมไปลงทะเบียน Entities ใน `src/modules/project/project.module.ts` นะครับ
|
||||
|
||||
@@ -2193,12 +2142,12 @@ import { ContractOrganization } from './entities/contract-organization.entity.js
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Project,
|
||||
Organization,
|
||||
Project,
|
||||
Organization,
|
||||
Contract,
|
||||
ProjectOrganization, // ลงทะเบียน
|
||||
ContractOrganization // ลงทะเบียน
|
||||
])
|
||||
ContractOrganization, // ลงทะเบียน
|
||||
]),
|
||||
],
|
||||
controllers: [ProjectController],
|
||||
providers: [ProjectService],
|
||||
@@ -2226,7 +2175,7 @@ export class ProjectService {
|
||||
@InjectRepository(Project)
|
||||
private projectRepository: Repository<Project>,
|
||||
@InjectRepository(Organization)
|
||||
private organizationRepository: Repository<Organization>,
|
||||
private organizationRepository: Repository<Organization>
|
||||
) {}
|
||||
|
||||
// ดึงรายการ Project ทั้งหมด
|
||||
@@ -2257,7 +2206,7 @@ export class ProjectController {
|
||||
findAll() {
|
||||
return this.projectService.findAllProjects();
|
||||
}
|
||||
|
||||
|
||||
@Get('organizations')
|
||||
findAllOrgs() {
|
||||
return this.projectService.findAllOrganizations();
|
||||
@@ -2265,7 +2214,7 @@ export class ProjectController {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### ✅ เสร็จสิ้น Task T1.5
|
||||
|
||||
@@ -2304,7 +2253,7 @@ import {
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
DeleteDateColumn,
|
||||
ManyToOne, // <--- เพิ่มตรงนี้
|
||||
ManyToOne, // <--- เพิ่มตรงนี้
|
||||
JoinColumn, // <--- เพิ่มตรงนี้
|
||||
} from 'typeorm';
|
||||
import { Organization } from '../../project/entities/organization.entity.js'; // อย่าลืม import Organization
|
||||
@@ -2354,7 +2303,7 @@ export class User {
|
||||
|
||||
ลอง Save แล้วรันใหม่ครับ Error นี้จะหายไปแน่นอนครับ
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
หากคอมไพล์ผ่านแล้วและไม่มี Error เพิ่มเติม เราถือว่า **จบ Phase 1** อย่างสมบูรณ์แล้วครับ
|
||||
|
||||
@@ -2381,23 +2330,23 @@ export class User {
|
||||
return this.usersRepository.find({
|
||||
// ✅ แก้ไขชื่อ field ให้ตรงกับ Entity Class
|
||||
select: [
|
||||
'user_id',
|
||||
'username',
|
||||
'email',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'isActive',
|
||||
'user_id',
|
||||
'username',
|
||||
'email',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'isActive',
|
||||
'createdAt', // แก้ตรงนี้ (จาก created_at)
|
||||
'updatedAt' // แก้ตรงนี้ (จาก updated_at)
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ...
|
||||
```
|
||||
|
||||
ลองแก้จุดนี้แล้ว Save ดูครับ Error จะหายไปครับ
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 🚀
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# **BACKEND DEVELOPMENT**
|
||||
|
||||
## **Phase 2: High-Integrity Data & File Management**
|
||||
|
||||
### **T2.1 CommonModule**
|
||||
|
||||
|
||||
### **T2.2 FileStorageService - Two-Phase Storage**
|
||||
|
||||
|
||||
ระบบนี้ออกแบบมาเพื่อแก้ปัญหา "ไฟล์ขยะ" (Orphan Files) ที่เกิดจากการอัปโหลดแล้ว User ไม่กดยืนยัน โดยเราจะแบ่งการทำงานเป็น 2 เฟส:
|
||||
|
||||
1. **Upload (Temp):** เอาไฟล์ไปพักไว้ก่อน (ยังไม่ลง DB ถาวร)
|
||||
2. **Commit (Permanent):** เมื่อ User กด Save ฟอร์มสำเร็จ ค่อยย้ายไฟล์ไปเก็บจริง
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### ขั้นตอนที่ 1: ติดตั้ง Libraries ที่จำเป็น
|
||||
|
||||
@@ -111,7 +111,7 @@ import { Attachment } from './entities/attachment.entity.js';
|
||||
export class FileStorageModule {}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### 📥 งานถัดไป (สำคัญมาก)
|
||||
|
||||
@@ -149,14 +149,12 @@ export class FileStorageService {
|
||||
constructor(
|
||||
@InjectRepository(Attachment)
|
||||
private attachmentRepository: Repository<Attachment>,
|
||||
private configService: ConfigService,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
// ใช้ Path จริงถ้าอยู่บน Server (Production) หรือใช้ ./uploads ถ้าอยู่ Local
|
||||
this.uploadRoot =
|
||||
this.configService.get('NODE_ENV') === 'production'
|
||||
? '/share/dms-data'
|
||||
: path.join(process.cwd(), 'uploads');
|
||||
|
||||
this.configService.get('NODE_ENV') === 'production' ? '/share/dms-data' : path.join(process.cwd(), 'uploads');
|
||||
|
||||
// สร้างโฟลเดอร์รอไว้เลยถ้ายังไม่มี
|
||||
fs.ensureDirSync(path.join(this.uploadRoot, 'temp'));
|
||||
}
|
||||
@@ -215,7 +213,7 @@ export class FileStorageService {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear().toString();
|
||||
const month = (today.getMonth() + 1).toString().padStart(2, '0');
|
||||
|
||||
|
||||
// โฟลเดอร์ถาวรแยกตาม ปี/เดือน
|
||||
const permanentDir = path.join(this.uploadRoot, 'permanent', year, month);
|
||||
await fs.ensureDir(permanentDir);
|
||||
@@ -227,7 +225,7 @@ export class FileStorageService {
|
||||
try {
|
||||
// ย้ายไฟล์
|
||||
await fs.move(oldPath, newPath, { overwrite: true });
|
||||
|
||||
|
||||
// อัปเดตข้อมูลใน DB
|
||||
att.filePath = newPath;
|
||||
att.isTemporary = false;
|
||||
@@ -251,7 +249,7 @@ export class FileStorageService {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
##### 2. สร้าง `FileStorageController` (Endpoint สำหรับ Upload)
|
||||
|
||||
@@ -290,10 +288,10 @@ export class FileStorageController {
|
||||
// ตรวจสอบประเภทไฟล์ (Regex)
|
||||
new FileTypeValidator({ fileType: /(pdf|msword|openxmlformats|zip|octet-stream)/ }),
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
@Request() req,
|
||||
@Request() req
|
||||
) {
|
||||
// ส่ง userId จาก Token ไปด้วย
|
||||
return this.fileStorageService.upload(file, req.user.userId);
|
||||
@@ -301,20 +299,20 @@ export class FileStorageController {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
##### 3. ทดสอบด้วย Postman
|
||||
|
||||
1. **Start Server:** `pnpm start:dev`
|
||||
2. **Login:** ขอ Token ของ Admin หรือ User มาก่อน
|
||||
3. **สร้าง Request ใหม่:**
|
||||
* **Method:** `POST`
|
||||
* **URL:** `http://localhost:3000/api/files/upload`
|
||||
* **Auth:** Bearer Token
|
||||
* **Body:**
|
||||
* เลือก `form-data`
|
||||
* Key: `file` (เลือกชนิดเป็น **File**) -\> เลือกไฟล์ PDF หรือรูปภาพสักไฟล์
|
||||
* **Send**
|
||||
- **Method:** `POST`
|
||||
- **URL:** `http://localhost:3000/api/files/upload`
|
||||
- **Auth:** Bearer Token
|
||||
- **Body:**
|
||||
- เลือก `form-data`
|
||||
- Key: `file` (เลือกชนิดเป็น **File**) -\> เลือกไฟล์ PDF หรือรูปภาพสักไฟล์
|
||||
- **Send**
|
||||
|
||||
**ผลลัพธ์ที่ควรได้:**
|
||||
JSON ตอบกลับที่มีข้อมูลไฟล์ และ `isTemporary: true`, `tempId: "..."` ครับ
|
||||
@@ -371,10 +369,10 @@ export class FileStorageController {
|
||||
new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }), // 50MB
|
||||
new FileTypeValidator({ fileType: /(pdf|msword|openxmlformats|zip|octet-stream)/ }),
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
@Request() req: RequestWithUser, // ✅ 2. ระบุ Type ตรงนี้แทน any
|
||||
@Request() req: RequestWithUser // ✅ 2. ระบุ Type ตรงนี้แทน any
|
||||
) {
|
||||
return this.fileStorageService.upload(file, req.user.userId);
|
||||
}
|
||||
@@ -407,7 +405,7 @@ Error แจ้งว่า `Cannot GET ...` แสดงว่าคุณก
|
||||
|
||||
เราจะใช้เทคนิค **Double-Lock** ตามแผน: **Redis Lock (ด่านแรก)** + **Optimistic Lock (ด่านสุดท้าย)**
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### ขั้นตอนที่ 1: ติดตั้ง Redis Client
|
||||
|
||||
@@ -420,7 +418,7 @@ pnpm add ioredis redlock
|
||||
pnpm add -D @types/ioredis
|
||||
```
|
||||
|
||||
*(หมายเหตุ: `redlock` เวอร์ชันล่าสุดอาจรวมอยู่ใน ioredis หรือใช้ library แยก ตรวจสอบ version compatibility ด้วยครับ แต่วิธีมาตรฐานคือลงแยก)*
|
||||
_(หมายเหตุ: `redlock` เวอร์ชันล่าสุดอาจรวมอยู่ใน ioredis หรือใช้ library แยก ตรวจสอบ version compatibility ด้วยครับ แต่วิธีมาตรฐานคือลงแยก)_
|
||||
|
||||
#### ขั้นตอนที่ 2: สร้าง Module และ Entities
|
||||
|
||||
@@ -436,7 +434,7 @@ nest g service modules/document-numbering
|
||||
#### ขั้นตอนที่ 3: สร้าง Entities
|
||||
|
||||
สร้างไฟล์: `src/modules/document-numbering/entities/document-number-format.entity.ts`
|
||||
*(เก็บ Template เช่น `{ORG}-{TYPE}-{SEQ:4}`)*
|
||||
_(เก็บ Template เช่น `{ORG}-{TYPE}-{SEQ:4}`)_
|
||||
|
||||
```typescript
|
||||
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Unique } from 'typeorm';
|
||||
@@ -467,7 +465,7 @@ export class DocumentNumberFormat {
|
||||
```
|
||||
|
||||
สร้างไฟล์: `src/modules/document-numbering/entities/document-number-counter.entity.ts`
|
||||
*(เก็บเลขล่าสุด)*
|
||||
_(เก็บเลขล่าสุด)_
|
||||
|
||||
```typescript
|
||||
import { Entity, Column, PrimaryColumn, VersionColumn } from 'typeorm';
|
||||
@@ -491,8 +489,8 @@ export class DocumentNumberCounter {
|
||||
lastNumber!: number;
|
||||
|
||||
// ✨ หัวใจสำคัญของ Optimistic Lock
|
||||
@VersionColumn()
|
||||
version!: number;
|
||||
@VersionColumn()
|
||||
version!: number;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -508,16 +506,14 @@ import { DocumentNumberFormat } from './entities/document-number-format.entity.j
|
||||
import { DocumentNumberCounter } from './entities/document-number-counter.entity.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([DocumentNumberFormat, DocumentNumberCounter]),
|
||||
],
|
||||
imports: [TypeOrmModule.forFeature([DocumentNumberFormat, DocumentNumberCounter])],
|
||||
providers: [DocumentNumberingService],
|
||||
exports: [DocumentNumberingService], // Export ให้คนอื่นเรียกใช้
|
||||
})
|
||||
export class DocumentNumberingModule {}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
**งานถัดไป (ยากสุด):** เขียน Logic ใน `DocumentNumberingService` เพื่อ:
|
||||
|
||||
@@ -531,7 +527,7 @@ export class DocumentNumberingModule {}
|
||||
|
||||
ดังนั้น **ผ่าน** ครับ ลุยต่อได้เลย\!
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### 🏗️ งานหลัก: เขียน Logic ใน `DocumentNumberingService`
|
||||
|
||||
@@ -560,7 +556,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
private counterRepo: Repository<DocumentNumberCounter>,
|
||||
@InjectRepository(DocumentNumberFormat)
|
||||
private formatRepo: Repository<DocumentNumberFormat>,
|
||||
private configService: ConfigService,
|
||||
private configService: ConfigService
|
||||
) {}
|
||||
|
||||
// 1. เริ่มต้นเชื่อมต่อ Redis และ Redlock เมื่อ Module ถูกโหลด
|
||||
@@ -571,16 +567,13 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
password: this.configService.get<string>('REDIS_PASSWORD'),
|
||||
});
|
||||
|
||||
this.redlock = new Redlock(
|
||||
[this.redisClient],
|
||||
{
|
||||
driftFactor: 0.01,
|
||||
retryCount: 10, // ลองใหม่ 10 ครั้งถ้า Lock ไม่สำเร็จ
|
||||
retryDelay: 200, // รอ 200ms ก่อนลองใหม่
|
||||
retryJitter: 200,
|
||||
}
|
||||
);
|
||||
|
||||
this.redlock = new Redlock([this.redisClient], {
|
||||
driftFactor: 0.01,
|
||||
retryCount: 10, // ลองใหม่ 10 ครั้งถ้า Lock ไม่สำเร็จ
|
||||
retryDelay: 200, // รอ 200ms ก่อนลองใหม่
|
||||
retryJitter: 200,
|
||||
});
|
||||
|
||||
this.logger.log('Redis & Redlock initialized for Document Numbering');
|
||||
}
|
||||
|
||||
@@ -601,11 +594,11 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
orgId: number,
|
||||
typeId: number,
|
||||
year: number,
|
||||
replacements: Record<string, string> = {},
|
||||
replacements: Record<string, string> = {}
|
||||
): Promise<string> {
|
||||
const resourceKey = `doc_num:${projectId}:${typeId}:${year}`;
|
||||
const ttl = 5000; // Lock จะหมดอายุใน 5 วินาที (ป้องกัน Deadlock)
|
||||
|
||||
|
||||
let lock;
|
||||
try {
|
||||
// 🔒 Step 1: Redis Lock (Distributed Lock)
|
||||
@@ -641,19 +634,17 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// 2.4 ถ้าบันทึกผ่าน -> สร้าง String ตาม Format
|
||||
return await this.formatNumber(projectId, typeId, counter.lastNumber, replacements);
|
||||
|
||||
} catch (err) {
|
||||
// ถ้า Version ชนกัน (Optimistic Lock Error) ให้วนลูปทำใหม่
|
||||
if (err instanceof OptimisticLockVersionMismatchError) {
|
||||
this.logger.warn(`Optimistic Lock Hit! Retrying... (${i + 1}/${maxRetries})`);
|
||||
continue;
|
||||
continue;
|
||||
}
|
||||
throw err; // ถ้าเป็น Error อื่น ให้โยนออกไปเลย
|
||||
}
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException('Failed to generate document number after retries');
|
||||
|
||||
throw new InternalServerErrorException('Failed to generate document number after retries');
|
||||
} catch (err) {
|
||||
this.logger.error('Error generating document number', err);
|
||||
throw err;
|
||||
@@ -667,16 +658,16 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// Helper: แปลงเลขเป็น String ตาม Template (เช่น {ORG}-{SEQ:004})
|
||||
private async formatNumber(
|
||||
projectId: number,
|
||||
typeId: number,
|
||||
seq: number,
|
||||
projectId: number,
|
||||
typeId: number,
|
||||
seq: number,
|
||||
replacements: Record<string, string>
|
||||
): Promise<string> {
|
||||
// 1. หา Template
|
||||
const format = await this.formatRepo.findOne({ where: { projectId, correspondenceTypeId: typeId } });
|
||||
|
||||
|
||||
// ถ้าไม่มี Template ให้ใช้ Default: {SEQ}
|
||||
let template = format ? format.formatTemplate : '{SEQ:4}';
|
||||
let template = format ? format.formatTemplate : '{SEQ:4}';
|
||||
|
||||
// 2. แทนที่ค่าต่างๆ (ORG_CODE, TYPE_CODE, YEAR)
|
||||
for (const [key, value] of Object.entries(replacements)) {
|
||||
@@ -694,7 +685,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
**✅ สิ่งที่ Logic นี้ทำ:**
|
||||
|
||||
@@ -716,7 +707,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
1. **Rate Limiting:** ป้องกันการโดนยิง API รัวๆ (Brute-force / DDoS) ด้วย `@nestjs/throttler`
|
||||
2. **Security Headers:** ป้องกันการโจมตีพื้นฐานทาง Web (XSS, Clickjacking) ด้วย `helmet`
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### ขั้นตอนที่ 1: ติดตั้ง Libraries
|
||||
|
||||
@@ -756,7 +747,7 @@ import { AuthModule } from './common/auth/auth.module.js';
|
||||
validationSchema: envValidationSchema,
|
||||
validationOptions: { abortEarly: true },
|
||||
}),
|
||||
|
||||
|
||||
// 🛡️ 1. Setup Throttler Module (Rate Limiting)
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
@@ -844,7 +835,7 @@ async function bootstrap() {
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
app.useGlobalInterceptors(new TransformInterceptor());
|
||||
@@ -856,7 +847,7 @@ async function bootstrap() {
|
||||
bootstrap();
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### 💡 วิธีปรับจูน Rate Limit เฉพาะจุด (Optional)
|
||||
|
||||
@@ -887,7 +878,7 @@ import { SkipThrottle } from '@nestjs/throttler';
|
||||
check() { ... }
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### ✅ เสร็จสิ้น T2.4
|
||||
|
||||
@@ -897,7 +888,7 @@ check() { ... }
|
||||
2. **Helmet:** ป้องกัน XSS/Header Exploits
|
||||
3. **CORS:** ควบคุมโดเมนที่เรียกใช้ได้
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### 🚀 T2.5 JSON Schema System (Final Task of Phase 2)
|
||||
|
||||
@@ -912,7 +903,7 @@ Task สุดท้ายของ Phase 2 คือการเตรียม
|
||||
|
||||
ระบบนี้จะช่วยให้เรามั่นใจว่าข้อมูลในฟิลด์ `details` (JSON) ของเอกสารต่างๆ (เช่น RFA, Correspondence) มีโครงสร้างที่ถูกต้องเสมอ ไม่ใช่แค่ Text อะไรก็ได้
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### ขั้นตอนที่ 1: ติดตั้ง Library (AJV)
|
||||
|
||||
@@ -937,7 +928,7 @@ nest g controller modules/json-schema
|
||||
```
|
||||
|
||||
สร้างไฟล์ Entity: `src/modules/json-schema/entities/json-schema.entity.ts`
|
||||
*(อ้างอิงตาม Data Dictionary)*
|
||||
_(อ้างอิงตาม Data Dictionary)_
|
||||
|
||||
```typescript
|
||||
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
@@ -989,11 +980,11 @@ import { JsonSchema } from './entities/json-schema.entity.js';
|
||||
export class JsonSchemaService implements OnModuleInit {
|
||||
private ajv: Ajv;
|
||||
// Cache ตัว Validator ที่ Compile แล้ว เพื่อประสิทธิภาพ
|
||||
private validators = new Map<string, any>();
|
||||
private validators = new Map<string, any>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(JsonSchema)
|
||||
private schemaRepo: Repository<JsonSchema>,
|
||||
private schemaRepo: Repository<JsonSchema>
|
||||
) {
|
||||
// ตั้งค่า AJV
|
||||
this.ajv = new Ajv({ allErrors: true, strict: false }); // strict: false เพื่อยืดหยุ่นกับ custom keywords
|
||||
@@ -1013,8 +1004,8 @@ export class JsonSchemaService implements OnModuleInit {
|
||||
|
||||
// ถ้ายังไม่มีใน Cache หรือต้องการตัวล่าสุด ให้ดึงจาก DB
|
||||
if (!validate) {
|
||||
const schema = await this.schemaRepo.findOne({
|
||||
where: { schemaCode, isActive: true }
|
||||
const schema = await this.schemaRepo.findOne({
|
||||
where: { schemaCode, isActive: true },
|
||||
});
|
||||
|
||||
if (!schema) {
|
||||
@@ -1094,7 +1085,7 @@ export class JsonSchemaController {
|
||||
|
||||
// Endpoint สำหรับ Test Validate (Optional)
|
||||
@Post(':code/validate')
|
||||
@RequirePermission('document.view')
|
||||
@RequirePermission('document.view')
|
||||
async validate(@Param('code') code: string, @Body() data: any) {
|
||||
const isValid = await this.schemaService.validate(code, data);
|
||||
return { valid: isValid };
|
||||
@@ -1143,7 +1134,7 @@ import { JsonSchemaModule } from './modules/json-schema/json-schema.module.js';
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
#### ✅ จบ Phase 2 สมบูรณ์แบบครับ\!
|
||||
|
||||
@@ -1160,7 +1151,7 @@ Backend ของเราแข็งแกร่งมากแล้วคร
|
||||
|
||||
ถ้าผ่านแล้ว พิมพ์ **"ปิด Phase 2 เริ่ม Phase 3"** ได้เลยครับ เราจะไปสร้าง "Unified Workflow Engine" กัน\!
|
||||
|
||||
### T2.5 Error**
|
||||
### T2.5 Error\*\*
|
||||
|
||||
ขออภัยครับ ผมลืมใส่บรรทัด **Import** ให้ในตัวอย่างก่อนหน้านี้ครับ 😅
|
||||
|
||||
@@ -1185,12 +1176,9 @@ export class AuthController {
|
||||
|
||||
@Post('login')
|
||||
// จำกัดการ Login: ได้แค่ 5 ครั้ง ใน 60 วินาที (60000 ms) ต่อ IP
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
const user = await this.authService.validateUser(
|
||||
loginDto.username,
|
||||
loginDto.password,
|
||||
);
|
||||
const user = await this.authService.validateUser(loginDto.username, loginDto.password);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
@@ -1213,14 +1201,12 @@ export class AuthController {
|
||||
#### ปัญหาเหล่านี้เกิดจาก **Strict Mode** ครับ
|
||||
|
||||
1. **Error TS2564 (Property has no initializer):** ตัวแปร `redisClient` และ `redlock` ถูกประกาศไว้แต่ไม่ได้ใส่ค่าใน `constructor` (เพราะเราไปใส่ใน `onModuleInit` แทน) TypeScript เลยกลัวว่ามันจะเป็น undefined
|
||||
|
||||
* **วิธีแก้:** ใส่เครื่องหมาย **`!`** ต่อท้ายชื่อตัวแปร เพื่อยืนยันว่า "เดี๋ยวมีค่าแน่นอน ไม่ต้องห่วง"
|
||||
- **วิธีแก้:** ใส่เครื่องหมาย **`!`** ต่อท้ายชื่อตัวแปร เพื่อยืนยันว่า "เดี๋ยวมีค่าแน่นอน ไม่ต้องห่วง"
|
||||
|
||||
2. **Error TS7016 (Could not find a declaration file for 'redlock'):** ไลบรารี `redlock` (เวอร์ชันที่เราลง) ไม่มีไฟล์ Type Definition มาให้ หรือ TypeScript หาไม่เจอ
|
||||
- **วิธีแก้:** สร้างไฟล์ประกาศ Type (Declaration File) ขึ้นมาเองเพื่อบอก TypeScript ว่า "รู้จัก Module นี้นะ"
|
||||
|
||||
* **วิธีแก้:** สร้างไฟล์ประกาศ Type (Declaration File) ขึ้นมาเองเพื่อบอก TypeScript ว่า "รู้จัก Module นี้นะ"
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
**🛠️ วิธีแก้ไข**
|
||||
|
||||
@@ -1234,7 +1220,7 @@ export class AuthController {
|
||||
@Injectable()
|
||||
export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(DocumentNumberingService.name);
|
||||
|
||||
|
||||
// ✅ ใส่ ! ต่อท้ายชื่อตัวแปร
|
||||
private redisClient!: Redis;
|
||||
private redlock!: Redlock;
|
||||
@@ -1242,7 +1228,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(
|
||||
// ...
|
||||
) {}
|
||||
|
||||
|
||||
// ...
|
||||
```
|
||||
|
||||
@@ -1340,10 +1326,10 @@ Error `WRONGPASS` ชัดเจนว่าพยายามเชื่อ
|
||||
เปิดไฟล์ `docker-compose.yml` แล้วดูบรรทัด `command` ของ service `redis` ว่าตั้งรหัสผ่านว่าอะไร
|
||||
|
||||
```yaml
|
||||
redis:
|
||||
# ...
|
||||
# ดูตรงคำว่า "redis_password_secure" (หรือค่าที่คุณตั้งไว้)
|
||||
command: redis-server --requirepass "redis_password_secure"
|
||||
redis:
|
||||
# ...
|
||||
# ดูตรงคำว่า "redis_password_secure" (หรือค่าที่คุณตั้งไว้)
|
||||
command: redis-server --requirepass "redis_password_secure"
|
||||
```
|
||||
|
||||
##### 2. เช็คไฟล์ `.env`
|
||||
@@ -1379,8 +1365,8 @@ pnpm start:dev
|
||||
|
||||
คราวนี้น่าจะเชื่อมต่อผ่านแล้วครับ\! (ถ้ายังไม่ได้ ลองเช็คว่าเผลอมี Redis ตัวอื่นรันซ้อนอยู่หรือเปล่าด้วย `docker ps` ครับ)
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## ปิด Phase 2 เริ่ม Phase 3
|
||||
|
||||
ถ้าแก้ไขผ่านแล้ว พิมพ์ **"ปิด Phase 2 เริ่ม Phase 3"** ได้เลยครับ เราจะไปลุย **Unified Workflow Engine** ที่เป็นหัวใจสำคัญของระบบใน Phase 3 กันครับ\!
|
||||
ถ้าแก้ไขผ่านแล้ว พิมพ์ **"ปิด Phase 2 เริ่ม Phase 3"** ได้เลยครับ เราจะไปลุย **Unified Workflow Engine** ที่เป็นหัวใจสำคัญของระบบใน Phase 3 กันครับ\!
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Phase นี้เน้นเรื่อง **การจัดการไฟล์ (File Storage)**, **ความปลอดภัย (Security)** และ **ระบบตรวจสอบข้อมูล (JSON Schema)** ครับ
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## 📋 Phase 2 Integration Test Plan
|
||||
|
||||
@@ -11,21 +11,21 @@ Phase นี้เน้นเรื่อง **การจัดการไ
|
||||
1. **Server:** รัน `pnpm start:dev`
|
||||
2. **Auth:** Login ด้วย `admin` เพื่อขอ Access Token (ใช้แนบใน Header: `Authorization: Bearer <token>`)
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 🧪 Scenario 1: File Storage (T2.2)
|
||||
|
||||
**เป้าหมาย:** ทดสอบว่าระบบอัปโหลดไฟล์ทำงานถูกต้อง (Two-Phase Storage)
|
||||
|
||||
| Step | Action (API Endpoint) | Method | Body (Form-Data) | Expected Result |
|
||||
| :------ | :-------------------------------------------------- | :----- | :---------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Step | Action (API Endpoint) | Method | Body (Form-Data) | Expected Result |
|
||||
| :------ | :-------------------------------------------------- | :----- | :------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **1.1** | **Upload Valid File**<br>`/api/files/upload` | POST | Key: `file` (Type: File)<br>Value: (เลือกไฟล์ PDF/IMG ขนาด \< 50MB) | - **Status: 201 Created**<br>- Response มี `id`, `originalFilename`<br>- `isTemporary`: **true**<br>- `tempId`: (มีค่า UUID) |
|
||||
| **1.2** | **Upload Invalid File Type**<br>`/api/files/upload` | POST | Key: `file` (Type: File)<br>Value: (เลือกไฟล์ .exe หรือ .bat) | - **Status: 400 Bad Request**<br>- Message: "Validation failed... expected type is..." |
|
||||
| **1.3** | **Upload Too Large File**<br>`/api/files/upload` | POST | Key: `file` (Type: File)<br>Value: (ไฟล์ขนาด \> 50MB) | - **Status: 413 Payload Too Large** หรือ **400 Bad Request** |
|
||||
| **1.2** | **Upload Invalid File Type**<br>`/api/files/upload` | POST | Key: `file` (Type: File)<br>Value: (เลือกไฟล์ .exe หรือ .bat) | - **Status: 400 Bad Request**<br>- Message: "Validation failed... expected type is..." |
|
||||
| **1.3** | **Upload Too Large File**<br>`/api/files/upload` | POST | Key: `file` (Type: File)<br>Value: (ไฟล์ขนาด \> 50MB) | - **Status: 413 Payload Too Large** หรือ **400 Bad Request** |
|
||||
|
||||
*หมายเหตุ: การ Commit ไฟล์ (ย้ายจาก Temp -\> Permanent) จะเกิดขึ้นอัตโนมัติเมื่อเรานำไฟล์ไปผูกกับเอกสารใน Phase 3*
|
||||
_หมายเหตุ: การ Commit ไฟล์ (ย้ายจาก Temp -\> Permanent) จะเกิดขึ้นอัตโนมัติเมื่อเรานำไฟล์ไปผูกกับเอกสารใน Phase 3_
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 🧪 Scenario 2: JSON Schema Validation (T2.5)
|
||||
|
||||
@@ -33,35 +33,35 @@ Phase นี้เน้นเรื่อง **การจัดการไ
|
||||
|
||||
| Step | Action (API Endpoint) | Method | Body (JSON) | Expected Result |
|
||||
| :------ | :-------------------------------------------------------------------- | :----- | :------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------- |
|
||||
| **2.1** | **Register Schema**<br>`/api/json-schemas/TEST_SCHEMA` | POST | `{ "type": "object", "properties": { "age": { "type": "integer" } }, "required": ["age"] }` | - **Status: 201 Created**<br>- Response มี `id`, `schemaCode`: "TEST\_SCHEMA" |
|
||||
| **2.1** | **Register Schema**<br>`/api/json-schemas/TEST_SCHEMA` | POST | `{ "type": "object", "properties": { "age": { "type": "integer" } }, "required": ["age"] }` | - **Status: 201 Created**<br>- Response มี `id`, `schemaCode`: "TEST_SCHEMA" |
|
||||
| **2.2** | **Validate Valid Data**<br>`/api/json-schemas/TEST_SCHEMA/validate` | POST | `{ "age": 25 }` | - **Status: 201 Created**<br>- Response: `{ "valid": true }` |
|
||||
| **2.3** | **Validate Invalid Data**<br>`/api/json-schemas/TEST_SCHEMA/validate` | POST | `{ "age": "twenty-five" }` | - **Status: 400 Bad Request**<br>- Message: "JSON Validation Failed..." |
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 🧪 Scenario 3: Security & Rate Limiting (T2.4)
|
||||
|
||||
**เป้าหมาย:** ทดสอบระบบป้องกันการโจมตี
|
||||
|
||||
| Step | Action (API Endpoint) | Method | Details | Expected Result |
|
||||
| :------ | :------------------------------------------------- | :----- | :-------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Step | Action (API Endpoint) | Method | Details | Expected Result |
|
||||
| :------ | :------------------------------------------------- | :----- | :------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **3.1** | **Brute Force Login**<br>`/api/auth/login` | POST | กด Send รัวๆ เกิน 5 ครั้ง ภายใน 1 นาที | - **ครั้งที่ 1-5:** Status 201/401 (ปกติ)<br>- **ครั้งที่ 6+:** **Status 429 Too Many Requests**<br>- Message: "ThrottlerException: Too Many Requests" |
|
||||
| **3.2** | **Security Headers**<br>(ตรวจสอบ Response Headers) | ANY | ยิง Request อะไรก็ได้ | - Header `X-Powered-By` **ต้องไม่มี** (ถูก Helmet ซ่อน)<br>- Header `Content-Security-Policy` **ต้องมี** |
|
||||
| **3.2** | **Security Headers**<br>(ตรวจสอบ Response Headers) | ANY | ยิง Request อะไรก็ได้ | - Header `X-Powered-By` **ต้องไม่มี** (ถูก Helmet ซ่อน)<br>- Header `Content-Security-Policy` **ต้องมี** |
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 🧪 Scenario 4: Document Numbering (T2.3)
|
||||
|
||||
**เป้าหมาย:** ทดสอบการรันเลขที่เอกสาร (ทดสอบผ่านการสร้างเอกสารใน Phase 3)
|
||||
|
||||
*เนื่องจาก Service นี้เป็น Internal เราจะทดสอบผ่านการสร้าง Correspondence*
|
||||
_เนื่องจาก Service นี้เป็น Internal เราจะทดสอบผ่านการสร้าง Correspondence_
|
||||
|
||||
| Step | Action (API Endpoint) | Method | Body (JSON) | Expected Result |
|
||||
| :------ | :------------------------------------------------- | :----- | :---------------------------------------------------------- | :----------------------------------------------------------------- |
|
||||
| **4.1** | **Generate Number**<br>`/api/correspondences` | POST | `{ "projectId": 1, "typeId": 1, "title": "Test Number 1" }` | - `correspondenceNumber` ลงท้ายด้วย **0001** (หรือเลขล่าสุด +1) |
|
||||
| Step | Action (API Endpoint) | Method | Body (JSON) | Expected Result |
|
||||
| :------ | :------------------------------------------------- | :----- | :---------------------------------------------------------- | :-------------------------------------------------------------------------- |
|
||||
| **4.1** | **Generate Number**<br>`/api/correspondences` | POST | `{ "projectId": 1, "typeId": 1, "title": "Test Number 1" }` | - `correspondenceNumber` ลงท้ายด้วย **0001** (หรือเลขล่าสุด +1) |
|
||||
| **4.2** | **Generate Next Number**<br>`/api/correspondences` | POST | `{ "projectId": 1, "typeId": 1, "title": "Test Number 2" }` | - `correspondenceNumber` ต้องเป็นเลขถัดไป (เช่น **0002**) ห้ามซ้ำกับข้อ 4.1 |
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### ✅ Checklist การตรวจสอบใน Server (Files)
|
||||
|
||||
@@ -69,4 +69,4 @@ Phase นี้เน้นเรื่อง **การจัดการไ
|
||||
2. ตรวจสอบโฟลเดอร์ `uploads/temp`
|
||||
3. **สิ่งที่ต้องเจอ:** ไฟล์ที่อัปโหลดในข้อ **1.1** ต้องปรากฏอยู่ในนี้ โดยชื่อไฟล์จะเป็น UUID (ไม่ใช่ชื่อเดิม)
|
||||
|
||||
ถ้าผ่านครบทุกข้อนี้ แสดงว่า **Phase 2 (Infrastructure & Integrity)** แข็งแกร่งพร้อมใช้งานครับ\!
|
||||
ถ้าผ่านครบทุกข้อนี้ แสดงว่า **Phase 2 (Infrastructure & Integrity)** แข็งแกร่งพร้อมใช้งานครับ\!
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,61 +2,61 @@
|
||||
|
||||
แผนนี้ออกแบบมาเพื่อทดสอบการทำงานร่วมกันของ `Create` -\> `Submit` -\> `Process Action` ให้ครอบคลุมทั้งกรณีปกติ (Happy Path) และกรณีขัดแย้ง (Edge Cases) ครับ
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## 📋 Phase 3 Integration Test Plan: Correspondence Workflow
|
||||
|
||||
**Pre-requisites (เตรียมข้อมูลก่อนเริ่ม):**
|
||||
|
||||
1. **Users:**
|
||||
* `admin` (Superadmin) - เอาไว้สร้าง Master Data
|
||||
* `user_org1` (อยู่ Org ID: 1) - เป็นคนสร้างเอกสาร (Originator)
|
||||
* `user_org2` (อยู่ Org ID: 2) - เป็นคนอนุมัติ (Reviewer)
|
||||
- `admin` (Superadmin) - เอาไว้สร้าง Master Data
|
||||
- `user_org1` (อยู่ Org ID: 1) - เป็นคนสร้างเอกสาร (Originator)
|
||||
- `user_org2` (อยู่ Org ID: 2) - เป็นคนอนุมัติ (Reviewer)
|
||||
2. **Master Data:**
|
||||
* มี `correspondence_types` (เช่น ID: 1 = RFA)
|
||||
* มี `correspondence_status` (เช่น ID: 1 = DRAFT)
|
||||
* มี `organizations` (ID: 1 และ 2)
|
||||
- มี `correspondence_types` (เช่น ID: 1 = RFA)
|
||||
- มี `correspondence_status` (เช่น ID: 1 = DRAFT)
|
||||
- มี `organizations` (ID: 1 และ 2)
|
||||
3. **Template:**
|
||||
* รัน SQL Seed สร้าง Template ID: 1 (Step 1 -\> Org 1, Step 2 -\> Org 2) ตามที่เคยทำไป
|
||||
- รัน SQL Seed สร้าง Template ID: 1 (Step 1 -\> Org 1, Step 2 -\> Org 2) ตามที่เคยทำไป
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 🧪 Scenario 1: Happy Path (Create -\> Submit -\> Approve -\> Complete)
|
||||
|
||||
**เป้าหมาย:** ทดสอบการไหลของงานปกติจนจบกระบวนการ
|
||||
|
||||
| Step | Action (API Endpoint) | Method | Actor (Token) | Body (JSON) | Expected Result |
|
||||
| :------ | :---------------------------------------------------------------------- | :----- | :----------------------------- | :---------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **1.1** | **Create Document**<br>`/api/correspondences` | POST | `user_org1` | `{ "projectId": 1, "typeId": 1, "title": "Test Workflow 01", "details": {} }` | - Status: `201 Created`<br>- Response มี `id` (จดไว้ สมมติ `10`)<br>- Response มี `correspondenceNumber` |
|
||||
| **1.2** | **Submit Document**<br>`/api/correspondences/10/submit` | POST | `user_org1` | `{ "templateId": 1 }` | - Status: `201 Created`<br>- Response คือ `CorrespondenceRouting`<br>- `sequence`: 1<br>- `status`: "SENT"<br>- `toOrganizationId`: 1 (ส่งหาตัวเองก่อนตาม Template) |
|
||||
| **1.3** | **Approve Step 1**<br>`/api/correspondences/10/workflow/action` | POST | `user_org1` | `{ "action": "APPROVE", "comments": "Review passed" }` | - Status: `201 Created`<br>- **Result:** "Action processed successfully"<br>- มีการสร้าง Step ถัดไป (Sequence 2) ส่งไปหา Org 2 |
|
||||
| **1.4** | **Approve Step 2 (Final)**<br>`/api/correspondences/10/workflow/action` | POST | `user_org2`<br>*(เปลี่ยน Token)* | `{ "action": "APPROVE", "comments": "Final Approval" }` | - Status: `201 Created`<br>- **Result:** "Action processed successfully"<br>- **ไม่สร้าง Step ถัดไป** (เพราะหมดแล้ว)<br>- Workflow จบสมบูรณ์ |
|
||||
| Step | Action (API Endpoint) | Method | Actor (Token) | Body (JSON) | Expected Result |
|
||||
| :------ | :---------------------------------------------------------------------- | :----- | :------------------------------- | :---------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **1.1** | **Create Document**<br>`/api/correspondences` | POST | `user_org1` | `{ "projectId": 1, "typeId": 1, "title": "Test Workflow 01", "details": {} }` | - Status: `201 Created`<br>- Response มี `id` (จดไว้ สมมติ `10`)<br>- Response มี `correspondenceNumber` |
|
||||
| **1.2** | **Submit Document**<br>`/api/correspondences/10/submit` | POST | `user_org1` | `{ "templateId": 1 }` | - Status: `201 Created`<br>- Response คือ `CorrespondenceRouting`<br>- `sequence`: 1<br>- `status`: "SENT"<br>- `toOrganizationId`: 1 (ส่งหาตัวเองก่อนตาม Template) |
|
||||
| **1.3** | **Approve Step 1**<br>`/api/correspondences/10/workflow/action` | POST | `user_org1` | `{ "action": "APPROVE", "comments": "Review passed" }` | - Status: `201 Created`<br>- **Result:** "Action processed successfully"<br>- มีการสร้าง Step ถัดไป (Sequence 2) ส่งไปหา Org 2 |
|
||||
| **1.4** | **Approve Step 2 (Final)**<br>`/api/correspondences/10/workflow/action` | POST | `user_org2`<br>_(เปลี่ยน Token)_ | `{ "action": "APPROVE", "comments": "Final Approval" }` | - Status: `201 Created`<br>- **Result:** "Action processed successfully"<br>- **ไม่สร้าง Step ถัดไป** (เพราะหมดแล้ว)<br>- Workflow จบสมบูรณ์ |
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 🧪 Scenario 2: Rejection Flow (การปฏิเสธเอกสาร)
|
||||
|
||||
**เป้าหมาย:** ทดสอบว่าเมื่อกด Reject แล้ว Workflow ต้องหยุดทันที
|
||||
|
||||
| Step | Action (API Endpoint) | Method | Actor (Token) | Body (JSON) | Expected Result |
|
||||
| :------ | :--------------------------------------------------------------- | :----- | :------------ | :----------------------------------------------------------- | :------------------------------------------------------------------------------------------------------- |
|
||||
| **2.1** | **Create Document**<br>`/api/correspondences` | POST | `user_org1` | `{ "projectId": 1, "typeId": 1, "title": "Test Reject 01" }` | - ได้ `id` ใหม่ (สมมติ `11`) |
|
||||
| **2.2** | **Submit Document**<br>`/api/correspondences/11/submit` | POST | `user_org1` | `{ "templateId": 1 }` | - สร้าง Routing Sequence 1 |
|
||||
| Step | Action (API Endpoint) | Method | Actor (Token) | Body (JSON) | Expected Result |
|
||||
| :------ | :--------------------------------------------------------------- | :----- | :------------ | :----------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------- |
|
||||
| **2.1** | **Create Document**<br>`/api/correspondences` | POST | `user_org1` | `{ "projectId": 1, "typeId": 1, "title": "Test Reject 01" }` | - ได้ `id` ใหม่ (สมมติ `11`) |
|
||||
| **2.2** | **Submit Document**<br>`/api/correspondences/11/submit` | POST | `user_org1` | `{ "templateId": 1 }` | - สร้าง Routing Sequence 1 |
|
||||
| **2.3** | **Reject Document**<br>`/api/correspondences/11/workflow/action` | POST | `user_org1` | `{ "action": "REJECT", "comments": "Invalid Data" }` | - Status: `201 Created`<br>- Step 1 Status เปลี่ยนเป็น `REJECTED`<br>- **ไม่มีการสร้าง Step 2** (Workflow หยุด) |
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### 🧪 Scenario 3: Security Check (ข้ามหน้าข้ามตา)
|
||||
|
||||
**เป้าหมาย:** ทดสอบว่าคนนอก (User ที่ไม่อยู่ใน Org ปลายทาง) จะไม่สามารถกดอนุมัติได้
|
||||
|
||||
| Step | Action (API Endpoint) | Method | Actor (Token) | Body (JSON) | Expected Result |
|
||||
| :------ | :------------------------------------------------ | :----- | :--------------------------------- | :------------------------ | :------------------------------------------------------------------------------------------------------------- |
|
||||
| **3.1** | **Create & Submit** | POST | `user_org1` | *(ทำเหมือน 1.1 และ 1.2)* | - ได้ `id` ใหม่ (สมมติ `12`)<br>- Routing Seq 1 (ส่งหา Org 1) |
|
||||
| **3.2** | **Approve Step 1** | POST | `user_org1` | `{ "action": "APPROVE" }` | - ผ่าน (เพราะ User 1 อยู่ Org 1)<br>- สร้าง Seq 2 (ส่งหา Org 2) |
|
||||
| **3.3** | **Try to Approve Step 2**<br>*(โดยใช้ User Org 1)* | POST | **`user_org1`**<br>*(เจตนาใช้ผิดคน)* | `{ "action": "APPROVE" }` | - **Status: `400 Bad Request`**<br>- Message: "You are not authorized to process this step"<br>*(ป้องกันสำเร็จ\!)* |
|
||||
| Step | Action (API Endpoint) | Method | Actor (Token) | Body (JSON) | Expected Result |
|
||||
| :------ | :------------------------------------------------- | :----- | :----------------------------------- | :------------------------ | :----------------------------------------------------------------------------------------------------------------- |
|
||||
| **3.1** | **Create & Submit** | POST | `user_org1` | _(ทำเหมือน 1.1 และ 1.2)_ | - ได้ `id` ใหม่ (สมมติ `12`)<br>- Routing Seq 1 (ส่งหา Org 1) |
|
||||
| **3.2** | **Approve Step 1** | POST | `user_org1` | `{ "action": "APPROVE" }` | - ผ่าน (เพราะ User 1 อยู่ Org 1)<br>- สร้าง Seq 2 (ส่งหา Org 2) |
|
||||
| **3.3** | **Try to Approve Step 2**<br>_(โดยใช้ User Org 1)_ | POST | **`user_org1`**<br>_(เจตนาใช้ผิดคน)_ | `{ "action": "APPROVE" }` | - **Status: `400 Bad Request`**<br>- Message: "You are not authorized to process this step"<br>_(ป้องกันสำเร็จ\!)_ |
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
### ✅ Checklist การตรวจสอบผลลัพธ์ใน Database
|
||||
|
||||
@@ -71,4 +71,4 @@ SELECT * FROM correspondence_routings WHERE correspondence_id = 10 ORDER BY sequ
|
||||
1. Row 1: `sequence`=1, `status`='ACTIONED', `comments`='Review passed'
|
||||
2. Row 2: `sequence`=2, `status`='ACTIONED', `comments`='Final Approval'
|
||||
|
||||
ถ้าผลการทดสอบเป็นไปตามนี้ทั้งหมด แสดงว่า **Phase 3 สมบูรณ์แบบ** พร้อมไปต่อ Phase 4 (RFA) ได้เลยครับ\!
|
||||
ถ้าผลการทดสอบเป็นไปตามนี้ทั้งหมด แสดงว่า **Phase 3 สมบูรณ์แบบ** พร้อมไปต่อ Phase 4 (RFA) ได้เลยครับ\!
|
||||
|
||||
Reference in New Issue
Block a user